From eadcbea1c93b5cf3789ffd50ea2c0ca95a46840b Mon Sep 17 00:00:00 2001 From: "Andrew B." <37745667+AndrianBalanescu@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:26:13 -0500 Subject: [PATCH] fix(combo): strip boolean reasoning field for opencode-go providers (#7891) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(combo): strip boolean reasoning field for opencode-go providers opencode-go backed providers (ollama-cloud, opencode-go, opencode, opencode-zen) use a Go ChatCompletionRequest struct where the reasoning field is typed as openai.Reasoning (a structured type). When a client sends reasoning: true or reasoning: false — valid per the OpenAI API — 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 before forwarding to these providers, allowing the upstream to apply its own default reasoning behavior. Object/string forms are left untouched. Observed in production: 3 consecutive 400 errors from ollama-cloud/glm-5.2 in a 30-second window, each with the unmarshal error. * fix(opencode): add null/primitive guard in stripBooleanReasoning Adds defensive check for null, undefined, and non-object inputs as suggested in review. Added unit test coverage for these edge cases. --- open-sse/handlers/chatCore.ts | 11 ++ .../services/opencodeReasoningSanitizer.ts | 46 ++++++++ tests/unit/opencodeReasoningSanitizer.test.ts | 111 ++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 open-sse/services/opencodeReasoningSanitizer.ts create mode 100644 tests/unit/opencodeReasoningSanitizer.test.ts 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); + }); +});