Skip to content

Commit 82f9a2f

Browse files
feat(opencode-go): add Opencode Go as a first-class provider (#172)
Opencode Go's models were only usable by configuring each one manually as a separate OpenAI-Compatible provider, with no on-the-fly model switching. Add Opencode Go as a dedicated OpenAI-compatible gateway provider (https://opencode.ai/zen/go/v1) with an API key and a dynamic model list fetched from /v1/models, so models can be switched on the fly via the model picker. Mirrors the VercelAiGateway provider (RouterProvider + fetcher). Pricing is intentionally not parsed (units undocumented; cost shown as unknown rather than wrong). First pass covers the OpenAI-compatible models; the Anthropic-format models on the Go plan (/v1/messages) are a follow-up. Adds a fetcher spec. tsc/eslint/prettier and affected suites pass.
1 parent 9d022d4 commit 82f9a2f

20 files changed

Lines changed: 434 additions & 0 deletions

File tree

packages/types/src/global-settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ export const SECRET_STATE_KEYS = [
279279
"zaiApiKey",
280280
"fireworksApiKey",
281281
"vercelAiGatewayApiKey",
282+
"opencodeGoApiKey",
282283
"basetenApiKey",
283284
] as const
284285

packages/types/src/provider-settings.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export const dynamicProviders = [
4343
"unbound",
4444
"poe",
4545
"deepseek",
46+
"opencode-go",
4647
] as const
4748

4849
export type DynamicProvider = (typeof dynamicProviders)[number]
@@ -399,6 +400,11 @@ const vercelAiGatewaySchema = baseProviderSettingsSchema.extend({
399400
vercelAiGatewayModelId: z.string().optional(),
400401
})
401402

403+
const opencodeGoSchema = baseProviderSettingsSchema.extend({
404+
opencodeGoApiKey: z.string().optional(),
405+
opencodeGoModelId: z.string().optional(),
406+
})
407+
402408
const basetenSchema = apiModelIdProviderModelSchema.extend({
403409
basetenApiKey: z.string().optional(),
404410
})
@@ -437,6 +443,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv
437443
fireworksSchema.merge(z.object({ apiProvider: z.literal("fireworks") })),
438444
qwenCodeSchema.merge(z.object({ apiProvider: z.literal("qwen-code") })),
439445
vercelAiGatewaySchema.merge(z.object({ apiProvider: z.literal("vercel-ai-gateway") })),
446+
opencodeGoSchema.merge(z.object({ apiProvider: z.literal("opencode-go") })),
440447
defaultSchema,
441448
])
442449

@@ -471,6 +478,7 @@ export const providerSettingsSchema = z.object({
471478
...fireworksSchema.shape,
472479
...qwenCodeSchema.shape,
473480
...vercelAiGatewaySchema.shape,
481+
...opencodeGoSchema.shape,
474482
...codebaseIndexProviderSchema.shape,
475483
})
476484

@@ -501,6 +509,7 @@ export const modelIdKeys = [
501509
"unboundModelId",
502510
"litellmModelId",
503511
"vercelAiGatewayModelId",
512+
"opencodeGoModelId",
504513
] as const satisfies readonly (keyof ProviderSettings)[]
505514

506515
export type ModelIdKey = (typeof modelIdKeys)[number]
@@ -546,6 +555,7 @@ export const modelIdKeysByProvider: Record<TypicalProvider, ModelIdKey> = {
546555
zai: "apiModelId",
547556
fireworks: "apiModelId",
548557
"vercel-ai-gateway": "vercelAiGatewayModelId",
558+
"opencode-go": "opencodeGoModelId",
549559
}
550560

551561
/**
@@ -662,6 +672,7 @@ export const MODELS_BY_PROVIDER: Record<
662672
requesty: { id: "requesty", label: "Requesty", models: [] },
663673
unbound: { id: "unbound", label: "Unbound", models: [] },
664674
"vercel-ai-gateway": { id: "vercel-ai-gateway", label: "Vercel AI Gateway", models: [] },
675+
"opencode-go": { id: "opencode-go", label: "Opencode Go", models: [] },
665676

666677
// Local providers; models discovered from localhost endpoints.
667678
lmstudio: { id: "lmstudio", label: "LM Studio", models: [] },

packages/types/src/providers/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export * from "./vertex.js"
2222
export * from "./vscode-llm.js"
2323
export * from "./xai.js"
2424
export * from "./vercel-ai-gateway.js"
25+
export * from "./opencode-go.js"
2526
export * from "./zai.js"
2627
export * from "./minimax.js"
2728
export * from "./mimo.js"
@@ -46,6 +47,7 @@ import { vertexDefaultModelId } from "./vertex.js"
4647
import { vscodeLlmDefaultModelId } from "./vscode-llm.js"
4748
import { xaiDefaultModelId } from "./xai.js"
4849
import { vercelAiGatewayDefaultModelId } from "./vercel-ai-gateway.js"
50+
import { opencodeGoDefaultModelId } from "./opencode-go.js"
4951
import { internationalZAiDefaultModelId, mainlandZAiDefaultModelId } from "./zai.js"
5052
import { minimaxDefaultModelId } from "./minimax.js"
5153
import { mimoDefaultModelId } from "./mimo.js"
@@ -115,6 +117,8 @@ export function getProviderDefaultModelId(
115117
return unboundDefaultModelId
116118
case "vercel-ai-gateway":
117119
return vercelAiGatewayDefaultModelId
120+
case "opencode-go":
121+
return opencodeGoDefaultModelId
118122
case "anthropic":
119123
case "gemini-cli":
120124
case "fake-ai":
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { ModelInfo } from "../model.js"
2+
3+
// Opencode "Go" plan — OpenAI-compatible gateway.
4+
// https://opencode.ai/docs/go/ · base URL: https://opencode.ai/zen/go/v1
5+
//
6+
// The full model list (and metadata) is fetched dynamically from
7+
// `https://opencode.ai/zen/go/v1/models`, so models can be switched on the fly.
8+
// The values below are only a fallback used before the live list resolves.
9+
export const opencodeGoDefaultModelId = "glm-5.1"
10+
11+
export const opencodeGoDefaultModelInfo: ModelInfo = {
12+
maxTokens: 32_768,
13+
contextWindow: 200_000,
14+
supportsImages: false,
15+
supportsPromptCache: false,
16+
inputPrice: 0,
17+
outputPrice: 0,
18+
description: "Opencode Go plan model. Available models and metadata are resolved dynamically from /v1/models.",
19+
}
20+
21+
export const OPENCODE_GO_DEFAULT_TEMPERATURE = 0

src/api/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
ZAiHandler,
3333
FireworksHandler,
3434
VercelAiGatewayHandler,
35+
OpencodeGoHandler,
3536
MiniMaxHandler,
3637
MimoHandler,
3738
BasetenHandler,
@@ -176,6 +177,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
176177
return new FireworksHandler(options)
177178
case "vercel-ai-gateway":
178179
return new VercelAiGatewayHandler(options)
180+
case "opencode-go":
181+
return new OpencodeGoHandler(options)
179182
case "minimax":
180183
return new MiniMaxHandler(options)
181184
case "baseten":
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// npx vitest run src/api/providers/fetchers/__tests__/opencode-go.spec.ts
2+
3+
import axios from "axios"
4+
5+
import { opencodeGoDefaultModelInfo } from "@roo-code/types"
6+
7+
import { getOpencodeGoModels, parseOpencodeGoModel } from "../opencode-go"
8+
9+
vitest.mock("axios")
10+
const mockedAxios = axios as any
11+
12+
describe("Opencode Go Fetchers", () => {
13+
beforeEach(() => {
14+
vitest.clearAllMocks()
15+
})
16+
17+
describe("getOpencodeGoModels", () => {
18+
it("maps the /models response and sends the API key as a Bearer header", async () => {
19+
mockedAxios.get.mockResolvedValue({
20+
data: {
21+
data: [
22+
{
23+
id: "glm-5.1",
24+
name: "GLM-5.1",
25+
description: "Zhipu GLM 5.1",
26+
context_window: 202752,
27+
max_output_tokens: 32768,
28+
},
29+
{ id: "deepseek-v4-pro", context_length: 1048576 },
30+
],
31+
},
32+
})
33+
34+
const models = await getOpencodeGoModels("test-key")
35+
36+
expect(mockedAxios.get).toHaveBeenCalledWith("https://opencode.ai/zen/go/v1/models", {
37+
headers: { Authorization: "Bearer test-key" },
38+
})
39+
40+
expect(Object.keys(models).sort()).toEqual(["deepseek-v4-pro", "glm-5.1"])
41+
expect(models["glm-5.1"]).toMatchObject({
42+
contextWindow: 202752,
43+
maxTokens: 32768,
44+
supportsPromptCache: false,
45+
description: "Zhipu GLM 5.1",
46+
})
47+
expect(models["deepseek-v4-pro"].contextWindow).toBe(1048576)
48+
})
49+
50+
it("falls back to default context/max tokens when metadata is absent", async () => {
51+
mockedAxios.get.mockResolvedValue({ data: { data: [{ id: "kimi-k2.6" }] } })
52+
53+
const models = await getOpencodeGoModels("k")
54+
55+
expect(models["kimi-k2.6"]).toMatchObject({
56+
contextWindow: opencodeGoDefaultModelInfo.contextWindow,
57+
maxTokens: opencodeGoDefaultModelInfo.maxTokens,
58+
supportsPromptCache: false,
59+
})
60+
})
61+
62+
it("returns an empty map on network error", async () => {
63+
mockedAxios.get.mockRejectedValue(new Error("network"))
64+
expect(await getOpencodeGoModels("k")).toEqual({})
65+
})
66+
})
67+
68+
describe("parseOpencodeGoModel", () => {
69+
it("treats a model with no cache pricing as not cache-capable", () => {
70+
const info = parseOpencodeGoModel({ id: "x", context_window: 100000, max_tokens: 8000 })
71+
expect(info.supportsPromptCache).toBe(false)
72+
expect(info.contextWindow).toBe(100000)
73+
expect(info.maxTokens).toBe(8000)
74+
})
75+
})
76+
})

src/api/providers/fetchers/modelCache.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { fileExistsAtPath } from "../../../utils/fs"
1818

1919
import { getOpenRouterModels } from "./openrouter"
2020
import { getVercelAiGatewayModels } from "./vercel-ai-gateway"
21+
import { getOpencodeGoModels } from "./opencode-go"
2122
import { getRequestyModels } from "./requesty"
2223
import { getUnboundModels } from "./unbound"
2324
import { getLiteLLMModels } from "./litellm"
@@ -86,6 +87,9 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise<Model
8687
case "vercel-ai-gateway":
8788
models = await getVercelAiGatewayModels()
8889
break
90+
case "opencode-go":
91+
models = await getOpencodeGoModels(options.apiKey)
92+
break
8993
case "poe":
9094
models = await getPoeModels(options.apiKey, options.baseUrl)
9195
break
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import axios from "axios"
2+
import { z } from "zod"
3+
4+
import type { ModelInfo } from "@roo-code/types"
5+
import { opencodeGoDefaultModelInfo } from "@roo-code/types"
6+
7+
const OPENCODE_GO_BASE_URL = "https://opencode.ai/zen/go/v1"
8+
9+
// The Opencode Go `/models` endpoint follows the OpenAI `/models` shape. The
10+
// `id` is the only guaranteed field; metadata is optional and best-effort, so
11+
// the schema is intentionally permissive. Pricing is intentionally NOT parsed:
12+
// the units returned by the endpoint aren't documented, and reporting a wrong
13+
// cost is worse than reporting "unknown" — so cost stays undefined until the
14+
// pricing shape is confirmed against the live endpoint.
15+
const opencodeGoModelSchema = z.object({
16+
id: z.string(),
17+
name: z.string().optional(),
18+
description: z.string().optional(),
19+
context_window: z.number().optional(),
20+
context_length: z.number().optional(),
21+
max_tokens: z.number().optional(),
22+
max_output_tokens: z.number().optional(),
23+
supports_images: z.boolean().optional(),
24+
})
25+
26+
export type OpencodeGoModel = z.infer<typeof opencodeGoModelSchema>
27+
28+
const opencodeGoModelsResponseSchema = z.object({
29+
data: z.array(opencodeGoModelSchema),
30+
})
31+
32+
export const parseOpencodeGoModel = (model: OpencodeGoModel): ModelInfo => ({
33+
maxTokens: model.max_output_tokens ?? model.max_tokens ?? opencodeGoDefaultModelInfo.maxTokens,
34+
contextWindow: model.context_window ?? model.context_length ?? opencodeGoDefaultModelInfo.contextWindow,
35+
supportsImages: model.supports_images ?? false,
36+
supportsPromptCache: false,
37+
description: model.description ?? model.name,
38+
})
39+
40+
export async function getOpencodeGoModels(apiKey?: string): Promise<Record<string, ModelInfo>> {
41+
const models: Record<string, ModelInfo> = {}
42+
43+
try {
44+
const response = await axios.get(`${OPENCODE_GO_BASE_URL}/models`, {
45+
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined,
46+
})
47+
48+
const result = opencodeGoModelsResponseSchema.safeParse(response.data)
49+
const data = result.success ? result.data.data : (response.data?.data ?? [])
50+
51+
if (!result.success) {
52+
console.error(`Opencode Go models response is invalid: ${JSON.stringify(result.error.format())}`)
53+
}
54+
55+
for (const model of data) {
56+
if (model?.id) {
57+
models[model.id] = parseOpencodeGoModel(model)
58+
}
59+
}
60+
} catch (error) {
61+
console.error(`Error fetching Opencode Go models: ${error instanceof Error ? error.message : String(error)}`)
62+
}
63+
64+
return models
65+
}

src/api/providers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export { XAIHandler } from "./xai"
2525
export { ZAiHandler } from "./zai"
2626
export { FireworksHandler } from "./fireworks"
2727
export { VercelAiGatewayHandler } from "./vercel-ai-gateway"
28+
export { OpencodeGoHandler } from "./opencode-go"
2829
export { MiniMaxHandler } from "./minimax"
2930
export { MimoHandler } from "./mimo"
3031
export { BasetenHandler } from "./baseten"

0 commit comments

Comments
 (0)