Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/opencode/src/provider/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export namespace ModelsDev {
experimental: z.boolean().optional(),
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
options: z.record(z.string(), z.any()),
protocol: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
provider: z.object({ npm: z.string() }).optional(),
variants: z.record(z.string(), z.record(z.string(), z.any())).optional(),
Expand Down
68 changes: 58 additions & 10 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,19 +344,53 @@ export namespace Provider {
},
}
},
"google-vertex": async () => {
const project = Env.get("GOOGLE_CLOUD_PROJECT") ?? Env.get("GCP_PROJECT") ?? Env.get("GCLOUD_PROJECT")
const location = Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "us-east5"
"google-vertex": async (provider) => {
const project =
provider.options?.project ??
Env.get("GOOGLE_CLOUD_PROJECT") ??
Env.get("GCP_PROJECT") ??
Env.get("GCLOUD_PROJECT")

const location =
provider.options?.location ?? Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "us-central1"

const autoload = Boolean(project)
if (!autoload) return { autoload: false }

return {
autoload: true,
options: {
project,
location,
baseURL:
location === "global"
? `https://aiplatform.googleapis.com/v1/projects/${project}/locations/${location}/endpoints/openapi`
: `https://${location}-aiplatform.googleapis.com/v1/projects/${project}/locations/${location}/endpoints/openapi`,
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
const { GoogleAuth } = await import(await BunProc.install("google-auth-library"))
const auth = new GoogleAuth()
const client = await auth.getApplicationDefault()
const credentials = await client.credential
const token = await credentials.getAccessToken()

const headers = new Headers(init?.headers)
headers.set("Authorization", `Bearer ${token.token}`)

return fetch(input, { ...init, headers })
},
},
async getModel(sdk: any, modelID: string) {
const id = String(modelID).trim()
// For official SDK, it expects languageModel(id). For openai-compatible (via openapi), it likely expects just the ID or similar,
// but relying on the defaults in getSDK should handle the sdk() call if it's not the official one.
// Yet, looking at the previous implementations:
// vertex: sdk.languageModel(id)
// vertex-openapi: sdk(modelID)
// We need to know which SDK we are dealing with.
// However, getModel is called with the *instantiated* SDK.
// If it's @ai-sdk/google-vertex, it has .languageModel
// If it's @ai-sdk/openai-compatible, it is a function.
if (typeof sdk === "function") return sdk(id)
return sdk.languageModel(id)
},
}
Expand Down Expand Up @@ -630,13 +664,13 @@ export namespace Provider {
},
experimentalOver200K: model.cost?.context_over_200k
? {
cache: {
read: model.cost.context_over_200k.cache_read ?? 0,
write: model.cost.context_over_200k.cache_write ?? 0,
},
input: model.cost.context_over_200k.input,
output: model.cost.context_over_200k.output,
}
cache: {
read: model.cost.context_over_200k.cache_read ?? 0,
write: model.cost.context_over_200k.cache_write ?? 0,
},
input: model.cost.context_over_200k.input,
output: model.cost.context_over_200k.output,
}
: undefined,
},
limit: {
Expand Down Expand Up @@ -762,6 +796,7 @@ export namespace Provider {
api: {
id: model.id ?? existingModel?.api.id ?? modelID,
npm:
(model.protocol === "openapi" ? "@ai-sdk/openai-compatible" : undefined) ??
model.provider?.npm ??
provider.npm ??
existingModel?.api.npm ??
Expand Down Expand Up @@ -978,6 +1013,19 @@ export namespace Provider {
const provider = s.providers[model.providerID]
const options = { ...provider.options }

// Sanitize options for official Google Vertex SDK to prevent conflicts
if (model.api.npm === "@ai-sdk/google-vertex" || model.api.npm === "@ai-sdk/google") {
// Only strip if it's a standard Google URL (which conflicts with official SDK internals).
// Custom proxies and localhost will have a baseURL that doesn't include "googleapis.com",
// so they will bypass this stripping.
const isGoogle = !options.baseURL || options.baseURL.includes("googleapis.com")
if (isGoogle) {
delete options.baseURL
delete options.fetch
delete options.headers
}
}

if (model.api.npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) {
options["includeUsage"] = true
}
Expand Down
203 changes: 203 additions & 0 deletions packages/opencode/test/provider/google-vertex.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { test, expect, mock, describe, beforeAll, afterAll } from "bun:test"
import path from "path"

// Mock BunProc to return pkg name
mock.module("../../src/bun/index", () => ({
BunProc: {
install: async (pkg: string, _version?: string) => {
return pkg
},
run: async () => { throw new Error("BunProc.run should not be called in tests") },
which: () => process.execPath,
InstallFailedError: class extends Error { },
},
}))

// Mock auth plugins
const mockPlugin = () => ({})
mock.module("opencode-copilot-auth", () => ({ default: mockPlugin }))
mock.module("opencode-anthropic-auth", () => ({ default: mockPlugin }))
mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: mockPlugin }))

// Mock Google Auth Library (required for official SDK initialization)
mock.module("google-auth-library", () => ({
GoogleAuth: class {
async getApplicationDefault() {
return {
credential: {
getAccessToken: async () => ({
token: "mock-access-token",
}),
},
}
}
},
}))

describe("Google Vertex Provider Merge", () => {
let Provider: any
let Instance: any
let Env: any
let tmpdir: any

beforeAll(async () => {
// Dynamic import to ensure mocks are active before modules load
const fixture = await import("../fixture/fixture")
tmpdir = fixture.tmpdir

const instance = await import("../../src/project/instance")
Instance = instance.Instance

const provider = await import("../../src/provider/provider")
Provider = provider.Provider

const env = await import("../../src/env")
Env = env.Env
})

test("loader returns merged options including baseURL and fetch", async () => {
await using tmp = await tmpdir({
init: async (dir: string) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GOOGLE_CLOUD_PROJECT", "test-project")
Env.set("GOOGLE_CLOUD_LOCATION", "us-central1")
},
fn: async () => {
const providers = await Provider.list()
const vertex = providers["google-vertex"]
expect(vertex).toBeDefined()
expect(vertex.options.project).toBe("test-project")
expect(vertex.options.location).toBe("us-central1")
expect(vertex.options.baseURL).toContain("us-central1-aiplatform.googleapis.com")
expect(vertex.options.fetch).toBeDefined()
},
})
})

test("official SDK options STRIP googleapis.com baseURL but RETAIN custom proxy", async () => {
await using tmp = await tmpdir({
init: async (dir: string) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
"google-vertex": {
models: {
"google-stripped": { api: { npm: "@ai-sdk/google-vertex", id: "gemini-1.5-pro" } },
"proxy-preserved": { api: { npm: "@ai-sdk/google-vertex", id: "gemini-1.5-pro" } },
"localhost-preserved": { api: { npm: "@ai-sdk/google-vertex", id: "gemini-1.5-pro" } }
}
}
}
}),
)
},
})

await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GOOGLE_CLOUD_PROJECT", "test-project")
},
fn: async () => {
const providers = await Provider.list()
const vertex = providers["google-vertex"]

// Case 1: Standard Google URL -> should be stripped
vertex.options.baseURL = "https://us-central1-aiplatform.googleapis.com/v1"
const modelGoogle = await Provider.getModel("google-vertex", "google-stripped")
const sdkGoogle = await Provider.getLanguage(modelGoogle) as any
expect(sdkGoogle.config.baseURL).not.toBe("https://us-central1-aiplatform.googleapis.com/v1")

// Case 2: Custom Proxy -> should be RETAINED
// We MUST use a different model to avoid cache in getLanguage
vertex.options.baseURL = "https://my-proxy.com/v1"
const modelProxy = await Provider.getModel("google-vertex", "proxy-preserved")
const sdkProxy = await Provider.getLanguage(modelProxy) as any
expect(sdkProxy.config.baseURL).toBe("https://my-proxy.com/v1")

// Case 3: Localhost -> should be RETAINED
vertex.options.baseURL = "http://localhost:8080/v1"
const modelLocal = await Provider.getModel("google-vertex", "localhost-preserved")
const sdkLocal = await Provider.getLanguage(modelLocal) as any
expect(sdkLocal.config.baseURL).toBe("http://localhost:8080/v1")
},
})
})

test("OpenAPI model options RETAIN baseURL and fetch", async () => {
const server = Bun.serve({
port: 0,
fetch(req) {
return new Response(JSON.stringify({ id: "test", choices: [] }), { status: 200 })
}
})

await using tmp = await tmpdir({
init: async (dir: string) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
"google-vertex": {
models: {
"openapi-model": {
protocol: "openapi",
id: "gemini-1.5-pro-alias"
}
}
}
}
}),
)
},
})

await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GOOGLE_CLOUD_PROJECT", "test-project")
Env.set("GOOGLE_CLOUD_LOCATION", "us-central1")
},
fn: async () => {
// Trigger SDK loading
const model = await Provider.getModel("google-vertex", "openapi-model")
expect(model).toBeDefined()
expect(model.api.npm).toBe("@ai-sdk/openai-compatible")

// Manually inject baseURL into the provider options for this test session
const providers = await Provider.list()
const vertex = providers["google-vertex"]
vertex.options.baseURL = server.url.origin + "/v1"
vertex.options.fetch = fetch

const sdk = await Provider.getLanguage(model)

try {
await sdk.doGenerate({
inputFormat: "messages",
mode: { type: "regular" },
modelId: "test-model",
prompt: [{ role: "user", content: [{ type: "text", text: "test" }] }],
})
} catch (e) {
// Success if we hit the server
}
},
})

server.stop()
})
})
14 changes: 14 additions & 0 deletions packages/opencode/test/provider/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@ mock.module("opencode-copilot-auth", () => ({ default: mockPlugin }))
mock.module("opencode-anthropic-auth", () => ({ default: mockPlugin }))
mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: mockPlugin }))

mock.module("google-auth-library", () => ({
GoogleAuth: class {
async getApplicationDefault() {
return {
credential: {
getAccessToken: async () => ({
token: "mock-access-token-12345",
}),
},
}
}
},
}))

import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Provider } from "../../src/provider/provider"
Expand Down
Loading