Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/16646-reasoning-alias-collision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- sse: drop the `reasoning` alias once `reasoning_content` carries the replay, fixing GLM/Kimi 400 "duplicate field reasoning_content" (#16646)
7 changes: 7 additions & 0 deletions open-sse/translator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
64 changes: 64 additions & 0 deletions tests/unit/translator-reasoning-alias-collision-4841.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => ({
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");
});
}
Loading