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: #3113
This commit is contained in:
Luis Pater
2026-06-18 04:13:04 +08:00
parent cde5081e94
commit dd49a52003
8 changed files with 153 additions and 0 deletions
@@ -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":""}]}`)
@@ -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",
@@ -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":""}`)
@@ -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(`{
@@ -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 {
@@ -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")
}
}
@@ -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":[]}]`)
@@ -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