diff --git a/src/api/providers/__tests__/opencode-go.spec.ts b/src/api/providers/__tests__/opencode-go.spec.ts index 2f859a8c43..0d4a65f24a 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,39 @@ 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 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, 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) + }) + + 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 () => { @@ -754,7 +825,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 +836,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 +949,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 +1432,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..3c17ad8319 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,12 +118,18 @@ 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, 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, }, }) } @@ -247,11 +261,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 +396,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 +524,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}`) @@ -756,7 +766,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 +796,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) {