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)) + } +}