Skip to content

Commit 4a4d662

Browse files
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
1 parent f2a4a0b commit 4a4d662

4 files changed

Lines changed: 83 additions & 6 deletions

File tree

src/api/providers/__tests__/opencode-go.spec.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ describe("OpencodeGoHandler", () => {
147147
model: "glm-5.1",
148148
stream: true,
149149
stream_options: { include_usage: true },
150+
max_completion_tokens: 32768,
151+
temperature: expect.any(Number),
150152
}),
151153
)
152154
})
@@ -157,7 +159,13 @@ describe("OpencodeGoHandler", () => {
157159
mockCreate.mockResolvedValue({ choices: [{ message: { content: "the answer" } }] })
158160
const handler = new OpencodeGoHandler(mockOptions)
159161
expect(await handler.completePrompt("ping")).toBe("the answer")
160-
expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: "glm-5.1", stream: false }))
162+
expect(mockCreate).toHaveBeenCalledWith(
163+
expect.objectContaining({
164+
model: "glm-5.1",
165+
stream: false,
166+
max_completion_tokens: 32768,
167+
}),
168+
)
161169
})
162170

163171
it("wraps errors with an Opencode Go-specific message", async () => {

src/api/providers/fetchers/__tests__/opencode-go.spec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,31 @@ describe("Opencode Go Fetchers", () => {
6464
mockedAxios.get.mockRejectedValue(new Error("network"))
6565
expect(await getOpencodeGoModels("k")).toEqual({})
6666
})
67+
68+
it("falls back to an empty array when response.data.data is not an array", async () => {
69+
mockedAxios.get.mockResolvedValue({ data: { data: null } })
70+
expect(await getOpencodeGoModels("k")).toEqual({})
71+
})
72+
73+
it("skips entries that fail safeParse with a console.warn", async () => {
74+
mockedAxios.get.mockResolvedValue({
75+
data: {
76+
data: [
77+
{ id: "valid-model", context_window: 50000 },
78+
{ not_a_field: true }, // no `id` — will fail safeParse
79+
],
80+
},
81+
})
82+
const warnSpy = vitest.spyOn(console, "warn").mockImplementation(() => {})
83+
84+
const models = await getOpencodeGoModels("k")
85+
86+
expect(Object.keys(models)).toEqual(["valid-model"])
87+
expect(warnSpy).toHaveBeenCalledTimes(1)
88+
expect(warnSpy.mock.calls[0][0]).toContain("Skipping invalid Opencode Go model entry")
89+
90+
warnSpy.mockRestore()
91+
})
6792
})
6893

6994
describe("parseOpencodeGoModel", () => {

src/api/providers/fetchers/opencode-go.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ const opencodeGoModelsResponseSchema = z.object({
2929
data: z.array(opencodeGoModelSchema),
3030
})
3131

32+
/**
33+
* Maps a raw Opencode Go model entry to the internal {@link ModelInfo} shape.
34+
*
35+
* Falls back to {@link opencodeGoDefaultModelInfo} when the upstream payload
36+
* omits context-window or max-token fields, ensuring downstream consumers
37+
* always receive a fully-populated object.
38+
*
39+
* @param model - Validated model entry from the `/models` response.
40+
* @returns Normalised model metadata suitable for the model picker.
41+
*/
3242
export const parseOpencodeGoModel = (model: OpencodeGoModel): ModelInfo => ({
3343
maxTokens: model.max_output_tokens ?? model.max_tokens ?? opencodeGoDefaultModelInfo.maxTokens,
3444
contextWindow: model.context_window ?? model.context_length ?? opencodeGoDefaultModelInfo.contextWindow,
@@ -37,6 +47,17 @@ export const parseOpencodeGoModel = (model: OpencodeGoModel): ModelInfo => ({
3747
description: model.description ?? model.name,
3848
})
3949

50+
/**
51+
* Fetches the list of available models from the Opencode Go `/models` endpoint.
52+
*
53+
* The endpoint shape mirrors the OpenAI `/models` response. A permissive Zod
54+
* schema is used so that unknown fields are silently dropped rather than
55+
* causing a hard failure. Invalid entries (e.g. missing `id`) are skipped
56+
* with a console warning rather than propagated to the UI.
57+
*
58+
* @param apiKey - Optional Bearer token for authenticated requests.
59+
* @returns A record mapping model IDs to their normalised {@link ModelInfo}.
60+
*/
4061
export async function getOpencodeGoModels(apiKey?: string): Promise<Record<string, ModelInfo>> {
4162
const models: Record<string, ModelInfo> = {}
4263

@@ -47,16 +68,20 @@ export async function getOpencodeGoModels(apiKey?: string): Promise<Record<strin
4768
})
4869

4970
const result = opencodeGoModelsResponseSchema.safeParse(response.data)
50-
const data = result.success ? result.data.data : (response.data?.data ?? [])
71+
const rawData = result.success ? result.data.data : response.data?.data
72+
const data = Array.isArray(rawData) ? rawData : []
5173

5274
if (!result.success) {
5375
console.error(`Opencode Go models response is invalid: ${JSON.stringify(result.error.format())}`)
5476
}
5577

56-
for (const model of data) {
57-
if (model?.id) {
58-
models[model.id] = parseOpencodeGoModel(model)
78+
for (const rawModel of data) {
79+
const parsed = opencodeGoModelSchema.safeParse(rawModel)
80+
if (!parsed.success) {
81+
console.warn(`Skipping invalid Opencode Go model entry: ${JSON.stringify(rawModel)}`)
82+
continue
5983
}
84+
models[parsed.data.id] = parseOpencodeGoModel(parsed.data)
6085
}
6186
} catch (error) {
6287
console.error(`Error fetching Opencode Go models: ${error instanceof Error ? error.message : String(error)}`)

src/api/providers/opencode-go.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,22 @@ import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from ".
1212
import { RouterProvider } from "./router-provider"
1313

1414
/**
15-
* Opencode "Go" plan — OpenAI-compatible gateway (https://opencode.ai/zen/go/v1).
15+
* API handler for the Opencode "Go" subscription plan.
16+
*
17+
* Routes requests through the OpenAI-compatible gateway at
18+
* `https://opencode.ai/zen/go/v1`, delegating model resolution and streaming
19+
* logic to the shared {@link RouterProvider} base class.
1620
*
1721
* Exposes the Go subscription's models as a first-class provider with a dynamic
1822
* model list (fetched from `/v1/models`) so users can switch models on the fly,
1923
* instead of configuring each one manually as a separate OpenAI-Compatible
2024
* provider (#172).
25+
*
26+
* Supports text generation, reasoning content (GLM/DeepSeek), tool calls,
27+
* and non-streaming prompt completion.
2128
*/
2229
export class OpencodeGoHandler extends RouterProvider implements SingleCompletionHandler {
30+
/** Creates a new handler bound to the user's Go API key and selected model. */
2331
constructor(options: ApiHandlerOptions) {
2432
super({
2533
options,
@@ -32,6 +40,10 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio
3240
})
3341
}
3442

43+
/**
44+
* Streams a chat completion response, yielding typed chunks for text,
45+
* reasoning, partial tool calls, and token usage.
46+
*/
3547
override async *createMessage(
3648
systemPrompt: string,
3749
messages: Anthropic.Messages.MessageParam[],
@@ -96,6 +108,13 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio
96108
}
97109
}
98110

111+
/**
112+
* Performs a non-streaming chat completion and returns the full response text.
113+
*
114+
* @param prompt - The user prompt to send as a single user message.
115+
* @returns The model's reply text, or an empty string if no content is returned.
116+
* @throws Error with an Opencode Go-specific prefix if the request fails.
117+
*/
99118
async completePrompt(prompt: string): Promise<string> {
100119
const { id: modelId, info } = await this.fetchModel()
101120

0 commit comments

Comments
 (0)