diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request.go b/internal/translator/claude/openai/chat-completions/claude_openai_request.go index bad56d12..b4df9b54 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request.go @@ -16,6 +16,7 @@ import ( "github.com/google/uuid" "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -212,6 +213,7 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream if toolCallID == "" { toolCallID = genToolCallID() } + toolCallID = util.SanitizeClaudeToolID(toolCallID) function := toolCall.Get("function") toolUse := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`) @@ -247,6 +249,7 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream case "tool": // Handle tool result messages conversion toolCallID := message.Get("tool_call_id").String() + toolCallID = util.SanitizeClaudeToolID(toolCallID) toolContentResult := message.Get("content") msg := []byte(`{"role":"user","content":[{"type":"tool_result","tool_use_id":"","content":""}]}`) diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go index ead08d72..8adf74fe 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go @@ -6,6 +6,44 @@ import ( "github.com/tidwall/gjson" ) +func TestConvertOpenAIRequestToClaude_SanitizesToolCallIDsForClaude(t *testing.T) { + inputJSON := `{ + "model": "gpt-4.1", + "messages": [ + { + "role": "assistant", + "tool_calls": [ + { + "id": "call.with space:1", + "type": "function", + "function": { + "name": "Read", + "arguments": "{\"path\":\"README.md\"}" + } + } + ] + }, + { + "role": "tool", + "tool_call_id": "call.with space:1", + "content": "ok" + } + ] + }` + + result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false) + resultJSON := gjson.ParseBytes(result) + toolUseID := resultJSON.Get("messages.0.content.0.id").String() + toolResultID := resultJSON.Get("messages.1.content.0.tool_use_id").String() + + if toolUseID != "call_with_space_1" { + t.Fatalf("tool_use id = %q, want %q", toolUseID, "call_with_space_1") + } + if toolResultID != toolUseID { + t.Fatalf("tool_result tool_use_id = %q, want same sanitized id %q", toolResultID, toolUseID) + } +} + func TestConvertOpenAIRequestToClaude_ToolResultTextAndBase64Image(t *testing.T) { inputJSON := `{ "model": "gpt-4.1", diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request.go b/internal/translator/claude/openai/responses/claude_openai-responses_request.go index 1fa00ae2..c1af9d11 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -12,6 +12,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -371,6 +372,7 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte if callID == "" { callID = genToolCallID() } + callID = util.SanitizeClaudeToolID(callID) name := item.Get("name").String() argsStr := item.Get("arguments").String() @@ -399,6 +401,7 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte flushPendingReasoning() // Map to user tool_result callID := item.Get("call_id").String() + callID = util.SanitizeClaudeToolID(callID) flushPendingToolUseFor(callID) outputStr := item.Get("output").String() toolResult := []byte(`{"type":"tool_result","tool_use_id":"","content":""}`) diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go index aa38627c..fd6386dc 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go @@ -9,6 +9,37 @@ import ( "google.golang.org/protobuf/encoding/protowire" ) +func TestConvertOpenAIResponsesRequestToClaude_SanitizesToolCallIDsForClaude(t *testing.T) { + inputJSON := `{ + "model": "gpt-4.1", + "input": [ + { + "type": "function_call", + "call_id": "call.with space:1", + "name": "Read", + "arguments": "{\"path\":\"README.md\"}" + }, + { + "type": "function_call_output", + "call_id": "call.with space:1", + "output": "ok" + } + ] + }` + + result := ConvertOpenAIResponsesRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false) + resultJSON := gjson.ParseBytes(result) + toolUseID := resultJSON.Get("messages.0.content.0.id").String() + toolResultID := resultJSON.Get("messages.1.content.0.tool_use_id").String() + + if toolUseID != "call_with_space_1" { + t.Fatalf("tool_use id = %q, want %q", toolUseID, "call_with_space_1") + } + if toolResultID != toolUseID { + t.Fatalf("tool_result tool_use_id = %q, want same sanitized id %q", toolResultID, toolUseID) + } +} + func TestConvertOpenAIResponsesRequestToClaude_ReasoningItemToThinkingBlock(t *testing.T) { rawSignature, expectedSignature := testClaudeResponsesThinkingSignature(t) raw := []byte(`{ diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go index bf4e9805..4a59c6cc 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go @@ -294,6 +294,16 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) } } + // Gemini/Vertex accepts assistant/model turns in history, but some model + // surfaces reject requests whose final turn is model-authored prefill. + contents := gjson.GetBytes(out, "contents") + if contents.Exists() && contents.IsArray() { + arr := contents.Array() + if len(arr) > 0 && arr[len(arr)-1].Get("role").String() == "model" { + out, _ = sjson.DeleteBytes(out, fmt.Sprintf("contents.%d", len(arr)-1)) + } + } + // tools -> tools[].functionDeclarations + tools[].googleSearch/codeExecution/urlContext passthrough tools := gjson.GetBytes(rawJSON, "tools") if tools.IsArray() && len(tools.Array()) > 0 { diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_request_test.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_request_test.go new file mode 100644 index 00000000..f9c0d272 --- /dev/null +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request_test.go @@ -0,0 +1,28 @@ +package chat_completions + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertOpenAIRequestToGemini_StripsTrailingAssistantPrefill(t *testing.T) { + inputJSON := `{ + "model": "gpt-5.4", + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "previous answer"} + ] + }` + + result := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false) + resultJSON := gjson.ParseBytes(result) + contents := resultJSON.Get("contents").Array() + + if len(contents) != 1 { + t.Fatalf("contents length = %d, want 1. contents=%s", len(contents), resultJSON.Get("contents").Raw) + } + if got := contents[0].Get("role").String(); got != "user" { + t.Fatalf("final remaining role = %q, want %q", got, "user") + } +} diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go index f7d0f18a..0907519b 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go @@ -2,6 +2,7 @@ package responses import ( "encoding/json" + "fmt" "strings" sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" @@ -369,6 +370,16 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte out, _ = sjson.SetRawBytes(out, "contents.-1", userContent) } + // Gemini/Vertex accepts assistant/model turns in history, but some model + // surfaces reject requests whose final turn is model-authored prefill. + contents := gjson.GetBytes(out, "contents") + if contents.Exists() && contents.IsArray() { + arr := contents.Array() + if len(arr) > 0 && arr[len(arr)-1].Get("role").String() == "model" { + out, _ = sjson.DeleteBytes(out, fmt.Sprintf("contents.%d", len(arr)-1)) + } + } + // Convert tools to Gemini functionDeclarations format if tools := root.Get("tools"); tools.Exists() && tools.IsArray() { geminiTools := []byte(`[{"functionDeclarations":[]}]`) diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go index 0693b63d..071fadc8 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go @@ -9,6 +9,35 @@ import ( const testResponsesGeminiThoughtSignature = "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA" +func TestConvertOpenAIResponsesRequestToGemini_StripsTrailingAssistantPrefill(t *testing.T) { + inputJSON := `{ + "model": "gpt-5.4", + "input": [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "hello"}] + }, + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "previous answer"}] + } + ] + }` + + result := ConvertOpenAIResponsesRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false) + resultJSON := gjson.ParseBytes(result) + contents := resultJSON.Get("contents").Array() + + if len(contents) != 1 { + t.Fatalf("contents length = %d, want 1. contents=%s", len(contents), resultJSON.Get("contents").Raw) + } + if got := contents[0].Get("role").String(); got != "user" { + t.Fatalf("final remaining role = %q, want %q", got, "user") + } +} + func TestConvertOpenAIResponsesRequestToGemini_ReasoningSignatureCompatibility(t *testing.T) { tests := []struct { name string