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
This commit is contained in:
Luis Pater
2026-06-20 14:12:14 +08:00
parent 011ffe1dd5
commit 57e1bf97a5
8 changed files with 322 additions and 9 deletions
@@ -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:]
@@ -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))
}
})
}
}
@@ -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":
@@ -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, &param)
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, &param)
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, &param)
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))
}
}
@@ -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:]
@@ -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))
}
})
}
}
@@ -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)
}
@@ -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\"}"}}`), &param)
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}}}`), &param)
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))
}
}