fix: correct finish_reason for tool calls in OpenAI Responses translator

Apply fix from PR #354 by @tannk4w to properly signal tool_calls finish_reason
when model emits tool calls, allowing OpenAI-compatible clients to continue with
tool result processing instead of stopping prematurely.

Refactored finish_reason logic into computeFinishReason() helper to eliminate
duplication and improve maintainability across flush and completion paths.

Co-authored-by: tannk4w <tannk@tmi-soft.vn>

Thanks to @tannk4w, @trungtq2799, @quanhavn, and @East-rayyy for the thorough
review and improvement suggestions on the original PR.

Made-with: Cursor
This commit is contained in:
decolua
2026-03-28 14:45:07 +07:00
parent bf99c600f1
commit 11e6004fcb
@@ -355,6 +355,14 @@ function flushEvents(state) {
return events;
}
// currentToolCallId is intentionally sticky for the current turn so flush/completion
// can still finalize as tool_calls even if the tool call was emitted before stream end.
function computeFinishReason(state) {
return state.toolCallIndex > 0 || state.currentToolCallId
? "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
@@ -362,22 +370,30 @@ function flushEvents(state) {
export function openaiResponsesToOpenAIResponse(chunk, state) {
if (!chunk) {
// Flush: send final chunk with finish_reason
if (!state.finishReasonSent && state.started) {
state.finishReasonSent = true;
const hasToolCalls = state.toolCallIndex > 0;
return {
id: state.chatId || `chatcmpl-${Date.now()}`,
object: "chat.completion.chunk",
created: state.created || Math.floor(Date.now() / 1000),
model: state.model || "unknown",
choices: [{
index: 0,
delta: {},
finish_reason: hasToolCalls ? "tool_calls" : "stop"
}]
};
if (state.finishReasonSent || !state.started) return null;
const finishReason = computeFinishReason(state);
state.finishReasonSent = true;
state.finishReason = finishReason;
const finalChunk = {
id: state.chatId || `chatcmpl-${Date.now()}`,
object: "chat.completion.chunk",
created: state.created || Math.floor(Date.now() / 1000),
model: state.model || "unknown",
choices: [{
index: 0,
delta: {},
finish_reason: finishReason
}]
};
if (state.usage && typeof state.usage === "object") {
finalChunk.usage = state.usage;
}
return null;
return finalChunk;
}
// Handle different event types from Responses API
@@ -505,10 +521,10 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
}
if (!state.finishReasonSent) {
const finishReason = computeFinishReason(state);
state.finishReasonSent = true;
const hasToolCalls = state.toolCallIndex > 0;
const resolvedFinishReason = hasToolCalls ? "tool_calls" : "stop";
state.finishReason = resolvedFinishReason; // Mark for usage injection in stream.js
state.finishReason = finishReason; // Mark for usage injection in stream.js
const finalChunk = {
id: state.chatId,
@@ -518,7 +534,7 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
choices: [{
index: 0,
delta: {},
finish_reason: resolvedFinishReason
finish_reason: finishReason
}]
};
@@ -571,4 +587,3 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
// Register both directions
register(FORMATS.OPENAI, FORMATS.OPENAI_RESPONSES, null, openaiToOpenAIResponsesResponse);
register(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI, null, openaiResponsesToOpenAIResponse);