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
22 changes: 18 additions & 4 deletions open-sse/translator/response/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,21 @@ function withAssistantRoleOnFirstDelta(state, result) {
return result;
}

/**
* Resolve the terminal finish_reason for a Responses→Chat stream.
*
* `currentToolCallId` is intentionally sticky for the current turn: it is set when a
* function_call item is announced (`response.output_item.added`) and is only cleared once
* the matching `response.output_item.done` advances `toolCallIndex`. If the stream ends
* (flush or `response.completed`) after a tool call was emitted but BEFORE its
* `output_item.done` arrived, `toolCallIndex` is still 0 while `currentToolCallId` is set.
* Guarding on it as well lets us still finalize as `tool_calls` instead of `stop`, so
* OpenAI-compatible clients continue tool-result processing instead of stopping prematurely.
*/
function computeFinishReason(state): "tool_calls" | "stop" {
return (state.toolCallIndex || 0) > 0 || state.currentToolCallId ? "tool_calls" : "stop";
}
Comment on lines +545 to +547

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If a tool call is deferred (i.e., state.currentToolCallDeferred is true because the provider emitted an empty or placeholder tool name), no tool call has actually been sent to the client yet. If the stream ends early in this state, finalizing with "tool_calls" will confuse OpenAI-compatible clients since they received no tool call metadata. We should guard against state.currentToolCallDeferred being true when checking state.currentToolCallId.

Suggested change
function computeFinishReason(state): "tool_calls" | "stop" {
return (state.toolCallIndex || 0) > 0 || state.currentToolCallId ? "tool_calls" : "stop";
}
function computeFinishReason(state): "tool_calls" | "stop" {
const hasActiveToolCall = state.currentToolCallId && !state.currentToolCallDeferred;
return (state.toolCallIndex || 0) > 0 || hasActiveToolCall ? "tool_calls" : "stop";
}


/**
* Translate OpenAI Responses API chunk to OpenAI Chat Completions format
* This is for when Codex returns data and we need to send it to an OpenAI-compatible client
Expand All @@ -544,7 +559,7 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
// Flush: send final chunk with finish_reason
if (!state.finishReasonSent && state.started) {
state.finishReasonSent = true;
const hadToolCalls = (state.toolCallIndex || 0) > 0;
const finishReason = computeFinishReason(state);
return {
id: state.chatId || `chatcmpl-${Date.now()}`,
object: "chat.completion.chunk",
Expand All @@ -554,7 +569,7 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
{
index: 0,
delta: {},
finish_reason: hadToolCalls ? "tool_calls" : "stop",
finish_reason: finishReason,
},
],
};
Expand Down Expand Up @@ -830,8 +845,7 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {

if (!state.finishReasonSent) {
state.finishReasonSent = true;
const hadToolCalls = (state.toolCallIndex || 0) > 0;
const reason = hadToolCalls ? "tool_calls" : "stop";
const reason = computeFinishReason(state);
state.finishReason = reason; // Mark for usage injection in stream.js

const finalChunk: Record<string, unknown> = {
Expand Down
54 changes: 54 additions & 0 deletions tests/unit/responses-translation-fixes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,3 +459,57 @@ test("Chat→Responses streaming: multiple <think> tags in one chunk handled", (
const combined = textDeltas.join("");
assert.ok(!combined.includes("<think>"), `text should not contain <think> tag, got: ${combined}`);
});

// Regression: a tool call was announced (response.output_item.added set currentToolCallId)
// but the stream ended before response.output_item.done could advance toolCallIndex. The
// terminal finish_reason must still be "tool_calls", not "stop", so OpenAI-compatible
// clients keep processing the tool result instead of stopping prematurely.
test("Responses→Chat streaming: flush finalizes tool_calls when currentToolCallId set but toolCallIndex==0", () => {
const state = {
started: true,
chatId: "chatcmpl-x",
created: 1_700_000_000,
model: "gpt-4",
toolCallIndex: 0,
currentToolCallId: "call_abc",
finishReasonSent: false,
};

const result = openaiResponsesToOpenAIResponse(null, state);
assert.ok(result, "flush should emit a final chunk");
assert.equal(result.choices[0].finish_reason, "tool_calls");
});

test("Responses→Chat streaming: response.completed finalizes tool_calls when currentToolCallId set but toolCallIndex==0", () => {
const state = {
started: true,
chatId: "chatcmpl-y",
created: 1_700_000_000,
model: "gpt-4",
toolCallIndex: 0,
currentToolCallId: "call_def",
finishReasonSent: false,
};

const chunk = { type: "response.completed", data: { response: {} } };
const result = openaiResponsesToOpenAIResponse(chunk, state);
assert.ok(result, "response.completed should emit a final chunk");
assert.equal(result.choices[0].finish_reason, "tool_calls");
assert.equal(state.finishReason, "tool_calls");
});

test("Responses→Chat streaming: flush finalizes stop when no tool call was emitted", () => {
const state = {
started: true,
chatId: "chatcmpl-z",
created: 1_700_000_000,
model: "gpt-4",
toolCallIndex: 0,
currentToolCallId: null,
finishReasonSent: false,
};

const result = openaiResponsesToOpenAIResponse(null, state);
assert.ok(result, "flush should emit a final chunk");
assert.equal(result.choices[0].finish_reason, "stop");
});
Loading