Move base64 image data from Claude tool_result into functionResponse.parts as inlineData instead of outer sibling parts, preventing context bloat. Unify all inlineData field naming to camelCase mimeType across Claude, OpenAI, and Gemini translators. Add comprehensive edge case tests and Gemini-side regression test for functionResponse.parts preservation.
1202 lines
36 KiB
Go
1202 lines
36 KiB
Go
package claude
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
|
|
"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) {
|
|
cache.ClearSignatureCache("")
|
|
|
|
// Valid signature must be at least 50 characters
|
|
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
|
|
thinkingText := "Let me think..."
|
|
|
|
// Pre-cache the signature (simulating a previous response for the same thinking text)
|
|
inputJSON := []byte(`{
|
|
"model": "claude-sonnet-4-5-thinking",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "text", "text": "Test user message"}]
|
|
},
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + validSignature + `"},
|
|
{"type": "text", "text": "Answer"}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, validSignature)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
// Check thinking block conversion (now in contents.1 due to user message)
|
|
firstPart := gjson.Get(outputStr, "request.contents.1.parts.0")
|
|
if !firstPart.Get("thought").Bool() {
|
|
t.Error("thinking block should have thought: true")
|
|
}
|
|
if firstPart.Get("text").String() != thinkingText {
|
|
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) {
|
|
cache.ClearSignatureCache("")
|
|
|
|
// 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) {
|
|
cache.ClearSignatureCache("")
|
|
|
|
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
|
|
thinkingText := "Let me think..."
|
|
|
|
inputJSON := []byte(`{
|
|
"model": "claude-sonnet-4-5-thinking",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "text", "text": "Test user message"}]
|
|
},
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + validSignature + `"},
|
|
{
|
|
"type": "tool_use",
|
|
"id": "call_123",
|
|
"name": "get_weather",
|
|
"input": "{\"location\": \"Paris\"}"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, validSignature)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
// Check function call has the signature from the preceding thinking block (now in contents.1)
|
|
part := gjson.Get(outputStr, "request.contents.1.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) {
|
|
cache.ClearSignatureCache("")
|
|
|
|
// Case: text block followed by thinking block -> should be reordered to thinking first
|
|
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
|
|
thinkingText := "Planning..."
|
|
|
|
inputJSON := []byte(`{
|
|
"model": "claude-sonnet-4-5-thinking",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "text", "text": "Test user message"}]
|
|
},
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "text", "text": "Here is the plan."},
|
|
{"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + validSignature + `"}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, validSignature)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
// Verify order: Thinking block MUST be first (now in contents.1 due to user message)
|
|
parts := gjson.Get(outputStr, "request.contents.1.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("includeThoughts").Bool() {
|
|
t.Error("includeThoughts 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("mimeType").String() != "image/png" {
|
|
t.Error("mimeType 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) {
|
|
cache.ClearSignatureCache("")
|
|
|
|
// Last assistant message ends with signed thinking block - should be kept
|
|
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
|
|
thinkingText := "Valid thinking..."
|
|
|
|
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": "` + thinkingText + `", "signature": "` + validSignature + `"}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, validSignature)
|
|
|
|
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_ToolResultNoContent(t *testing.T) {
|
|
// Bug repro: tool_result with no content field produces invalid JSON
|
|
inputJSON := []byte(`{
|
|
"model": "claude-opus-4-6-thinking",
|
|
"messages": [
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "MyTool-123-456",
|
|
"name": "MyTool",
|
|
"input": {"key": "value"}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "MyTool-123-456"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-opus-4-6-thinking", inputJSON, true)
|
|
outputStr := string(output)
|
|
|
|
if !gjson.Valid(outputStr) {
|
|
t.Errorf("Result is not valid JSON:\n%s", outputStr)
|
|
}
|
|
|
|
// Verify the functionResponse has a valid result value
|
|
fr := gjson.Get(outputStr, "request.contents.1.parts.0.functionResponse.response.result")
|
|
if !fr.Exists() {
|
|
t.Error("functionResponse.response.result should exist")
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ToolResultNullContent(t *testing.T) {
|
|
// Bug repro: tool_result with null content produces invalid JSON
|
|
inputJSON := []byte(`{
|
|
"model": "claude-opus-4-6-thinking",
|
|
"messages": [
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "MyTool-123-456",
|
|
"name": "MyTool",
|
|
"input": {"key": "value"}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "MyTool-123-456",
|
|
"content": null
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-opus-4-6-thinking", inputJSON, true)
|
|
outputStr := string(output)
|
|
|
|
if !gjson.Valid(outputStr) {
|
|
t.Errorf("Result is not valid JSON:\n%s", outputStr)
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ToolResultWithImage(t *testing.T) {
|
|
// tool_result with array content containing text + image should place
|
|
// image data inside functionResponse.parts as inlineData, not as a
|
|
// sibling part in the outer content (to avoid base64 context bloat).
|
|
inputJSON := []byte(`{
|
|
"model": "claude-3-5-sonnet-20240620",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "Read-123-456",
|
|
"content": [
|
|
{
|
|
"type": "text",
|
|
"text": "File content here"
|
|
},
|
|
{
|
|
"type": "image",
|
|
"source": {
|
|
"type": "base64",
|
|
"media_type": "image/png",
|
|
"data": "iVBORw0KGgoAAAANSUhEUg=="
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
if !gjson.Valid(outputStr) {
|
|
t.Fatalf("Result is not valid JSON:\n%s", outputStr)
|
|
}
|
|
|
|
// Image should be inside functionResponse.parts, not as outer sibling part
|
|
funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
|
|
if !funcResp.Exists() {
|
|
t.Fatal("functionResponse should exist")
|
|
}
|
|
|
|
// Text content should be in response.result
|
|
resultText := funcResp.Get("response.result.text").String()
|
|
if resultText != "File content here" {
|
|
t.Errorf("Expected response.result.text = 'File content here', got '%s'", resultText)
|
|
}
|
|
|
|
// Image should be in functionResponse.parts[0].inlineData
|
|
inlineData := funcResp.Get("parts.0.inlineData")
|
|
if !inlineData.Exists() {
|
|
t.Fatal("functionResponse.parts[0].inlineData should exist")
|
|
}
|
|
if inlineData.Get("mimeType").String() != "image/png" {
|
|
t.Errorf("Expected mimeType 'image/png', got '%s'", inlineData.Get("mimeType").String())
|
|
}
|
|
if !strings.Contains(inlineData.Get("data").String(), "iVBORw0KGgo") {
|
|
t.Error("data mismatch")
|
|
}
|
|
|
|
// Image should NOT be in outer parts (only functionResponse part should exist)
|
|
outerParts := gjson.Get(outputStr, "request.contents.0.parts")
|
|
if outerParts.IsArray() && len(outerParts.Array()) > 1 {
|
|
t.Errorf("Expected only 1 outer part (functionResponse), got %d", len(outerParts.Array()))
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ToolResultWithSingleImage(t *testing.T) {
|
|
// tool_result with single image object as content should place
|
|
// image data inside functionResponse.parts, not as outer sibling part.
|
|
inputJSON := []byte(`{
|
|
"model": "claude-3-5-sonnet-20240620",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "Read-789-012",
|
|
"content": {
|
|
"type": "image",
|
|
"source": {
|
|
"type": "base64",
|
|
"media_type": "image/jpeg",
|
|
"data": "/9j/4AAQSkZJRgABAQ=="
|
|
}
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
if !gjson.Valid(outputStr) {
|
|
t.Fatalf("Result is not valid JSON:\n%s", outputStr)
|
|
}
|
|
|
|
funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
|
|
if !funcResp.Exists() {
|
|
t.Fatal("functionResponse should exist")
|
|
}
|
|
|
|
// response.result should be empty (image only)
|
|
if funcResp.Get("response.result").String() != "" {
|
|
t.Errorf("Expected empty response.result for image-only content, got '%s'", funcResp.Get("response.result").String())
|
|
}
|
|
|
|
// Image should be in functionResponse.parts[0].inlineData
|
|
inlineData := funcResp.Get("parts.0.inlineData")
|
|
if !inlineData.Exists() {
|
|
t.Fatal("functionResponse.parts[0].inlineData should exist")
|
|
}
|
|
if inlineData.Get("mimeType").String() != "image/jpeg" {
|
|
t.Errorf("Expected mimeType 'image/jpeg', got '%s'", inlineData.Get("mimeType").String())
|
|
}
|
|
|
|
// Image should NOT be in outer parts
|
|
outerParts := gjson.Get(outputStr, "request.contents.0.parts")
|
|
if outerParts.IsArray() && len(outerParts.Array()) > 1 {
|
|
t.Errorf("Expected only 1 outer part, got %d", len(outerParts.Array()))
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ToolResultWithMultipleImagesAndTexts(t *testing.T) {
|
|
// tool_result with array content: 2 text items + 2 images
|
|
// All images go into functionResponse.parts, texts into response.result array
|
|
inputJSON := []byte(`{
|
|
"model": "claude-3-5-sonnet-20240620",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "Multi-001",
|
|
"content": [
|
|
{"type": "text", "text": "First text"},
|
|
{
|
|
"type": "image",
|
|
"source": {"type": "base64", "media_type": "image/png", "data": "AAAA"}
|
|
},
|
|
{"type": "text", "text": "Second text"},
|
|
{
|
|
"type": "image",
|
|
"source": {"type": "base64", "media_type": "image/jpeg", "data": "BBBB"}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
if !gjson.Valid(outputStr) {
|
|
t.Fatalf("Result is not valid JSON:\n%s", outputStr)
|
|
}
|
|
|
|
funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
|
|
if !funcResp.Exists() {
|
|
t.Fatal("functionResponse should exist")
|
|
}
|
|
|
|
// Multiple text items => response.result is an array
|
|
resultArr := funcResp.Get("response.result")
|
|
if !resultArr.IsArray() {
|
|
t.Fatalf("Expected response.result to be an array, got: %s", resultArr.Raw)
|
|
}
|
|
results := resultArr.Array()
|
|
if len(results) != 2 {
|
|
t.Fatalf("Expected 2 result items, got %d", len(results))
|
|
}
|
|
|
|
// Both images should be in functionResponse.parts
|
|
imgParts := funcResp.Get("parts").Array()
|
|
if len(imgParts) != 2 {
|
|
t.Fatalf("Expected 2 image parts in functionResponse.parts, got %d", len(imgParts))
|
|
}
|
|
if imgParts[0].Get("inlineData.mimeType").String() != "image/png" {
|
|
t.Errorf("Expected first image mimeType 'image/png', got '%s'", imgParts[0].Get("inlineData.mimeType").String())
|
|
}
|
|
if imgParts[0].Get("inlineData.data").String() != "AAAA" {
|
|
t.Errorf("Expected first image data 'AAAA', got '%s'", imgParts[0].Get("inlineData.data").String())
|
|
}
|
|
if imgParts[1].Get("inlineData.mimeType").String() != "image/jpeg" {
|
|
t.Errorf("Expected second image mimeType 'image/jpeg', got '%s'", imgParts[1].Get("inlineData.mimeType").String())
|
|
}
|
|
if imgParts[1].Get("inlineData.data").String() != "BBBB" {
|
|
t.Errorf("Expected second image data 'BBBB', got '%s'", imgParts[1].Get("inlineData.data").String())
|
|
}
|
|
|
|
// Only 1 outer part (the functionResponse itself)
|
|
outerParts := gjson.Get(outputStr, "request.contents.0.parts").Array()
|
|
if len(outerParts) != 1 {
|
|
t.Errorf("Expected 1 outer part, got %d", len(outerParts))
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ToolResultWithOnlyMultipleImages(t *testing.T) {
|
|
// tool_result with only images (no text) — response.result should be empty string
|
|
inputJSON := []byte(`{
|
|
"model": "claude-3-5-sonnet-20240620",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "ImgOnly-001",
|
|
"content": [
|
|
{
|
|
"type": "image",
|
|
"source": {"type": "base64", "media_type": "image/png", "data": "PNG1"}
|
|
},
|
|
{
|
|
"type": "image",
|
|
"source": {"type": "base64", "media_type": "image/gif", "data": "GIF1"}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
if !gjson.Valid(outputStr) {
|
|
t.Fatalf("Result is not valid JSON:\n%s", outputStr)
|
|
}
|
|
|
|
funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
|
|
if !funcResp.Exists() {
|
|
t.Fatal("functionResponse should exist")
|
|
}
|
|
|
|
// No text => response.result should be empty string
|
|
if funcResp.Get("response.result").String() != "" {
|
|
t.Errorf("Expected empty response.result, got '%s'", funcResp.Get("response.result").String())
|
|
}
|
|
|
|
// Both images in functionResponse.parts
|
|
imgParts := funcResp.Get("parts").Array()
|
|
if len(imgParts) != 2 {
|
|
t.Fatalf("Expected 2 image parts, got %d", len(imgParts))
|
|
}
|
|
if imgParts[0].Get("inlineData.mimeType").String() != "image/png" {
|
|
t.Error("first image mimeType mismatch")
|
|
}
|
|
if imgParts[1].Get("inlineData.mimeType").String() != "image/gif" {
|
|
t.Error("second image mimeType mismatch")
|
|
}
|
|
|
|
// Only 1 outer part
|
|
outerParts := gjson.Get(outputStr, "request.contents.0.parts").Array()
|
|
if len(outerParts) != 1 {
|
|
t.Errorf("Expected 1 outer part, got %d", len(outerParts))
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ToolResultImageNotBase64(t *testing.T) {
|
|
// image with source.type != "base64" should be treated as non-image (falls through)
|
|
inputJSON := []byte(`{
|
|
"model": "claude-3-5-sonnet-20240620",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "NotB64-001",
|
|
"content": [
|
|
{"type": "text", "text": "some output"},
|
|
{
|
|
"type": "image",
|
|
"source": {"type": "url", "url": "https://example.com/img.png"}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
if !gjson.Valid(outputStr) {
|
|
t.Fatalf("Result is not valid JSON:\n%s", outputStr)
|
|
}
|
|
|
|
funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
|
|
if !funcResp.Exists() {
|
|
t.Fatal("functionResponse should exist")
|
|
}
|
|
|
|
// Non-base64 image is treated as non-image, so it goes into the filtered results
|
|
// along with the text item. Since there are 2 non-image items, result is array.
|
|
resultArr := funcResp.Get("response.result")
|
|
if !resultArr.IsArray() {
|
|
t.Fatalf("Expected response.result to be an array (2 non-image items), got: %s", resultArr.Raw)
|
|
}
|
|
results := resultArr.Array()
|
|
if len(results) != 2 {
|
|
t.Fatalf("Expected 2 result items, got %d", len(results))
|
|
}
|
|
|
|
// No functionResponse.parts (no base64 images collected)
|
|
if funcResp.Get("parts").Exists() {
|
|
t.Error("functionResponse.parts should NOT exist when no base64 images")
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ToolResultImageMissingData(t *testing.T) {
|
|
// image with source.type=base64 but missing data field
|
|
inputJSON := []byte(`{
|
|
"model": "claude-3-5-sonnet-20240620",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "NoData-001",
|
|
"content": [
|
|
{"type": "text", "text": "output"},
|
|
{
|
|
"type": "image",
|
|
"source": {"type": "base64", "media_type": "image/png"}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
if !gjson.Valid(outputStr) {
|
|
t.Fatalf("Result is not valid JSON:\n%s", outputStr)
|
|
}
|
|
|
|
funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
|
|
if !funcResp.Exists() {
|
|
t.Fatal("functionResponse should exist")
|
|
}
|
|
|
|
// The image is still classified as base64 image (type check passes),
|
|
// but data field is missing => inlineData has mimeType but no data
|
|
imgParts := funcResp.Get("parts").Array()
|
|
if len(imgParts) != 1 {
|
|
t.Fatalf("Expected 1 image part, got %d", len(imgParts))
|
|
}
|
|
if imgParts[0].Get("inlineData.mimeType").String() != "image/png" {
|
|
t.Error("mimeType should still be set")
|
|
}
|
|
if imgParts[0].Get("inlineData.data").Exists() {
|
|
t.Error("data should not exist when source.data is missing")
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ToolResultImageMissingMediaType(t *testing.T) {
|
|
// image with source.type=base64 but missing media_type field
|
|
inputJSON := []byte(`{
|
|
"model": "claude-3-5-sonnet-20240620",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "NoMime-001",
|
|
"content": [
|
|
{"type": "text", "text": "output"},
|
|
{
|
|
"type": "image",
|
|
"source": {"type": "base64", "data": "AAAA"}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
if !gjson.Valid(outputStr) {
|
|
t.Fatalf("Result is not valid JSON:\n%s", outputStr)
|
|
}
|
|
|
|
funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
|
|
if !funcResp.Exists() {
|
|
t.Fatal("functionResponse should exist")
|
|
}
|
|
|
|
// The image is still classified as base64 image,
|
|
// but media_type is missing => inlineData has data but no mimeType
|
|
imgParts := funcResp.Get("parts").Array()
|
|
if len(imgParts) != 1 {
|
|
t.Fatalf("Expected 1 image part, got %d", len(imgParts))
|
|
}
|
|
if imgParts[0].Get("inlineData.mimeType").Exists() {
|
|
t.Error("mimeType should not exist when media_type is missing")
|
|
}
|
|
if imgParts[0].Get("inlineData.data").String() != "AAAA" {
|
|
t.Error("data should still be set")
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|