From ab044e6d6d49864f569d333872f539a7641b2cd4 Mon Sep 17 00:00:00 2001 From: anojndr Date: Fri, 28 Aug 2026 11:32:56 +0700 Subject: [PATCH] fix(opencode): route Muse Spark through the Responses API muse-spark-1.2-contributor-free returned HTTP 500 on /zen/v1/chat/completions. The model is only served by /zen/v1/responses, so route it there via a per-model targetFormat and normalize the Chat fields the Responses API rejects (max_tokens -> max_output_tokens, reasoning_effort -> reasoning{effort,summary}), clamping max/ultra down to the highest effort the model accepts (xhigh). Routing stays per-model: the other free models (big-pickle, hy3-free, mimo, nemotron, laguna) are not served by /responses and keep /chat/completions. --- open-sse/executors/opencode.js | 61 ++++++++++-- open-sse/providers/capabilities.js | 2 + open-sse/providers/registry/opencode.js | 6 +- .../translator/request/openai-responses.js | 19 +++- tests/unit/executor-const-guard.test.js | 34 +++++++ .../unit/opencode-muse-spark-thinking.test.js | 94 +++++++++++++++++++ 6 files changed, 203 insertions(+), 13 deletions(-) create mode 100644 tests/unit/opencode-muse-spark-thinking.test.js diff --git a/open-sse/executors/opencode.js b/open-sse/executors/opencode.js index 27a81c00..4215c218 100644 --- a/open-sse/executors/opencode.js +++ b/open-sse/executors/opencode.js @@ -1,11 +1,13 @@ import crypto from "crypto"; import { BaseExecutor } from "./base.js"; import { PROVIDERS } from "../config/providers.js"; +import { getThinkingLevels } from "../providers/thinkingLevels.js"; import { injectReasoningContent } from "../utils/reasoningContentInjector.js"; import { resolveSessionId } from "../utils/sessionManager.js"; const OPENCODE_UA = "opencode"; -const MESSAGES_MODELS = new Set(); +// Models served by /zen/v1/responses; every other model stays on /chat/completions. +const RESPONSES_MODELS = new Set(["muse-spark-1.2-contributor-free"]); function generateRequestId() { return `msg_${crypto.randomUUID().replace(/-/g, "")}`; @@ -15,19 +17,47 @@ function generateSessionId() { return `ses_${crypto.randomUUID().replace(/-/g, "")}`; } -// Normalize any resolved id into opencode's ses_ format (stable per-conversation) -function toOpencodeSession(id) { - const stripped = String(id || "").replace(/^ses_/, "").replace(/-/g, ""); - return stripped ? `ses_${stripped}` : null; +// Strip the thinking suffix "model(level)" so registry lookups hit the base id. +function baseModelId(model) { + return String(model || "").replace(/\([^()]+\)\s*$/, "").trim(); +} + +function isResponsesModel(model) { + return RESPONSES_MODELS.has(baseModelId(model)); } function resolveOpencodeSession(body, credentials) { - return toOpencodeSession(resolveSessionId({ - headers: credentials?.rawHeaders, + const headers = credentials?.rawHeaders || {}; + return resolveSessionId({ + headers, body, connectionId: credentials?.connectionId, scope: "opencode", - })); + generate: generateSessionId, + }); +} + +function normalizeOpencodeReasoning(model, body) { + const current = body.reasoning; + const currentReasoning = current && typeof current === "object" && !Array.isArray(current) + ? current + : null; + const requestedEffort = typeof body.reasoning_effort === "string" + ? body.reasoning_effort + : currentReasoning?.effort; + if (typeof requestedEffort !== "string") return; + + const cleanModel = baseModelId(model || body.model); + const supportedLevels = getThinkingLevels("opencode", cleanModel); + let effort = requestedEffort.toLowerCase().trim(); + if ((effort === "max" || effort === "ultra") && supportedLevels?.length && !supportedLevels.includes(effort)) { + if (effort === "ultra" && supportedLevels.includes("max")) effort = "max"; + else if (supportedLevels.includes("xhigh")) effort = "xhigh"; + } + + body.reasoning = { ...currentReasoning, effort }; + if (!body.reasoning.summary) body.reasoning.summary = "auto"; + delete body.reasoning_effort; } export class OpenCodeExecutor extends BaseExecutor { @@ -38,13 +68,24 @@ export class OpenCodeExecutor extends BaseExecutor { transformRequest(model, body, stream, credentials) { this._currentSessionId = resolveOpencodeSession(body, credentials); + if (isResponsesModel(model)) { + // Responses API names the output cap max_output_tokens and takes thinking + // as reasoning:{effort,summary} — normalize the Chat fields at this boundary. + if (body.max_output_tokens === undefined) { + if (body.max_completion_tokens !== undefined) body.max_output_tokens = body.max_completion_tokens; + else if (body.max_tokens !== undefined) body.max_output_tokens = body.max_tokens; + } + delete body.max_tokens; + delete body.max_completion_tokens; + normalizeOpencodeReasoning(model, body); + } return injectReasoningContent({ provider: this.provider, model, body }); } buildUrl(model) { const base = this.config.baseUrl; - return MESSAGES_MODELS.has(model) - ? `${base}/zen/v1/messages` + return isResponsesModel(model) + ? `${base}/zen/v1/responses` : `${base}/zen/v1/chat/completions`; } diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index 24434a86..6ecf216d 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -125,6 +125,8 @@ export const MODEL_CAPABILITIES = { "kimi-for-coding-highspeed": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 }, "kimi-k2.7-code": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 }, "kimi-k2.7-code-highspeed": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 }, + // OpenCode Free Muse Spark — OpenAI Responses reasoning supports up to xhigh. + "muse-spark-1.2-contributor-free": { reasoning: true, thinkingFormat: "openai", contextWindow: 1048576, maxOutput: 131072 }, }; const KIRO_GPT_5_6_CAPABILITIES = { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 272000, maxOutput: 128000 }; diff --git a/open-sse/providers/registry/opencode.js b/open-sse/providers/registry/opencode.js index e83ad3a7..469c64ca 100644 --- a/open-sse/providers/registry/opencode.js +++ b/open-sse/providers/registry/opencode.js @@ -19,7 +19,11 @@ export default { }, noAuth: true, }, - models: [], + models: [ + // Only this model is served by /zen/v1/responses; the rest stay on + // /chat/completions, so the format is declared per-model, not per-provider. + { id: "muse-spark-1.2-contributor-free", name: "Muse Spark 1.2 Contributor Free", targetFormat: "openai-responses" }, + ], modelsFetcher: { url: "https://opencode.ai/zen/v1/models", type: "opencode-free" }, passthroughModels: true, }; diff --git a/open-sse/translator/request/openai-responses.js b/open-sse/translator/request/openai-responses.js index 29e43152..cf5bc529 100644 --- a/open-sse/translator/request/openai-responses.js +++ b/open-sse/translator/request/openai-responses.js @@ -300,7 +300,16 @@ function buildReasoningInputItem(msg) { */ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) { // Body already in Responses API format (e.g. Cursor CLI calling /chat/completions with input[]) - if (body.input) return { ...body, model, stream: true }; + if (body.input) { + const out = { ...body, model, stream: true }; + if (out.max_output_tokens === undefined) { + if (out.max_completion_tokens !== undefined) out.max_output_tokens = out.max_completion_tokens; + else if (out.max_tokens !== undefined) out.max_output_tokens = out.max_tokens; + } + delete out.max_tokens; + delete out.max_completion_tokens; + return out; + } const result = { model, @@ -416,7 +425,13 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) // Pass through other relevant fields if (body.temperature !== undefined) result.temperature = body.temperature; - if (body.max_tokens !== undefined) result.max_tokens = body.max_tokens; + if (body.max_output_tokens !== undefined) { + result.max_output_tokens = body.max_output_tokens; + } else if (body.max_completion_tokens !== undefined) { + result.max_output_tokens = body.max_completion_tokens; + } else if (body.max_tokens !== undefined) { + result.max_output_tokens = body.max_tokens; + } if (body.top_p !== undefined) result.top_p = body.top_p; if (body.reasoning !== undefined) result.reasoning = body.reasoning; if (body.reasoning_effort !== undefined) result.reasoning = { effort: body.reasoning_effort, summary: "auto" }; diff --git a/tests/unit/executor-const-guard.test.js b/tests/unit/executor-const-guard.test.js index 2c466ce1..e84f5733 100644 --- a/tests/unit/executor-const-guard.test.js +++ b/tests/unit/executor-const-guard.test.js @@ -9,6 +9,7 @@ import { DEFAULT_MAX_TOKENS, DEFAULT_MIN_TOKENS } from "../../open-sse/config/ru import mimoFree from "../../open-sse/providers/registry/mimo-free.js"; import opencode from "../../open-sse/providers/registry/opencode.js"; import antigravity from "../../open-sse/providers/registry/antigravity.js"; +import { OpenCodeExecutor } from "../../open-sse/executors/opencode.js"; describe("compat base URLs / version", () => { it("OPENAI_COMPAT_BASE", () => { @@ -46,3 +47,36 @@ describe("antigravity retry (intentional change: 429=6, 503=3)", () => { expect(antigravity.transport.retry["503"].attempts).toBe(3); }); }); + +describe("OpenCode Free endpoint routing", () => { + const MUSE = "muse-spark-1.2-contributor-free"; + + it("declares the Responses format only on the Muse Spark model", () => { + expect(opencode.transport.format).toBeUndefined(); + const muse = opencode.models.find((m) => m.id === MUSE); + expect(muse?.targetFormat).toBe("openai-responses"); + }); + + it("routes Muse Spark to /responses and every other model to /chat/completions", () => { + const executor = new OpenCodeExecutor(); + expect(executor.buildUrl(MUSE)).toBe("https://opencode.ai/zen/v1/responses"); + expect(executor.buildUrl(`${MUSE}(xhigh)`)).toBe("https://opencode.ai/zen/v1/responses"); + expect(executor.buildUrl("big-pickle")).toBe("https://opencode.ai/zen/v1/chat/completions"); + expect(executor.buildUrl("hy3-free")).toBe("https://opencode.ai/zen/v1/chat/completions"); + }); + + it("normalizes Chat token/thinking fields only for the Responses model", () => { + const executor = new OpenCodeExecutor(); + const muse = { max_tokens: 4096, reasoning_effort: "high" }; + executor.transformRequest(MUSE, muse, true, {}); + expect(muse.max_output_tokens).toBe(4096); + expect(muse.max_tokens).toBeUndefined(); + expect(muse.reasoning).toEqual({ effort: "high", summary: "auto" }); + + const chat = { max_tokens: 4096, reasoning_effort: "high" }; + executor.transformRequest("big-pickle", chat, true, {}); + expect(chat.max_tokens).toBe(4096); + expect(chat.max_output_tokens).toBeUndefined(); + expect(chat.reasoning_effort).toBe("high"); + }); +}); diff --git a/tests/unit/opencode-muse-spark-thinking.test.js b/tests/unit/opencode-muse-spark-thinking.test.js new file mode 100644 index 00000000..36338117 --- /dev/null +++ b/tests/unit/opencode-muse-spark-thinking.test.js @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js"; +import { PROVIDER_MODELS } from "../../open-sse/config/providerModels.js"; +import { getThinkingLevels } from "../../open-sse/providers/thinkingLevels.js"; +import { FORMATS } from "../../open-sse/translator/formats.js"; +import { OpenCodeExecutor } from "../../open-sse/executors/opencode.js"; +import "../translator/registerAll.js"; +import { translateRequest } from "../../open-sse/translator/index.js"; + +const MODEL = "muse-spark-1.2-contributor-free"; +const PROVIDER = "opencode"; + +const input = [{ + type: "message", + role: "user", + content: [{ type: "input_text", text: "Think, then answer: 2 + 2?" }], +}]; + +describe("OpenCode Free Muse Spark thinking", () => { + it("advertises reasoning and the requested model limits", () => { + expect(PROVIDER_MODELS.oc?.some((model) => model.id === MODEL)).toBe(true); + expect(getCapabilitiesForModel(PROVIDER, MODEL)).toMatchObject({ + reasoning: true, + thinkingFormat: "openai", + contextWindow: 1048576, + maxOutput: 131072, + }); + expect(getCapabilitiesForModel(PROVIDER, `oc/${MODEL}`)).toMatchObject({ + reasoning: true, + contextWindow: 1048576, + maxOutput: 131072, + }); + expect(getThinkingLevels(PROVIDER, MODEL)).toEqual([ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + ]); + }); + + it("clamps max to xhigh and emits the Responses reasoning shape", () => { + const body = { + input, + reasoning: { effort: "max" }, + max_tokens: 131072, + }; + + const out = new OpenCodeExecutor().transformRequest(MODEL, body, true, { + connectionId: "opencode-muse-spark-test", + }); + + expect(out.reasoning).toEqual({ effort: "xhigh", summary: "auto" }); + expect(out.reasoning_effort).toBeUndefined(); + expect(out.max_output_tokens).toBe(131072); + expect(out.max_tokens).toBeUndefined(); + }); + + it("leaves the other free models on Chat Completions", () => { + const executor = new OpenCodeExecutor(); + const body = { messages: [{ role: "user", content: "hi" }], max_tokens: 1024 }; + executor.transformRequest("big-pickle", body, true, {}); + expect(executor.buildUrl("big-pickle")).toBe("https://opencode.ai/zen/v1/chat/completions"); + expect(body.max_tokens).toBe(1024); + expect(body.max_output_tokens).toBeUndefined(); + }); + + it("translates Chat Completions max thinking into a Responses request", () => { + const body = { + model: `oc/${MODEL}`, + messages: [{ role: "user", content: "Think, then answer: 2 + 2?" }], + reasoning_effort: "max", + max_tokens: 131072, + }; + + const translated = translateRequest( + FORMATS.OPENAI, + FORMATS.OPENAI_RESPONSES, + MODEL, + body, + true, + {}, + PROVIDER, + ); + const out = new OpenCodeExecutor().transformRequest(MODEL, translated, true, { + connectionId: "opencode-muse-spark-translation-test", + }); + + expect(out.reasoning).toEqual({ effort: "xhigh", summary: "auto" }); + expect(out.max_output_tokens).toBe(131072); + expect(out.max_tokens).toBeUndefined(); + }); +});