diff --git a/changelog.d/fixes/16646-reasoning-alias-collision.md b/changelog.d/fixes/16646-reasoning-alias-collision.md new file mode 100644 index 000000000000..28b54b7f0c4a --- /dev/null +++ b/changelog.d/fixes/16646-reasoning-alias-collision.md @@ -0,0 +1 @@ +- sse: drop the `reasoning` alias once `reasoning_content` carries the replay, fixing GLM/Kimi 400 "duplicate field reasoning_content" (#16646) diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 2055d4123f65..cbfca95b9508 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -246,6 +246,13 @@ function replayOpenAIReasoningMessage( ) { message.reasoning_content = message.reasoning; } + // Serde-based upstreams (z-ai GLM, Moonshot Kimi) alias `reasoning` to + // `reasoning_content`, so a turn carrying both is rejected with 400 + // "duplicate field `reasoning_content`" (9router#4841). Once + // `reasoning_content` holds the text it is the canonical carrier. + if (typeof message.reasoning === "string" && hasNonEmptyReasoningContent(message)) { + delete message.reasoning; + } const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : []; const hasToolCalls = toolCalls.length > 0; diff --git a/tests/unit/translator-reasoning-alias-collision-4841.test.ts b/tests/unit/translator-reasoning-alias-collision-4841.test.ts new file mode 100644 index 000000000000..0b1d8793e355 --- /dev/null +++ b/tests/unit/translator-reasoning-alias-collision-4841.test.ts @@ -0,0 +1,64 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { translateRequest } = await import("../../open-sse/translator/index.ts"); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); + +// Regression for upstream issue 9router#4841: serde-based OpenAI-compatible +// upstreams (z-ai GLM, Moonshot Kimi) declare `reasoning` as an alias of +// `reasoning_content`. A replayed assistant turn carrying BOTH keys is rejected +// with 400 "duplicate field `reasoning_content`". On reasoning-replay targets +// `reasoning_content` is the canonical carrier, so `reasoning` must be dropped. +const historyWith = (assistant: Record) => ({ + messages: [ + { role: "user", content: "hi" }, + { + role: "assistant", + content: "ok", + tool_calls: [{ id: "c1", type: "function", function: { name: "f", arguments: "{}" } }], + ...assistant, + }, + { role: "tool", tool_call_id: "c1", content: "r" }, + { role: "user", content: "say ok" }, + ], +}); + +const TARGETS: Array<[string, string]> = [ + ["glm", "glm-4.6-thinking"], + ["moonshot", "kimi-k2.5"], + ["moonshot", "kimi-k3"], +]; + +for (const [provider, model] of TARGETS) { + test(`${provider}/${model}: assistant turn with both reasoning and reasoning_content forwards only reasoning_content (9router#4841)`, () => { + const result = translateRequest( + FORMATS.OPENAI, + FORMATS.OPENAI, + model, + { model, ...historyWith({ reasoning: "dup test", reasoning_content: "dup test" }) }, + true, + null, + provider + ); + const assistant = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistant, "assistant message present"); + assert.equal(assistant.reasoning_content, "dup test"); + assert.equal("reasoning" in assistant, false, "alias `reasoning` must not be forwarded"); + }); + + test(`${provider}/${model}: reasoning-only assistant turn is still promoted to reasoning_content (9router#4841)`, () => { + const result = translateRequest( + FORMATS.OPENAI, + FORMATS.OPENAI, + model, + { model, ...historyWith({ reasoning: "only reasoning" }) }, + true, + null, + provider + ); + const assistant = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistant, "assistant message present"); + assert.equal(assistant.reasoning_content, "only reasoning"); + assert.equal("reasoning" in assistant, false, "alias `reasoning` must not be forwarded"); + }); +}