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:
roboomp
2026-05-28 16:47:01 +00:00
parent f8ab2eaf78
commit 4215228b81
2 changed files with 28 additions and 6 deletions
@@ -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