Skip to content

Commit 4d71e5f

Browse files
proyectoauraorgedelaunanavedmerchant
authored
feat(opencode-go): add Opencode Go as a first-class provider (#172) (#319)
* 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. * fix(opencode-go): green CI and address review nits (#172) - Add missing opencodeGo translation keys across all 17 non-English locales (check-translations was failing). - Include the opencode-go entry in expected routerModels for the ClineProvider and webviewMessageHandler requestRouterModels suites (unit tests were failing). - Add a request timeout to the /models fetch so it can't hang. - Fall back to the default ModelInfo in useSelectedModel when the /models list is empty, keeping capability-driven UI working. - Validate the Opencode Go API key and add validate.spec + OpenCodeGo component tests. * test(opencode-go): cover OpencodeGoHandler streaming and completePrompt (#172) Adds unit tests for the handler: client init (base URL/key), fetchModel (configured + default), createMessage streaming (text/reasoning/tool_call/ usage), and completePrompt (content + error wrapping). Raises patch coverage to green CI on #319. * fix(opencode-go): address CodeRabbit review — defensive validation, stronger assertions, JSDoc (#172) - Add Array.isArray guard + per-model safeParse with console.warn in getOpencodeGoModels - Assert max_completion_tokens and temperature in handler tests - Add test cases for non-array response.data.data and invalid model entries - Add JSDoc with @param/@returns to all public functions * fix(opencode-go): omit price from fallback model info ModelInfoView renders a 0 price field as "$0.00 / 1M tokens", implying the service is free. Drop inputPrice/outputPrice from the fallback so it stays unknown until the live /v1/models list resolves, matching the fetched models which leave price fields absent. Addresses PR #319 review (edelauna, navedmerchant). * Update src/api/providers/fetchers/opencode-go.ts * Update src/api/providers/fetchers/__tests__/opencode-go.spec.ts --------- Co-authored-by: Armando Vaquera <263793884+proyectoauraorg@users.noreply.github.com> Co-authored-by: edelauna <54631123+edelauna@users.noreply.github.com> Co-authored-by: Naved Merchant <naved.merchant@gmail.com>
1 parent 78d3dac commit 4d71e5f

42 files changed

Lines changed: 858 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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+
// Pricing is intentionally omitted: ModelInfoView renders a `0` field as "$0.00 / 1M tokens"
17+
// (implying the service is free), so we leave it unknown — consistent with the dynamically
18+
// fetched models, which also leave price fields absent. See PR #319 review.
19+
description: "Opencode Go plan model. Available models and metadata are resolved dynamically from /v1/models.",
20+
}
21+
22+
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: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
// npx vitest run src/api/providers/__tests__/opencode-go.spec.ts
2+
3+
// Mock vscode first to avoid import errors
4+
vitest.mock("vscode", () => ({}))
5+
6+
import { Anthropic } from "@anthropic-ai/sdk"
7+
import OpenAI from "openai"
8+
9+
import { opencodeGoDefaultModelId } from "@roo-code/types"
10+
11+
import { OpencodeGoHandler } from "../opencode-go"
12+
import { ApiHandlerOptions } from "../../../shared/api"
13+
14+
vitest.mock("openai")
15+
vitest.mock("delay", () => ({ default: vitest.fn(() => Promise.resolve()) }))
16+
vitest.mock("../fetchers/modelCache", () => ({
17+
getModels: vitest.fn().mockImplementation(() =>
18+
Promise.resolve({
19+
"glm-5.1": {
20+
maxTokens: 32768,
21+
contextWindow: 200000,
22+
supportsImages: false,
23+
supportsPromptCache: false,
24+
description: "GLM 5.1",
25+
},
26+
}),
27+
),
28+
getModelsFromCache: vitest.fn().mockReturnValue(undefined),
29+
}))
30+
31+
const mockCreate = vitest.fn()
32+
33+
;(OpenAI as any).mockImplementation(() => ({
34+
chat: { completions: { create: mockCreate } },
35+
}))
36+
37+
describe("OpencodeGoHandler", () => {
38+
const mockOptions: ApiHandlerOptions = {
39+
opencodeGoApiKey: "test-key",
40+
opencodeGoModelId: "glm-5.1",
41+
}
42+
43+
beforeEach(() => {
44+
vitest.clearAllMocks()
45+
mockCreate.mockClear()
46+
})
47+
48+
it("initializes the OpenAI client with the Opencode Go base URL and key", () => {
49+
const handler = new OpencodeGoHandler(mockOptions)
50+
expect(handler).toBeInstanceOf(OpencodeGoHandler)
51+
expect(OpenAI).toHaveBeenCalledWith(
52+
expect.objectContaining({
53+
baseURL: "https://opencode.ai/zen/go/v1",
54+
apiKey: "test-key",
55+
}),
56+
)
57+
})
58+
59+
describe("fetchModel", () => {
60+
it("returns the configured model info", async () => {
61+
const handler = new OpencodeGoHandler(mockOptions)
62+
const result = await handler.fetchModel()
63+
expect(result.id).toBe("glm-5.1")
64+
expect(result.info.maxTokens).toBe(32768)
65+
expect(result.info.contextWindow).toBe(200000)
66+
expect(result.info.supportsPromptCache).toBe(false)
67+
})
68+
69+
it("falls back to the default model id when none is configured", async () => {
70+
const handler = new OpencodeGoHandler({ opencodeGoApiKey: "test-key" })
71+
const result = await handler.fetchModel()
72+
expect(result.id).toBe(opencodeGoDefaultModelId)
73+
})
74+
})
75+
76+
describe("createMessage", () => {
77+
beforeEach(() => {
78+
mockCreate.mockImplementation(async () => ({
79+
[Symbol.asyncIterator]: async function* () {
80+
yield {
81+
choices: [
82+
{
83+
delta: {
84+
content: "Hello",
85+
reasoning_content: "thinking…",
86+
tool_calls: [
87+
{
88+
index: 0,
89+
id: "call_1",
90+
function: { name: "read_file", arguments: '{"path":' },
91+
},
92+
],
93+
},
94+
index: 0,
95+
},
96+
],
97+
usage: null,
98+
}
99+
yield {
100+
choices: [{ delta: {}, index: 0 }],
101+
usage: {
102+
prompt_tokens: 12,
103+
completion_tokens: 7,
104+
total_tokens: 19,
105+
prompt_tokens_details: { cached_tokens: 4 },
106+
},
107+
}
108+
},
109+
}))
110+
})
111+
112+
it("streams text, reasoning, tool-call and usage chunks", async () => {
113+
const handler = new OpencodeGoHandler(mockOptions)
114+
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }]
115+
116+
const chunks = []
117+
for await (const chunk of handler.createMessage("You are helpful.", messages)) {
118+
chunks.push(chunk)
119+
}
120+
121+
expect(chunks).toContainEqual({ type: "text", text: "Hello" })
122+
expect(chunks).toContainEqual({ type: "reasoning", text: "thinking…" })
123+
expect(chunks).toContainEqual({
124+
type: "tool_call_partial",
125+
index: 0,
126+
id: "call_1",
127+
name: "read_file",
128+
arguments: '{"path":',
129+
})
130+
expect(chunks).toContainEqual({
131+
type: "usage",
132+
inputTokens: 12,
133+
outputTokens: 7,
134+
cacheReadTokens: 4,
135+
})
136+
})
137+
138+
it("requests a streaming completion with usage included", async () => {
139+
const handler = new OpencodeGoHandler(mockOptions)
140+
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }]
141+
for await (const _chunk of handler.createMessage("sys", messages)) {
142+
void _chunk // drain
143+
}
144+
145+
expect(mockCreate).toHaveBeenCalledWith(
146+
expect.objectContaining({
147+
model: "glm-5.1",
148+
stream: true,
149+
stream_options: { include_usage: true },
150+
max_completion_tokens: 32768,
151+
temperature: expect.any(Number),
152+
}),
153+
)
154+
})
155+
})
156+
157+
describe("completePrompt", () => {
158+
it("returns the message content for a non-streaming completion", async () => {
159+
mockCreate.mockResolvedValue({ choices: [{ message: { content: "the answer" } }] })
160+
const handler = new OpencodeGoHandler(mockOptions)
161+
expect(await handler.completePrompt("ping")).toBe("the answer")
162+
expect(mockCreate).toHaveBeenCalledWith(
163+
expect.objectContaining({
164+
model: "glm-5.1",
165+
stream: false,
166+
max_completion_tokens: 32768,
167+
}),
168+
)
169+
})
170+
171+
it("wraps errors with an Opencode Go-specific message", async () => {
172+
mockCreate.mockRejectedValue(new Error("boom"))
173+
const handler = new OpencodeGoHandler(mockOptions)
174+
await expect(handler.completePrompt("ping")).rejects.toThrow("Opencode Go completion error: boom")
175+
})
176+
})
177+
})

0 commit comments

Comments
 (0)