From 34639c3cf904247d0694a384c0af671d786b4359 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 20 Jun 2026 00:11:12 +0800 Subject: [PATCH] feat(translator): defer Codex function call starts until function name is available - Updated `ConvertCodexResponseToClaude` to delay emitting `function_call` start events until the `name` field is resolved. - Introduced `pendingCodexFunctionCall` for buffering incomplete function calls. - Added tests to ensure proper behavior for deferred starts, including argument buffering and finalization. Closes: #3471 --- .../codex/claude/codex_claude_response.go | 186 ++++++++++++++---- .../claude/codex_claude_response_test.go | 56 ++++++ 2 files changed, 203 insertions(+), 39 deletions(-) diff --git a/internal/translator/codex/claude/codex_claude_response.go b/internal/translator/codex/claude/codex_claude_response.go index 3a8dab5e..ace43013 100644 --- a/internal/translator/codex/claude/codex_claude_response.go +++ b/internal/translator/codex/claude/codex_claude_response.go @@ -23,18 +23,28 @@ var ( // ConvertCodexResponseToClaudeParams holds parameters for response conversion. type ConvertCodexResponseToClaudeParams struct { - HasToolCall bool + HasToolCall bool + BlockIndex int + HasReceivedArgumentsDelta bool + HasTextDelta bool + TextBlockOpen bool + ThinkingBlockOpen bool + ThinkingStopPending bool + ThinkingSignature string + ThinkingSummarySeen bool + WebSearchToolUseIDs map[string]struct{} + WebSearchToolResultIDs map[string]struct{} + LastWebSearchToolUseID string + PendingFunctionCalls map[string]*pendingCodexFunctionCall + LastPendingFunctionCallKey string +} + +type pendingCodexFunctionCall struct { + CallID string + Arguments string BlockIndex int HasReceivedArgumentsDelta bool - HasTextDelta bool - TextBlockOpen bool - ThinkingBlockOpen bool - ThinkingStopPending bool - ThinkingSignature string - ThinkingSummarySeen bool - WebSearchToolUseIDs map[string]struct{} - WebSearchToolResultIDs map[string]struct{} - LastWebSearchToolUseID string + StartEmitted bool } // ConvertCodexResponseToClaude performs sophisticated streaming response format conversion. @@ -145,24 +155,26 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa output = append(output, stopCodexTextBlock(params)...) params.HasToolCall = true params.HasReceivedArgumentsDelta = false - template = []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`) - template, _ = sjson.SetBytes(template, "index", params.BlockIndex) - template, _ = sjson.SetBytes(template, "content_block.id", shortenCodexCallIDIfNeeded(util.SanitizeClaudeToolID(itemResult.Get("call_id").String()))) - { - name := itemResult.Get("name").String() - rev := buildReverseMapFromClaudeOriginalShortToOriginal(originalRequestRawJSON) - if orig, ok := rev[name]; ok { - name = orig + + callID := itemResult.Get("call_id").String() + name := itemResult.Get("name").String() + key := codexFunctionCallKey(rootResult, itemResult) + if name == "" { + if params.PendingFunctionCalls == nil { + params.PendingFunctionCalls = map[string]*pendingCodexFunctionCall{} } - template, _ = sjson.SetBytes(template, "content_block.name", name) + params.PendingFunctionCalls[key] = &pendingCodexFunctionCall{ + CallID: callID, + BlockIndex: params.BlockIndex, + } + params.LastPendingFunctionCallKey = key + params.BlockIndex++ + break } - output = translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2) - - template = []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`) - template, _ = sjson.SetBytes(template, "index", params.BlockIndex) - - output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2) + delete(params.PendingFunctionCalls, key) + output = appendCodexFunctionCallStart(output, originalRequestRawJSON, callID, name, params.BlockIndex) + output = appendCodexFunctionCallArgumentDelta(output, "", params.BlockIndex) case "reasoning": params.ThinkingSummarySeen = false params.ThinkingSignature = itemResult.Get("encrypted_content").String() @@ -207,11 +219,36 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa output = append(output, stopCodexTextBlock(params)...) params.HasTextDelta = true case "function_call": - template = []byte(`{"type":"content_block_stop","index":0}`) - template, _ = sjson.SetBytes(template, "index", params.BlockIndex) - params.BlockIndex++ + key := codexFunctionCallKey(rootResult, itemResult) + if pending, pendingKey := pendingCodexFunctionCallForKey(params, key); pending != nil && !pending.StartEmitted { + name := itemResult.Get("name").String() + if name == "" { + return [][]byte{output} + } + callID := pending.CallID + if callID == "" { + callID = itemResult.Get("call_id").String() + } + output = appendCodexFunctionCallStart(output, originalRequestRawJSON, callID, name, pending.BlockIndex) + pending.StartEmitted = true - output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2) + args := pending.Arguments + if args == "" { + args = itemResult.Get("arguments").String() + } + if args != "" { + output = appendCodexFunctionCallArgumentDelta(output, args, pending.BlockIndex) + } + output = appendCodexFunctionCallStop(output, pending.BlockIndex) + + delete(params.PendingFunctionCalls, pendingKey) + if params.LastPendingFunctionCallKey == pendingKey { + params.LastPendingFunctionCallKey = "" + } + } else { + output = appendCodexFunctionCallStop(output, params.BlockIndex) + params.BlockIndex++ + } case "reasoning": if signature := itemResult.Get("encrypted_content").String(); signature != "" { params.ThinkingSignature = signature @@ -227,20 +264,28 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa output = appendCodexWebSearchToolResult(output, params, rootResult, itemResult) } case "response.function_call_arguments.delta": - params.HasReceivedArgumentsDelta = true - template = []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`) - template, _ = sjson.SetBytes(template, "index", params.BlockIndex) - template, _ = sjson.SetBytes(template, "delta.partial_json", rootResult.Get("delta").String()) + delta := rootResult.Get("delta").String() + key := codexArgumentsFunctionCallKey(params, rootResult) + if pending, _ := pendingCodexFunctionCallForKey(params, key); pending != nil && !pending.StartEmitted { + pending.HasReceivedArgumentsDelta = true + pending.Arguments += delta + break + } - output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2) + params.HasReceivedArgumentsDelta = true + output = appendCodexFunctionCallArgumentDelta(output, delta, params.BlockIndex) case "response.function_call_arguments.done": + key := codexArgumentsFunctionCallKey(params, rootResult) + if pending, _ := pendingCodexFunctionCallForKey(params, key); pending != nil && !pending.StartEmitted { + if !pending.HasReceivedArgumentsDelta { + pending.Arguments = rootResult.Get("arguments").String() + } + break + } + if !params.HasReceivedArgumentsDelta { if args := rootResult.Get("arguments").String(); args != "" { - template = []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`) - template, _ = sjson.SetBytes(template, "index", params.BlockIndex) - template, _ = sjson.SetBytes(template, "delta.partial_json", args) - - output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2) + output = appendCodexFunctionCallArgumentDelta(output, args, params.BlockIndex) } } } @@ -458,6 +503,69 @@ func setClaudeStopSequence(out []byte, path string, responseData gjson.Result) [ return out } +func codexFunctionCallKey(rootResult, itemResult gjson.Result) string { + if outputIndex := rootResult.Get("output_index"); outputIndex.Exists() { + return "output:" + outputIndex.Raw + } + if callID := itemResult.Get("call_id").String(); callID != "" { + return "call:" + callID + } + return "last" +} + +func codexArgumentsFunctionCallKey(params *ConvertCodexResponseToClaudeParams, rootResult gjson.Result) string { + if outputIndex := rootResult.Get("output_index"); outputIndex.Exists() { + return "output:" + outputIndex.Raw + } + return params.LastPendingFunctionCallKey +} + +func pendingCodexFunctionCallForKey(params *ConvertCodexResponseToClaudeParams, key string) (*pendingCodexFunctionCall, string) { + if params == nil || params.PendingFunctionCalls == nil { + return nil, "" + } + if key != "" { + if pending, ok := params.PendingFunctionCalls[key]; ok { + return pending, key + } + } + if params.LastPendingFunctionCallKey != "" { + if pending, ok := params.PendingFunctionCalls[params.LastPendingFunctionCallKey]; ok { + return pending, params.LastPendingFunctionCallKey + } + } + return nil, "" +} + +func appendCodexFunctionCallStart(output []byte, originalRequestRawJSON []byte, callID, name string, blockIndex int) []byte { + template := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`) + template, _ = sjson.SetBytes(template, "index", blockIndex) + template, _ = sjson.SetBytes(template, "content_block.id", shortenCodexCallIDIfNeeded(util.SanitizeClaudeToolID(callID))) + template, _ = sjson.SetBytes(template, "content_block.name", resolveCodexClaudeToolUseName(originalRequestRawJSON, name)) + return translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2) +} + +func appendCodexFunctionCallArgumentDelta(output []byte, partialJSON string, blockIndex int) []byte { + template := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`) + template, _ = sjson.SetBytes(template, "index", blockIndex) + template, _ = sjson.SetBytes(template, "delta.partial_json", partialJSON) + return translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2) +} + +func appendCodexFunctionCallStop(output []byte, blockIndex int) []byte { + template := []byte(`{"type":"content_block_stop","index":0}`) + template, _ = sjson.SetBytes(template, "index", blockIndex) + return translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2) +} + +func resolveCodexClaudeToolUseName(originalRequestRawJSON []byte, name string) string { + rev := buildReverseMapFromClaudeOriginalShortToOriginal(originalRequestRawJSON) + if orig, ok := rev[name]; ok { + return orig + } + return name +} + func extractResponsesUsage(usage gjson.Result) (int64, int64, int64) { if !usage.Exists() || usage.Type == gjson.Null { return 0, 0, 0 diff --git a/internal/translator/codex/claude/codex_claude_response_test.go b/internal/translator/codex/claude/codex_claude_response_test.go index e707fa6f..c4c82862 100644 --- a/internal/translator/codex/claude/codex_claude_response_test.go +++ b/internal/translator/codex/claude/codex_claude_response_test.go @@ -531,6 +531,62 @@ func TestConvertCodexResponseToClaude_StreamTextBeforeToolCallsDoesNotEmitGhostS } } +func TestConvertCodexResponseToClaude_StreamFunctionCallDefersStartUntilDoneName(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"tools":[{"name":"web_search","description":"search"}]}`) + var param any + + _ = ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, []byte(`data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-5"}}`), ¶m) + addedOutputs := ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, []byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_1"},"output_index":1}`), ¶m) + argumentsOutputs := ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, []byte(`data: {"type":"response.function_call_arguments.done","arguments":"{\"query\":\"example\"}","output_index":1}`), ¶m) + doneOutputs := ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, []byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_1","name":"web_search","arguments":"{\"query\":\"example\"}"},"output_index":1}`), ¶m) + + if bytes.Contains(bytes.Join(addedOutputs, nil), []byte(`"content_block_start"`)) { + t.Fatalf("function_call without name must not emit content_block_start: %q", addedOutputs) + } + if bytes.Contains(bytes.Join(argumentsOutputs, nil), []byte(`"input_json_delta"`)) { + t.Fatalf("arguments must be buffered until the tool name is available: %q", argumentsOutputs) + } + + var toolStartCount int + var toolStopCount int + var argumentDeltas []string + for _, out := range doneOutputs { + for _, line := range strings.Split(string(out), "\n") { + if !strings.HasPrefix(line, "data: ") { + continue + } + data := gjson.Parse(strings.TrimPrefix(line, "data: ")) + switch data.Get("type").String() { + case "content_block_start": + if data.Get("content_block.type").String() != "tool_use" { + continue + } + toolStartCount++ + if got := data.Get("content_block.name").String(); got != "web_search" { + t.Fatalf("unexpected tool_use name %q in %s", got, data.Raw) + } + case "content_block_delta": + if data.Get("delta.type").String() == "input_json_delta" { + argumentDeltas = append(argumentDeltas, data.Get("delta.partial_json").String()) + } + case "content_block_stop": + toolStopCount++ + } + } + } + + if toolStartCount != 1 { + t.Fatalf("expected one deferred tool_use start, got %d in %q", toolStartCount, doneOutputs) + } + if len(argumentDeltas) != 1 || argumentDeltas[0] != `{"query":"example"}` { + t.Fatalf("unexpected buffered argument deltas: %v", argumentDeltas) + } + if toolStopCount != 1 { + t.Fatalf("expected one deferred tool_use stop, got %d in %q", toolStopCount, doneOutputs) + } +} + func TestConvertCodexResponseToClaude_StreamEmptyOutputUsesOutputItemDoneMessageFallback(t *testing.T) { ctx := context.Background() originalRequest := []byte(`{"tools":[]}`)