diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 3009500e80..168c06c343 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -110,6 +110,7 @@ import { } from "../services/modelStrip.ts"; import { resolveModelAlias } from "../services/modelDeprecation.ts"; import { normalizeMimoThinking } from "../services/mimoThinking.ts"; +import { isOpencodeGoProvider, stripBooleanReasoning } from "../services/opencodeReasoningSanitizer.ts"; import { normalizeClaudeAdaptiveThinking } from "../services/claudeAdaptiveThinking.ts"; import { normalizeClaudeHaikuConstraints } from "../services/claudeHaikuConstraints.ts"; import { applyDefaultReasoningEffort } from "../services/defaultReasoningEffort.ts"; @@ -2222,6 +2223,16 @@ export async function handleChatCore({ translatedBody = normalizeMimoThinking(translatedBody); } + // opencode-go backed providers (ollama-cloud, opencode-go, opencode, + // opencode-zen) use a Go ChatCompletionRequest struct where `reasoning` + // is typed as openai.Reasoning (a structured type). A boolean + // `reasoning: true/false` — valid per the OpenAI API — causes a 400 + // "json: cannot unmarshal bool into Go struct field" on the Go side. + // Strip the boolean before forwarding. See opencodeReasoningSanitizer.ts. + if (isOpencodeGoProvider(provider)) { + translatedBody = stripBooleanReasoning(translatedBody); + } + const previousResponseIdPolicy = applyResponsesPreviousResponseIdPolicy(translatedBody, { mode: settings.responsesPreviousResponseIdMode, sourceFormat, diff --git a/open-sse/services/opencodeReasoningSanitizer.ts b/open-sse/services/opencodeReasoningSanitizer.ts new file mode 100644 index 0000000000..1af9ce7574 --- /dev/null +++ b/open-sse/services/opencodeReasoningSanitizer.ts @@ -0,0 +1,46 @@ +type JsonRecord = Record; + +/** + * Strip boolean `reasoning` for opencode-go based providers. + * + * Providers backed by the opencode-go backend (ollama-cloud, opencode-go, + * opencode, opencode-zen) use a Go ChatCompletionRequest struct where the + * `reasoning` field is typed as `openai.Reasoning` (a structured type, not + * a bool). When a client sends `reasoning: true` or `reasoning: false` — + * which is valid per the OpenAI API for enabling/disabling reasoning — the + * Go JSON decoder rejects it with: + * + * 400: json: cannot unmarshal bool into Go struct field + * ChatCompletionRequest.reasoning of type openai.Reasoning + * + * This strips the boolean `reasoning` field from the request body before it + * is forwarded to these providers, allowing the upstream to apply its own + * default reasoning behavior. If `reasoning` is already an object/string + * (matching the Go struct), it is left untouched. + * + * Related: services/mimoThinking.ts uses the same pattern for Xiaomi MiMo. + */ + +const OPENCODE_GO_PROVIDERS = new Set(["ollama-cloud", "opencode-go", "opencode", "opencode-zen"]); + +/** True when the provider is backed by the opencode-go backend. */ +export function isOpencodeGoProvider(provider: string): boolean { + return OPENCODE_GO_PROVIDERS.has(provider); +} + +/** + * Remove a boolean `reasoning` field from the request body. + * Returns the same object reference if no change is needed, or a shallow + * clone with the field removed. + */ +export function stripBooleanReasoning(body: JsonRecord): JsonRecord { + if (!body || typeof body !== "object") return body; + if (!("reasoning" in body)) return body; + const reasoning = body.reasoning; + // Only strip when reasoning is a boolean — object/string forms are valid + // for the Go struct and should be forwarded as-is. + if (typeof reasoning !== "boolean") return body; + const next = { ...body }; + delete next.reasoning; + return next; +} diff --git a/tests/unit/opencodeReasoningSanitizer.test.ts b/tests/unit/opencodeReasoningSanitizer.test.ts new file mode 100644 index 0000000000..a00b8999cb --- /dev/null +++ b/tests/unit/opencodeReasoningSanitizer.test.ts @@ -0,0 +1,111 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { + isOpencodeGoProvider, + stripBooleanReasoning, +} from "../../open-sse/services/opencodeReasoningSanitizer.ts"; + +describe("isOpencodeGoProvider", () => { + it("returns true for ollama-cloud", () => { + assert.equal(isOpencodeGoProvider("ollama-cloud"), true); + }); + + it("returns true for opencode-go", () => { + assert.equal(isOpencodeGoProvider("opencode-go"), true); + }); + + it("returns true for opencode", () => { + assert.equal(isOpencodeGoProvider("opencode"), true); + }); + + it("returns true for opencode-zen", () => { + assert.equal(isOpencodeGoProvider("opencode-zen"), true); + }); + + it("returns false for other providers", () => { + assert.equal(isOpencodeGoProvider("featherless-ai"), false); + assert.equal(isOpencodeGoProvider("glm"), false); + assert.equal(isOpencodeGoProvider("antigravity"), false); + assert.equal(isOpencodeGoProvider(""), false); + }); +}); + +describe("stripBooleanReasoning", () => { + it("removes reasoning when it is boolean true", () => { + const body = { model: "glm-5.2", reasoning: true, messages: [] }; + const result = stripBooleanReasoning(body); + assert.equal("reasoning" in result, false); + assert.equal(result.model, "glm-5.2"); + assert.deepEqual(result.messages, []); + }); + + it("removes reasoning when it is boolean false", () => { + const body = { model: "glm-5.2", reasoning: false, messages: [] }; + const result = stripBooleanReasoning(body); + assert.equal("reasoning" in result, false); + }); + + it("does NOT remove reasoning when it is an object (structured type)", () => { + const body = { model: "glm-5.2", reasoning: { effort: "high" }, messages: [] }; + const result = stripBooleanReasoning(body); + assert.deepEqual(result.reasoning, { effort: "high" }); + }); + + it("does NOT remove reasoning when it is a string", () => { + const body = { model: "glm-5.2", reasoning: "high", messages: [] }; + const result = stripBooleanReasoning(body); + assert.equal(result.reasoning, "high"); + }); + + it("returns the same object reference when no reasoning field exists", () => { + const body = { model: "glm-5.2", messages: [] }; + const result = stripBooleanReasoning(body); + assert.equal(result, body); + }); + + it("returns the same object reference when reasoning is non-boolean", () => { + const body = { model: "glm-5.2", reasoning: { effort: "low" }, messages: [] }; + const result = stripBooleanReasoning(body); + assert.equal(result, body); + }); + + it("preserves other fields in the body", () => { + const body = { + model: "ollama-cloud/glm-5.2", + reasoning: true, + temperature: 0.7, + stream: true, + messages: [{ role: "user", content: "hi" }], + }; + const result = stripBooleanReasoning(body); + assert.equal(result.model, "ollama-cloud/glm-5.2"); + assert.equal(result.temperature, 0.7); + assert.equal(result.stream, true); + assert.deepEqual(result.messages, [{ role: "user", content: "hi" }]); + assert.equal("reasoning" in result, false); + }); + + it("handles empty body object", () => { + const body = {}; + const result = stripBooleanReasoning(body); + assert.equal(result, body); + }); + + it("returns null/undefined/primitive bodies unchanged", () => { + assert.equal(stripBooleanReasoning(null as unknown as Record), null); + assert.equal(stripBooleanReasoning(undefined as unknown as Record), undefined); + assert.equal( + stripBooleanReasoning("not an object" as unknown as Record), + "not an object" + ); + assert.equal(stripBooleanReasoning(42 as unknown as Record), 42); + }); + + it("does not mutate the original body", () => { + const body = { model: "glm-5.2", reasoning: true }; + const result = stripBooleanReasoning(body); + assert.equal(body.reasoning, true); + assert.equal("reasoning" in result, false); + }); +});