From 57e1bf97a534f06e4cd89dd3455ac5952dfa3242 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 20 Jun 2026 14:12:14 +0800 Subject: [PATCH] feat(translator): ensure preservation of tool and call IDs in Gemini request and response translations - Updated `ConvertGeminiRequestToClaude`, `ConvertGeminiRequestToCodex`, and their respective response counterparts to include logic for retaining and using tool/call IDs when present from gateway-provided inputs. - Enhanced pairing logic between function calls and responses to handle custom and auto-generated IDs consistently. - Introduced tests validating ID preservation and proper behavior in both streaming and non-streaming flows. Closes: #3878 --- .../claude/gemini/claude_gemini_request.go | 32 ++++++++-- .../gemini/claude_gemini_request_test.go | 63 +++++++++++++++++++ .../claude/gemini/claude_gemini_response.go | 34 ++++++++++ .../gemini/claude_gemini_response_test.go | 53 ++++++++++++++++ .../codex/gemini/codex_gemini_request.go | 33 ++++++++-- .../codex/gemini/codex_gemini_request_test.go | 63 +++++++++++++++++++ .../codex/gemini/codex_gemini_response.go | 13 ++++ .../gemini/codex_gemini_response_test.go | 40 ++++++++++++ 8 files changed, 322 insertions(+), 9 deletions(-) create mode 100644 internal/translator/claude/gemini/claude_gemini_request_test.go create mode 100644 internal/translator/claude/gemini/claude_gemini_response_test.go create mode 100644 internal/translator/codex/gemini/codex_gemini_request_test.go diff --git a/internal/translator/claude/gemini/claude_gemini_request.go b/internal/translator/claude/gemini/claude_gemini_request.go index d716d28f..1f5bf8ed 100644 --- a/internal/translator/claude/gemini/claude_gemini_request.go +++ b/internal/translator/claude/gemini/claude_gemini_request.go @@ -80,6 +80,25 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream return "toolu_" + b.String() } + getGeminiToolID := func(value gjson.Result) string { + if toolID := strings.TrimSpace(value.Get("id").String()); toolID != "" { + return toolID + } + return strings.TrimSpace(value.Get("call_id").String()) + } + + removePendingToolID := func(ids []string, toolID string) []string { + if toolID == "" { + return ids + } + for idx, pendingID := range ids { + if pendingID == toolID { + return append(ids[:idx], ids[idx+1:]...) + } + } + return ids + } + // FIFO queue to store tool call IDs for matching with tool results // Gemini uses sequential pairing across possibly multiple in-flight // functionCalls, so we keep a FIFO queue of generated tool IDs and @@ -262,9 +281,11 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream if fc := part.Get("functionCall"); fc.Exists() && role == "assistant" { toolUse := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`) - // Generate a unique tool ID and enqueue it for later matching - // with the corresponding functionResponse - toolID := genToolCallID() + // Reuse gateway-provided IDs when present, otherwise generate one for pairing. + toolID := getGeminiToolID(fc) + if toolID == "" { + toolID = genToolCallID() + } pendingToolIDs = append(pendingToolIDs, toolID) toolUse, _ = sjson.SetBytes(toolUse, "id", toolID) @@ -285,7 +306,10 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream // Attach the oldest queued tool_id to pair the response // with its call. If the queue is empty, generate a new id. var toolID string - if len(pendingToolIDs) > 0 { + if customID := getGeminiToolID(fr); customID != "" { + toolID = customID + pendingToolIDs = removePendingToolID(pendingToolIDs, toolID) + } else if len(pendingToolIDs) > 0 { toolID = pendingToolIDs[0] // Pop the first element from the queue pendingToolIDs = pendingToolIDs[1:] diff --git a/internal/translator/claude/gemini/claude_gemini_request_test.go b/internal/translator/claude/gemini/claude_gemini_request_test.go new file mode 100644 index 00000000..06224d5a --- /dev/null +++ b/internal/translator/claude/gemini/claude_gemini_request_test.go @@ -0,0 +1,63 @@ +package gemini + +import ( + "fmt" + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertGeminiRequestToClaude_PreservesCustomToolIDs(t *testing.T) { + tests := []struct { + name string + callField string + responseField string + want string + }{ + { + name: "id", + callField: `"id":"call_gateway_id"`, + responseField: `"id":"call_gateway_id"`, + want: "call_gateway_id", + }, + { + name: "call_id", + callField: `"call_id":"call_gateway_call_id"`, + responseField: `"call_id":"call_gateway_call_id"`, + want: "call_gateway_call_id", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw := []byte(fmt.Sprintf(`{ + "contents": [ + { + "role": "model", + "parts": [ + {"functionCall": {"name": "lookup", %s, "args": {"query": "status"}}} + ] + }, + { + "role": "user", + "parts": [ + {"functionResponse": {"name": "lookup", %s, "response": {"result": "ok"}}} + ] + } + ] + }`, tt.callField, tt.responseField)) + + out := ConvertGeminiRequestToClaude("claude-sonnet-4", raw, false) + + gotCallID := gjson.GetBytes(out, "messages.0.content.0.id").String() + if gotCallID != tt.want { + t.Fatalf("expected tool_use id %q, got %q; output=%s", tt.want, gotCallID, string(out)) + } + + gotResultID := gjson.GetBytes(out, "messages.1.content.0.tool_use_id").String() + if gotResultID != tt.want { + t.Fatalf("expected tool_result tool_use_id %q, got %q; output=%s", tt.want, gotResultID, string(out)) + } + }) + } +} diff --git a/internal/translator/claude/gemini/claude_gemini_response.go b/internal/translator/claude/gemini/claude_gemini_response.go index 3f127e32..74865ead 100644 --- a/internal/translator/claude/gemini/claude_gemini_response.go +++ b/internal/translator/claude/gemini/claude_gemini_response.go @@ -37,6 +37,7 @@ type ConvertAnthropicResponseToGeminiParams struct { // Keyed by content_block index from Claude SSE events ToolUseNames map[int]string // function/tool name per block index ToolUseArgs map[int]*strings.Builder // accumulates partial_json across deltas + ToolUseIDs map[int]string // tool use ID per block index } // ConvertClaudeResponseToGemini converts Claude Code streaming response format to Gemini format. @@ -110,6 +111,12 @@ func ConvertClaudeResponseToGemini(_ context.Context, modelName string, original if name := cb.Get("name"); name.Exists() { (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames[idx] = name.String() } + if toolID := cb.Get("id").String(); toolID != "" { + if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs == nil { + (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs = map[int]string{} + } + (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs[idx] = toolID + } } } return [][]byte{} @@ -169,6 +176,10 @@ func ConvertClaudeResponseToGemini(_ context.Context, modelName string, original argsTrim = strings.TrimSpace(b.String()) } } + toolID := "" + if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs != nil { + toolID = (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs[idx] + } if name != "" || argsTrim != "" { functionCall := []byte(`{"functionCall":{"name":"","args":{}}}`) if name != "" { @@ -177,6 +188,9 @@ func ConvertClaudeResponseToGemini(_ context.Context, modelName string, original if argsTrim != "" { functionCall, _ = sjson.SetRawBytes(functionCall, "functionCall.args", []byte(argsTrim)) } + if toolID != "" { + functionCall, _ = sjson.SetBytes(functionCall, "functionCall.id", toolID) + } template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", functionCall) template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP") (*param).(*ConvertAnthropicResponseToGeminiParams).LastStorageOutput = append([]byte(nil), template...) @@ -187,6 +201,9 @@ func ConvertClaudeResponseToGemini(_ context.Context, modelName string, original if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames != nil { delete((*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames, idx) } + if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs != nil { + delete((*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs, idx) + } return [][]byte{template} } return [][]byte{} @@ -308,6 +325,7 @@ func ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string, IsStreaming: false, ToolUseNames: nil, ToolUseArgs: nil, + ToolUseIDs: nil, } // Process each streaming event and collect parts @@ -348,6 +366,12 @@ func ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string, if name := cb.Get("name"); name.Exists() { newParam.ToolUseNames[idx] = name.String() } + if toolID := cb.Get("id").String(); toolID != "" { + if newParam.ToolUseIDs == nil { + newParam.ToolUseIDs = map[int]string{} + } + newParam.ToolUseIDs[idx] = toolID + } } } continue @@ -401,6 +425,10 @@ func ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string, argsTrim = strings.TrimSpace(b.String()) } } + toolID := "" + if newParam.ToolUseIDs != nil { + toolID = newParam.ToolUseIDs[idx] + } if name != "" || argsTrim != "" { functionCallJSON := []byte(`{"functionCall":{"name":"","args":{}}}`) if name != "" { @@ -409,6 +437,9 @@ func ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string, if argsTrim != "" { functionCallJSON, _ = sjson.SetRawBytes(functionCallJSON, "functionCall.args", []byte(argsTrim)) } + if toolID != "" { + functionCallJSON, _ = sjson.SetBytes(functionCallJSON, "functionCall.id", toolID) + } allParts = append(allParts, functionCallJSON) // cleanup used state for this index if newParam.ToolUseArgs != nil { @@ -417,6 +448,9 @@ func ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string, if newParam.ToolUseNames != nil { delete(newParam.ToolUseNames, idx) } + if newParam.ToolUseIDs != nil { + delete(newParam.ToolUseIDs, idx) + } } case "message_delta": diff --git a/internal/translator/claude/gemini/claude_gemini_response_test.go b/internal/translator/claude/gemini/claude_gemini_response_test.go new file mode 100644 index 00000000..8fb6744c --- /dev/null +++ b/internal/translator/claude/gemini/claude_gemini_response_test.go @@ -0,0 +1,53 @@ +package gemini + +import ( + "context" + "strings" + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertClaudeResponseToGemini_StreamPreservesToolUseID(t *testing.T) { + ctx := context.Background() + var param any + + start := []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_gateway","name":"lookup"}}`) + out := ConvertClaudeResponseToGemini(ctx, "gemini-2.5-pro", nil, nil, start, ¶m) + if len(out) != 0 { + t.Fatalf("expected content_block_start to be buffered, got %d chunks", len(out)) + } + + delta := []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\":\"status\"}"}}`) + out = ConvertClaudeResponseToGemini(ctx, "gemini-2.5-pro", nil, nil, delta, ¶m) + if len(out) != 0 { + t.Fatalf("expected input_json_delta to be buffered, got %d chunks", len(out)) + } + + stop := []byte(`data: {"type":"content_block_stop","index":0}`) + out = ConvertClaudeResponseToGemini(ctx, "gemini-2.5-pro", nil, nil, stop, ¶m) + if len(out) != 1 { + t.Fatalf("expected content_block_stop to emit 1 chunk, got %d", len(out)) + } + + got := gjson.GetBytes(out[0], "candidates.0.content.parts.0.functionCall.id").String() + if got != "toolu_gateway" { + t.Fatalf("expected functionCall.id %q, got %q; chunk=%s", "toolu_gateway", got, string(out[0])) + } +} + +func TestConvertClaudeResponseToGeminiNonStreamPreservesToolUseID(t *testing.T) { + ctx := context.Background() + raw := []byte(strings.Join([]string{ + `data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_gateway","name":"lookup"}}`, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\":\"status\"}"}}`, + `data: {"type":"content_block_stop","index":0}`, + }, "\n")) + + out := ConvertClaudeResponseToGeminiNonStream(ctx, "gemini-2.5-pro", nil, nil, raw, nil) + + got := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.id").String() + if got != "toolu_gateway" { + t.Fatalf("expected functionCall.id %q, got %q; chunk=%s", "toolu_gateway", got, string(out)) + } +} diff --git a/internal/translator/codex/gemini/codex_gemini_request.go b/internal/translator/codex/gemini/codex_gemini_request.go index e96d5aac..03a862ba 100644 --- a/internal/translator/codex/gemini/codex_gemini_request.go +++ b/internal/translator/codex/gemini/codex_gemini_request.go @@ -81,6 +81,25 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool) return "call_" + b.String() } + getGeminiCallID := func(value gjson.Result) string { + if callID := strings.TrimSpace(value.Get("id").String()); callID != "" { + return callID + } + return strings.TrimSpace(value.Get("call_id").String()) + } + + removePendingCallID := func(ids []string, callID string) []string { + if callID == "" { + return ids + } + for idx, pendingID := range ids { + if pendingID == callID { + return append(ids[:idx], ids[idx+1:]...) + } + } + return ids + } + // Model out, _ = sjson.SetBytes(out, "model", modelName) @@ -155,10 +174,11 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool) if args := fc.Get("args"); args.Exists() { fn, _ = sjson.SetBytes(fn, "arguments", args.Raw) } - // generate a paired random call_id and enqueue it so the - // corresponding functionResponse can pop the earliest id - // to preserve ordering when multiple calls are present. - id := genCallID() + // Reuse gateway-provided IDs when present, otherwise generate one for pairing. + id := getGeminiCallID(fc) + if id == "" { + id = genCallID() + } fn, _ = sjson.SetBytes(fn, "call_id", id) pendingCallIDs = append(pendingCallIDs, id) out, _ = sjson.SetRawBytes(out, "input.-1", fn) @@ -178,7 +198,10 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool) // attach the oldest queued call_id to pair the response // with its call. If the queue is empty, generate a new id. var id string - if len(pendingCallIDs) > 0 { + if customID := getGeminiCallID(fr); customID != "" { + id = customID + pendingCallIDs = removePendingCallID(pendingCallIDs, id) + } else if len(pendingCallIDs) > 0 { id = pendingCallIDs[0] // pop the first element pendingCallIDs = pendingCallIDs[1:] diff --git a/internal/translator/codex/gemini/codex_gemini_request_test.go b/internal/translator/codex/gemini/codex_gemini_request_test.go new file mode 100644 index 00000000..a98cdba4 --- /dev/null +++ b/internal/translator/codex/gemini/codex_gemini_request_test.go @@ -0,0 +1,63 @@ +package gemini + +import ( + "fmt" + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertGeminiRequestToCodex_PreservesCustomCallIDs(t *testing.T) { + tests := []struct { + name string + callField string + responseField string + want string + }{ + { + name: "id", + callField: `"id":"call_gateway_id"`, + responseField: `"id":"call_gateway_id"`, + want: "call_gateway_id", + }, + { + name: "call_id", + callField: `"call_id":"call_gateway_call_id"`, + responseField: `"call_id":"call_gateway_call_id"`, + want: "call_gateway_call_id", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw := []byte(fmt.Sprintf(`{ + "contents": [ + { + "role": "model", + "parts": [ + {"functionCall": {"name": "lookup", %s, "args": {"query": "status"}}} + ] + }, + { + "role": "user", + "parts": [ + {"functionResponse": {"name": "lookup", %s, "response": {"result": "ok"}}} + ] + } + ] + }`, tt.callField, tt.responseField)) + + out := ConvertGeminiRequestToCodex("gpt-5.1-codex", raw, false) + + gotCallID := gjson.GetBytes(out, "input.0.call_id").String() + if gotCallID != tt.want { + t.Fatalf("expected function_call call_id %q, got %q; output=%s", tt.want, gotCallID, string(out)) + } + + gotOutputID := gjson.GetBytes(out, "input.1.call_id").String() + if gotOutputID != tt.want { + t.Fatalf("expected function_call_output call_id %q, got %q; output=%s", tt.want, gotOutputID, string(out)) + } + }) + } +} diff --git a/internal/translator/codex/gemini/codex_gemini_response.go b/internal/translator/codex/gemini/codex_gemini_response.go index ecf9cf4d..a5144ea6 100644 --- a/internal/translator/codex/gemini/codex_gemini_response.go +++ b/internal/translator/codex/gemini/codex_gemini_response.go @@ -156,6 +156,7 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR functionCall, _ = sjson.SetRawBytes(functionCall, "functionCall.args", []byte(argsStr)) } } + functionCall = setGeminiFunctionCallID(functionCall, itemResult) template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", functionCall) template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP") @@ -361,6 +362,7 @@ func ConvertCodexResponseToGeminiNonStream(_ context.Context, modelName string, functionCall, _ = sjson.SetRawBytes(functionCall, "functionCall.args", []byte(argsStr)) } } + functionCall = setGeminiFunctionCallID(functionCall, value) pendingFunctionCalls = append(pendingFunctionCalls, functionCall) } @@ -410,6 +412,17 @@ func buildReverseMapFromGeminiOriginal(original []byte) map[string]string { return rev } +func setGeminiFunctionCallID(functionCall []byte, item gjson.Result) []byte { + if callID := strings.TrimSpace(item.Get("call_id").String()); callID != "" { + functionCall, _ = sjson.SetBytes(functionCall, "functionCall.id", callID) + return functionCall + } + if id := strings.TrimSpace(item.Get("id").String()); id != "" { + functionCall, _ = sjson.SetBytes(functionCall, "functionCall.id", id) + } + return functionCall +} + func GeminiTokenCount(ctx context.Context, count int64) []byte { return translatorcommon.GeminiTokenCountJSON(count) } diff --git a/internal/translator/codex/gemini/codex_gemini_response_test.go b/internal/translator/codex/gemini/codex_gemini_response_test.go index 547ee847..55b13529 100644 --- a/internal/translator/codex/gemini/codex_gemini_response_test.go +++ b/internal/translator/codex/gemini/codex_gemini_response_test.go @@ -109,3 +109,43 @@ func TestConvertCodexResponseToGemini_NonStreamImageGenerationCallAddsInlineData t.Fatalf("expected inlineData.mimeType %q, got %q; chunk=%s", "image/png", gotMime, string(out)) } } + +func TestConvertCodexResponseToGemini_StreamPreservesFunctionCallID(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"tools":[]}`) + var param any + + out := ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, []byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_gateway","name":"lookup","arguments":"{\"query\":\"status\"}"}}`), ¶m) + if len(out) != 0 { + t.Fatalf("expected function call output to be buffered, got %d chunks", len(out)) + } + + out = ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, []byte(`data: {"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1}}}`), ¶m) + if len(out) == 0 { + t.Fatal("expected buffered function call to be emitted on completion") + } + + got := "" + for _, chunk := range out { + if value := gjson.GetBytes(chunk, "candidates.0.content.parts.0.functionCall.id").String(); value != "" { + got = value + break + } + } + if got != "call_gateway" { + t.Fatalf("expected functionCall.id %q, got %q; chunks=%q", "call_gateway", got, out) + } +} + +func TestConvertCodexResponseToGeminiNonStreamPreservesFunctionCallID(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"tools":[]}`) + + raw := []byte(`{"type":"response.completed","response":{"id":"resp_123","created_at":1700000000,"usage":{"input_tokens":1,"output_tokens":1},"output":[{"type":"function_call","call_id":"call_gateway","name":"lookup","arguments":"{\"query\":\"status\"}"}]}}`) + out := ConvertCodexResponseToGeminiNonStream(ctx, "gemini-2.5-pro", originalRequest, nil, raw, nil) + + got := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.id").String() + if got != "call_gateway" { + t.Fatalf("expected functionCall.id %q, got %q; chunk=%s", "call_gateway", got, string(out)) + } +} -- 2.51.2