mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-09 18:06:50 +08:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user