From f76b38a03ece5aba33f9c431c2acad7a0405c737 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Sun, 20 Sep 2026 10:39:25 +0900 Subject: [PATCH 1/2] fix(opencode-go): always send x-opencode-session via per-instance session id The Go gateway requires the x-opencode-session header on every request for operator-side session affinity (requests missing it may error). It was only sent when metadata.taskId existed, and completePrompt never sent it at all. Add a uuidv7 per-instance session id on the handler (mirroring the OpenAiNativeHandler fallback pattern) and send x-opencode-session: metadata.taskId ?? sessionId at every request site: chat-completions streaming, Anthropic /v1/messages streaming, Responses streaming, and all three completePrompt paths. Fixes #1511 --- .../providers/__tests__/opencode-go.spec.ts | 185 +++++++++++++----- src/api/providers/opencode-go.ts | 65 +++--- 2 files changed, 173 insertions(+), 77 deletions(-) diff --git a/src/api/providers/__tests__/opencode-go.spec.ts b/src/api/providers/__tests__/opencode-go.spec.ts index 2f859a8c43..96ef3ccf88 100644 --- a/src/api/providers/__tests__/opencode-go.spec.ts +++ b/src/api/providers/__tests__/opencode-go.spec.ts @@ -198,17 +198,16 @@ describe("OpencodeGoHandler", () => { const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] await collectStream(handler.createMessage("sys", messages)) - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: "glm-5.1", - stream: true, - stream_options: { include_usage: true }, - // glm-5.1 maxTokens (131_072) is clamped to 20% of its 204_800 - // context window => 40_960. - max_completion_tokens: 40_960, - temperature: expect.any(Number), - }), - ) + const callArgs = mockCreate.mock.calls[0][0] as Record + expect(callArgs).toMatchObject({ + model: "glm-5.1", + stream: true, + stream_options: { include_usage: true }, + // glm-5.1 maxTokens (131_072) is clamped to 20% of its 204_800 + // context window => 40_960. + max_completion_tokens: 40_960, + temperature: expect.any(Number), + }) }) it("sends the stable conversation ID to chat completions", async () => { @@ -222,18 +221,31 @@ describe("OpencodeGoHandler", () => { }) }) + it("falls back to a stable per-instance session ID when no taskId is provided", async () => { + const handler = new OpencodeGoHandler(mockOptions) + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] + + await collectStream(handler.createMessage("sys", messages)) + + const sessionId = mockCreate.mock.calls[0][1]?.headers["x-opencode-session"] + expect(sessionId).toEqual(expect.any(String)) + + // The same handler instance reuses its session ID across requests. + await collectStream(handler.createMessage("sys", messages)) + expect(mockCreate.mock.calls[1][1]?.headers["x-opencode-session"]).toBe(sessionId) + }) + it("forwards the model's default reasoning_effort for reasoning-capable models", async () => { const handler = new OpencodeGoHandler(mockOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] await collectStream(handler.createMessage("sys", messages)) // glm-5.1 advertises supportsReasoningEffort with a default of "medium". - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: "glm-5.1", - reasoning_effort: "medium", - }), - ) + const callArgs = mockCreate.mock.calls[0][0] as Record + expect(callArgs).toMatchObject({ + model: "glm-5.1", + reasoning_effort: "medium", + }) }) it("omits reasoning_effort when the user disables reasoning", async () => { @@ -383,7 +395,8 @@ describe("OpencodeGoHandler", () => { await collectStream(handler.createMessage("sys", messages)) - expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ max_completion_tokens: 999 })) + const callArgs = mockCreate.mock.calls[0][0] as Record + expect(callArgs.max_completion_tokens).toBe(999) }) }) @@ -392,15 +405,14 @@ describe("OpencodeGoHandler", () => { mockCreate.mockResolvedValue({ choices: [{ message: { content: "the answer" } }] }) const handler = new OpencodeGoHandler(mockOptions) expect(await handler.completePrompt("ping")).toBe("the answer") - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: "glm-5.1", - stream: false, - // glm-5.1 maxTokens (131_072) clamped to 20% of 204_800 => 40_960. - max_completion_tokens: 40_960, - reasoning_effort: "medium", - }), - ) + const callArgs = mockCreate.mock.calls[0][0] as Record + expect(callArgs).toMatchObject({ + model: "glm-5.1", + stream: false, + // glm-5.1 maxTokens (131_072) clamped to 20% of 204_800 => 40_960. + max_completion_tokens: 40_960, + reasoning_effort: "medium", + }) }) it("wraps errors with an Opencode Go-specific message", async () => { @@ -425,7 +437,22 @@ describe("OpencodeGoHandler", () => { mockCreate.mockResolvedValue({ choices: [{ message: { content: "ok" } }] }) const handler = new OpencodeGoHandler({ ...mockOptions, includeMaxTokens: true, modelMaxTokens: 4321 }) await handler.completePrompt("ping") - expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ max_completion_tokens: 4321 })) + const callArgs = mockCreate.mock.calls[0][0] as Record + expect(callArgs.max_completion_tokens).toBe(4321) + }) + + it("sends the per-instance session ID on non-streaming completions", async () => { + mockCreate.mockResolvedValue({ choices: [{ message: { content: "ok" } }] }) + const handler = new OpencodeGoHandler(mockOptions) + + await handler.completePrompt("ping") + const sessionId = mockCreate.mock.calls[0][1]?.headers["x-opencode-session"] + expect(sessionId).toEqual(expect.any(String)) + + // completePrompt has no taskId metadata, so the per-instance ID is + // reused across calls on the same handler. + await handler.completePrompt("ping") + expect(mockCreate.mock.calls[1][1]?.headers["x-opencode-session"]).toBe(sessionId) }) }) @@ -481,13 +508,12 @@ describe("OpencodeGoHandler", () => { await collectStream(handler.createMessage("sys", messages)) - expect(mockAnthropicCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: "qwen3.7-max", - stream: true, - system: expect.arrayContaining([expect.objectContaining({ type: "text", text: "sys" })]), - }), - ) + const callArgs = mockAnthropicCreate.mock.calls[0][0] as Record + expect(callArgs).toMatchObject({ + model: "qwen3.7-max", + stream: true, + system: expect.arrayContaining([expect.objectContaining({ type: "text", text: "sys" })]), + }) // The OpenAI chat completions endpoint must NOT be used for this model. expect(mockCreate).not.toHaveBeenCalled() }) @@ -503,6 +529,20 @@ describe("OpencodeGoHandler", () => { }) }) + it("falls back to a stable per-instance session ID when no taskId is provided", async () => { + const handler = new OpencodeGoHandler(anthropicOptions) + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] + + await collectStream(handler.createMessage("sys", messages)) + + const sessionId = mockAnthropicCreate.mock.calls[0][1]?.headers["x-opencode-session"] + expect(sessionId).toEqual(expect.any(String)) + + // The same handler instance reuses its session ID across requests. + await collectStream(handler.createMessage("sys", messages)) + expect(mockAnthropicCreate.mock.calls[1][1]?.headers["x-opencode-session"]).toBe(sessionId) + }) + it("streams text, tool-call, usage and cost chunks from the Anthropic stream", async () => { const handler = new OpencodeGoHandler(anthropicOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] @@ -569,17 +609,16 @@ describe("OpencodeGoHandler", () => { const handler = new OpencodeGoHandler(anthropicOptions) expect(await handler.completePrompt("ping")).toBe("the answer") - expect(mockAnthropicCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: "qwen3.7-max", - stream: false, - messages: [{ role: "user", content: "ping" }], - // qwen3.7-max maxTokens (65_536) clamped to 20% of its 1M - // context window (200_000) => 65_536. includeMaxTokens is off, - // so the model default is used. - max_tokens: 65_536, - }), - ) + const callArgs = mockAnthropicCreate.mock.calls[0][0] as Record + expect(callArgs).toMatchObject({ + model: "qwen3.7-max", + stream: false, + messages: [{ role: "user", content: "ping" }], + // qwen3.7-max maxTokens (65_536) clamped to 20% of its 1M + // context window (200_000) => 65_536. includeMaxTokens is off, + // so the model default is used. + max_tokens: 65_536, + }) expect(mockCreate).not.toHaveBeenCalled() }) @@ -594,7 +633,21 @@ describe("OpencodeGoHandler", () => { modelMaxTokens: 2048, }) await handler.completePrompt("ping") - expect(mockAnthropicCreate).toHaveBeenCalledWith(expect.objectContaining({ max_tokens: 2048 })) + const callArgs = mockAnthropicCreate.mock.calls[0][0] as Record + expect(callArgs.max_tokens).toBe(2048) + }) + + it("completePrompt sends the per-instance session ID to the Anthropic endpoint", async () => { + mockAnthropicCreate.mockResolvedValue({ + content: [{ type: "text", text: "ok" }], + }) + + const handler = new OpencodeGoHandler(anthropicOptions) + await handler.completePrompt("ping") + + expect(mockAnthropicCreate.mock.calls[0][1]?.headers).toMatchObject({ + "x-opencode-session": expect.any(String), + }) }) it("completePrompt rethrows non-Error values unchanged from the Anthropic path", async () => { @@ -754,7 +807,8 @@ describe("OpencodeGoHandler", () => { await collectStream(handler.createMessage("sys", messages)) - expect(mockAnthropicCreate).toHaveBeenCalledWith(expect.objectContaining({ max_tokens: 8192 })) + const callArgs = mockAnthropicCreate.mock.calls[0][0] as Record + expect(callArgs.max_tokens).toBe(8192) }) it("falls back to the model max_tokens when includeMaxTokens is on but modelMaxTokens is unset", async () => { @@ -764,7 +818,8 @@ describe("OpencodeGoHandler", () => { await collectStream(handler.createMessage("sys", messages)) // qwen3.7-max maxTokens (65_536) clamped to 20% of 1M context => 65_536. - expect(mockAnthropicCreate).toHaveBeenCalledWith(expect.objectContaining({ max_tokens: 65_536 })) + const callArgs = mockAnthropicCreate.mock.calls[0][0] as Record + expect(callArgs.max_tokens).toBe(65_536) }) it("accumulates output tokens across message_delta events into the final cost", async () => { @@ -876,6 +931,22 @@ describe("OpencodeGoHandler", () => { }) }) + it("falls back to a stable per-instance session ID when no taskId is provided", async () => { + const handler = new OpencodeGoHandler(lunaOptions) + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] + + await collectStream(handler.createMessage("sys", messages)) + + const requestOptions = mockResponsesCreate.mock.calls[0][1] as { headers: Record } + const sessionId = requestOptions.headers["x-opencode-session"] + expect(sessionId).toEqual(expect.any(String)) + + // The same handler instance reuses its session ID across requests. + await collectStream(handler.createMessage("sys", messages)) + const secondOptions = mockResponsesCreate.mock.calls[1][1] as { headers: Record } + expect(secondOptions.headers["x-opencode-session"]).toBe(sessionId) + }) + it("closes the Responses iterator when the consumer stops early", async () => { const iterator = { next: vitest.fn().mockResolvedValueOnce({ @@ -1343,10 +1414,24 @@ describe("OpencodeGoHandler", () => { await handler.completePrompt("ping", { abortSignal: controller.signal }) - expect(mockResponsesCreate.mock.calls[0][1]).toEqual({ signal: controller.signal }) + expect(mockResponsesCreate.mock.calls[0][1]).toEqual({ + signal: controller.signal, + headers: { "x-opencode-session": expect.any(String) }, + }) expect(mockCreate).not.toHaveBeenCalled() }) + it("completePrompt sends the per-instance session ID to the Responses endpoint", async () => { + mockResponsesCreate.mockResolvedValue({ output_text: "Hello!" }) + const handler = new OpencodeGoHandler(lunaOptions) + + await handler.completePrompt("ping") + + expect(mockResponsesCreate.mock.calls[0][1]?.headers).toMatchObject({ + "x-opencode-session": expect.any(String), + }) + }) + it("completePrompt wraps errors with an Opencode Go-specific message", async () => { mockResponsesCreate.mockRejectedValue(new Error("boom")) const handler = new OpencodeGoHandler(lunaOptions) diff --git a/src/api/providers/opencode-go.ts b/src/api/providers/opencode-go.ts index 46ea52a2be..95b13afbb9 100644 --- a/src/api/providers/opencode-go.ts +++ b/src/api/providers/opencode-go.ts @@ -1,3 +1,4 @@ +import { v7 as uuidv7 } from "uuid" import { Anthropic } from "@anthropic-ai/sdk" import { CacheControlEphemeral } from "@anthropic-ai/sdk/resources" import OpenAI from "openai" @@ -98,6 +99,13 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio */ private readonly anthropicClient: Anthropic + /** + * Stable per-handler session ID sent as `x-opencode-session` when no + * `metadata.taskId` is available, so every request carries operator-side + * session affinity (mirrors the OpenAiNativeHandler fallback pattern). + */ + private readonly sessionId: string + /** Creates a new handler bound to the user's Go API key and selected model. */ constructor(options: ApiHandlerOptions) { super({ @@ -110,6 +118,8 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio defaultModelInfo: opencodeGoDefaultModelInfo, }) + this.sessionId = uuidv7() + this.anthropicClient = new Anthropic({ baseURL: "https://opencode.ai/zen/go", apiKey: options.opencodeGoApiKey, @@ -247,11 +257,9 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio }), } - const completion = metadata?.taskId - ? await this.client.chat.completions.create(body, { - headers: { "x-opencode-session": metadata.taskId }, - }) - : await this.client.chat.completions.create(body) + const completion = await this.client.chat.completions.create(body, { + headers: { "x-opencode-session": metadata?.taskId ?? this.sessionId }, + }) for await (const chunk of completion) { const delta = chunk.choices[0]?.delta @@ -384,7 +392,7 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio try { stream = await this.client.responses.create(requestBody, { signal: metadata?.abortSignal, - headers: metadata?.taskId ? { "x-opencode-session": metadata.taskId } : undefined, + headers: { "x-opencode-session": metadata?.taskId ?? this.sessionId }, }) } catch (error) { if (error instanceof Error) { @@ -512,11 +520,9 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio // errors propagate unchanged, matching the OpenAI streaming path. let stream try { - stream = metadata?.taskId - ? await this.anthropicClient.messages.create(requestParams, { - headers: { "x-opencode-session": metadata.taskId }, - }) - : await this.anthropicClient.messages.create(requestParams) + stream = await this.anthropicClient.messages.create(requestParams, { + headers: { "x-opencode-session": metadata?.taskId ?? this.sessionId }, + }) } catch (error) { if (error instanceof Error) { throw new Error(`Opencode Go completion error: ${error.message}`) @@ -702,20 +708,23 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio if (format === "anthropic") { try { - const message = await this.anthropicClient.messages.create({ - model: modelId, - // Honour the same includeMaxTokens/modelMaxTokens override - // logic as the streaming path so non-streaming completions - // respect the user's max-output slider instead of always - // falling back to the model default. - max_tokens: - this.options.includeMaxTokens === true - ? this.options.modelMaxTokens || maxTokens || 16_384 - : (maxTokens ?? 16_384), - temperature: this.supportsTemperature(modelId) ? (temperature ?? 1.0) : undefined, - messages: [{ role: "user", content: prompt }], - stream: false, - }) + const message = await this.anthropicClient.messages.create( + { + model: modelId, + // Honour the same includeMaxTokens/modelMaxTokens override + // logic as the streaming path so non-streaming completions + // respect the user's max-output slider instead of always + // falling back to the model default. + max_tokens: + this.options.includeMaxTokens === true + ? this.options.modelMaxTokens || maxTokens || 16_384 + : (maxTokens ?? 16_384), + temperature: this.supportsTemperature(modelId) ? (temperature ?? 1.0) : undefined, + messages: [{ role: "user", content: prompt }], + stream: false, + }, + { headers: { "x-opencode-session": this.sessionId } }, + ) const content = message.content.find(({ type }) => type === "text") return content?.type === "text" ? content.text : "" @@ -756,7 +765,7 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio } : {}), }, - { signal: options?.abortSignal }, + { signal: options?.abortSignal, headers: { "x-opencode-session": this.sessionId } }, ) return response.output_text || "" } catch (error) { @@ -786,7 +795,9 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio reasoningEffort as OpenAI.Chat.ChatCompletionCreateParams["reasoning_effort"] } - const response = await this.client.chat.completions.create(requestOptions) + const response = await this.client.chat.completions.create(requestOptions, { + headers: { "x-opencode-session": this.sessionId }, + }) return response.choices[0]?.message.content || "" } catch (error) { if (error instanceof Error) { From 1d04f1d9d01dad097d7c1a2f4af83e9a907ca1a4 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Mon, 21 Sep 2026 01:16:30 +0900 Subject: [PATCH 2/2] test(opencode-go): cover completePrompt max_tokens fallback and trim diff churn The codecov patch partials were not the streaming header fallbacks (those arms are all exercised by the metadata-absent createMessage tests): they were lines 720-722, pre-existing conditionals that became patch lines because the completePrompt Anthropic restructure re-indented the untouched params object. - Send the session header on the Anthropic client's defaultHeaders instead of a per-request option in completePrompt (per-request headers still override it with taskId when streaming), so the completePrompt params block stays byte-identical to main and out of the diff. The supportsTemperature false arm there is unreachable for Anthropic-format Go models by design. - Add a completePrompt test for the includeMaxTokens-on / modelMaxTokens-unset max_tokens fallback, mirroring the streaming test of the same name, and assert the defaultHeaders session header. Patch coverage of opencode-go.ts vs base is now 100% with no untaken branch arms on added lines. --- .../providers/__tests__/opencode-go.spec.ts | 26 +++++++++++--- src/api/providers/opencode-go.ts | 35 ++++++++++--------- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/src/api/providers/__tests__/opencode-go.spec.ts b/src/api/providers/__tests__/opencode-go.spec.ts index 96ef3ccf88..0d4a65f24a 100644 --- a/src/api/providers/__tests__/opencode-go.spec.ts +++ b/src/api/providers/__tests__/opencode-go.spec.ts @@ -637,17 +637,35 @@ describe("OpencodeGoHandler", () => { expect(callArgs.max_tokens).toBe(2048) }) - it("completePrompt sends the per-instance session ID to the Anthropic endpoint", async () => { + it("completePrompt falls back to the model max_tokens when includeMaxTokens is on but modelMaxTokens is unset", async () => { mockAnthropicCreate.mockResolvedValue({ content: [{ type: "text", text: "ok" }], }) - const handler = new OpencodeGoHandler(anthropicOptions) + const handler = new OpencodeGoHandler({ ...anthropicOptions, includeMaxTokens: true }) await handler.completePrompt("ping") + const callArgs = mockAnthropicCreate.mock.calls[0][0] as Record + // qwen3.7-max maxTokens (65_536) clamped to 20% of 1M context => 65_536. + expect(callArgs.max_tokens).toBe(65_536) + }) - expect(mockAnthropicCreate.mock.calls[0][1]?.headers).toMatchObject({ - "x-opencode-session": expect.any(String), + it("completePrompt sends the per-instance session ID via the Anthropic client default headers", async () => { + mockAnthropicCreate.mockResolvedValue({ + content: [{ type: "text", text: "ok" }], }) + + const handler = new OpencodeGoHandler(anthropicOptions) + await handler.completePrompt("ping") + + // completePrompt passes no per-request options; the session header + // is a default header on the Anthropic client (streaming overrides + // it with taskId when available). + expect(mockAnthropicCreate.mock.calls[0][1]).toBeUndefined() + expect(Anthropic).toHaveBeenCalledWith( + expect.objectContaining({ + defaultHeaders: expect.objectContaining({ "x-opencode-session": expect.any(String) }), + }), + ) }) it("completePrompt rethrows non-Error values unchanged from the Anthropic path", async () => { diff --git a/src/api/providers/opencode-go.ts b/src/api/providers/opencode-go.ts index 95b13afbb9..3c17ad8319 100644 --- a/src/api/providers/opencode-go.ts +++ b/src/api/providers/opencode-go.ts @@ -126,6 +126,10 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio defaultHeaders: { ...DEFAULT_HEADERS, ...(options.openAiHeaders || {}), + // completePrompt has no taskId metadata, so the per-handler + // session id provides operator-side session affinity here; + // streaming requests override it with taskId when available. + "x-opencode-session": this.sessionId, }, }) } @@ -708,23 +712,20 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio if (format === "anthropic") { try { - const message = await this.anthropicClient.messages.create( - { - model: modelId, - // Honour the same includeMaxTokens/modelMaxTokens override - // logic as the streaming path so non-streaming completions - // respect the user's max-output slider instead of always - // falling back to the model default. - max_tokens: - this.options.includeMaxTokens === true - ? this.options.modelMaxTokens || maxTokens || 16_384 - : (maxTokens ?? 16_384), - temperature: this.supportsTemperature(modelId) ? (temperature ?? 1.0) : undefined, - messages: [{ role: "user", content: prompt }], - stream: false, - }, - { headers: { "x-opencode-session": this.sessionId } }, - ) + const message = await this.anthropicClient.messages.create({ + model: modelId, + // Honour the same includeMaxTokens/modelMaxTokens override + // logic as the streaming path so non-streaming completions + // respect the user's max-output slider instead of always + // falling back to the model default. + max_tokens: + this.options.includeMaxTokens === true + ? this.options.modelMaxTokens || maxTokens || 16_384 + : (maxTokens ?? 16_384), + temperature: this.supportsTemperature(modelId) ? (temperature ?? 1.0) : undefined, + messages: [{ role: "user", content: prompt }], + stream: false, + }) const content = message.content.find(({ type }) => type === "text") return content?.type === "text" ? content.text : ""