From 4215228b810e8f0a4a09d79627855174aa8b7331 Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 28 May 2026 16:47:01 +0000 Subject: [PATCH] fix(ai): coerce opencode kimi reasoning replay onto reasoning_content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The opencode-kimi thinking-mode override flipped `requiresReasoningContentForToolCalls` on but left the rest of compat at its default: `allowsSyntheticReasoningContentForToolCalls=true` and the streamed-signature path in `convertMessages` echoed whichever recognized field the upstream emitted. Opencode Kimi streams reasoning under `reasoning`, so a follow-up replay landed `reasoning` on the assistant message and left `reasoning_content` empty — the gateway still 400s with 'reasoning_content is missing in assistant tool call message at index N'. Force `reasoning_content` as the wire field for this override: - `buildParams` now also sets `allowsSyntheticReasoningContentForToolCalls=false` and `reasoningContentField="reasoning_content"` on the same gated branch (kimi + opencode + thinking-on + not forced-tool). - `convertMessages` thinking-block branch now respects `allowsSyntheticReasoningContentForToolCalls`: when false, replay always uses the configured `reasoningContentField` instead of the streamed signature, so we never simultaneously write to both `reasoning` and `reasoning_content`. DeepSeek already runs through the same code with `allowsSynthetic=false` and existing tests continue to pass under the cleaner output. Updated the #1484 regression test to use the upstream's actual `thinkingSignature: "reasoning"` shape and to additionally assert that `reasoning` is absent from the wire body so a future regression to dual-key emission would fail. --- .../ai/src/providers/openai-completions.ts | 26 +++++++++++++++---- .../ai/test/openai-completions-compat.test.ts | 8 +++++- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index fe80ff36d..f616bdc05 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -1009,6 +1009,12 @@ function buildParams( // later `disableReasoningOnForcedToolChoice` guard at the bottom of // `buildParams` strips thinking from the wire body for Kimi — keeping the // replay on under those conditions would resurrect the #1071 failure. + // + // `allowsSyntheticReasoningContentForToolCalls` is forced to `false` on + // the same path: the gateway specifically requires `reasoning_content`, + // and the default synthetic-friendly behavior would echo whichever field + // the upstream streamed (e.g. `reasoning` for many opencode Kimi turns), + // landing the replay in the wrong key and re-triggering the 400. const isKimiModelId = model.id.includes("moonshotai/kimi") || /(^|\/)kimi[-.]/i.test(model.id); const isOpenCodeProvider = model.provider === "opencode-go" || model.provider === "opencode-zen"; const thinkingEnabledForRequest = @@ -1018,6 +1024,8 @@ function buildParams( isForcedToolChoice(mapToOpenAICompletionsToolChoice(options?.toolChoice)); if (isKimiModelId && isOpenCodeProvider && thinkingEnabledForRequest && !forcedToolChoiceSuppressesThinking) { compat.requiresReasoningContentForToolCalls = true; + compat.allowsSyntheticReasoningContentForToolCalls = false; + compat.reasoningContentField = "reasoning_content"; } const messages = convertMessages(model, context, compat); maybeAddOpenRouterAnthropicCacheControl(model, messages); @@ -1486,13 +1494,21 @@ export function convertMessages( assistantMsg.content = [{ type: "text", text: thinkingText }]; } } else if (compat.requiresReasoningContentForToolCalls) { - // Use the signature from the first thinking block if available, but only for - // recognized OpenAI-compat reasoning field names. Opaque signatures from other - // providers (Anthropic encrypted, OpenAI Responses JSON) are not valid property names. + // Use the streamed signature when the backend accepts whichever + // recognized field name was emitted (allowsSynthetic=true). Backends + // like opencode-kimi-with-thinking and DeepSeek demand the exact + // configured `reasoningContentField` instead, so honor that here + // rather than echoing the upstream field name. const signature = nonEmptyThinkingBlocks[0].thinkingSignature; const recognizedFields = ["reasoning_content", "reasoning", "reasoning_text"]; - if (signature && recognizedFields.includes(signature)) { - (assistantMsg as any)[signature] = nonEmptyThinkingBlocks.map(b => b.thinking).join("\n"); + const wireField = + compat.allowsSyntheticReasoningContentForToolCalls && signature && recognizedFields.includes(signature) + ? signature + : signature && recognizedFields.includes(signature) + ? (compat.reasoningContentField ?? "reasoning_content") + : undefined; + if (wireField) { + (assistantMsg as any)[wireField] = nonEmptyThinkingBlocks.map(b => b.thinking).join("\n"); } } } diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts index cf68275bf..826999992 100644 --- a/packages/ai/test/openai-completions-compat.test.ts +++ b/packages/ai/test/openai-completions-compat.test.ts @@ -656,7 +656,10 @@ describe("kimi model detection via detectCompat", () => { { type: "thinking", thinking: "Need to read the file before answering.", - thinkingSignature: "reasoning_content", + // OpenCode Kimi streams reasoning under the `reasoning` field + // name; the override must coerce it into `reasoning_content` + // when replaying tool-call history. + thinkingSignature: "reasoning", }, { type: "toolCall", @@ -710,6 +713,9 @@ describe("kimi model detection via detectCompat", () => { const assistant = payload.messages.find(m => m.role === "assistant"); expect(assistant).toBeDefined(); expect(Reflect.get(assistant as object, "reasoning_content")).toBe("Need to read the file before answering."); + // The streamed `reasoning` key must NOT land in the wire body alongside + // `reasoning_content`; opencode's strict schema rejects unknown fields. + expect(Reflect.get(assistant as object, "reasoning")).toBeUndefined(); }); // #1071 regression guard alongside the #1484 fix: with thinking disabled the