From c08c3c0f19499ed2098db31926b2cdfecd923968 Mon Sep 17 00:00:00 2001 From: Anthony Gallon <64985097+AntzCode@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:38:41 +1200 Subject: [PATCH 1/2] [Fix] Opencode Go routes gpt-5.6-luna through /v1/responses Routes gpt-5.6-luna through the Responses API required by the Opencode Go gateway. Fixes #1431 --- .../types/src/__tests__/opencode-go.test.ts | 54 ++++ packages/types/src/providers/opencode-go.ts | 70 ++++- .../providers/__tests__/opencode-go.spec.ts | 296 +++++++++++++++++- src/api/providers/opencode-go.ts | 279 +++++++++++++++-- 4 files changed, 663 insertions(+), 36 deletions(-) diff --git a/packages/types/src/__tests__/opencode-go.test.ts b/packages/types/src/__tests__/opencode-go.test.ts index adacbe274c..c65de6165c 100644 --- a/packages/types/src/__tests__/opencode-go.test.ts +++ b/packages/types/src/__tests__/opencode-go.test.ts @@ -4,7 +4,9 @@ import { opencodeGoModels, OPENCODE_GO_DEFAULT_TEMPERATURE, OPENCODE_GO_ANTHROPIC_FORMAT_MODELS, + OPENCODE_GO_RESPONSES_FORMAT_MODELS, isOpencodeGoAnthropicFormatModel, + isOpencodeGoResponsesFormatModel, getOpencodeGoModelInfo, } from "../providers/opencode-go.js" @@ -129,6 +131,58 @@ describe("opencode-go registry", () => { }) }) + describe("OPENCODE_GO_RESPONSES_FORMAT_MODELS", () => { + it("contains exactly the Responses-only models", () => { + expect([...OPENCODE_GO_RESPONSES_FORMAT_MODELS].sort()).toEqual(["gpt-5.6-luna"]) + }) + + it("classifies gpt-5.6-luna as Responses-format", () => { + expect(isOpencodeGoResponsesFormatModel("gpt-5.6-luna")).toBe(true) + }) + + it("classifies Anthropic-format and OpenAI-compatible models as non-Responses-format", () => { + for (const id of anthropicFormatModels) { + expect(isOpencodeGoResponsesFormatModel(id)).toBe(false) + } + for (const id of openaiFormatModels) { + expect(isOpencodeGoResponsesFormatModel(id)).toBe(false) + } + }) + + it("defaults unknown model IDs to the OpenAI-compatible format", () => { + expect(isOpencodeGoResponsesFormatModel("some-future-model")).toBe(false) + expect(isOpencodeGoResponsesFormatModel("")).toBe(false) + }) + + it("curates gpt-5.6-luna with its Go Responses capabilities", () => { + expect(getOpencodeGoModelInfo("gpt-5.6-luna")).toMatchObject({ + maxTokens: 128_000, + contextWindow: 1_050_000, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["none", "low", "medium", "high", "xhigh", "max"], + reasoningEffort: "medium", + inputPrice: 0.2, + outputPrice: 1.2, + cacheWritesPrice: 0.25, + cacheReadsPrice: 0.02, + longContextPricing: { + thresholdTokens: 272_000, + inputPriceMultiplier: 2, + outputPriceMultiplier: 1.5, + cacheWritesPriceMultiplier: 2, + cacheReadsPriceMultiplier: 2, + }, + }) + }) + + it("is disjoint from the Anthropic-format set", () => { + for (const id of OPENCODE_GO_RESPONSES_FORMAT_MODELS) { + expect(OPENCODE_GO_ANTHROPIC_FORMAT_MODELS.has(id)).toBe(false) + } + }) + }) + describe("opencodeGoModels registry invariants", () => { it("every entry has a positive maxTokens and contextWindow", () => { for (const [id, info] of Object.entries(opencodeGoModels)) { diff --git a/packages/types/src/providers/opencode-go.ts b/packages/types/src/providers/opencode-go.ts index a7ae0de259..01497e7506 100644 --- a/packages/types/src/providers/opencode-go.ts +++ b/packages/types/src/providers/opencode-go.ts @@ -34,10 +34,11 @@ export const OPENCODE_GO_DEFAULT_TEMPERATURE = 0 * This registry encodes the native capabilities of each curated Go model, * sourced from the same vendor specs used by the dedicated providers * (zai/moonshot/mimo/minimax/deepseek/qwen) and the Go pricing table at - * https://opencode.ai/docs/go/#usage-limits. The fetcher merges the live - * `/models` payload on top of these defaults so that context-window and - * max-token values stay in sync with the gateway while capability flags and - * pricing remain correct. + * https://opencode.ai/docs/go/#usage-limits. It also includes explicitly + * curated Responses-format models whose gateway metadata is not available + * through `/models`. The fetcher merges the live `/models` payload on top of + * these defaults so that context-window and max-token values stay in sync + * with the gateway while capability flags and pricing remain correct. * * `supportsPromptCache` has two distinct meanings depending on the wire format: * @@ -343,6 +344,31 @@ export const opencodeGoModels: Record = { description: "DeepSeek-V4-Pro-0813 is DeepSeek's strongest V4 model for reasoning, coding, long-context, and agentic workloads. Available via the Opencode Go plan.", }, + // --- OpenAI Responses --- + // Luna is curated here because the Go gateway's model catalogue does not + // currently provide its capability metadata. These values intentionally + // describe the Go Responses route, not the OpenAI-native or Codex routes. + "gpt-5.6-luna": { + maxTokens: 128_000, + contextWindow: 1_050_000, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["none", "low", "medium", "high", "xhigh", "max"], + reasoningEffort: "medium", + inputPrice: 0.2, + outputPrice: 1.2, + cacheWritesPrice: 0.25, + cacheReadsPrice: 0.02, + longContextPricing: { + thresholdTokens: 272_000, + inputPriceMultiplier: 2, + outputPriceMultiplier: 1.5, + cacheWritesPriceMultiplier: 2, + cacheReadsPriceMultiplier: 2, + }, + description: "GPT-5.6 Luna via the OpenCode Go Responses API.", + }, + "deepseek-v4-flash": { maxTokens: 384_000, contextWindow: 1_000_000, @@ -388,16 +414,48 @@ export const OPENCODE_GO_ANTHROPIC_FORMAT_MODELS = new Set([ "minimax-m2.5", ]) +/** + * OpenCode Go models that must be requested via the OpenAI Responses API + * (`/v1/responses`), not the OpenAI-compatible Chat Completions endpoint + * (`/v1/chat/completions`). + * + * The Go gateway maps every model to exactly one wire format. Some models + * (currently only `gpt-5.6-luna`) are Responses-only and are also explicitly + * curated in `opencodeGoModels`: the gateway's + * `/v1/chat/completions` adapter for them fails with an opaque HTTP 500 + * (`{"type":"error","error":{"type":"error","message":"Internal server error"}}`), + * while `/v1/responses` succeeds (Zoo-Code-Org/Zoo-Code#1431). + * + * Drive routing from this set rather than from the model ID string so the + * gateway's protocol contract stays explicit, testable, and easy to extend + * when the next Responses-only model lands. Unknown model IDs default to the + * OpenAI-compatible chat completions format. + */ +export const OPENCODE_GO_RESPONSES_FORMAT_MODELS = new Set([ + // --- OpenAI --- + "gpt-5.6-luna", +]) + /** * Returns `true` when the given Go-plan model ID must be requested via the * Anthropic Messages format (`/v1/messages`) rather than the OpenAI-compatible - * chat completions format. Unknown (non-curated) model IDs default to the - * OpenAI-compatible format, matching the gateway's default routing. + * chat completions format. Unknown model IDs default to the OpenAI-compatible + * format, matching the gateway's default routing. */ export function isOpencodeGoAnthropicFormatModel(modelId: string): boolean { return OPENCODE_GO_ANTHROPIC_FORMAT_MODELS.has(modelId) } +/** + * Returns `true` when the given Go-plan model ID must be requested via the + * OpenAI Responses API (`/v1/responses`) rather than the OpenAI-compatible + * chat completions format. Unknown model IDs default to the OpenAI-compatible + * format, matching the gateway's default routing. + */ +export function isOpencodeGoResponsesFormatModel(modelId: string): boolean { + return OPENCODE_GO_RESPONSES_FORMAT_MODELS.has(modelId) +} + /** * Returns the native {@link ModelInfo} for a Go-plan model ID, or `undefined` * when the ID is not part of the curated registry. Callers should fall back to diff --git a/src/api/providers/__tests__/opencode-go.spec.ts b/src/api/providers/__tests__/opencode-go.spec.ts index 75ad5ff077..036cd71c16 100644 --- a/src/api/providers/__tests__/opencode-go.spec.ts +++ b/src/api/providers/__tests__/opencode-go.spec.ts @@ -12,7 +12,12 @@ vitest.mock("vscode", () => ({ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { opencodeGoDefaultModelId, opencodeGoModels, isOpencodeGoAnthropicFormatModel } from "@roo-code/types" +import { + opencodeGoDefaultModelId, + opencodeGoModels, + isOpencodeGoAnthropicFormatModel, + isOpencodeGoResponsesFormatModel, +} from "@roo-code/types" import { OpencodeGoHandler } from "../opencode-go" import { getModels } from "../fetchers/modelCache" @@ -34,12 +39,15 @@ vitest.mock("../fetchers/modelCache", () => ({ "glm-5.1": { ...opencodeGoModels["glm-5.1"] }, // Anthropic-format model used to exercise the /v1/messages path. "qwen3.7-max": { ...opencodeGoModels["qwen3.7-max"] }, + // Responses-format model (Zoo-Code-Org/Zoo-Code#1431). + "gpt-5.6-luna": { ...opencodeGoModels["gpt-5.6-luna"] }, }) }), refreshModels: vitest.fn().mockImplementation(function () { return Promise.resolve({ "glm-5.1": { ...opencodeGoModels["glm-5.1"] }, "qwen3.7-max": { ...opencodeGoModels["qwen3.7-max"] }, + "gpt-5.6-luna": { ...opencodeGoModels["gpt-5.6-luna"] }, }) }), getModelsFromCache: vitest.fn().mockReturnValue(undefined), @@ -47,10 +55,12 @@ vitest.mock("../fetchers/modelCache", () => ({ const mockCreate = vitest.fn() const mockAnthropicCreate = vitest.fn() +const mockResponsesCreate = vitest.fn() ;(OpenAI as any).mockImplementation(function () { return { chat: { completions: { create: mockCreate } }, + responses: { create: mockResponsesCreate }, } }) @@ -74,6 +84,7 @@ describe("OpencodeGoHandler", () => { clearAllMocks() mockCreate.mockClear() mockAnthropicCreate.mockClear() + mockResponsesCreate.mockClear() }) it("initializes the OpenAI client with the Opencode Go base URL and key", () => { @@ -797,6 +808,289 @@ describe("OpencodeGoHandler", () => { }) }) + describe("Responses-format models (gpt-5.6-luna)", () => { + // gpt-5.6-luna is Responses-only on the Go gateway: its chat-completions + // adapter fails with an opaque HTTP 500 (Zoo-Code-Org/Zoo-Code#1431), + // so the handler must route it through /v1/responses and never fall + // back to chat completions. + const lunaOptions: ApiHandlerOptions = { + opencodeGoApiKey: "test-key", + opencodeGoModelId: "gpt-5.6-luna", + } + + beforeEach(() => { + mockResponsesCreate.mockImplementation(async () => + asyncStreamFrom([ + { type: "response.output_text.delta", delta: "Hello" }, + { type: "response.reasoning_summary_text.delta", delta: "thinking" }, + { + type: "response.completed", + response: { + usage: { + input_tokens: 10, + output_tokens: 5, + }, + }, + }, + ]), + ) + }) + + it("routes the request through responses.create, not chat completions or Anthropic messages", async () => { + const handler = new OpencodeGoHandler(lunaOptions) + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] + + await collectStream(handler.createMessage("sys", messages)) + + expect(mockResponsesCreate).toHaveBeenCalledTimes(1) + expect(mockCreate).not.toHaveBeenCalled() + expect(mockAnthropicCreate).not.toHaveBeenCalled() + }) + + it("streams text and reasoning chunks from the Responses event stream", async () => { + const handler = new OpencodeGoHandler(lunaOptions) + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] + + const chunks = await collectStream(handler.createMessage("sys", messages)) + + expect(chunks).toContainEqual({ type: "text", text: "Hello" }) + expect(chunks).toContainEqual({ type: "reasoning", text: "thinking" }) + }) + + it("sends the system prompt as top-level instructions with stream/store flags", async () => { + const handler = new OpencodeGoHandler(lunaOptions) + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] + + await collectStream(handler.createMessage("sys", messages)) + + const callArgs = mockResponsesCreate.mock.calls[0][0] as Record + expect(callArgs.model).toBe("gpt-5.6-luna") + expect(callArgs.instructions).toBe("sys") + expect(callArgs.stream).toBe(true) + expect(callArgs.store).toBe(false) + const input = callArgs.input as unknown[] + expect(input.some((item) => (item as { role?: string }).role === "system")).toBe(false) + // The gateway rejects temperature for Responses-format models. + expect(callArgs.temperature).toBeUndefined() + // No tools were provided, so no tools/tool_choice are sent. + expect(callArgs.tools).toBeUndefined() + }) + + it("converts messages to the Responses input shape for a tool_use/tool_result round-trip", async () => { + const handler = new OpencodeGoHandler(lunaOptions) + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "List the files" }, + { + role: "assistant", + content: [{ type: "tool_use", id: "toolu_1", name: "read_file", input: { path: "a.ts" } }], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "file contents" }], + }, + ] + + await collectStream(handler.createMessage("sys", messages)) + + const callArgs = mockResponsesCreate.mock.calls[0][0] as Record + expect(callArgs.input).toEqual([ + { role: "user", content: [{ type: "input_text", text: "List the files" }] }, + { type: "function_call", call_id: "toolu_1", name: "read_file", arguments: '{"path":"a.ts"}' }, + { type: "function_call_output", call_id: "toolu_1", output: "file contents" }, + ]) + }) + + it("flattens Chat Completions-shaped tools into Responses function tools", async () => { + const handler = new OpencodeGoHandler(lunaOptions) + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] + const tools: OpenAI.Chat.ChatCompletionTool[] = [ + { + type: "function", + function: { + name: "read_file", + description: "read a file", + parameters: { type: "object", properties: { path: { type: "string" } } }, + }, + }, + ] + + await collectStream(handler.createMessage("sys", messages, { taskId: "test-task", tools })) + + const callArgs = mockResponsesCreate.mock.calls[0][0] as Record + expect(callArgs.tools).toEqual([ + { + type: "function", + name: "read_file", + description: "read a file", + parameters: expect.objectContaining({ + type: "object", + additionalProperties: false, + required: ["path"], + }), + strict: true, + }, + ]) + expect(callArgs.tool_choice).toBe("auto") + expect(callArgs.parallel_tool_calls).toBe(true) + }) + + it("streams tool-call partials and emits unstreamed calls from output_item.done", async () => { + mockResponsesCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { + type: "response.function_call_arguments.delta", + call_id: "call_1", + name: "read_file", + delta: '{"path":', + index: 0, + }, + { + type: "response.output_item.done", + item: { + type: "function_call", + call_id: "call_1", + name: "read_file", + arguments: '{"path":"a.ts"}', + }, + }, + { + type: "response.output_item.done", + item: { type: "function_call", call_id: "call_2", name: "list_files", arguments: "{}" }, + }, + ]), + ) + + const handler = new OpencodeGoHandler(lunaOptions) + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] + + const chunks = await collectStream(handler.createMessage("sys", messages)) + + const partials = chunks.filter((c) => c.type === "tool_call_partial") + expect(partials).toHaveLength(1) + expect(partials[0]).toMatchObject({ id: "call_1", name: "read_file", arguments: '{"path":' }) + + // call_1 was streamed via deltas, so output_item.done must not + // duplicate it; call_2 only appeared in output_item.done. + const completes = chunks.filter((c) => c.type === "tool_call") + expect(completes).toHaveLength(1) + expect(completes[0]).toMatchObject({ id: "call_2", name: "list_files", arguments: "{}" }) + }) + + it("emits a usage chunk with cache tokens and cost from response.completed", async () => { + mockResponsesCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { + type: "response.completed", + response: { + usage: { + input_tokens: 100, + output_tokens: 50, + input_tokens_details: { cached_tokens: 40 }, + output_tokens_details: { reasoning_tokens: 20 }, + }, + }, + }, + ]), + ) + + const handler = new OpencodeGoHandler(lunaOptions) + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] + + const chunks = await collectStream(handler.createMessage("sys", messages)) + + const usageChunk = chunks.find((c) => c.type === "usage") + if (!usageChunk || usageChunk.type !== "usage") { + throw new Error("Expected usage chunk") + } + expect(usageChunk.inputTokens).toBe(100) + expect(usageChunk.outputTokens).toBe(50) + expect(usageChunk.cacheReadTokens).toBe(40) + expect(usageChunk.reasoningTokens).toBe(20) + // Luna Go pricing: https://opencode.ai/docs/zen/#pricing + // input $0.20/M, output $1.20/M, cache reads $0.02/M. + // 60 non-cached input + 40 cached reads + 50 output tokens (under 272k). + expect(usageChunk.totalCost).toBeCloseTo((60 * 0.2 + 40 * 0.02 + 50 * 1.2) / 1_000_000, 10) + }) + + it("maps the model default reasoning effort to reasoning.effort", async () => { + const handler = new OpencodeGoHandler(lunaOptions) + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] + + await collectStream(handler.createMessage("sys", messages)) + + const callArgs = mockResponsesCreate.mock.calls[0][0] as Record + expect(callArgs.reasoning).toEqual({ effort: "medium" }) + }) + + it("omits reasoning when the user disables reasoning effort", async () => { + const handler = new OpencodeGoHandler({ ...lunaOptions, reasoningEffort: "disable" }) + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] + + await collectStream(handler.createMessage("sys", messages)) + + const callArgs = mockResponsesCreate.mock.calls[0][0] as Record + expect(callArgs.reasoning).toBeUndefined() + }) + + it("maps max tokens to max_output_tokens (GPT-5 models bypass the 20% clamp)", async () => { + const handler = new OpencodeGoHandler(lunaOptions) + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] + + await collectStream(handler.createMessage("sys", messages)) + + const callArgs = mockResponsesCreate.mock.calls[0][0] as Record + expect(callArgs.max_output_tokens).toBe(128_000) + }) + + it("honors includeMaxTokens/modelMaxTokens override for max_output_tokens", async () => { + const handler = new OpencodeGoHandler({ ...lunaOptions, includeMaxTokens: true, modelMaxTokens: 5_000 }) + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] + + await collectStream(handler.createMessage("sys", messages)) + + const callArgs = mockResponsesCreate.mock.calls[0][0] as Record + expect(callArgs.max_output_tokens).toBe(5_000) + }) + + it("completePrompt calls responses.create and returns output_text", async () => { + mockResponsesCreate.mockResolvedValue({ output_text: "Hello!" }) + const handler = new OpencodeGoHandler(lunaOptions) + + const result = await handler.completePrompt("ping") + + expect(result).toBe("Hello!") + const callArgs = mockResponsesCreate.mock.calls[0][0] as Record + expect(callArgs.model).toBe("gpt-5.6-luna") + expect(callArgs.store).toBe(false) + // completePrompt has no system prompt, so no instructions are sent. + expect(callArgs.instructions).toBeUndefined() + expect(callArgs.input).toEqual([{ role: "user", content: [{ type: "input_text", text: "ping" }] }]) + expect(callArgs.temperature).toBeUndefined() + }) + + it("completePrompt wraps errors with an Opencode Go-specific message", async () => { + mockResponsesCreate.mockRejectedValue(new Error("boom")) + const handler = new OpencodeGoHandler(lunaOptions) + await expect(handler.completePrompt("ping")).rejects.toThrow("Opencode Go completion error: boom") + }) + + it("wraps pre-stream responses.create errors from createMessage with an Opencode Go-specific message", async () => { + mockResponsesCreate.mockRejectedValue(new Error("internal server error")) + const handler = new OpencodeGoHandler(lunaOptions) + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] + await expect(async () => { + await collectStream(handler.createMessage("sys", messages)) + }).rejects.toThrow("Opencode Go completion error: internal server error") + }) + + it("classifies gpt-5.6-luna as Responses-format and other models as not", () => { + expect(isOpencodeGoResponsesFormatModel("gpt-5.6-luna")).toBe(true) + expect(isOpencodeGoResponsesFormatModel("glm-5.3")).toBe(false) + expect(isOpencodeGoResponsesFormatModel("qwen3.7-max")).toBe(false) + expect(isOpencodeGoResponsesFormatModel("some-unknown-model")).toBe(false) + }) + }) + describe("isOpencodeGoAnthropicFormatModel", () => { it("classifies Qwen and MiniMax Go models as Anthropic-format", () => { expect(isOpencodeGoAnthropicFormatModel("qwen3.7-max")).toBe(true) diff --git a/src/api/providers/opencode-go.ts b/src/api/providers/opencode-go.ts index 9456ac8fdb..2c79924632 100644 --- a/src/api/providers/opencode-go.ts +++ b/src/api/providers/opencode-go.ts @@ -4,31 +4,44 @@ import OpenAI from "openai" import { type ModelInfo, + type ReasoningEffortExtended, opencodeGoDefaultModelId, opencodeGoDefaultModelInfo, OPENCODE_GO_DEFAULT_TEMPERATURE, isOpencodeGoAnthropicFormatModel, + isOpencodeGoResponsesFormatModel, providerIdentifiers, } from "@roo-code/types" import { ApiHandlerOptions } from "../../shared/api" -import { ApiStream } from "../transform/stream" +import { ApiStream, type ApiStreamUsageChunk } from "../transform/stream" import { convertToOpenAiMessages } from "../transform/openai-format" import { convertToR1Format } from "../transform/r1-format" import { filterNonAnthropicBlocks } from "../transform/anthropic-filter" import { getModelParams } from "../transform/model-params" +import { convertToResponsesApiInput } from "../transform/responses-api-input" +import { processResponsesApiStream, createUsageNormalizer } from "../transform/responses-api-stream" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" import { RouterProvider } from "./router-provider" import { extractReasoningFromDelta } from "./utils/extract-reasoning" import { DEFAULT_HEADERS } from "./constants" -import { calculateApiCostAnthropic } from "../../shared/cost" +import { calculateApiCostAnthropic, calculateApiCostOpenAI } from "../../shared/cost" import { convertOpenAIToolsToAnthropic, convertOpenAIToolChoiceToAnthropic, } from "../../core/prompts/tools/native-tools/converters" +/** + * The wire formats exposed by the Opencode Go gateway: + * + * - `anthropic` — Anthropic Messages (`/v1/messages`) + * - `responses` — OpenAI Responses (`/v1/responses`) + * - `openai` — OpenAI Chat Completions (`/v1/chat/completions`, the default) + */ +type OpencodeGoFormat = "anthropic" | "openai" | "responses" + /** * API handler for the Opencode "Go" subscription plan. * @@ -50,19 +63,26 @@ import { * * ## Wire-format routing * - * The Go gateway exposes two wire formats and maps every model to exactly one - * of them (see https://opencode.ai/docs/go): + * The Go gateway exposes three wire formats and maps every model to exactly + * one of them (see https://opencode.ai/docs/go): * * - OpenAI-compatible chat completions (`/v1/chat/completions`, "oa-compat") * — used by GLM, Kimi, DeepSeek, and MiMo models. * - Anthropic Messages (`/v1/messages`) — used by Qwen (qwen3.8-max, * qwen3.7-max, qwen3.7-plus, qwen3.6-plus) and MiniMax (minimax-m3, * minimax-m2.7, minimax-m2.5) models. + * - OpenAI Responses (`/v1/responses`) — used by gpt-5.6-luna, whose + * chat-completions adapter fails with an opaque HTTP 500 + * (Zoo-Code-Org/Zoo-Code#1431). * * Sending an Anthropic-format model to the chat completions endpoint is * rejected with `401 Model is not supported for format oa-compat`, so this * handler inspects {@link isOpencodeGoAnthropicFormatModel} and routes those * models through a dedicated Anthropic SDK client against `/v1/messages`. + * Responses-format models are identified via + * {@link isOpencodeGoResponsesFormatModel} and routed through the shared OpenAI + * client's `responses` endpoint; there is deliberately no chat-completions + * fallback for them. * * Supports text generation, reasoning content (GLM/DeepSeek), tool calls, * and non-streaming prompt completion. @@ -104,10 +124,14 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio * Resolves the configured model and computes model parameters * (max tokens, temperature, reasoning effort) from the merged model info. * - * The wire format is derived from the model ID via - * {@link isOpencodeGoAnthropicFormatModel}: Anthropic-format models compute - * parameters with the `anthropic` format so reasoning is mapped to the - * Anthropic-style controls; everything else uses the `openai` format. + * The wire format is derived from the model ID via the format registries in + * `@roo-code/types`: Anthropic-format models + * ({@link isOpencodeGoAnthropicFormatModel}) compute parameters with the + * `anthropic` format so reasoning is mapped to the Anthropic-style controls; + * Responses-format models ({@link isOpencodeGoResponsesFormatModel}) and + * everything else use the `openai` format, whose `reasoningEffort` maps to + * `reasoning.effort` on the Responses wire and `reasoning_effort` on Chat + * Completions. * * Fetches the live model list first so the merged native + `/models` * metadata (context window, capability flags, pricing) is available before @@ -115,29 +139,38 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio */ private async resolveModel() { const { id, info } = await this.fetchModel() - const isAnthropic = isOpencodeGoAnthropicFormatModel(id) + // Resolution order: Anthropic Messages → OpenAI Responses → OpenAI Chat + // Completions (default). Unknown model IDs keep the Chat Completions + // fallback, matching the gateway's default routing. + const format: OpencodeGoFormat = isOpencodeGoAnthropicFormatModel(id) + ? "anthropic" + : isOpencodeGoResponsesFormatModel(id) + ? "responses" + : "openai" // getModelParams is overloaded on a literal `format`, so branch the call // rather than passing a union — this keeps the returned params typed as a - // single concrete shape per branch. - const params = isAnthropic - ? getModelParams({ - format: "anthropic", - modelId: id, - model: info, - settings: this.options, - defaultTemperature: OPENCODE_GO_DEFAULT_TEMPERATURE, - }) - : getModelParams({ - format: "openai", - modelId: id, - model: info, - settings: this.options, - defaultTemperature: OPENCODE_GO_DEFAULT_TEMPERATURE, - }) + // single concrete shape per branch. Responses-format models share the + // `openai` parameter computation with Chat Completions. + const params = + format === "anthropic" + ? getModelParams({ + format: "anthropic", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: OPENCODE_GO_DEFAULT_TEMPERATURE, + }) + : getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: OPENCODE_GO_DEFAULT_TEMPERATURE, + }) return { id, info, - format: isAnthropic ? ("anthropic" as const) : ("openai" as const), + format, maxTokens: params.maxTokens, temperature: params.temperature, reasoningEffort: params.reasoningEffort, @@ -149,8 +182,10 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio * reasoning, partial tool calls, and token usage. * * Anthropic-format models (Qwen/MiniMax) are streamed via - * {@link streamAnthropicMessage} against `/v1/messages`; all other models - * use the OpenAI-compatible chat completions endpoint. + * {@link streamAnthropicMessage} against `/v1/messages`; Responses-format + * models (gpt-5.6-luna) are streamed via {@link streamResponsesMessage} + * against `/v1/responses`; all other models use the OpenAI-compatible chat + * completions endpoint. * * For OpenAI-format models that require reasoning_content to be passed back * during multi-turn tool calls (`preserveReasoning`), messages are @@ -170,6 +205,20 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio return } + if (format === "responses") { + yield* this.streamResponsesMessage( + modelId, + info, + temperature, + maxTokens, + reasoningEffort, + systemPrompt, + messages, + metadata, + ) + return + } + // preserveReasoning models (GLM/DeepSeek/MiMo/MiniMax/Qwen) require // reasoning_content to be carried across tool-call continuations. const preserveReasoning = info.preserveReasoning === true @@ -237,6 +286,140 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio } } + /** + * Streams an OpenAI Responses-format completion for Go models that only + * accept the `/v1/responses` endpoint (currently gpt-5.6-luna). + * + * Follows the focused xAI handler pattern: the conversation is converted + * with the shared {@link convertToResponsesApiInput} transform, the system + * prompt is sent as top-level `instructions`, reasoning effort maps to + * `reasoning.effort`, and the event stream is processed by the shared + * {@link processResponsesApiStream} transform with cost computed via + * {@link calculateApiCostOpenAI}. + * + * There is deliberately no fallback to `/v1/chat/completions`: the + * gateway's chat-completions adapter for these models fails with an opaque + * HTTP 500 (Zoo-Code-Org/Zoo-Code#1431), so failures are surfaced as-is + * with the same `Opencode Go completion error:` prefix used by the other + * wire formats. + */ + private async *streamResponsesMessage( + modelId: string, + info: ModelInfo, + temperature: number | undefined, + maxTokens: number | undefined, + reasoningEffort: ReasoningEffortExtended | undefined, + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + const input = convertToResponsesApiInput(messages) + + // convertToolsForOpenAI returns Chat Completions-shaped tools + // (`{ type: "function", function: { name, ... } }`); the Responses API + // expects the function fields flattened onto the tool object. + const responseTools = (this.convertToolsForOpenAI(metadata?.tools) ?? []).filter( + (tool) => tool?.type === "function" && typeof tool.function?.name === "string", + ) + + // Map the Chat Completions tool_choice shape to the Responses shape: + // string options pass through; named function choices flatten + // `{ function: { name } }` to `{ name }`. + const toolChoice = metadata?.tool_choice + const responsesToolChoice: OpenAI.Responses.ResponseCreateParamsStreaming["tool_choice"] = + typeof toolChoice === "string" + ? toolChoice + : toolChoice?.type === "function" + ? { type: "function", name: toolChoice.function.name } + : "auto" + + const requestBody: OpenAI.Responses.ResponseCreateParamsStreaming = { + model: modelId, + instructions: systemPrompt, + input, + stream: true, + store: false, + // The gateway accepts extended effort values ("none"/"xhigh"/"max") + // that postdate the SDK's narrower ReasoningEffort union; the wire + // value is a plain string, so this cast is safe. + ...(reasoningEffort + ? { + reasoning: { + effort: reasoningEffort, + } as OpenAI.Responses.ResponseCreateParamsStreaming["reasoning"], + } + : {}), + // OpenCode Go Responses models currently reject temperature, even when + // the model metadata does not explicitly opt out of it. + // Honour the same includeMaxTokens/modelMaxTokens override logic as + // the chat-completions and Anthropic streaming paths. + ...(maxTokens !== undefined + ? { + max_output_tokens: + this.options.includeMaxTokens === true + ? this.options.modelMaxTokens || maxTokens + : maxTokens, + } + : {}), + ...(responseTools.length + ? { + tools: responseTools.map((tool) => ({ + type: "function", + name: tool.function.name, + description: tool.function.description, + parameters: tool.function.parameters, + strict: tool.function.strict, + })), + tool_choice: responsesToolChoice, + parallel_tool_calls: metadata?.parallelToolCalls ?? true, + } + : {}), + } + + let stream: AsyncIterable + try { + stream = await this.client.responses.create(requestBody) + } catch (error) { + if (error instanceof Error) { + throw new Error(`Opencode Go completion error: ${error.message}`) + } + throw error + } + + // Normalize the Responses usage payload locally so all reported token + // categories, including cache writes, are passed to the cost calculator. + const normalizeUsage = (usage: unknown): ApiStreamUsageChunk | undefined => { + if (!usage) return undefined + const data = usage as { + input_tokens_details?: { cached_tokens?: number } + prompt_tokens_details?: { cached_tokens?: number } + input_tokens?: number + output_tokens?: number + cache_creation_input_tokens?: number + cache_write_tokens?: number + cache_read_input_tokens?: number + } + const inputTokens = data.input_tokens ?? 0 + const outputTokens = data.output_tokens ?? 0 + const cacheWriteTokens = data.cache_creation_input_tokens ?? data.cache_write_tokens ?? 0 + const inputDetails = data.input_tokens_details ?? data.prompt_tokens_details + const cacheReadTokens = data.cache_read_input_tokens ?? inputDetails?.cached_tokens ?? 0 + const reasoningTokens = (usage as { output_tokens_details?: { reasoning_tokens?: number } }) + .output_tokens_details?.reasoning_tokens + return { + type: "usage", + inputTokens, + outputTokens, + cacheWriteTokens, + cacheReadTokens, + ...(typeof reasoningTokens === "number" ? { reasoningTokens } : {}), + totalCost: calculateApiCostOpenAI(info, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens) + .totalCost, + } + } + yield* processResponsesApiStream(stream, normalizeUsage) + } + /** * Streams an Anthropic Messages-format completion for Go models that only * accept the `/v1/messages` endpoint (Qwen/MiniMax). @@ -480,7 +663,8 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio * Performs a non-streaming chat completion and returns the full response text. * * Anthropic-format models are completed via the `/v1/messages` endpoint; - * all other models use the OpenAI-compatible chat completions endpoint. + * Responses-format models (gpt-5.6-luna) via `/v1/responses`; all other + * models use the OpenAI-compatible chat completions endpoint. * * @param prompt - The user prompt to send as a single user message. * @returns The model's reply text, or an empty string if no content is returned. @@ -516,6 +700,43 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio } } + if (format === "responses") { + try { + const response = await this.client.responses.create({ + model: modelId, + input: [{ role: "user", content: [{ type: "input_text", text: prompt }] }], + store: false, + // OpenCode Go Responses models currently reject temperature. + // Honour the same includeMaxTokens/modelMaxTokens override + // logic as the chat-completions path. + ...(maxTokens !== undefined + ? { + max_output_tokens: + this.options.includeMaxTokens === true + ? this.options.modelMaxTokens || maxTokens + : maxTokens, + } + : {}), + // The gateway accepts extended effort values ("none"/"xhigh"/ + // "max") that postdate the SDK's narrower ReasoningEffort + // union; the wire value is a plain string, so this cast is safe. + ...(reasoningEffort + ? { + reasoning: { + effort: reasoningEffort, + } as OpenAI.Responses.ResponseCreateParamsNonStreaming["reasoning"], + } + : {}), + }) + return response.output_text || "" + } catch (error) { + if (error instanceof Error) { + throw new Error(`Opencode Go completion error: ${error.message}`) + } + throw error + } + } + try { const requestOptions: OpenAI.Chat.ChatCompletionCreateParams = { model: modelId, From e539385023a165e6bb92f46332a79bf6469e145e Mon Sep 17 00:00:00 2001 From: Anthony Gallon <64985097+AntzCode@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:45:22 +1200 Subject: [PATCH 2/2] [Fix] Opencode Go routes gpt-5.6-luna through /v1/responses Address CodeRabbit and Codecov findings from PR #1443. --- packages/types/src/providers/opencode-go.ts | 31 ++- .../providers/__tests__/opencode-go.spec.ts | 251 ++++++++++++++++++ src/api/providers/opencode-go.ts | 77 ++++-- 3 files changed, 314 insertions(+), 45 deletions(-) diff --git a/packages/types/src/providers/opencode-go.ts b/packages/types/src/providers/opencode-go.ts index 01497e7506..10d6bcbc50 100644 --- a/packages/types/src/providers/opencode-go.ts +++ b/packages/types/src/providers/opencode-go.ts @@ -344,6 +344,21 @@ export const opencodeGoModels: Record = { description: "DeepSeek-V4-Pro-0813 is DeepSeek's strongest V4 model for reasoning, coding, long-context, and agentic workloads. Available via the Opencode Go plan.", }, + "deepseek-v4-flash": { + maxTokens: 384_000, + contextWindow: 1_000_000, + supportsImages: false, + supportsPromptCache: true, + supportsMaxTokens: true, + supportsReasoningEffort: ["disable", "low", "medium", "high", "xhigh"], + preserveReasoning: true, + reasoningEffort: "high", + inputPrice: 0.14, + outputPrice: 0.28, + cacheReadsPrice: 0.0028, + description: + "DeepSeek-V4-Flash is DeepSeek's fast, cost-efficient V4 model supporting thinking and non-thinking modes. Available via the Opencode Go plan.", + }, // --- OpenAI Responses --- // Luna is curated here because the Go gateway's model catalogue does not // currently provide its capability metadata. These values intentionally @@ -368,22 +383,6 @@ export const opencodeGoModels: Record = { }, description: "GPT-5.6 Luna via the OpenCode Go Responses API.", }, - - "deepseek-v4-flash": { - maxTokens: 384_000, - contextWindow: 1_000_000, - supportsImages: false, - supportsPromptCache: true, - supportsMaxTokens: true, - supportsReasoningEffort: ["disable", "low", "medium", "high", "xhigh"], - preserveReasoning: true, - reasoningEffort: "high", - inputPrice: 0.14, - outputPrice: 0.28, - cacheReadsPrice: 0.0028, - description: - "DeepSeek-V4-Flash is DeepSeek's fast, cost-efficient V4 model supporting thinking and non-thinking modes. Available via the Opencode Go plan.", - }, } /** diff --git a/src/api/providers/__tests__/opencode-go.spec.ts b/src/api/providers/__tests__/opencode-go.spec.ts index 036cd71c16..828ffb8655 100644 --- a/src/api/providers/__tests__/opencode-go.spec.ts +++ b/src/api/providers/__tests__/opencode-go.spec.ts @@ -836,6 +836,113 @@ describe("OpencodeGoHandler", () => { ) }) + it("forwards the abort signal to the streaming Responses request", async () => { + const handler = new OpencodeGoHandler(lunaOptions) + const controller = new AbortController() + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] + + await collectStream( + handler.createMessage("sys", messages, { taskId: "test-task", abortSignal: controller.signal }), + ) + + expect(mockResponsesCreate.mock.calls[0][1]).toEqual({ signal: controller.signal }) + }) + + it("closes the Responses iterator when the consumer stops early", async () => { + const iterator = { + next: vitest.fn().mockResolvedValueOnce({ + done: false, + value: { type: "response.output_text.delta", delta: "partial" }, + }), + return: vitest.fn().mockResolvedValue({ done: true, value: undefined }), + [Symbol.asyncIterator]() { + return this + }, + } + mockResponsesCreate.mockResolvedValue(iterator) + const handler = new OpencodeGoHandler(lunaOptions) + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] + const responseStream = handler.createMessage("sys", messages) + + await expect(responseStream.next()).resolves.toEqual({ + done: false, + value: { type: "text", text: "partial" }, + }) + await responseStream.return(undefined) + + expect(iterator.return).toHaveBeenCalledTimes(2) + }) + + it("preserves the stream error when iterator cleanup fails", async () => { + const circular: { self?: unknown } = {} + circular.self = circular + const iterator = { + next: vitest.fn().mockResolvedValueOnce({ + done: false, + value: { + type: "response.output_item.done", + item: { type: "function_call", call_id: "call_1", name: "read_file", arguments: circular }, + }, + }), + return: vitest.fn().mockRejectedValue(new Error("cleanup failed")), + [Symbol.asyncIterator]() { + return this + }, + } + mockResponsesCreate.mockResolvedValue(iterator) + const handler = new OpencodeGoHandler(lunaOptions) + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] + + await expect(collectStream(handler.createMessage("sys", messages))).rejects.toThrow("circular") + expect(iterator.return).toHaveBeenCalled() + }) + + it("stops an in-flight Responses iterator when its abort signal rejects the read", async () => { + const controller = new AbortController() + let rejectNext: ((reason?: unknown) => void) | undefined + const iterator = { + next: vitest.fn().mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectNext = reject + controller.signal.addEventListener("abort", () => reject(new Error("request aborted")), { + once: true, + }) + }), + ), + return: vitest.fn().mockResolvedValue({ done: true, value: undefined }), + [Symbol.asyncIterator]() { + return this + }, + } + mockResponsesCreate.mockImplementation(async (_body: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => rejectNext?.(new Error("request aborted")), { + once: true, + }) + return iterator + }) + const handler = new OpencodeGoHandler(lunaOptions) + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] + const responseStream = handler.createMessage("sys", messages, { + taskId: "test-task", + abortSignal: controller.signal, + }) + const nextPromise = responseStream.next() + await vitest.waitFor(() => expect(mockResponsesCreate).toHaveBeenCalled()) + controller.abort() + + await expect(nextPromise).rejects.toThrow("request aborted") + expect(iterator.return).toHaveBeenCalled() + }) + + it("rethrows non-Error Responses streaming failures unchanged", async () => { + mockResponsesCreate.mockRejectedValue("stream failure") + const handler = new OpencodeGoHandler(lunaOptions) + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] + + await expect(collectStream(handler.createMessage("sys", messages))).rejects.toBe("stream failure") + }) + it("routes the request through responses.create, not chat completions or Anthropic messages", async () => { const handler = new OpencodeGoHandler(lunaOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] @@ -1012,6 +1119,85 @@ describe("OpencodeGoHandler", () => { expect(usageChunk.totalCost).toBeCloseTo((60 * 0.2 + 40 * 0.02 + 50 * 1.2) / 1_000_000, 10) }) + it("supports named and string tool choices and disables parallel calls", async () => { + const handler = new OpencodeGoHandler(lunaOptions) + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] + const tools: OpenAI.Chat.ChatCompletionTool[] = [ + { type: "function", function: { name: "read_file", parameters: { type: "object" } } }, + ] + + await collectStream( + handler.createMessage("sys", messages, { + taskId: "test-task", + tools, + tool_choice: { type: "function", function: { name: "read_file" } }, + parallelToolCalls: false, + }), + ) + let callArgs = mockResponsesCreate.mock.calls[0][0] as Record + expect(callArgs.tool_choice).toEqual({ type: "function", name: "read_file" }) + expect(callArgs.parallel_tool_calls).toBe(false) + + mockResponsesCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { type: "response.completed", response: { usage: { input_tokens: 1, output_tokens: 1 } } }, + ]), + ) + await collectStream( + handler.createMessage("sys", messages, { + taskId: "test-task", + tools, + tool_choice: "required", + }), + ) + callArgs = mockResponsesCreate.mock.calls[1][0] as Record + expect(callArgs.tool_choice).toBe("required") + }) + + it("omits max_output_tokens when no max token limit is available", async () => { + mockResponsesCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { type: "response.completed", response: { usage: { input_tokens: 1, output_tokens: 1 } } }, + ]), + ) + vitest.mocked(getModels).mockResolvedValueOnce({ + "gpt-5.6-luna": { ...opencodeGoModels["gpt-5.6-luna"], maxTokens: undefined }, + }) + const handler = new OpencodeGoHandler(lunaOptions) + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] + + await collectStream(handler.createMessage("sys", messages)) + + const callArgs = mockResponsesCreate.mock.calls[0][0] as Record + expect(callArgs.max_output_tokens).toBeUndefined() + }) + + it.each(["cache_creation_input_tokens", "cache_write_tokens"] as const)( + "normalizes %s as cache-write usage and includes it in the total cost", + async (cacheWriteField) => { + mockResponsesCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { + type: "response.completed", + response: { + usage: { + input_tokens: 100, + output_tokens: 50, + [cacheWriteField]: 20, + }, + }, + }, + ]), + ) + const handler = new OpencodeGoHandler(lunaOptions) + const chunks = await collectStream(handler.createMessage("sys", [{ role: "user", content: "Hi" }])) + const usageChunk = chunks.find((chunk) => chunk.type === "usage") + if (!usageChunk || usageChunk.type !== "usage") throw new Error("Expected usage chunk") + expect(usageChunk.cacheWriteTokens).toBe(20) + expect(usageChunk.totalCost).toBeCloseTo((80 * 0.2 + 50 * 1.2 + 20 * 0.25) / 1_000_000, 10) + }, + ) + it("maps the model default reasoning effort to reasoning.effort", async () => { const handler = new OpencodeGoHandler(lunaOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] @@ -1052,6 +1238,38 @@ describe("OpencodeGoHandler", () => { expect(callArgs.max_output_tokens).toBe(5_000) }) + it.each([{ output_text: "" }, {}])( + "returns an empty string when completePrompt output_text is empty or absent", + async (response) => { + mockResponsesCreate.mockResolvedValue(response) + const handler = new OpencodeGoHandler(lunaOptions) + + await expect(handler.completePrompt("ping")).resolves.toBe("") + }, + ) + + it("rethrows non-Error completePrompt failures unchanged", async () => { + mockResponsesCreate.mockRejectedValue("completion failure") + const handler = new OpencodeGoHandler(lunaOptions) + + await expect(handler.completePrompt("ping")).rejects.toBe("completion failure") + }) + + it("rejects non-streaming Responses completion when the abort signal fires", async () => { + const controller = new AbortController() + const request = new Promise((_resolve, reject) => { + controller.signal.addEventListener("abort", () => reject(new Error("request aborted")), { once: true }) + }) + mockResponsesCreate.mockReturnValue(request) + const handler = new OpencodeGoHandler(lunaOptions) + const completion = handler.completePrompt("ping", { abortSignal: controller.signal }) + + await vitest.waitFor(() => expect(mockResponsesCreate).toHaveBeenCalled()) + controller.abort() + + await expect(completion).rejects.toThrow("request aborted") + }) + it("completePrompt calls responses.create and returns output_text", async () => { mockResponsesCreate.mockResolvedValue({ output_text: "Hello!" }) const handler = new OpencodeGoHandler(lunaOptions) @@ -1059,6 +1277,7 @@ describe("OpencodeGoHandler", () => { const result = await handler.completePrompt("ping") expect(result).toBe("Hello!") + expect(mockCreate).not.toHaveBeenCalled() const callArgs = mockResponsesCreate.mock.calls[0][0] as Record expect(callArgs.model).toBe("gpt-5.6-luna") expect(callArgs.store).toBe(false) @@ -1068,6 +1287,38 @@ describe("OpencodeGoHandler", () => { expect(callArgs.temperature).toBeUndefined() }) + it("forwards Responses-specific max_output_tokens and reasoning in completePrompt", async () => { + mockResponsesCreate.mockResolvedValue({ output_text: "Hello!" }) + const handler = new OpencodeGoHandler({ ...lunaOptions, includeMaxTokens: true, modelMaxTokens: 7_500 }) + + await handler.completePrompt("ping") + + const callArgs = mockResponsesCreate.mock.calls[0][0] as Record + expect(callArgs.max_output_tokens).toBe(7_500) + expect(callArgs.reasoning).toEqual({ effort: "medium" }) + }) + + it("omits reasoning in completePrompt when reasoning effort is disabled", async () => { + mockResponsesCreate.mockResolvedValue({ output_text: "Hello!" }) + const handler = new OpencodeGoHandler({ ...lunaOptions, reasoningEffort: "disable" }) + + await handler.completePrompt("ping") + + const callArgs = mockResponsesCreate.mock.calls[0][0] as Record + expect(callArgs.reasoning).toBeUndefined() + }) + + it("forwards the abort signal to the non-streaming Responses request", async () => { + mockResponsesCreate.mockResolvedValue({ output_text: "Hello!" }) + const handler = new OpencodeGoHandler(lunaOptions) + const controller = new AbortController() + + await handler.completePrompt("ping", { abortSignal: controller.signal }) + + expect(mockResponsesCreate.mock.calls[0][1]).toEqual({ signal: controller.signal }) + expect(mockCreate).not.toHaveBeenCalled() + }) + 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 2c79924632..ba1c87e223 100644 --- a/src/api/providers/opencode-go.ts +++ b/src/api/providers/opencode-go.ts @@ -21,7 +21,7 @@ import { convertToR1Format } from "../transform/r1-format" import { filterNonAnthropicBlocks } from "../transform/anthropic-filter" import { getModelParams } from "../transform/model-params" import { convertToResponsesApiInput } from "../transform/responses-api-input" -import { processResponsesApiStream, createUsageNormalizer } from "../transform/responses-api-stream" +import { processResponsesApiStream } from "../transform/responses-api-stream" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" import { RouterProvider } from "./router-provider" @@ -378,7 +378,7 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio let stream: AsyncIterable try { - stream = await this.client.responses.create(requestBody) + stream = await this.client.responses.create(requestBody, { signal: metadata?.abortSignal }) } catch (error) { if (error instanceof Error) { throw new Error(`Opencode Go completion error: ${error.message}`) @@ -417,7 +417,23 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio .totalCost, } } - yield* processResponsesApiStream(stream, normalizeUsage) + + // Capture the iterator handle. If the consumer exits early (e.g., via a timeout + // or cancellation), we must explicitly close this specific instance to release + // the open HTTP connection and stop the provider from generating. + const streamIterator = stream[Symbol.asyncIterator]() + + try { + // Pass the captured iterator to ensure early cancellation triggers the finally block. + yield* processResponsesApiStream({ [Symbol.asyncIterator]: () => streamIterator }, normalizeUsage) + } finally { + try { + // Explicitly close the iterator to drop the connection and halt work. + await streamIterator.return?.() + } catch { + // Swallow cleanup errors so they do not mask the primary stream or runtime error. + } + } } /** @@ -702,32 +718,35 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio if (format === "responses") { try { - const response = await this.client.responses.create({ - model: modelId, - input: [{ role: "user", content: [{ type: "input_text", text: prompt }] }], - store: false, - // OpenCode Go Responses models currently reject temperature. - // Honour the same includeMaxTokens/modelMaxTokens override - // logic as the chat-completions path. - ...(maxTokens !== undefined - ? { - max_output_tokens: - this.options.includeMaxTokens === true - ? this.options.modelMaxTokens || maxTokens - : maxTokens, - } - : {}), - // The gateway accepts extended effort values ("none"/"xhigh"/ - // "max") that postdate the SDK's narrower ReasoningEffort - // union; the wire value is a plain string, so this cast is safe. - ...(reasoningEffort - ? { - reasoning: { - effort: reasoningEffort, - } as OpenAI.Responses.ResponseCreateParamsNonStreaming["reasoning"], - } - : {}), - }) + const response = await this.client.responses.create( + { + model: modelId, + input: [{ role: "user", content: [{ type: "input_text", text: prompt }] }], + store: false, + // OpenCode Go Responses models currently reject temperature. + // Honour the same includeMaxTokens/modelMaxTokens override + // logic as the chat-completions path. + ...(maxTokens !== undefined + ? { + max_output_tokens: + this.options.includeMaxTokens === true + ? this.options.modelMaxTokens || maxTokens + : maxTokens, + } + : {}), + // The gateway accepts extended effort values ("none"/"xhigh"/ + // "max") that postdate the SDK's narrower ReasoningEffort + // union; the wire value is a plain string, so this cast is safe. + ...(reasoningEffort + ? { + reasoning: { + effort: reasoningEffort, + } as OpenAI.Responses.ResponseCreateParamsNonStreaming["reasoning"], + } + : {}), + }, + { signal: options?.abortSignal }, + ) return response.output_text || "" } catch (error) { if (error instanceof Error) {