fix(ai): coerce opencode kimi reasoning replay onto reasoning_content
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.
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user