Files
CLIProxyAPI/internal/translator/antigravity/claude/antigravity_claude_request_test.go
이대희 1e9e4a86a2 Improve thinking/tool signature handling for Claude and Gemini requests
Prefer cached signatures and avoid injecting dummy thinking blocks; instead remove unsigned thinking blocks and add a skip sentinel for tool calls without a valid signature. Generate stable session IDs from the first user message, apply schema cleaning only for Claude models, and reorder thinking parts so thinking appears first. For Gemini, remove thinking blocks and attach a skip sentinel to function calls. Simplify response handling by passing raw function args through (remove special Bash conversion). Update and add tests to reflect the new behavior.

These changes prevent rejected dummy signatures, improve compatibility with Antigravity’s signature validation, provide more stable session IDs for conversation grouping, and make request/response translation more robust.
2025-12-21 15:15:50 +09:00

659 lines
20 KiB
Go

package claude
import (
"strings"
"testing"
"github.com/tidwall/gjson"
)
func TestConvertClaudeRequestToAntigravity_BasicStructure(t *testing.T) {
inputJSON := []byte(`{
"model": "claude-3-5-sonnet-20240620",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "Hello"}
]
}
],
"system": [
{"type": "text", "text": "You are helpful"}
]
}`)
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
outputStr := string(output)
// Check model
if gjson.Get(outputStr, "model").String() != "claude-sonnet-4-5" {
t.Errorf("Expected model 'claude-sonnet-4-5', got '%s'", gjson.Get(outputStr, "model").String())
}
// Check contents exist
contents := gjson.Get(outputStr, "request.contents")
if !contents.Exists() || !contents.IsArray() {
t.Error("request.contents should exist and be an array")
}
// Check role mapping (assistant -> model)
firstContent := gjson.Get(outputStr, "request.contents.0")
if firstContent.Get("role").String() != "user" {
t.Errorf("Expected role 'user', got '%s'", firstContent.Get("role").String())
}
// Check systemInstruction
sysInstruction := gjson.Get(outputStr, "request.systemInstruction")
if !sysInstruction.Exists() {
t.Error("systemInstruction should exist")
}
if sysInstruction.Get("parts.0.text").String() != "You are helpful" {
t.Error("systemInstruction text mismatch")
}
}
func TestConvertClaudeRequestToAntigravity_RoleMapping(t *testing.T) {
inputJSON := []byte(`{
"model": "claude-3-5-sonnet-20240620",
"messages": [
{"role": "user", "content": [{"type": "text", "text": "Hi"}]},
{"role": "assistant", "content": [{"type": "text", "text": "Hello"}]}
]
}`)
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
outputStr := string(output)
// assistant should be mapped to model
secondContent := gjson.Get(outputStr, "request.contents.1")
if secondContent.Get("role").String() != "model" {
t.Errorf("Expected role 'model' (mapped from 'assistant'), got '%s'", secondContent.Get("role").String())
}
}
func TestConvertClaudeRequestToAntigravity_ThinkingBlocks(t *testing.T) {
// Valid signature must be at least 50 characters
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
inputJSON := []byte(`{
"model": "claude-sonnet-4-5-thinking",
"messages": [
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "Let me think...", "signature": "` + validSignature + `"},
{"type": "text", "text": "Answer"}
]
}
]
}`)
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
outputStr := string(output)
// Check thinking block conversion
firstPart := gjson.Get(outputStr, "request.contents.0.parts.0")
if !firstPart.Get("thought").Bool() {
t.Error("thinking block should have thought: true")
}
if firstPart.Get("text").String() != "Let me think..." {
t.Error("thinking text mismatch")
}
if firstPart.Get("thoughtSignature").String() != validSignature {
t.Errorf("Expected thoughtSignature '%s', got '%s'", validSignature, firstPart.Get("thoughtSignature").String())
}
}
func TestConvertClaudeRequestToAntigravity_ThinkingBlockWithoutSignature(t *testing.T) {
// Unsigned thinking blocks should be removed entirely (not converted to text)
inputJSON := []byte(`{
"model": "claude-sonnet-4-5-thinking",
"messages": [
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "Let me think..."},
{"type": "text", "text": "Answer"}
]
}
]
}`)
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
outputStr := string(output)
// Without signature, thinking block should be removed (not converted to text)
parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
if len(parts) != 1 {
t.Fatalf("Expected 1 part (thinking removed), got %d", len(parts))
}
// Only text part should remain
if parts[0].Get("thought").Bool() {
t.Error("Thinking block should be removed, not preserved")
}
if parts[0].Get("text").String() != "Answer" {
t.Errorf("Expected text 'Answer', got '%s'", parts[0].Get("text").String())
}
}
func TestConvertClaudeRequestToAntigravity_ToolDeclarations(t *testing.T) {
inputJSON := []byte(`{
"model": "claude-3-5-sonnet-20240620",
"messages": [],
"tools": [
{
"name": "test_tool",
"description": "A test tool",
"input_schema": {
"type": "object",
"properties": {
"name": {"type": "string"}
},
"required": ["name"]
}
}
]
}`)
output := ConvertClaudeRequestToAntigravity("gemini-1.5-pro", inputJSON, false)
outputStr := string(output)
// Check tools structure
tools := gjson.Get(outputStr, "request.tools")
if !tools.Exists() {
t.Error("Tools should exist in output")
}
funcDecl := gjson.Get(outputStr, "request.tools.0.functionDeclarations.0")
if funcDecl.Get("name").String() != "test_tool" {
t.Errorf("Expected tool name 'test_tool', got '%s'", funcDecl.Get("name").String())
}
// Check input_schema renamed to parametersJsonSchema
if funcDecl.Get("parametersJsonSchema").Exists() {
t.Log("parametersJsonSchema exists (expected)")
}
if funcDecl.Get("input_schema").Exists() {
t.Error("input_schema should be removed")
}
}
func TestConvertClaudeRequestToAntigravity_ToolUse(t *testing.T) {
inputJSON := []byte(`{
"model": "claude-3-5-sonnet-20240620",
"messages": [
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "call_123",
"name": "get_weather",
"input": "{\"location\": \"Paris\"}"
}
]
}
]
}`)
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
outputStr := string(output)
// Now we expect only 1 part (tool_use), no dummy thinking block injected
parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
if len(parts) != 1 {
t.Fatalf("Expected 1 part (tool only, no dummy injection), got %d", len(parts))
}
// Check function call conversion at parts[0]
funcCall := parts[0].Get("functionCall")
if !funcCall.Exists() {
t.Error("functionCall should exist at parts[0]")
}
if funcCall.Get("name").String() != "get_weather" {
t.Errorf("Expected function name 'get_weather', got '%s'", funcCall.Get("name").String())
}
if funcCall.Get("id").String() != "call_123" {
t.Errorf("Expected function id 'call_123', got '%s'", funcCall.Get("id").String())
}
// Verify skip_thought_signature_validator is added (bypass for tools without valid thinking)
expectedSig := "skip_thought_signature_validator"
actualSig := parts[0].Get("thoughtSignature").String()
if actualSig != expectedSig {
t.Errorf("Expected thoughtSignature '%s', got '%s'", expectedSig, actualSig)
}
}
func TestConvertClaudeRequestToAntigravity_ToolUse_WithSignature(t *testing.T) {
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
inputJSON := []byte(`{
"model": "claude-sonnet-4-5-thinking",
"messages": [
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "Let me think...", "signature": "` + validSignature + `"},
{
"type": "tool_use",
"id": "call_123",
"name": "get_weather",
"input": "{\"location\": \"Paris\"}"
}
]
}
]
}`)
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
outputStr := string(output)
// Check function call has the signature from the preceding thinking block
part := gjson.Get(outputStr, "request.contents.0.parts.1")
if part.Get("functionCall.name").String() != "get_weather" {
t.Errorf("Expected functionCall, got %s", part.Raw)
}
if part.Get("thoughtSignature").String() != validSignature {
t.Errorf("Expected thoughtSignature '%s' on tool_use, got '%s'", validSignature, part.Get("thoughtSignature").String())
}
}
func TestConvertClaudeRequestToAntigravity_ReorderThinking(t *testing.T) {
// Case: text block followed by thinking block -> should be reordered to thinking first
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
inputJSON := []byte(`{
"model": "claude-sonnet-4-5-thinking",
"messages": [
{
"role": "assistant",
"content": [
{"type": "text", "text": "Here is the plan."},
{"type": "thinking", "thinking": "Planning...", "signature": "` + validSignature + `"}
]
}
]
}`)
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
outputStr := string(output)
// Verify order: Thinking block MUST be first
parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
if len(parts) != 2 {
t.Fatalf("Expected 2 parts, got %d", len(parts))
}
if !parts[0].Get("thought").Bool() {
t.Error("First part should be thinking block after reordering")
}
if parts[1].Get("text").String() != "Here is the plan." {
t.Error("Second part should be text block")
}
}
func TestConvertClaudeRequestToAntigravity_ToolResult(t *testing.T) {
inputJSON := []byte(`{
"model": "claude-3-5-sonnet-20240620",
"messages": [
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "get_weather-call-123",
"content": "22C sunny"
}
]
}
]
}`)
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
outputStr := string(output)
// Check function response conversion
funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
if !funcResp.Exists() {
t.Error("functionResponse should exist")
}
if funcResp.Get("id").String() != "get_weather-call-123" {
t.Errorf("Expected function id, got '%s'", funcResp.Get("id").String())
}
}
func TestConvertClaudeRequestToAntigravity_ThinkingConfig(t *testing.T) {
// Note: This test requires the model to be registered in the registry
// with Thinking metadata. If the registry is not populated in test environment,
// thinkingConfig won't be added. We'll test the basic structure only.
inputJSON := []byte(`{
"model": "claude-sonnet-4-5-thinking",
"messages": [],
"thinking": {
"type": "enabled",
"budget_tokens": 8000
}
}`)
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
outputStr := string(output)
// Check thinking config conversion (only if model supports thinking in registry)
thinkingConfig := gjson.Get(outputStr, "request.generationConfig.thinkingConfig")
if thinkingConfig.Exists() {
if thinkingConfig.Get("thinkingBudget").Int() != 8000 {
t.Errorf("Expected thinkingBudget 8000, got %d", thinkingConfig.Get("thinkingBudget").Int())
}
if !thinkingConfig.Get("include_thoughts").Bool() {
t.Error("include_thoughts should be true")
}
} else {
t.Log("thinkingConfig not present - model may not be registered in test registry")
}
}
func TestConvertClaudeRequestToAntigravity_ImageContent(t *testing.T) {
inputJSON := []byte(`{
"model": "claude-3-5-sonnet-20240620",
"messages": [
{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": "iVBORw0KGgoAAAANSUhEUg=="
}
}
]
}
]
}`)
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
outputStr := string(output)
// Check inline data conversion
inlineData := gjson.Get(outputStr, "request.contents.0.parts.0.inlineData")
if !inlineData.Exists() {
t.Error("inlineData should exist")
}
if inlineData.Get("mime_type").String() != "image/png" {
t.Error("mime_type mismatch")
}
if !strings.Contains(inlineData.Get("data").String(), "iVBORw0KGgo") {
t.Error("data mismatch")
}
}
func TestConvertClaudeRequestToAntigravity_GenerationConfig(t *testing.T) {
inputJSON := []byte(`{
"model": "claude-3-5-sonnet-20240620",
"messages": [],
"temperature": 0.7,
"top_p": 0.9,
"top_k": 40,
"max_tokens": 2000
}`)
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
outputStr := string(output)
genConfig := gjson.Get(outputStr, "request.generationConfig")
if genConfig.Get("temperature").Float() != 0.7 {
t.Errorf("Expected temperature 0.7, got %f", genConfig.Get("temperature").Float())
}
if genConfig.Get("topP").Float() != 0.9 {
t.Errorf("Expected topP 0.9, got %f", genConfig.Get("topP").Float())
}
if genConfig.Get("topK").Float() != 40 {
t.Errorf("Expected topK 40, got %f", genConfig.Get("topK").Float())
}
if genConfig.Get("maxOutputTokens").Float() != 2000 {
t.Errorf("Expected maxOutputTokens 2000, got %f", genConfig.Get("maxOutputTokens").Float())
}
}
// ============================================================================
// Trailing Unsigned Thinking Block Removal
// ============================================================================
func TestConvertClaudeRequestToAntigravity_TrailingUnsignedThinking_Removed(t *testing.T) {
// Last assistant message ends with unsigned thinking block - should be removed
inputJSON := []byte(`{
"model": "claude-sonnet-4-5-thinking",
"messages": [
{
"role": "user",
"content": [{"type": "text", "text": "Hello"}]
},
{
"role": "assistant",
"content": [
{"type": "text", "text": "Here is my answer"},
{"type": "thinking", "thinking": "I should think more..."}
]
}
]
}`)
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
outputStr := string(output)
// The last part of the last assistant message should NOT be a thinking block
lastMessageParts := gjson.Get(outputStr, "request.contents.1.parts")
if !lastMessageParts.IsArray() {
t.Fatal("Last message should have parts array")
}
parts := lastMessageParts.Array()
if len(parts) == 0 {
t.Fatal("Last message should have at least one part")
}
// The unsigned thinking should be removed, leaving only the text
lastPart := parts[len(parts)-1]
if lastPart.Get("thought").Bool() {
t.Error("Trailing unsigned thinking block should be removed")
}
}
func TestConvertClaudeRequestToAntigravity_TrailingSignedThinking_Kept(t *testing.T) {
// Last assistant message ends with signed thinking block - should be kept
inputJSON := []byte(`{
"model": "claude-sonnet-4-5-thinking",
"messages": [
{
"role": "user",
"content": [{"type": "text", "text": "Hello"}]
},
{
"role": "assistant",
"content": [
{"type": "text", "text": "Here is my answer"},
{"type": "thinking", "thinking": "Valid thinking...", "signature": "abc123validSignature1234567890123456789012345678901234567890"}
]
}
]
}`)
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
outputStr := string(output)
// The signed thinking block should be preserved
lastMessageParts := gjson.Get(outputStr, "request.contents.1.parts")
parts := lastMessageParts.Array()
if len(parts) < 2 {
t.Error("Signed thinking block should be preserved")
}
}
func TestConvertClaudeRequestToAntigravity_MiddleUnsignedThinking_Removed(t *testing.T) {
// Middle message has unsigned thinking - should be removed entirely
inputJSON := []byte(`{
"model": "claude-sonnet-4-5-thinking",
"messages": [
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "Middle thinking..."},
{"type": "text", "text": "Answer"}
]
},
{
"role": "user",
"content": [{"type": "text", "text": "Follow up"}]
}
]
}`)
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
outputStr := string(output)
// Unsigned thinking should be removed entirely
parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
if len(parts) != 1 {
t.Fatalf("Expected 1 part (thinking removed), got %d", len(parts))
}
// Only text part should remain
if parts[0].Get("thought").Bool() {
t.Error("Thinking block should be removed, not preserved")
}
if parts[0].Get("text").String() != "Answer" {
t.Errorf("Expected text 'Answer', got '%s'", parts[0].Get("text").String())
}
}
// ============================================================================
// Tool + Thinking System Hint Injection
// ============================================================================
func TestConvertClaudeRequestToAntigravity_ToolAndThinking_HintInjected(t *testing.T) {
// When both tools and thinking are enabled, hint should be injected into system instruction
inputJSON := []byte(`{
"model": "claude-sonnet-4-5-thinking",
"messages": [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}],
"system": [{"type": "text", "text": "You are helpful."}],
"tools": [
{
"name": "get_weather",
"description": "Get weather",
"input_schema": {"type": "object", "properties": {"location": {"type": "string"}}}
}
],
"thinking": {"type": "enabled", "budget_tokens": 8000}
}`)
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
outputStr := string(output)
// System instruction should contain the interleaved thinking hint
sysInstruction := gjson.Get(outputStr, "request.systemInstruction")
if !sysInstruction.Exists() {
t.Fatal("systemInstruction should exist")
}
// Check if hint is appended
sysText := sysInstruction.Get("parts").Array()
found := false
for _, part := range sysText {
if strings.Contains(part.Get("text").String(), "Interleaved thinking is enabled") {
found = true
break
}
}
if !found {
t.Errorf("Interleaved thinking hint should be injected when tools and thinking are both active, got: %v", sysInstruction.Raw)
}
}
func TestConvertClaudeRequestToAntigravity_ToolsOnly_NoHint(t *testing.T) {
// When only tools are present (no thinking), hint should NOT be injected
inputJSON := []byte(`{
"model": "claude-sonnet-4-5",
"messages": [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}],
"system": [{"type": "text", "text": "You are helpful."}],
"tools": [
{
"name": "get_weather",
"description": "Get weather",
"input_schema": {"type": "object", "properties": {"location": {"type": "string"}}}
}
]
}`)
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
outputStr := string(output)
// System instruction should NOT contain the hint
sysInstruction := gjson.Get(outputStr, "request.systemInstruction")
if sysInstruction.Exists() {
for _, part := range sysInstruction.Get("parts").Array() {
if strings.Contains(part.Get("text").String(), "Interleaved thinking is enabled") {
t.Error("Hint should NOT be injected when only tools are present (no thinking)")
}
}
}
}
func TestConvertClaudeRequestToAntigravity_ThinkingOnly_NoHint(t *testing.T) {
// When only thinking is enabled (no tools), hint should NOT be injected
inputJSON := []byte(`{
"model": "claude-sonnet-4-5-thinking",
"messages": [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}],
"system": [{"type": "text", "text": "You are helpful."}],
"thinking": {"type": "enabled", "budget_tokens": 8000}
}`)
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
outputStr := string(output)
// System instruction should NOT contain the hint (no tools)
sysInstruction := gjson.Get(outputStr, "request.systemInstruction")
if sysInstruction.Exists() {
for _, part := range sysInstruction.Get("parts").Array() {
if strings.Contains(part.Get("text").String(), "Interleaved thinking is enabled") {
t.Error("Hint should NOT be injected when only thinking is present (no tools)")
}
}
}
}
func TestConvertClaudeRequestToAntigravity_ToolAndThinking_NoExistingSystem(t *testing.T) {
// When tools + thinking but no system instruction, should create one with hint
inputJSON := []byte(`{
"model": "claude-sonnet-4-5-thinking",
"messages": [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}],
"tools": [
{
"name": "get_weather",
"description": "Get weather",
"input_schema": {"type": "object", "properties": {"location": {"type": "string"}}}
}
],
"thinking": {"type": "enabled", "budget_tokens": 8000}
}`)
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
outputStr := string(output)
// System instruction should be created with hint
sysInstruction := gjson.Get(outputStr, "request.systemInstruction")
if !sysInstruction.Exists() {
t.Fatal("systemInstruction should be created when tools + thinking are active")
}
sysText := sysInstruction.Get("parts").Array()
found := false
for _, part := range sysText {
if strings.Contains(part.Get("text").String(), "Interleaved thinking is enabled") {
found = true
break
}
}
if !found {
t.Errorf("Interleaved thinking hint should be in created systemInstruction, got: %v", sysInstruction.Raw)
}
}