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/18193-deferred-finish-replay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(translator):** OpenAI→Claude deferred-finish flush no longer replays the finish chunk's trailing text / tool-arg fragment, which produced invalid tool-input JSON ([#18193](https://github.com/cheaper-inference/OmniRoute/pull/18193))
8 changes: 7 additions & 1 deletion open-sse/translator/response/openai-to-claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,13 @@ export function openaiToClaudeResponse(chunk, state) {
const choice = chunkChoice || state.pendingClaudeFinishChoice;
if (!choice) return null;
if (flushingPendingFinish) state.pendingClaudeFinishChoice = null;
const delta = choice.delta;
// The deferred choice was already processed on the pass that received it: its
// text / reasoning / tool-arg fragments were emitted then, and only the
// terminal blocks were held back for the real usage. Re-reading the delta
// here replays the trailing fragment a second time, so an OpenAI→Claude
// client that concatenates input_json_delta into one JSON string receives the
// final fragment twice and fails to parse the tool input.
const delta = flushingPendingFinish ? undefined : choice.delta;
// First chunk - ALWAYS send message_start first
if (!state.messageStartSent) {
state.messageStartSent = true;
Expand Down
141 changes: 140 additions & 1 deletion tests/unit/openai-to-claude-trailing-usage-deferred-finish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ test("trailing choices-empty chunk with tool_calls finish_reason preserves tool_
{
index: 0,
id: "call_123",
function: { name: "get_weather", arguments: "{\"city\":\"Beijing\"}" },
function: { name: "get_weather", arguments: '{"city":"Beijing"}' },
},
],
},
Expand Down Expand Up @@ -227,3 +227,142 @@ test("trailing choices-empty chunk with tool_calls finish_reason preserves tool_
{ type: "message_stop" },
]);
});

function joinDeltas(events: Array<Record<string, unknown>>, deltaType: string): string {
return events
.filter((event) => (event.delta as Record<string, unknown> | undefined)?.type === deltaType)
.map((event) =>
String(
(event.delta as Record<string, unknown>).partial_json ??
(event.delta as Record<string, unknown>).text ??
""
)
)
.join("");
}

// The deferred finish chunk is re-processed when the trailing usage chunk (or EOF)
// arrives. Its delta was already emitted on the pass that received it, so the
// replay must not re-emit the trailing fragment — otherwise an OpenAI→Claude
// client concatenating input_json_delta receives the final fragment twice and the
// assembled tool input is no longer valid JSON.
test("trailing usage flush does not replay the finish chunk's tool-arg fragment", () => {
const state = createState();
const events = collectEvents(
[
{
id: "chatcmpl-18065-tool-tail",
model: "accounts/fireworks/models/kimi-k3",
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: "call_tail",
function: { name: "run_python", arguments: '{"code":"print(1)' },
},
],
},
finish_reason: null,
},
],
},
{
id: "chatcmpl-18065-tool-tail",
model: "accounts/fireworks/models/kimi-k3",
choices: [
{
index: 0,
delta: { tool_calls: [{ index: 0, function: { arguments: '"}' } }] },
finish_reason: "tool_calls",
},
],
},
{
id: "chatcmpl-18065-tool-tail",
model: "accounts/fireworks/models/kimi-k3",
choices: [],
usage: TRAILING_USAGE,
},
],
state
);

assert.equal(joinDeltas(events, "input_json_delta"), '{"code":"print(1)"}');
assert.equal(events.filter((event) => event.type === "message_stop").length, 1);
const messageDelta = events.find((event) => event.type === "message_delta");
assert.equal(messageDelta?.delta?.stop_reason, "tool_use");
});

test("stream-end flush does not replay the finish chunk's tool-arg fragment", () => {
const state = createState();
const events = collectEvents(
[
{
id: "chatcmpl-18065-tool-tail-eof",
model: "accounts/fireworks/models/kimi-k3",
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: "call_tail_eof",
function: { name: "run_python", arguments: '{"code":"print(1)' },
},
],
},
finish_reason: null,
},
],
},
{
id: "chatcmpl-18065-tool-tail-eof",
model: "accounts/fireworks/models/kimi-k3",
choices: [
{
index: 0,
delta: { tool_calls: [{ index: 0, function: { arguments: '"}' } }] },
finish_reason: "tool_calls",
},
],
},
null,
],
state
);

assert.equal(joinDeltas(events, "input_json_delta"), '{"code":"print(1)"}');
assert.equal(events.filter((event) => event.type === "message_stop").length, 1);
});

test("trailing usage flush does not replay the finish chunk's trailing text", () => {
const state = createState();
const events = collectEvents(
[
{
id: "chatcmpl-18065-text-tail",
model: "accounts/fireworks/models/kimi-k3",
choices: [{ index: 0, delta: { content: "Hello" }, finish_reason: null }],
},
{
id: "chatcmpl-18065-text-tail",
model: "accounts/fireworks/models/kimi-k3",
choices: [{ index: 0, delta: { content: " world" }, finish_reason: "stop" }],
},
{
id: "chatcmpl-18065-text-tail",
model: "accounts/fireworks/models/kimi-k3",
choices: [],
usage: TRAILING_USAGE,
},
],
state
);

assert.equal(joinDeltas(events, "text_delta"), "Hello world");
assert.equal(events.filter((event) => event.type === "message_stop").length, 1);
});