From 011ffe1dd51a37b3640a0089b6f3026ea346743a Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 20 Jun 2026 14:01:50 +0800 Subject: [PATCH] feat(translator): enforce FIFO order in tool call ID consumption for Gemini requests - Updated `ConvertGeminiRequestToOpenAI` to consume `tool_call_id`s in FIFO order, ensuring consistent mapping between calls and responses. - Added fallback logic for unmatched or extra function responses to generate unique `tool_call_id`s with a `call_` prefix. - Introduced comprehensive unit tests to validate order enforcement, distinct ID assignment, and fallback behavior. Closes: #3874 --- .../openai/gemini/openai_gemini_request.go | 10 +- .../gemini/openai_gemini_request_test.go | 106 ++++++++++++++++++ 2 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 internal/translator/openai/gemini/openai_gemini_request_test.go diff --git a/internal/translator/openai/gemini/openai_gemini_request.go b/internal/translator/openai/gemini/openai_gemini_request.go index 7369de88..53773806 100644 --- a/internal/translator/openai/gemini/openai_gemini_request.go +++ b/internal/translator/openai/gemini/openai_gemini_request.go @@ -113,6 +113,7 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream // Process contents (Gemini messages) -> OpenAI messages var toolCallIDs []string // Track tool call IDs for matching with tool results + toolCallConsumeIdx := 0 // System instruction -> OpenAI system message // Gemini may provide `systemInstruction` or `system_instruction`; support both keys. @@ -241,12 +242,9 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream } } - // Try to match with previous tool call ID - _ = functionResponse.Get("name").String() // functionName not used for now - if len(toolCallIDs) > 0 { - // Use the last tool call ID (simple matching by function name) - // In a real implementation, you might want more sophisticated matching - toolMsg, _ = sjson.SetBytes(toolMsg, "tool_call_id", toolCallIDs[len(toolCallIDs)-1]) + if toolCallConsumeIdx < len(toolCallIDs) { + toolMsg, _ = sjson.SetBytes(toolMsg, "tool_call_id", toolCallIDs[toolCallConsumeIdx]) + toolCallConsumeIdx++ } else { // Generate a tool call ID if none available toolMsg, _ = sjson.SetBytes(toolMsg, "tool_call_id", genToolCallID()) diff --git a/internal/translator/openai/gemini/openai_gemini_request_test.go b/internal/translator/openai/gemini/openai_gemini_request_test.go new file mode 100644 index 00000000..7bfbaad5 --- /dev/null +++ b/internal/translator/openai/gemini/openai_gemini_request_test.go @@ -0,0 +1,106 @@ +package gemini + +import ( + "strings" + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertGeminiRequestToOpenAI_FunctionResponsesConsumeToolCallIDsFIFO(t *testing.T) { + inputJSON := []byte(`{ + "contents": [ + { + "role": "model", + "parts": [ + {"functionCall": {"name": "read_file", "args": {"path": "a.txt"}}}, + {"functionCall": {"name": "grep", "args": {"pattern": "needle"}}}, + {"functionCall": {"name": "list_dir", "args": {"path": "."}}} + ] + }, + { + "role": "function", + "parts": [ + {"functionResponse": {"name": "read_file", "response": {"result": "a"}}}, + {"functionResponse": {"name": "grep", "response": {"result": "b"}}}, + {"functionResponse": {"name": "list_dir", "response": {"result": "c"}}} + ] + } + ] + }`) + + out := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false) + firstID := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String() + secondID := gjson.GetBytes(out, "messages.0.tool_calls.1.id").String() + thirdID := gjson.GetBytes(out, "messages.0.tool_calls.2.id").String() + + if firstID == "" || secondID == "" || thirdID == "" { + t.Fatalf("expected all assistant tool call IDs to be set. Output: %s", string(out)) + } + if firstID == secondID || secondID == thirdID || firstID == thirdID { + t.Fatalf("expected distinct assistant tool call IDs, got %q, %q, %q", firstID, secondID, thirdID) + } + if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != firstID { + t.Fatalf("messages.1.tool_call_id = %q, want %q. Output: %s", got, firstID, string(out)) + } + if got := gjson.GetBytes(out, "messages.2.tool_call_id").String(); got != secondID { + t.Fatalf("messages.2.tool_call_id = %q, want %q. Output: %s", got, secondID, string(out)) + } + if got := gjson.GetBytes(out, "messages.3.tool_call_id").String(); got != thirdID { + t.Fatalf("messages.3.tool_call_id = %q, want %q. Output: %s", got, thirdID, string(out)) + } +} + +func TestConvertGeminiRequestToOpenAI_FunctionResponseWithoutPriorCallGetsFallbackID(t *testing.T) { + inputJSON := []byte(`{ + "contents": [ + { + "role": "function", + "parts": [ + {"functionResponse": {"name": "read_file", "response": {"result": "ok"}}} + ] + } + ] + }`) + + out := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false) + toolCallID := gjson.GetBytes(out, "messages.0.tool_call_id").String() + if !strings.HasPrefix(toolCallID, "call_") { + t.Fatalf("fallback tool_call_id = %q, want call_ prefix. Output: %s", toolCallID, string(out)) + } +} + +func TestConvertGeminiRequestToOpenAI_ExtraFunctionResponsesUseFallbackID(t *testing.T) { + inputJSON := []byte(`{ + "contents": [ + { + "role": "model", + "parts": [ + {"functionCall": {"name": "read_file", "args": {"path": "a.txt"}}} + ] + }, + { + "role": "function", + "parts": [ + {"functionResponse": {"name": "read_file", "response": {"result": "a"}}}, + {"functionResponse": {"name": "read_file", "response": {"result": "extra"}}} + ] + } + ] + }`) + + out := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false) + callID := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String() + firstResponseID := gjson.GetBytes(out, "messages.1.tool_call_id").String() + extraResponseID := gjson.GetBytes(out, "messages.2.tool_call_id").String() + + if firstResponseID != callID { + t.Fatalf("messages.1.tool_call_id = %q, want %q. Output: %s", firstResponseID, callID, string(out)) + } + if !strings.HasPrefix(extraResponseID, "call_") { + t.Fatalf("extra response fallback tool_call_id = %q, want call_ prefix. Output: %s", extraResponseID, string(out)) + } + if extraResponseID == callID { + t.Fatalf("extra response reused consumed tool_call_id %q. Output: %s", extraResponseID, string(out)) + } +} -- 2.51.2