Skip to content

Commit dd49a52

Browse files
committed
feat(translator): add tests to validate trailing assistant prefill stripping and sanitize tool call IDs
- Introduced tests for `ConvertOpenAIRequestToGemini`, `ConvertOpenAIResponsesRequestToGemini`, and related Claude functions to ensure trailing model-prefill turns are removed. - Enhanced tool call ID handling with `util.SanitizeClaudeToolID` to standardize IDs in Claude-related conversions and tests. - Updated logic in Gemini and Claude translators to handle edge cases for trailing assistant prefill and tool ID sanitization, ensuring compatibility across input variants. Closes: router-for-me#3113
1 parent cde5081 commit dd49a52

8 files changed

Lines changed: 153 additions & 0 deletions

File tree

internal/translator/claude/openai/chat-completions/claude_openai_request.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/google/uuid"
1717
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
1818
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
19+
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
1920
"github.com/tidwall/gjson"
2021
"github.com/tidwall/sjson"
2122
)
@@ -212,6 +213,7 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream
212213
if toolCallID == "" {
213214
toolCallID = genToolCallID()
214215
}
216+
toolCallID = util.SanitizeClaudeToolID(toolCallID)
215217

216218
function := toolCall.Get("function")
217219
toolUse := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
@@ -247,6 +249,7 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream
247249
case "tool":
248250
// Handle tool result messages conversion
249251
toolCallID := message.Get("tool_call_id").String()
252+
toolCallID = util.SanitizeClaudeToolID(toolCallID)
250253
toolContentResult := message.Get("content")
251254

252255
msg := []byte(`{"role":"user","content":[{"type":"tool_result","tool_use_id":"","content":""}]}`)

internal/translator/claude/openai/chat-completions/claude_openai_request_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,44 @@ import (
66
"github.com/tidwall/gjson"
77
)
88

9+
func TestConvertOpenAIRequestToClaude_SanitizesToolCallIDsForClaude(t *testing.T) {
10+
inputJSON := `{
11+
"model": "gpt-4.1",
12+
"messages": [
13+
{
14+
"role": "assistant",
15+
"tool_calls": [
16+
{
17+
"id": "call.with space:1",
18+
"type": "function",
19+
"function": {
20+
"name": "Read",
21+
"arguments": "{\"path\":\"README.md\"}"
22+
}
23+
}
24+
]
25+
},
26+
{
27+
"role": "tool",
28+
"tool_call_id": "call.with space:1",
29+
"content": "ok"
30+
}
31+
]
32+
}`
33+
34+
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
35+
resultJSON := gjson.ParseBytes(result)
36+
toolUseID := resultJSON.Get("messages.0.content.0.id").String()
37+
toolResultID := resultJSON.Get("messages.1.content.0.tool_use_id").String()
38+
39+
if toolUseID != "call_with_space_1" {
40+
t.Fatalf("tool_use id = %q, want %q", toolUseID, "call_with_space_1")
41+
}
42+
if toolResultID != toolUseID {
43+
t.Fatalf("tool_result tool_use_id = %q, want same sanitized id %q", toolResultID, toolUseID)
44+
}
45+
}
46+
947
func TestConvertOpenAIRequestToClaude_ToolResultTextAndBase64Image(t *testing.T) {
1048
inputJSON := `{
1149
"model": "gpt-4.1",

internal/translator/claude/openai/responses/claude_openai-responses_request.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
1313
sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
1414
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
15+
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
1516
"github.com/tidwall/gjson"
1617
"github.com/tidwall/sjson"
1718
)
@@ -371,6 +372,7 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
371372
if callID == "" {
372373
callID = genToolCallID()
373374
}
375+
callID = util.SanitizeClaudeToolID(callID)
374376
name := item.Get("name").String()
375377
argsStr := item.Get("arguments").String()
376378

@@ -399,6 +401,7 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
399401
flushPendingReasoning()
400402
// Map to user tool_result
401403
callID := item.Get("call_id").String()
404+
callID = util.SanitizeClaudeToolID(callID)
402405
flushPendingToolUseFor(callID)
403406
outputStr := item.Get("output").String()
404407
toolResult := []byte(`{"type":"tool_result","tool_use_id":"","content":""}`)

internal/translator/claude/openai/responses/claude_openai-responses_request_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,37 @@ import (
99
"google.golang.org/protobuf/encoding/protowire"
1010
)
1111

12+
func TestConvertOpenAIResponsesRequestToClaude_SanitizesToolCallIDsForClaude(t *testing.T) {
13+
inputJSON := `{
14+
"model": "gpt-4.1",
15+
"input": [
16+
{
17+
"type": "function_call",
18+
"call_id": "call.with space:1",
19+
"name": "Read",
20+
"arguments": "{\"path\":\"README.md\"}"
21+
},
22+
{
23+
"type": "function_call_output",
24+
"call_id": "call.with space:1",
25+
"output": "ok"
26+
}
27+
]
28+
}`
29+
30+
result := ConvertOpenAIResponsesRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
31+
resultJSON := gjson.ParseBytes(result)
32+
toolUseID := resultJSON.Get("messages.0.content.0.id").String()
33+
toolResultID := resultJSON.Get("messages.1.content.0.tool_use_id").String()
34+
35+
if toolUseID != "call_with_space_1" {
36+
t.Fatalf("tool_use id = %q, want %q", toolUseID, "call_with_space_1")
37+
}
38+
if toolResultID != toolUseID {
39+
t.Fatalf("tool_result tool_use_id = %q, want same sanitized id %q", toolResultID, toolUseID)
40+
}
41+
}
42+
1243
func TestConvertOpenAIResponsesRequestToClaude_ReasoningItemToThinkingBlock(t *testing.T) {
1344
rawSignature, expectedSignature := testClaudeResponsesThinkingSignature(t)
1445
raw := []byte(`{

internal/translator/gemini/openai/chat-completions/gemini_openai_request.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,16 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
294294
}
295295
}
296296

297+
// Gemini/Vertex accepts assistant/model turns in history, but some model
298+
// surfaces reject requests whose final turn is model-authored prefill.
299+
contents := gjson.GetBytes(out, "contents")
300+
if contents.Exists() && contents.IsArray() {
301+
arr := contents.Array()
302+
if len(arr) > 0 && arr[len(arr)-1].Get("role").String() == "model" {
303+
out, _ = sjson.DeleteBytes(out, fmt.Sprintf("contents.%d", len(arr)-1))
304+
}
305+
}
306+
297307
// tools -> tools[].functionDeclarations + tools[].googleSearch/codeExecution/urlContext passthrough
298308
tools := gjson.GetBytes(rawJSON, "tools")
299309
if tools.IsArray() && len(tools.Array()) > 0 {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package chat_completions
2+
3+
import (
4+
"testing"
5+
6+
"github.com/tidwall/gjson"
7+
)
8+
9+
func TestConvertOpenAIRequestToGemini_StripsTrailingAssistantPrefill(t *testing.T) {
10+
inputJSON := `{
11+
"model": "gpt-5.4",
12+
"messages": [
13+
{"role": "user", "content": "hello"},
14+
{"role": "assistant", "content": "previous answer"}
15+
]
16+
}`
17+
18+
result := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
19+
resultJSON := gjson.ParseBytes(result)
20+
contents := resultJSON.Get("contents").Array()
21+
22+
if len(contents) != 1 {
23+
t.Fatalf("contents length = %d, want 1. contents=%s", len(contents), resultJSON.Get("contents").Raw)
24+
}
25+
if got := contents[0].Get("role").String(); got != "user" {
26+
t.Fatalf("final remaining role = %q, want %q", got, "user")
27+
}
28+
}

internal/translator/gemini/openai/responses/gemini_openai-responses_request.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package responses
22

33
import (
44
"encoding/json"
5+
"fmt"
56
"strings"
67

78
sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
@@ -369,6 +370,16 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
369370
out, _ = sjson.SetRawBytes(out, "contents.-1", userContent)
370371
}
371372

373+
// Gemini/Vertex accepts assistant/model turns in history, but some model
374+
// surfaces reject requests whose final turn is model-authored prefill.
375+
contents := gjson.GetBytes(out, "contents")
376+
if contents.Exists() && contents.IsArray() {
377+
arr := contents.Array()
378+
if len(arr) > 0 && arr[len(arr)-1].Get("role").String() == "model" {
379+
out, _ = sjson.DeleteBytes(out, fmt.Sprintf("contents.%d", len(arr)-1))
380+
}
381+
}
382+
372383
// Convert tools to Gemini functionDeclarations format
373384
if tools := root.Get("tools"); tools.Exists() && tools.IsArray() {
374385
geminiTools := []byte(`[{"functionDeclarations":[]}]`)

internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,35 @@ import (
99

1010
const testResponsesGeminiThoughtSignature = "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA"
1111

12+
func TestConvertOpenAIResponsesRequestToGemini_StripsTrailingAssistantPrefill(t *testing.T) {
13+
inputJSON := `{
14+
"model": "gpt-5.4",
15+
"input": [
16+
{
17+
"type": "message",
18+
"role": "user",
19+
"content": [{"type": "input_text", "text": "hello"}]
20+
},
21+
{
22+
"type": "message",
23+
"role": "assistant",
24+
"content": [{"type": "output_text", "text": "previous answer"}]
25+
}
26+
]
27+
}`
28+
29+
result := ConvertOpenAIResponsesRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
30+
resultJSON := gjson.ParseBytes(result)
31+
contents := resultJSON.Get("contents").Array()
32+
33+
if len(contents) != 1 {
34+
t.Fatalf("contents length = %d, want 1. contents=%s", len(contents), resultJSON.Get("contents").Raw)
35+
}
36+
if got := contents[0].Get("role").String(); got != "user" {
37+
t.Fatalf("final remaining role = %q, want %q", got, "user")
38+
}
39+
}
40+
1241
func TestConvertOpenAIResponsesRequestToGemini_ReasoningSignatureCompatibility(t *testing.T) {
1342
tests := []struct {
1443
name string

0 commit comments

Comments
 (0)