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
3 changes: 2 additions & 1 deletion 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 All @@ -86,7 +87,7 @@ export namespace ModelsDev {

export const Data = lazy(async () => {
const file = Bun.file(filepath)
const result = await file.json().catch(() => {})
const result = await file.json().catch(() => { })
if (result) return result
// @ts-ignore
const snapshot = await import("./models-snapshot")
Expand Down
62 changes: 52 additions & 10 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,19 +340,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/v1beta1/projects/${project}/locations/${location}/endpoints/openapi`
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
? `https://aiplatform.googleapis.com/v1beta1/projects/${project}/locations/${location}/endpoints/openapi`
? `https://aiplatform.googleapis.com/v1/projects/${project}/locations/${location}/endpoints/openapi`

: `https://${location}-aiplatform.googleapis.com/v1beta1/projects/${project}/locations/${location}/endpoints/openapi`,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
: `https://${location}-aiplatform.googleapis.com/v1beta1/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 @@ -617,13 +651,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 @@ -749,6 +783,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 @@ -965,6 +1000,13 @@ 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") {
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
202 changes: 202 additions & 0 deletions packages/opencode/test/provider/google-vertex.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { test, expect, mock, describe, beforeAll } 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 External SDKs
const mockGoogleVertex = mock((options?: any) => {
return {
languageModel: (id: string) => ({ id, provider: "google-vertex", options }),
}
})

const mockOpenAI = mock((options: any) => ({
languageModel: (id: string) => ({ id, provider: "openai-compatible", options }),
}))

mock.module("@ai-sdk/google-vertex", () => ({
createVertex: (options: any) => {
return mockGoogleVertex(options)
}
}))

mock.module("@ai-sdk/openai-compatible", () => ({
createOpenAICompatible: (options: any) => {
return mockOpenAI(options)
},
OpenAICompatibleChatLanguageModel: class { constructor() { } },
OpenAICompatibleCompletionLanguageModel: class { constructor() { } },
OpenAICompatibleEmbeddingModel: class { constructor() { } },
OpenAICompatibleImageModel: class { constructor() { } }
}))

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 are sanitized (no baseURL/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",
provider: {
"google-vertex": {
models: {
"gemini-1.5-pro": {
api: { npm: "@ai-sdk/google-vertex" } // Force official SDK
}
}
}
}
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GOOGLE_CLOUD_PROJECT", "test-project")
},
fn: async () => {
mockGoogleVertex.mockClear()

// Trigger SDK loading
const model = await Provider.getModel("google-vertex", "gemini-1.5-pro")
await Provider.getLanguage(model)

expect(mockGoogleVertex).toHaveBeenCalled()
const callArgs = mockGoogleVertex.mock.calls[0][0] as any

// These should be STRIPPED for official SDK
expect(callArgs.baseURL).toBeUndefined()
// expect(callArgs.fetch).toBeUndefined() // Removed expectation due to wrapper
expect(callArgs.project).toBe("test-project")
},
})
})

test("OpenAPI model options RETAIN 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",
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 () => {
mockOpenAI.mockClear()

// Trigger SDK loading
const model = await Provider.getModel("google-vertex", "openapi-model")
expect(model).toBeDefined()
expect(model.api.npm).toBe("@ai-sdk/openai-compatible")
expect(model.api.id).toBe("gemini-1.5-pro-alias") // Verify aliasing via top-level id
const result = await Provider.getLanguage(model) as any

// Check options passed through the mock
expect(result.options).toBeDefined()
expect(result.options.baseURL).toBeDefined()
expect(result.options.baseURL).toContain("us-central1-aiplatform")
expect(result.options.fetch).toBeDefined()
expect(result.options.project).toBe("test-project")
},
})
})
})
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