From 5fa23c7f4141204c7b22db78a37827c6cfadd0f2 Mon Sep 17 00:00:00 2001 From: Kirill Turanskiy Date: Wed, 18 Feb 2026 13:42:24 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20handle=20tool=20call=20argument=20stream?= =?UTF-8?q?ing=20in=20Codex=E2=86=92OpenAI=20translator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenAI Chat Completions translator was silently dropping response.function_call_arguments.delta and response.function_call_arguments.done Codex SSE events, meaning tool call arguments were never streamed incrementally to clients. Add proper handling mirroring the proven Claude translator pattern: - response.output_item.added: announce tool call (id, name, empty args) - response.function_call_arguments.delta: stream argument chunks - response.function_call_arguments.done: emit full args if no deltas - response.output_item.done: defensive fallback for backward compat State tracking via HasReceivedArgumentsDelta and HasToolCallAnnounced ensures no duplicate argument emission and correct behavior for models like codex-spark that skip delta events entirely. --- .../chat-completions/codex_openai_response.go | 130 +++++++++++++----- 1 file changed, 96 insertions(+), 34 deletions(-) diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_response.go b/internal/translator/codex/openai/chat-completions/codex_openai_response.go index cdea33ee..f0e264c8 100644 --- a/internal/translator/codex/openai/chat-completions/codex_openai_response.go +++ b/internal/translator/codex/openai/chat-completions/codex_openai_response.go @@ -20,10 +20,12 @@ var ( // ConvertCliToOpenAIParams holds parameters for response conversion. type ConvertCliToOpenAIParams struct { - ResponseID string - CreatedAt int64 - Model string - FunctionCallIndex int + ResponseID string + CreatedAt int64 + Model string + FunctionCallIndex int + HasReceivedArgumentsDelta bool + HasToolCallAnnounced bool } // ConvertCodexResponseToOpenAI translates a single chunk of a streaming response from the @@ -43,10 +45,12 @@ type ConvertCliToOpenAIParams struct { func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string { if *param == nil { *param = &ConvertCliToOpenAIParams{ - Model: modelName, - CreatedAt: 0, - ResponseID: "", - FunctionCallIndex: -1, + Model: modelName, + CreatedAt: 0, + ResponseID: "", + FunctionCallIndex: -1, + HasReceivedArgumentsDelta: false, + HasToolCallAnnounced: false, } } @@ -118,35 +122,93 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR } template, _ = sjson.Set(template, "choices.0.finish_reason", finishReason) template, _ = sjson.Set(template, "choices.0.native_finish_reason", finishReason) - } else if dataType == "response.output_item.done" { - functionCallItemTemplate := `{"index":0,"id":"","type":"function","function":{"name":"","arguments":""}}` + } else if dataType == "response.output_item.added" { itemResult := rootResult.Get("item") - if itemResult.Exists() { - if itemResult.Get("type").String() != "function_call" { - return []string{} - } - - // set the index - (*param).(*ConvertCliToOpenAIParams).FunctionCallIndex++ - functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "index", (*param).(*ConvertCliToOpenAIParams).FunctionCallIndex) - - template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls", `[]`) - functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "id", itemResult.Get("call_id").String()) - - // Restore original tool name if it was shortened - name := itemResult.Get("name").String() - // Build reverse map on demand from original request tools - rev := buildReverseMapFromOriginalOpenAI(originalRequestRawJSON) - if orig, ok := rev[name]; ok { - name = orig - } - functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "function.name", name) - - functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "function.arguments", itemResult.Get("arguments").String()) - template, _ = sjson.Set(template, "choices.0.delta.role", "assistant") - template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls.-1", functionCallItemTemplate) + if !itemResult.Exists() || itemResult.Get("type").String() != "function_call" { + return []string{} } + // Increment index for this new function call item. + (*param).(*ConvertCliToOpenAIParams).FunctionCallIndex++ + (*param).(*ConvertCliToOpenAIParams).HasReceivedArgumentsDelta = false + (*param).(*ConvertCliToOpenAIParams).HasToolCallAnnounced = true + + functionCallItemTemplate := `{"index":0,"id":"","type":"function","function":{"name":"","arguments":""}}` + functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "index", (*param).(*ConvertCliToOpenAIParams).FunctionCallIndex) + functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "id", itemResult.Get("call_id").String()) + + // Restore original tool name if it was shortened. + name := itemResult.Get("name").String() + rev := buildReverseMapFromOriginalOpenAI(originalRequestRawJSON) + if orig, ok := rev[name]; ok { + name = orig + } + functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "function.name", name) + functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "function.arguments", "") + + template, _ = sjson.Set(template, "choices.0.delta.role", "assistant") + template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls", `[]`) + template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls.-1", functionCallItemTemplate) + + } else if dataType == "response.function_call_arguments.delta" { + (*param).(*ConvertCliToOpenAIParams).HasReceivedArgumentsDelta = true + + deltaValue := rootResult.Get("delta").String() + functionCallItemTemplate := `{"index":0,"function":{"arguments":""}}` + functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "index", (*param).(*ConvertCliToOpenAIParams).FunctionCallIndex) + functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "function.arguments", deltaValue) + + template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls", `[]`) + template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls.-1", functionCallItemTemplate) + + } else if dataType == "response.function_call_arguments.done" { + if (*param).(*ConvertCliToOpenAIParams).HasReceivedArgumentsDelta { + // Arguments were already streamed via delta events; nothing to emit. + return []string{} + } + + // Fallback: no delta events were received, emit the full arguments as a single chunk. + fullArgs := rootResult.Get("arguments").String() + functionCallItemTemplate := `{"index":0,"function":{"arguments":""}}` + functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "index", (*param).(*ConvertCliToOpenAIParams).FunctionCallIndex) + functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "function.arguments", fullArgs) + + template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls", `[]`) + template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls.-1", functionCallItemTemplate) + + } else if dataType == "response.output_item.done" { + itemResult := rootResult.Get("item") + if !itemResult.Exists() || itemResult.Get("type").String() != "function_call" { + return []string{} + } + + if (*param).(*ConvertCliToOpenAIParams).HasToolCallAnnounced { + // Tool call was already announced via output_item.added; skip emission. + (*param).(*ConvertCliToOpenAIParams).HasToolCallAnnounced = false + return []string{} + } + + // Fallback path: model skipped output_item.added, so emit complete tool call now. + (*param).(*ConvertCliToOpenAIParams).FunctionCallIndex++ + + functionCallItemTemplate := `{"index":0,"id":"","type":"function","function":{"name":"","arguments":""}}` + functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "index", (*param).(*ConvertCliToOpenAIParams).FunctionCallIndex) + + template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls", `[]`) + functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "id", itemResult.Get("call_id").String()) + + // Restore original tool name if it was shortened. + name := itemResult.Get("name").String() + rev := buildReverseMapFromOriginalOpenAI(originalRequestRawJSON) + if orig, ok := rev[name]; ok { + name = orig + } + functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "function.name", name) + + functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "function.arguments", itemResult.Get("arguments").String()) + template, _ = sjson.Set(template, "choices.0.delta.role", "assistant") + template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls.-1", functionCallItemTemplate) + } else { return []string{} }