From bbcb5552f34e6b107e8e52230d8cb446163865ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8C=80=ED=9D=AC?= Date: Fri, 19 Dec 2025 10:27:24 +0900 Subject: [PATCH 01/14] feat(cache): add signature cache for Claude thinking blocks --- internal/cache/signature_cache.go | 166 +++++++++++++++++++ internal/cache/signature_cache_test.go | 216 +++++++++++++++++++++++++ 2 files changed, 382 insertions(+) create mode 100644 internal/cache/signature_cache.go create mode 100644 internal/cache/signature_cache_test.go diff --git a/internal/cache/signature_cache.go b/internal/cache/signature_cache.go new file mode 100644 index 00000000..12f19cf0 --- /dev/null +++ b/internal/cache/signature_cache.go @@ -0,0 +1,166 @@ +package cache + +import ( + "crypto/sha256" + "encoding/hex" + "sync" + "time" +) + +// SignatureEntry holds a cached thinking signature with timestamp +type SignatureEntry struct { + Signature string + Timestamp time.Time +} + +const ( + // SignatureCacheTTL is how long signatures are valid + SignatureCacheTTL = 1 * time.Hour + + // MaxEntriesPerSession limits memory usage per session + MaxEntriesPerSession = 100 + + // SignatureTextHashLen is the length of the hash key (16 hex chars = 64-bit key space) + SignatureTextHashLen = 16 + + // MinValidSignatureLen is the minimum length for a signature to be considered valid + MinValidSignatureLen = 50 +) + +// signatureCache stores signatures by sessionId -> textHash -> SignatureEntry +var signatureCache sync.Map + +// sessionCache is the inner map type +type sessionCache struct { + mu sync.RWMutex + entries map[string]SignatureEntry +} + +// hashText creates a stable, Unicode-safe key from text content +func hashText(text string) string { + h := sha256.Sum256([]byte(text)) + return hex.EncodeToString(h[:])[:SignatureTextHashLen] +} + +// getOrCreateSession gets or creates a session cache +func getOrCreateSession(sessionID string) *sessionCache { + if val, ok := signatureCache.Load(sessionID); ok { + return val.(*sessionCache) + } + sc := &sessionCache{entries: make(map[string]SignatureEntry)} + actual, _ := signatureCache.LoadOrStore(sessionID, sc) + return actual.(*sessionCache) +} + +// CacheSignature stores a thinking signature for a given session and text. +// Used for Claude models that require signed thinking blocks in multi-turn conversations. +func CacheSignature(sessionID, text, signature string) { + if sessionID == "" || text == "" || signature == "" { + return + } + if len(signature) < MinValidSignatureLen { + return + } + + sc := getOrCreateSession(sessionID) + textHash := hashText(text) + + sc.mu.Lock() + defer sc.mu.Unlock() + + // Evict expired entries if at capacity + if len(sc.entries) >= MaxEntriesPerSession { + now := time.Now() + for key, entry := range sc.entries { + if now.Sub(entry.Timestamp) > SignatureCacheTTL { + delete(sc.entries, key) + } + } + // If still at capacity, remove oldest entries + if len(sc.entries) >= MaxEntriesPerSession { + // Find and remove oldest quarter + oldest := make([]struct { + key string + ts time.Time + }, 0, len(sc.entries)) + for key, entry := range sc.entries { + oldest = append(oldest, struct { + key string + ts time.Time + }{key, entry.Timestamp}) + } + // Simple approach: remove first quarter of entries + toRemove := len(oldest) / 4 + if toRemove < 1 { + toRemove = 1 + } + // Sort by timestamp (oldest first) - simple bubble for small N + for i := 0; i < toRemove; i++ { + minIdx := i + for j := i + 1; j < len(oldest); j++ { + if oldest[j].ts.Before(oldest[minIdx].ts) { + minIdx = j + } + } + oldest[i], oldest[minIdx] = oldest[minIdx], oldest[i] + delete(sc.entries, oldest[i].key) + } + } + } + + sc.entries[textHash] = SignatureEntry{ + Signature: signature, + Timestamp: time.Now(), + } +} + +// GetCachedSignature retrieves a cached signature for a given session and text. +// Returns empty string if not found or expired. +func GetCachedSignature(sessionID, text string) string { + if sessionID == "" || text == "" { + return "" + } + + val, ok := signatureCache.Load(sessionID) + if !ok { + return "" + } + sc := val.(*sessionCache) + + textHash := hashText(text) + + sc.mu.RLock() + entry, exists := sc.entries[textHash] + sc.mu.RUnlock() + + if !exists { + return "" + } + + // Check if expired + if time.Since(entry.Timestamp) > SignatureCacheTTL { + sc.mu.Lock() + delete(sc.entries, textHash) + sc.mu.Unlock() + return "" + } + + return entry.Signature +} + +// ClearSignatureCache clears signature cache for a specific session or all sessions. +func ClearSignatureCache(sessionID string) { + if sessionID != "" { + signatureCache.Delete(sessionID) + } else { + signatureCache.Range(func(key, _ any) bool { + signatureCache.Delete(key) + return true + }) + } +} + +// HasValidSignature checks if a signature is valid (non-empty and long enough) +func HasValidSignature(signature string) bool { + return signature != "" && len(signature) >= MinValidSignatureLen +} diff --git a/internal/cache/signature_cache_test.go b/internal/cache/signature_cache_test.go new file mode 100644 index 00000000..e4bddbe4 --- /dev/null +++ b/internal/cache/signature_cache_test.go @@ -0,0 +1,216 @@ +package cache + +import ( + "testing" + "time" +) + +func TestCacheSignature_BasicStorageAndRetrieval(t *testing.T) { + ClearSignatureCache("") + + sessionID := "test-session-1" + text := "This is some thinking text content" + signature := "abc123validSignature1234567890123456789012345678901234567890" + + // Store signature + CacheSignature(sessionID, text, signature) + + // Retrieve signature + retrieved := GetCachedSignature(sessionID, text) + if retrieved != signature { + t.Errorf("Expected signature '%s', got '%s'", signature, retrieved) + } +} + +func TestCacheSignature_DifferentSessions(t *testing.T) { + ClearSignatureCache("") + + text := "Same text in different sessions" + sig1 := "signature1_1234567890123456789012345678901234567890123456" + sig2 := "signature2_1234567890123456789012345678901234567890123456" + + CacheSignature("session-a", text, sig1) + CacheSignature("session-b", text, sig2) + + if GetCachedSignature("session-a", text) != sig1 { + t.Error("Session-a signature mismatch") + } + if GetCachedSignature("session-b", text) != sig2 { + t.Error("Session-b signature mismatch") + } +} + +func TestCacheSignature_NotFound(t *testing.T) { + ClearSignatureCache("") + + // Non-existent session + if got := GetCachedSignature("nonexistent", "some text"); got != "" { + t.Errorf("Expected empty string for nonexistent session, got '%s'", got) + } + + // Existing session but different text + CacheSignature("session-x", "text-a", "sigA12345678901234567890123456789012345678901234567890") + if got := GetCachedSignature("session-x", "text-b"); got != "" { + t.Errorf("Expected empty string for different text, got '%s'", got) + } +} + +func TestCacheSignature_EmptyInputs(t *testing.T) { + ClearSignatureCache("") + + // All empty/invalid inputs should be no-ops + CacheSignature("", "text", "sig12345678901234567890123456789012345678901234567890") + CacheSignature("session", "", "sig12345678901234567890123456789012345678901234567890") + CacheSignature("session", "text", "") + CacheSignature("session", "text", "short") // Too short + + if got := GetCachedSignature("session", "text"); got != "" { + t.Errorf("Expected empty after invalid cache attempts, got '%s'", got) + } +} + +func TestCacheSignature_ShortSignatureRejected(t *testing.T) { + ClearSignatureCache("") + + sessionID := "test-short-sig" + text := "Some text" + shortSig := "abc123" // Less than 50 chars + + CacheSignature(sessionID, text, shortSig) + + if got := GetCachedSignature(sessionID, text); got != "" { + t.Errorf("Short signature should be rejected, got '%s'", got) + } +} + +func TestClearSignatureCache_SpecificSession(t *testing.T) { + ClearSignatureCache("") + + sig := "validSig1234567890123456789012345678901234567890123456" + CacheSignature("session-1", "text", sig) + CacheSignature("session-2", "text", sig) + + ClearSignatureCache("session-1") + + if got := GetCachedSignature("session-1", "text"); got != "" { + t.Error("session-1 should be cleared") + } + if got := GetCachedSignature("session-2", "text"); got != sig { + t.Error("session-2 should still exist") + } +} + +func TestClearSignatureCache_AllSessions(t *testing.T) { + ClearSignatureCache("") + + sig := "validSig1234567890123456789012345678901234567890123456" + CacheSignature("session-1", "text", sig) + CacheSignature("session-2", "text", sig) + + ClearSignatureCache("") + + if got := GetCachedSignature("session-1", "text"); got != "" { + t.Error("session-1 should be cleared") + } + if got := GetCachedSignature("session-2", "text"); got != "" { + t.Error("session-2 should be cleared") + } +} + +func TestHasValidSignature(t *testing.T) { + tests := []struct { + name string + signature string + expected bool + }{ + {"valid long signature", "abc123validSignature1234567890123456789012345678901234567890", true}, + {"exactly 50 chars", "12345678901234567890123456789012345678901234567890", true}, + {"49 chars - invalid", "1234567890123456789012345678901234567890123456789", false}, + {"empty string", "", false}, + {"short signature", "abc", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := HasValidSignature(tt.signature) + if result != tt.expected { + t.Errorf("HasValidSignature(%q) = %v, expected %v", tt.signature, result, tt.expected) + } + }) + } +} + +func TestCacheSignature_TextHashCollisionResistance(t *testing.T) { + ClearSignatureCache("") + + sessionID := "hash-test-session" + + // Different texts should produce different hashes + text1 := "First thinking text" + text2 := "Second thinking text" + sig1 := "signature1_1234567890123456789012345678901234567890123456" + sig2 := "signature2_1234567890123456789012345678901234567890123456" + + CacheSignature(sessionID, text1, sig1) + CacheSignature(sessionID, text2, sig2) + + if GetCachedSignature(sessionID, text1) != sig1 { + t.Error("text1 signature mismatch") + } + if GetCachedSignature(sessionID, text2) != sig2 { + t.Error("text2 signature mismatch") + } +} + +func TestCacheSignature_UnicodeText(t *testing.T) { + ClearSignatureCache("") + + sessionID := "unicode-session" + text := "한글 텍스트와 이모지 🎉 그리고 特殊文字" + sig := "unicodeSig123456789012345678901234567890123456789012345" + + CacheSignature(sessionID, text, sig) + + if got := GetCachedSignature(sessionID, text); got != sig { + t.Errorf("Unicode text signature retrieval failed, got '%s'", got) + } +} + +func TestCacheSignature_Overwrite(t *testing.T) { + ClearSignatureCache("") + + sessionID := "overwrite-session" + text := "Same text" + sig1 := "firstSignature12345678901234567890123456789012345678901" + sig2 := "secondSignature1234567890123456789012345678901234567890" + + CacheSignature(sessionID, text, sig1) + CacheSignature(sessionID, text, sig2) // Overwrite + + if got := GetCachedSignature(sessionID, text); got != sig2 { + t.Errorf("Expected overwritten signature '%s', got '%s'", sig2, got) + } +} + +// Note: TTL expiration test is tricky to test without mocking time +// We test the logic path exists but actual expiration would require time manipulation +func TestCacheSignature_ExpirationLogic(t *testing.T) { + ClearSignatureCache("") + + // This test verifies the expiration check exists + // In a real scenario, we'd mock time.Now() + sessionID := "expiration-test" + text := "text" + sig := "validSig1234567890123456789012345678901234567890123456" + + CacheSignature(sessionID, text, sig) + + // Fresh entry should be retrievable + if got := GetCachedSignature(sessionID, text); got != sig { + t.Errorf("Fresh entry should be retrievable, got '%s'", got) + } + + // We can't easily test actual expiration without time mocking + // but the logic is verified by the implementation + _ = time.Now() // Acknowledge we're not testing time passage +} From 1bfa75f7801b80f2fd61d17293b8b23729c6e62b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8C=80=ED=9D=AC?= Date: Fri, 19 Dec 2025 10:27:24 +0900 Subject: [PATCH 02/14] feat(util): add helper to detect Claude thinking models --- internal/util/claude_model.go | 10 ++++++++ internal/util/claude_model_test.go | 41 ++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 internal/util/claude_model.go create mode 100644 internal/util/claude_model_test.go diff --git a/internal/util/claude_model.go b/internal/util/claude_model.go new file mode 100644 index 00000000..1534f02c --- /dev/null +++ b/internal/util/claude_model.go @@ -0,0 +1,10 @@ +package util + +import "strings" + +// IsClaudeThinkingModel checks if the model is a Claude thinking model +// that requires the interleaved-thinking beta header. +func IsClaudeThinkingModel(model string) bool { + lower := strings.ToLower(model) + return strings.Contains(lower, "claude") && strings.Contains(lower, "thinking") +} diff --git a/internal/util/claude_model_test.go b/internal/util/claude_model_test.go new file mode 100644 index 00000000..17f6106e --- /dev/null +++ b/internal/util/claude_model_test.go @@ -0,0 +1,41 @@ +package util + +import "testing" + +func TestIsClaudeThinkingModel(t *testing.T) { + tests := []struct { + name string + model string + expected bool + }{ + // Claude thinking models - should return true + {"claude-sonnet-4-5-thinking", "claude-sonnet-4-5-thinking", true}, + {"claude-opus-4-5-thinking", "claude-opus-4-5-thinking", true}, + {"Claude-Sonnet-Thinking uppercase", "Claude-Sonnet-4-5-Thinking", true}, + {"claude thinking mixed case", "Claude-THINKING-Model", true}, + + // Non-thinking Claude models - should return false + {"claude-sonnet-4-5 (no thinking)", "claude-sonnet-4-5", false}, + {"claude-opus-4-5 (no thinking)", "claude-opus-4-5", false}, + {"claude-3-5-sonnet", "claude-3-5-sonnet-20240620", false}, + + // Non-Claude models - should return false + {"gemini-3-pro-preview", "gemini-3-pro-preview", false}, + {"gemini-thinking model", "gemini-3-pro-thinking", false}, // not Claude + {"gpt-4o", "gpt-4o", false}, + {"empty string", "", false}, + + // Edge cases + {"thinking without claude", "thinking-model", false}, + {"claude without thinking", "claude-model", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsClaudeThinkingModel(tt.model) + if result != tt.expected { + t.Errorf("IsClaudeThinkingModel(%q) = %v, expected %v", tt.model, result, tt.expected) + } + }) + } +} From e44167d7a482aea95441243fdaa4e00355173971 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8C=80=ED=9D=AC?= Date: Fri, 19 Dec 2025 10:27:24 +0900 Subject: [PATCH 03/14] refactor(util/schema): rename and extend Gemini schema cleaning for Antigravity and add empty-schema placeholders --- internal/util/gemini_schema.go | 56 +++++- internal/util/gemini_schema_test.go | 293 +++++++++++++++++++++++----- 2 files changed, 302 insertions(+), 47 deletions(-) diff --git a/internal/util/gemini_schema.go b/internal/util/gemini_schema.go index b25d14e4..7ca9cf79 100644 --- a/internal/util/gemini_schema.go +++ b/internal/util/gemini_schema.go @@ -12,10 +12,10 @@ import ( var gjsonPathKeyReplacer = strings.NewReplacer(".", "\\.", "*", "\\*", "?", "\\?") -// CleanJSONSchemaForGemini transforms a JSON schema to be compatible with Gemini/Antigravity API. +// CleanJSONSchemaForAntigravity transforms a JSON schema to be compatible with Antigravity API. // It handles unsupported keywords, type flattening, and schema simplification while preserving // semantic information as description hints. -func CleanJSONSchemaForGemini(jsonStr string) string { +func CleanJSONSchemaForAntigravity(jsonStr string) string { // Phase 1: Convert and add hints jsonStr = convertRefsToHints(jsonStr) jsonStr = convertConstToEnum(jsonStr) @@ -32,6 +32,9 @@ func CleanJSONSchemaForGemini(jsonStr string) string { jsonStr = removeUnsupportedKeywords(jsonStr) jsonStr = cleanupRequiredFields(jsonStr) + // Phase 4: Add placeholder for empty object schemas (Claude VALIDATED mode requirement) + jsonStr = addEmptySchemaPlaceholder(jsonStr) + return jsonStr } @@ -105,7 +108,8 @@ func addAdditionalPropertiesHints(jsonStr string) string { var unsupportedConstraints = []string{ "minLength", "maxLength", "exclusiveMinimum", "exclusiveMaximum", - "pattern", "minItems", "maxItems", + "pattern", "minItems", "maxItems", "format", + "default", "examples", // Claude rejects these in VALIDATED mode } func moveConstraintsToDescription(jsonStr string) string { @@ -338,6 +342,52 @@ func cleanupRequiredFields(jsonStr string) string { return jsonStr } +// addEmptySchemaPlaceholder adds a placeholder "reason" property to empty object schemas. +// Claude VALIDATED mode requires at least one property in tool schemas. +func addEmptySchemaPlaceholder(jsonStr string) string { + // Find all "type" fields + paths := findPaths(jsonStr, "type") + + // Process from deepest to shallowest (to handle nested objects properly) + sortByDepth(paths) + + for _, p := range paths { + typeVal := gjson.Get(jsonStr, p) + if typeVal.String() != "object" { + continue + } + + // Get the parent path (the object containing "type") + parentPath := trimSuffix(p, ".type") + + // Check if properties exists and is empty or missing + propsPath := joinPath(parentPath, "properties") + propsVal := gjson.Get(jsonStr, propsPath) + + needsPlaceholder := false + if !propsVal.Exists() { + // No properties field at all + needsPlaceholder = true + } else if propsVal.IsObject() && len(propsVal.Map()) == 0 { + // Empty properties object + needsPlaceholder = true + } + + if needsPlaceholder { + // Add placeholder "reason" property + reasonPath := joinPath(propsPath, "reason") + jsonStr, _ = sjson.Set(jsonStr, reasonPath+".type", "string") + jsonStr, _ = sjson.Set(jsonStr, reasonPath+".description", "Brief explanation of why you are calling this tool") + + // Add to required array + reqPath := joinPath(parentPath, "required") + jsonStr, _ = sjson.Set(jsonStr, reqPath, []string{"reason"}) + } + } + + return jsonStr +} + // --- Helpers --- func findPaths(jsonStr, field string) []string { diff --git a/internal/util/gemini_schema_test.go b/internal/util/gemini_schema_test.go index 655511d9..0f9e3eba 100644 --- a/internal/util/gemini_schema_test.go +++ b/internal/util/gemini_schema_test.go @@ -5,9 +5,11 @@ import ( "reflect" "strings" "testing" + + "github.com/tidwall/gjson" ) -func TestCleanJSONSchemaForGemini_ConstToEnum(t *testing.T) { +func TestCleanJSONSchemaForAntigravity_ConstToEnum(t *testing.T) { input := `{ "type": "object", "properties": { @@ -28,11 +30,11 @@ func TestCleanJSONSchemaForGemini_ConstToEnum(t *testing.T) { } }` - result := CleanJSONSchemaForGemini(input) + result := CleanJSONSchemaForAntigravity(input) compareJSON(t, expected, result) } -func TestCleanJSONSchemaForGemini_TypeFlattening_Nullable(t *testing.T) { +func TestCleanJSONSchemaForAntigravity_TypeFlattening_Nullable(t *testing.T) { input := `{ "type": "object", "properties": { @@ -60,11 +62,11 @@ func TestCleanJSONSchemaForGemini_TypeFlattening_Nullable(t *testing.T) { "required": ["other"] }` - result := CleanJSONSchemaForGemini(input) + result := CleanJSONSchemaForAntigravity(input) compareJSON(t, expected, result) } -func TestCleanJSONSchemaForGemini_ConstraintsToDescription(t *testing.T) { +func TestCleanJSONSchemaForAntigravity_ConstraintsToDescription(t *testing.T) { input := `{ "type": "object", "properties": { @@ -81,7 +83,7 @@ func TestCleanJSONSchemaForGemini_ConstraintsToDescription(t *testing.T) { } }` - result := CleanJSONSchemaForGemini(input) + result := CleanJSONSchemaForAntigravity(input) // minItems should be REMOVED and moved to description if strings.Contains(result, `"minItems"`) { @@ -100,7 +102,7 @@ func TestCleanJSONSchemaForGemini_ConstraintsToDescription(t *testing.T) { } } -func TestCleanJSONSchemaForGemini_AnyOfFlattening_SmartSelection(t *testing.T) { +func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_SmartSelection(t *testing.T) { input := `{ "type": "object", "properties": { @@ -131,11 +133,11 @@ func TestCleanJSONSchemaForGemini_AnyOfFlattening_SmartSelection(t *testing.T) { } }` - result := CleanJSONSchemaForGemini(input) + result := CleanJSONSchemaForAntigravity(input) compareJSON(t, expected, result) } -func TestCleanJSONSchemaForGemini_OneOfFlattening(t *testing.T) { +func TestCleanJSONSchemaForAntigravity_OneOfFlattening(t *testing.T) { input := `{ "type": "object", "properties": { @@ -158,11 +160,11 @@ func TestCleanJSONSchemaForGemini_OneOfFlattening(t *testing.T) { } }` - result := CleanJSONSchemaForGemini(input) + result := CleanJSONSchemaForAntigravity(input) compareJSON(t, expected, result) } -func TestCleanJSONSchemaForGemini_AllOfMerging(t *testing.T) { +func TestCleanJSONSchemaForAntigravity_AllOfMerging(t *testing.T) { input := `{ "type": "object", "allOf": [ @@ -190,11 +192,11 @@ func TestCleanJSONSchemaForGemini_AllOfMerging(t *testing.T) { "required": ["a", "b"] }` - result := CleanJSONSchemaForGemini(input) + result := CleanJSONSchemaForAntigravity(input) compareJSON(t, expected, result) } -func TestCleanJSONSchemaForGemini_RefHandling(t *testing.T) { +func TestCleanJSONSchemaForAntigravity_RefHandling(t *testing.T) { input := `{ "definitions": { "User": { @@ -210,21 +212,29 @@ func TestCleanJSONSchemaForGemini_RefHandling(t *testing.T) { } }` + // After $ref is converted to placeholder object, empty schema placeholder is also added expected := `{ "type": "object", "properties": { "customer": { "type": "object", - "description": "See: User" + "description": "See: User", + "properties": { + "reason": { + "type": "string", + "description": "Brief explanation of why you are calling this tool" + } + }, + "required": ["reason"] } } }` - result := CleanJSONSchemaForGemini(input) + result := CleanJSONSchemaForAntigravity(input) compareJSON(t, expected, result) } -func TestCleanJSONSchemaForGemini_RefHandling_DescriptionEscaping(t *testing.T) { +func TestCleanJSONSchemaForAntigravity_RefHandling_DescriptionEscaping(t *testing.T) { input := `{ "definitions": { "User": { @@ -243,21 +253,29 @@ func TestCleanJSONSchemaForGemini_RefHandling_DescriptionEscaping(t *testing.T) } }` + // After $ref is converted, empty schema placeholder is also added expected := `{ "type": "object", "properties": { "customer": { "type": "object", - "description": "He said \"hi\"\\nsecond line (See: User)" + "description": "He said \"hi\"\\nsecond line (See: User)", + "properties": { + "reason": { + "type": "string", + "description": "Brief explanation of why you are calling this tool" + } + }, + "required": ["reason"] } } }` - result := CleanJSONSchemaForGemini(input) + result := CleanJSONSchemaForAntigravity(input) compareJSON(t, expected, result) } -func TestCleanJSONSchemaForGemini_CyclicRefDefaults(t *testing.T) { +func TestCleanJSONSchemaForAntigravity_CyclicRefDefaults(t *testing.T) { input := `{ "definitions": { "Node": { @@ -270,7 +288,7 @@ func TestCleanJSONSchemaForGemini_CyclicRefDefaults(t *testing.T) { "$ref": "#/definitions/Node" }` - result := CleanJSONSchemaForGemini(input) + result := CleanJSONSchemaForAntigravity(input) var resMap map[string]interface{} json.Unmarshal([]byte(result), &resMap) @@ -285,7 +303,7 @@ func TestCleanJSONSchemaForGemini_CyclicRefDefaults(t *testing.T) { } } -func TestCleanJSONSchemaForGemini_RequiredCleanup(t *testing.T) { +func TestCleanJSONSchemaForAntigravity_RequiredCleanup(t *testing.T) { input := `{ "type": "object", "properties": { @@ -304,11 +322,11 @@ func TestCleanJSONSchemaForGemini_RequiredCleanup(t *testing.T) { "required": ["a", "b"] }` - result := CleanJSONSchemaForGemini(input) + result := CleanJSONSchemaForAntigravity(input) compareJSON(t, expected, result) } -func TestCleanJSONSchemaForGemini_AllOfMerging_DotKeys(t *testing.T) { +func TestCleanJSONSchemaForAntigravity_AllOfMerging_DotKeys(t *testing.T) { input := `{ "type": "object", "allOf": [ @@ -336,11 +354,11 @@ func TestCleanJSONSchemaForGemini_AllOfMerging_DotKeys(t *testing.T) { "required": ["my.param", "b"] }` - result := CleanJSONSchemaForGemini(input) + result := CleanJSONSchemaForAntigravity(input) compareJSON(t, expected, result) } -func TestCleanJSONSchemaForGemini_PropertyNameCollision(t *testing.T) { +func TestCleanJSONSchemaForAntigravity_PropertyNameCollision(t *testing.T) { // A tool has an argument named "pattern" - should NOT be treated as a constraint input := `{ "type": "object", @@ -364,7 +382,7 @@ func TestCleanJSONSchemaForGemini_PropertyNameCollision(t *testing.T) { "required": ["pattern"] }` - result := CleanJSONSchemaForGemini(input) + result := CleanJSONSchemaForAntigravity(input) compareJSON(t, expected, result) var resMap map[string]interface{} @@ -375,7 +393,7 @@ func TestCleanJSONSchemaForGemini_PropertyNameCollision(t *testing.T) { } } -func TestCleanJSONSchemaForGemini_DotKeys(t *testing.T) { +func TestCleanJSONSchemaForAntigravity_DotKeys(t *testing.T) { input := `{ "type": "object", "properties": { @@ -389,7 +407,7 @@ func TestCleanJSONSchemaForGemini_DotKeys(t *testing.T) { } }` - result := CleanJSONSchemaForGemini(input) + result := CleanJSONSchemaForAntigravity(input) var resMap map[string]interface{} if err := json.Unmarshal([]byte(result), &resMap); err != nil { @@ -414,7 +432,7 @@ func TestCleanJSONSchemaForGemini_DotKeys(t *testing.T) { } } -func TestCleanJSONSchemaForGemini_AnyOfAlternativeHints(t *testing.T) { +func TestCleanJSONSchemaForAntigravity_AnyOfAlternativeHints(t *testing.T) { input := `{ "type": "object", "properties": { @@ -428,7 +446,7 @@ func TestCleanJSONSchemaForGemini_AnyOfAlternativeHints(t *testing.T) { } }` - result := CleanJSONSchemaForGemini(input) + result := CleanJSONSchemaForAntigravity(input) if !strings.Contains(result, "Accepts:") { t.Errorf("Expected alternative types hint, got: %s", result) @@ -438,7 +456,7 @@ func TestCleanJSONSchemaForGemini_AnyOfAlternativeHints(t *testing.T) { } } -func TestCleanJSONSchemaForGemini_NullableHint(t *testing.T) { +func TestCleanJSONSchemaForAntigravity_NullableHint(t *testing.T) { input := `{ "type": "object", "properties": { @@ -450,7 +468,7 @@ func TestCleanJSONSchemaForGemini_NullableHint(t *testing.T) { "required": ["name"] }` - result := CleanJSONSchemaForGemini(input) + result := CleanJSONSchemaForAntigravity(input) if !strings.Contains(result, "(nullable)") { t.Errorf("Expected nullable hint, got: %s", result) @@ -460,7 +478,7 @@ func TestCleanJSONSchemaForGemini_NullableHint(t *testing.T) { } } -func TestCleanJSONSchemaForGemini_TypeFlattening_Nullable_DotKey(t *testing.T) { +func TestCleanJSONSchemaForAntigravity_TypeFlattening_Nullable_DotKey(t *testing.T) { input := `{ "type": "object", "properties": { @@ -488,11 +506,11 @@ func TestCleanJSONSchemaForGemini_TypeFlattening_Nullable_DotKey(t *testing.T) { "required": ["other"] }` - result := CleanJSONSchemaForGemini(input) + result := CleanJSONSchemaForAntigravity(input) compareJSON(t, expected, result) } -func TestCleanJSONSchemaForGemini_EnumHint(t *testing.T) { +func TestCleanJSONSchemaForAntigravity_EnumHint(t *testing.T) { input := `{ "type": "object", "properties": { @@ -504,7 +522,7 @@ func TestCleanJSONSchemaForGemini_EnumHint(t *testing.T) { } }` - result := CleanJSONSchemaForGemini(input) + result := CleanJSONSchemaForAntigravity(input) if !strings.Contains(result, "Allowed:") { t.Errorf("Expected enum values hint, got: %s", result) @@ -514,7 +532,7 @@ func TestCleanJSONSchemaForGemini_EnumHint(t *testing.T) { } } -func TestCleanJSONSchemaForGemini_AdditionalPropertiesHint(t *testing.T) { +func TestCleanJSONSchemaForAntigravity_AdditionalPropertiesHint(t *testing.T) { input := `{ "type": "object", "properties": { @@ -523,14 +541,14 @@ func TestCleanJSONSchemaForGemini_AdditionalPropertiesHint(t *testing.T) { "additionalProperties": false }` - result := CleanJSONSchemaForGemini(input) + result := CleanJSONSchemaForAntigravity(input) if !strings.Contains(result, "No extra properties allowed") { t.Errorf("Expected additionalProperties hint, got: %s", result) } } -func TestCleanJSONSchemaForGemini_AnyOfFlattening_PreservesDescription(t *testing.T) { +func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_PreservesDescription(t *testing.T) { input := `{ "type": "object", "properties": { @@ -554,11 +572,11 @@ func TestCleanJSONSchemaForGemini_AnyOfFlattening_PreservesDescription(t *testin } }` - result := CleanJSONSchemaForGemini(input) + result := CleanJSONSchemaForAntigravity(input) compareJSON(t, expected, result) } -func TestCleanJSONSchemaForGemini_SingleEnumNoHint(t *testing.T) { +func TestCleanJSONSchemaForAntigravity_SingleEnumNoHint(t *testing.T) { input := `{ "type": "object", "properties": { @@ -569,14 +587,14 @@ func TestCleanJSONSchemaForGemini_SingleEnumNoHint(t *testing.T) { } }` - result := CleanJSONSchemaForGemini(input) + result := CleanJSONSchemaForAntigravity(input) if strings.Contains(result, "Allowed:") { t.Errorf("Single value enum should not add Allowed hint, got: %s", result) } } -func TestCleanJSONSchemaForGemini_MultipleNonNullTypes(t *testing.T) { +func TestCleanJSONSchemaForAntigravity_MultipleNonNullTypes(t *testing.T) { input := `{ "type": "object", "properties": { @@ -586,7 +604,7 @@ func TestCleanJSONSchemaForGemini_MultipleNonNullTypes(t *testing.T) { } }` - result := CleanJSONSchemaForGemini(input) + result := CleanJSONSchemaForAntigravity(input) if !strings.Contains(result, "Accepts:") { t.Errorf("Expected multiple types hint, got: %s", result) @@ -611,3 +629,190 @@ func compareJSON(t *testing.T, expectedJSON, actualJSON string) { t.Errorf("JSON mismatch:\nExpected:\n%s\n\nActual:\n%s", string(expBytes), string(actBytes)) } } + +// ============================================================================ +// P0-1: Empty Schema Placeholder Tests +// ============================================================================ + +func TestCleanJSONSchemaForAntigravity_EmptySchemaPlaceholder(t *testing.T) { + // Empty object schema with no properties should get a placeholder + input := `{ + "type": "object" + }` + + result := CleanJSONSchemaForAntigravity(input) + + // Should have placeholder property added + if !strings.Contains(result, `"reason"`) { + t.Errorf("Empty schema should have 'reason' placeholder property, got: %s", result) + } + if !strings.Contains(result, `"required"`) { + t.Errorf("Empty schema should have 'required' with 'reason', got: %s", result) + } +} + +func TestCleanJSONSchemaForAntigravity_EmptyPropertiesPlaceholder(t *testing.T) { + // Object with empty properties object + input := `{ + "type": "object", + "properties": {} + }` + + result := CleanJSONSchemaForAntigravity(input) + + // Should have placeholder property added + if !strings.Contains(result, `"reason"`) { + t.Errorf("Empty properties should have 'reason' placeholder, got: %s", result) + } +} + +func TestCleanJSONSchemaForAntigravity_NonEmptySchemaUnchanged(t *testing.T) { + // Schema with properties should NOT get placeholder + input := `{ + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": ["name"] + }` + + result := CleanJSONSchemaForAntigravity(input) + + // Should NOT have placeholder property + if strings.Contains(result, `"reason"`) { + t.Errorf("Non-empty schema should NOT have 'reason' placeholder, got: %s", result) + } + // Original properties should be preserved + if !strings.Contains(result, `"name"`) { + t.Errorf("Original property 'name' should be preserved, got: %s", result) + } +} + +func TestCleanJSONSchemaForAntigravity_NestedEmptySchema(t *testing.T) { + // Nested empty object in items should also get placeholder + input := `{ + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object" + } + } + } + }` + + result := CleanJSONSchemaForAntigravity(input) + + // Nested empty object should also get placeholder + // Check that the nested object has a reason property + parsed := gjson.Parse(result) + nestedProps := parsed.Get("properties.items.items.properties") + if !nestedProps.Exists() || !nestedProps.Get("reason").Exists() { + t.Errorf("Nested empty object should have 'reason' placeholder, got: %s", result) + } +} + +func TestCleanJSONSchemaForAntigravity_EmptySchemaWithDescription(t *testing.T) { + // Empty schema with description should preserve description and add placeholder + input := `{ + "type": "object", + "description": "An empty object" + }` + + result := CleanJSONSchemaForAntigravity(input) + + // Should have both description and placeholder + if !strings.Contains(result, `"An empty object"`) { + t.Errorf("Description should be preserved, got: %s", result) + } + if !strings.Contains(result, `"reason"`) { + t.Errorf("Empty schema should have 'reason' placeholder, got: %s", result) + } +} + +// ============================================================================ +// P0-2: Format field handling (ad-hoc patch removal) +// ============================================================================ + +func TestCleanJSONSchemaForAntigravity_FormatFieldRemoval(t *testing.T) { + // format:"uri" should be removed and added as hint + input := `{ + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "A URL" + } + } + }` + + result := CleanJSONSchemaForAntigravity(input) + + // format should be removed + if strings.Contains(result, `"format"`) { + t.Errorf("format field should be removed, got: %s", result) + } + // hint should be added to description + if !strings.Contains(result, "format: uri") { + t.Errorf("format hint should be added to description, got: %s", result) + } + // original description should be preserved + if !strings.Contains(result, "A URL") { + t.Errorf("Original description should be preserved, got: %s", result) + } +} + +func TestCleanJSONSchemaForAntigravity_FormatFieldNoDescription(t *testing.T) { + // format without description should create description with hint + input := `{ + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + } + } + }` + + result := CleanJSONSchemaForAntigravity(input) + + // format should be removed + if strings.Contains(result, `"format"`) { + t.Errorf("format field should be removed, got: %s", result) + } + // hint should be added + if !strings.Contains(result, "format: email") { + t.Errorf("format hint should be added, got: %s", result) + } +} + +func TestCleanJSONSchemaForAntigravity_MultipleFormats(t *testing.T) { + // Multiple format fields should all be handled + input := `{ + "type": "object", + "properties": { + "url": {"type": "string", "format": "uri"}, + "email": {"type": "string", "format": "email"}, + "date": {"type": "string", "format": "date-time"} + } + }` + + result := CleanJSONSchemaForAntigravity(input) + + // All format fields should be removed + if strings.Contains(result, `"format"`) { + t.Errorf("All format fields should be removed, got: %s", result) + } + // All hints should be added + if !strings.Contains(result, "format: uri") { + t.Errorf("uri format hint should be added, got: %s", result) + } + if !strings.Contains(result, "format: email") { + t.Errorf("email format hint should be added, got: %s", result) + } + if !strings.Contains(result, "format: date-time") { + t.Errorf("date-time format hint should be added, got: %s", result) + } +} From b6ba15fcbd2c163555f439170790eecd76196513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8C=80=ED=9D=AC?= Date: Fri, 19 Dec 2025 10:27:24 +0900 Subject: [PATCH 04/14] fix(runtime/executor): Antigravity executor schema handling and Claude-specific headers --- .../runtime/executor/antigravity_executor.go | 36 ++++++++++--------- .../claude/antigravity_claude_request.go | 21 +++++++++++ 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 8b4e37ee..6be5bf46 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -70,10 +70,6 @@ func (e *AntigravityExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Au // Execute performs a non-streaming request to the Antigravity API. func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { - if strings.Contains(req.Model, "claude") { - return e.executeClaudeNonStream(ctx, auth, req, opts) - } - token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth) if errToken != nil { return resp, errToken @@ -997,21 +993,23 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau payload = geminiToAntigravity(modelName, payload, projectID) payload, _ = sjson.SetBytes(payload, "model", alias2ModelName(modelName)) - if strings.Contains(modelName, "claude") { - strJSON := string(payload) - paths := make([]string, 0) - util.Walk(gjson.ParseBytes(payload), "", "parametersJsonSchema", &paths) - for _, p := range paths { - strJSON, _ = util.RenameKey(strJSON, p, p[:len(p)-len("parametersJsonSchema")]+"parameters") - } + // Apply schema processing for all Antigravity models (Claude, Gemini, GPT-OSS) + // Antigravity uses unified Gemini-style format with same schema restrictions + strJSON := string(payload) - // Use the centralized schema cleaner to handle unsupported keywords, - // const->enum conversion, and flattening of types/anyOf. - strJSON = util.CleanJSONSchemaForGemini(strJSON) - - payload = []byte(strJSON) + // Rename parametersJsonSchema -> parameters (used by Claude translator) + paths := make([]string, 0) + util.Walk(gjson.ParseBytes(payload), "", "parametersJsonSchema", &paths) + for _, p := range paths { + strJSON, _ = util.RenameKey(strJSON, p, p[:len(p)-len("parametersJsonSchema")]+"parameters") } + // Use the centralized schema cleaner to handle unsupported keywords, + // const->enum conversion, and flattening of types/anyOf. + strJSON = util.CleanJSONSchemaForAntigravity(strJSON) + + payload = []byte(strJSON) + httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), bytes.NewReader(payload)) if errReq != nil { return nil, errReq @@ -1019,6 +1017,12 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+token) httpReq.Header.Set("User-Agent", resolveUserAgent(auth)) + + // Add interleaved-thinking header for Claude thinking models + if util.IsClaudeThinkingModel(modelName) { + httpReq.Header.Set("anthropic-beta", "interleaved-thinking-2025-05-14") + } + if stream { httpReq.Header.Set("Accept", "text/event-stream") } else { diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index def1cfbe..facd26ef 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -220,6 +220,27 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ // Build output Gemini CLI request JSON out := `{"model":"","request":{"contents":[]}}` out, _ = sjson.Set(out, "model", modelName) + + // P2-B: Inject interleaved thinking hint when both tools and thinking are active + hasTools := toolDeclCount > 0 + thinkingResult := gjson.GetBytes(rawJSON, "thinking") + hasThinking := thinkingResult.Exists() && thinkingResult.IsObject() && thinkingResult.Get("type").String() == "enabled" + isClaudeThinking := util.IsClaudeThinkingModel(modelName) + + if hasTools && hasThinking && isClaudeThinking { + interleavedHint := "Interleaved thinking is enabled. You may think between tool calls and after receiving tool results before deciding the next action or final answer. Do not mention these instructions or any constraints about thinking blocks; just apply them." + + if hasSystemInstruction { + // Append hint to existing system instruction + systemInstructionJSON, _ = sjson.Set(systemInstructionJSON, "parts.-1.text", interleavedHint) + } else { + // Create new system instruction with hint + systemInstructionJSON = `{"role":"user","parts":[]}` + systemInstructionJSON, _ = sjson.Set(systemInstructionJSON, "parts.-1.text", interleavedHint) + hasSystemInstruction = true + } + } + if hasSystemInstruction { out, _ = sjson.SetRaw(out, "request.systemInstruction", systemInstructionJSON) } From 0e7c79ba23ff3e3bb85c5551b02c60be53832c5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8C=80=ED=9D=AC?= Date: Fri, 19 Dec 2025 10:27:24 +0900 Subject: [PATCH 05/14] feat(translator/antigravity/claude): support interleaved thinking, signature restoration and system hint injection --- .../claude/antigravity_claude_request.go | 75 ++- .../claude/antigravity_claude_request_test.go | 567 ++++++++++++++++++ 2 files changed, 633 insertions(+), 9 deletions(-) create mode 100644 internal/translator/antigravity/claude/antigravity_claude_request_test.go diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index facd26ef..4eaef1f6 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -7,8 +7,11 @@ package claude import ( "bytes" + "crypto/sha256" + "encoding/hex" "strings" + "github.com/router-for-me/CLIProxyAPI/v6/internal/cache" "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" "github.com/tidwall/gjson" @@ -17,6 +20,29 @@ import ( const geminiCLIClaudeThoughtSignature = "skip_thought_signature_validator" +// deriveSessionID generates a stable session ID from the request. +// Uses the hash of the first user message to identify the conversation. +func deriveSessionID(rawJSON []byte) string { + messages := gjson.GetBytes(rawJSON, "messages") + if !messages.IsArray() { + return "" + } + for _, msg := range messages.Array() { + if msg.Get("role").String() == "user" { + content := msg.Get("content").String() + if content == "" { + // Try to get text from content array + content = msg.Get("content.0.text").String() + } + if content != "" { + h := sha256.Sum256([]byte(content)) + return hex.EncodeToString(h[:16]) + } + } + } + return "" +} + // ConvertClaudeRequestToAntigravity parses and transforms a Claude Code API request into Gemini CLI API format. // It extracts the model name, system instruction, message contents, and tool declarations // from the raw JSON request and returns them in the format expected by the Gemini CLI API. @@ -37,7 +63,9 @@ const geminiCLIClaudeThoughtSignature = "skip_thought_signature_validator" // - []byte: The transformed request data in Gemini CLI API format func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ bool) []byte { rawJSON := bytes.Clone(inputRawJSON) - rawJSON = bytes.Replace(rawJSON, []byte(`"url":{"type":"string","format":"uri",`), []byte(`"url":{"type":"string",`), -1) + + // Derive session ID for signature caching + sessionID := deriveSessionID(rawJSON) // system instruction systemInstructionJSON := "" @@ -67,13 +95,15 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ messagesResult := gjson.GetBytes(rawJSON, "messages") if messagesResult.IsArray() { messageResults := messagesResult.Array() - for i := 0; i < len(messageResults); i++ { + numMessages := len(messageResults) + for i := 0; i < numMessages; i++ { messageResult := messageResults[i] roleResult := messageResult.Get("role") if roleResult.Type != gjson.String { continue } - role := roleResult.String() + originalRole := roleResult.String() + role := originalRole if role == "assistant" { role = "model" } @@ -82,20 +112,47 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ contentsResult := messageResult.Get("content") if contentsResult.IsArray() { contentResults := contentsResult.Array() - for j := 0; j < len(contentResults); j++ { + numContents := len(contentResults) + for j := 0; j < numContents; j++ { contentResult := contentResults[j] contentTypeResult := contentResult.Get("type") if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "thinking" { - prompt := contentResult.Get("thinking").String() + thinkingText := contentResult.Get("thinking").String() signatureResult := contentResult.Get("signature") - signature := geminiCLIClaudeThoughtSignature - if signatureResult.Exists() { + signature := "" + if signatureResult.Exists() && signatureResult.String() != "" { signature = signatureResult.String() } + + // P3: Try to restore signature from cache for unsigned thinking blocks + if !cache.HasValidSignature(signature) && sessionID != "" && thinkingText != "" { + if cachedSig := cache.GetCachedSignature(sessionID, thinkingText); cachedSig != "" { + signature = cachedSig + log.Debugf("Restored cached signature for thinking block") + } + } + + // P2-A: Skip trailing unsigned thinking blocks on last assistant message + isLastMessage := (i == numMessages-1) + isLastContent := (j == numContents-1) + isAssistant := (originalRole == "assistant") + isUnsigned := !cache.HasValidSignature(signature) + + if isLastMessage && isLastContent && isAssistant && isUnsigned { + // Skip this trailing unsigned thinking block + continue + } + + // Apply sentinel for unsigned thinking blocks that are not trailing + // (includes empty string and short/invalid signatures < 50 chars) + if isUnsigned { + signature = geminiCLIClaudeThoughtSignature + } + partJSON := `{}` partJSON, _ = sjson.Set(partJSON, "thought", true) - if prompt != "" { - partJSON, _ = sjson.Set(partJSON, "text", prompt) + if thinkingText != "" { + partJSON, _ = sjson.Set(partJSON, "text", thinkingText) } if signature != "" { partJSON, _ = sjson.Set(partJSON, "thoughtSignature", signature) diff --git a/internal/translator/antigravity/claude/antigravity_claude_request_test.go b/internal/translator/antigravity/claude/antigravity_claude_request_test.go new file mode 100644 index 00000000..a5bfe49b --- /dev/null +++ b/internal/translator/antigravity/claude/antigravity_claude_request_test.go @@ -0,0 +1,567 @@ +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) { + 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, should use sentinel value + firstPart := gjson.Get(outputStr, "request.contents.0.parts.0") + if firstPart.Get("thoughtSignature").String() != geminiCLIClaudeThoughtSignature { + t.Errorf("Expected sentinel signature '%s', got '%s'", + geminiCLIClaudeThoughtSignature, firstPart.Get("thoughtSignature").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) + + // Check function call conversion + funcCall := gjson.Get(outputStr, "request.contents.0.parts.0.functionCall") + if !funcCall.Exists() { + t.Error("functionCall should exist") + } + 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()) + } +} + +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()) + } +} + +// ============================================================================ +// P2-A: 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_SentinelApplied(t *testing.T) { + // Middle message has unsigned thinking - should use sentinel (existing behavior) + 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) + + // Middle unsigned thinking should have sentinel applied + thinkingPart := gjson.Get(outputStr, "request.contents.0.parts.0") + if !thinkingPart.Get("thought").Bool() { + t.Error("Middle thinking block should be preserved with sentinel") + } + if thinkingPart.Get("thoughtSignature").String() != geminiCLIClaudeThoughtSignature { + t.Errorf("Middle unsigned thinking should use sentinel signature, got: %s", thinkingPart.Get("thoughtSignature").String()) + } +} + +// ============================================================================ +// P2-B: 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) + } +} From 98fa2a15971d535bdee3412857ab20bec7c03bd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8C=80=ED=9D=AC?= Date: Fri, 19 Dec 2025 10:27:24 +0900 Subject: [PATCH 06/14] feat(translator/antigravity/claude): support interleaved thinking, signature restoration and system hint injection --- .../claude/antigravity_claude_response.go | 43 ++ .../antigravity_claude_response_test.go | 389 ++++++++++++++++++ 2 files changed, 432 insertions(+) create mode 100644 internal/translator/antigravity/claude/antigravity_claude_response_test.go diff --git a/internal/translator/antigravity/claude/antigravity_claude_response.go b/internal/translator/antigravity/claude/antigravity_claude_response.go index 52fc358e..d26a1c9f 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_response.go +++ b/internal/translator/antigravity/claude/antigravity_claude_response.go @@ -9,11 +9,16 @@ package claude import ( "bytes" "context" + "crypto/sha256" + "encoding/hex" "fmt" "strings" "sync/atomic" "time" + "github.com/router-for-me/CLIProxyAPI/v6/internal/cache" + log "github.com/sirupsen/logrus" + "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -35,6 +40,31 @@ type Params struct { HasSentFinalEvents bool // Indicates if final content/message events have been sent HasToolUse bool // Indicates if tool use was observed in the stream HasContent bool // Tracks whether any content (text, thinking, or tool use) has been output + + // P3: Signature caching support + SessionID string // Session ID derived from request for signature caching + CurrentThinkingText strings.Builder // Accumulates thinking text for signature caching +} + +// deriveSessionIDFromRequest generates a stable session ID from the request JSON. +func deriveSessionIDFromRequest(rawJSON []byte) string { + messages := gjson.GetBytes(rawJSON, "messages") + if !messages.IsArray() { + return "" + } + for _, msg := range messages.Array() { + if msg.Get("role").String() == "user" { + content := msg.Get("content").String() + if content == "" { + content = msg.Get("content.0.text").String() + } + if content != "" { + h := sha256.Sum256([]byte(content)) + return hex.EncodeToString(h[:16]) + } + } + } + return "" } // toolUseIDCounter provides a process-wide unique counter for tool use identifiers. @@ -62,6 +92,7 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq HasFirstResponse: false, ResponseType: 0, ResponseIndex: 0, + SessionID: deriveSessionIDFromRequest(originalRequestRawJSON), } } @@ -119,11 +150,20 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq // Process thinking content (internal reasoning) if partResult.Get("thought").Bool() { if thoughtSignature := partResult.Get("thoughtSignature"); thoughtSignature.Exists() && thoughtSignature.String() != "" { + log.Debug("Branch: signature_delta") + + if params.SessionID != "" && params.CurrentThinkingText.Len() > 0 { + cache.CacheSignature(params.SessionID, params.CurrentThinkingText.String(), thoughtSignature.String()) + log.Debugf("Cached signature for thinking block (sessionID=%s, textLen=%d)", params.SessionID, params.CurrentThinkingText.Len()) + params.CurrentThinkingText.Reset() + } + output = output + "event: content_block_delta\n" data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, params.ResponseIndex), "delta.signature", thoughtSignature.String()) output = output + fmt.Sprintf("data: %s\n\n\n", data) params.HasContent = true } else if params.ResponseType == 2 { // Continue existing thinking block if already in thinking state + params.CurrentThinkingText.WriteString(partTextResult.String()) output = output + "event: content_block_delta\n" data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, params.ResponseIndex), "delta.thinking", partTextResult.String()) output = output + fmt.Sprintf("data: %s\n\n\n", data) @@ -152,6 +192,9 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq output = output + fmt.Sprintf("data: %s\n\n\n", data) params.ResponseType = 2 // Set state to thinking params.HasContent = true + // P3: Start accumulating thinking text for signature caching + params.CurrentThinkingText.Reset() + params.CurrentThinkingText.WriteString(partTextResult.String()) } } else { finishReasonResult := gjson.GetBytes(rawJSON, "response.candidates.0.finishReason") diff --git a/internal/translator/antigravity/claude/antigravity_claude_response_test.go b/internal/translator/antigravity/claude/antigravity_claude_response_test.go new file mode 100644 index 00000000..7ffd7666 --- /dev/null +++ b/internal/translator/antigravity/claude/antigravity_claude_response_test.go @@ -0,0 +1,389 @@ +package claude + +import ( + "context" + "strings" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/cache" +) + +func TestConvertBashCommandToCmdField(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "basic command to cmd conversion", + input: `{"command": "git diff"}`, + expected: `{"cmd":"git diff"}`, + }, + { + name: "already has cmd field - no change", + input: `{"cmd": "git diff"}`, + expected: `{"cmd": "git diff"}`, + }, + { + name: "both cmd and command - keep cmd only", + input: `{"command": "git diff", "cmd": "ls"}`, + expected: `{"command": "git diff", "cmd": "ls"}`, // no change when cmd exists + }, + { + name: "command with special characters in value", + input: `{"command": "echo \"command\": test"}`, + expected: `{"cmd":"echo \"command\": test"}`, + }, + { + name: "command with nested quotes", + input: `{"command": "bash -c 'echo \"hello\"'"}`, + expected: `{"cmd":"bash -c 'echo \"hello\"'"}`, + }, + { + name: "command with newlines", + input: `{"command": "echo hello\necho world"}`, + expected: `{"cmd":"echo hello\necho world"}`, + }, + { + name: "empty command value", + input: `{"command": ""}`, + expected: `{"cmd":""}`, + }, + { + name: "command with other fields - preserves them", + input: `{"command": "git diff", "timeout": 30}`, + expected: `{ "timeout": 30,"cmd":"git diff"}`, + }, + { + name: "invalid JSON - returns unchanged", + input: `{invalid json`, + expected: `{invalid json`, + }, + { + name: "empty object", + input: `{}`, + expected: `{}`, + }, + { + name: "no command field", + input: `{"restart": true}`, + expected: `{"restart": true}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := convertBashCommandToCmdField(tt.input) + if result != tt.expected { + t.Errorf("convertBashCommandToCmdField(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +// ============================================================================ +// P3: Signature Caching Tests +// ============================================================================ + +func TestConvertAntigravityResponseToClaude_SessionIDDerived(t *testing.T) { + cache.ClearSignatureCache("") + + // Request with user message - should derive session ID + requestJSON := []byte(`{ + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "Hello world"}]} + ] + }`) + + // First response chunk with thinking + responseJSON := []byte(`{ + "response": { + "candidates": [{ + "content": { + "parts": [{"text": "Let me think...", "thought": true}] + } + }] + } + }`) + + var param any + ctx := context.Background() + ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, responseJSON, ¶m) + + // Verify session ID was set + params := param.(*Params) + if params.SessionID == "" { + t.Error("SessionID should be derived from request") + } +} + +func TestConvertAntigravityResponseToClaude_ThinkingTextAccumulated(t *testing.T) { + cache.ClearSignatureCache("") + + requestJSON := []byte(`{ + "messages": [{"role": "user", "content": [{"type": "text", "text": "Test"}]}] + }`) + + // First thinking chunk + chunk1 := []byte(`{ + "response": { + "candidates": [{ + "content": { + "parts": [{"text": "First part of thinking...", "thought": true}] + } + }] + } + }`) + + // Second thinking chunk (continuation) + chunk2 := []byte(`{ + "response": { + "candidates": [{ + "content": { + "parts": [{"text": " Second part of thinking...", "thought": true}] + } + }] + } + }`) + + var param any + ctx := context.Background() + + // Process first chunk - starts new thinking block + ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, chunk1, ¶m) + params := param.(*Params) + + if params.CurrentThinkingText.Len() == 0 { + t.Error("Thinking text should be accumulated after first chunk") + } + + // Process second chunk - continues thinking block + ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, chunk2, ¶m) + + text := params.CurrentThinkingText.String() + if !strings.Contains(text, "First part") || !strings.Contains(text, "Second part") { + t.Errorf("Thinking text should accumulate both parts, got: %s", text) + } +} + +func TestConvertAntigravityResponseToClaude_SignatureCached(t *testing.T) { + cache.ClearSignatureCache("") + + requestJSON := []byte(`{ + "messages": [{"role": "user", "content": [{"type": "text", "text": "Cache test"}]}] + }`) + + // Thinking chunk + thinkingChunk := []byte(`{ + "response": { + "candidates": [{ + "content": { + "parts": [{"text": "My thinking process here", "thought": true}] + } + }] + } + }`) + + // Signature chunk + validSignature := "abc123validSignature1234567890123456789012345678901234567890" + signatureChunk := []byte(`{ + "response": { + "candidates": [{ + "content": { + "parts": [{"text": "", "thought": true, "thoughtSignature": "` + validSignature + `"}] + } + }] + } + }`) + + var param any + ctx := context.Background() + + // Process thinking chunk + ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, thinkingChunk, ¶m) + params := param.(*Params) + sessionID := params.SessionID + thinkingText := params.CurrentThinkingText.String() + + if sessionID == "" { + t.Fatal("SessionID should be set") + } + if thinkingText == "" { + t.Fatal("Thinking text should be accumulated") + } + + // Process signature chunk - should cache the signature + ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, signatureChunk, ¶m) + + // Verify signature was cached + cachedSig := cache.GetCachedSignature(sessionID, thinkingText) + if cachedSig != validSignature { + t.Errorf("Expected cached signature '%s', got '%s'", validSignature, cachedSig) + } + + // Verify thinking text was reset after caching + if params.CurrentThinkingText.Len() != 0 { + t.Error("Thinking text should be reset after signature is cached") + } +} + +func TestConvertAntigravityResponseToClaude_MultipleThinkingBlocks(t *testing.T) { + cache.ClearSignatureCache("") + + requestJSON := []byte(`{ + "messages": [{"role": "user", "content": [{"type": "text", "text": "Multi block test"}]}] + }`) + + validSig1 := "signature1_12345678901234567890123456789012345678901234567" + validSig2 := "signature2_12345678901234567890123456789012345678901234567" + + // First thinking block with signature + block1Thinking := []byte(`{ + "response": { + "candidates": [{ + "content": { + "parts": [{"text": "First thinking block", "thought": true}] + } + }] + } + }`) + block1Sig := []byte(`{ + "response": { + "candidates": [{ + "content": { + "parts": [{"text": "", "thought": true, "thoughtSignature": "` + validSig1 + `"}] + } + }] + } + }`) + + // Text content (breaks thinking) + textBlock := []byte(`{ + "response": { + "candidates": [{ + "content": { + "parts": [{"text": "Regular text output"}] + } + }] + } + }`) + + // Second thinking block with signature + block2Thinking := []byte(`{ + "response": { + "candidates": [{ + "content": { + "parts": [{"text": "Second thinking block", "thought": true}] + } + }] + } + }`) + block2Sig := []byte(`{ + "response": { + "candidates": [{ + "content": { + "parts": [{"text": "", "thought": true, "thoughtSignature": "` + validSig2 + `"}] + } + }] + } + }`) + + var param any + ctx := context.Background() + + // Process first thinking block + ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, block1Thinking, ¶m) + params := param.(*Params) + sessionID := params.SessionID + firstThinkingText := params.CurrentThinkingText.String() + + ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, block1Sig, ¶m) + + // Verify first signature cached + if cache.GetCachedSignature(sessionID, firstThinkingText) != validSig1 { + t.Error("First thinking block signature should be cached") + } + + // Process text (transitions out of thinking) + ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, textBlock, ¶m) + + // Process second thinking block + ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, block2Thinking, ¶m) + secondThinkingText := params.CurrentThinkingText.String() + + ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, block2Sig, ¶m) + + // Verify second signature cached + if cache.GetCachedSignature(sessionID, secondThinkingText) != validSig2 { + t.Error("Second thinking block signature should be cached") + } +} + +func TestDeriveSessionIDFromRequest(t *testing.T) { + tests := []struct { + name string + input []byte + wantEmpty bool + }{ + { + name: "valid user message", + input: []byte(`{"messages": [{"role": "user", "content": "Hello"}]}`), + wantEmpty: false, + }, + { + name: "user message with content array", + input: []byte(`{"messages": [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}]}`), + wantEmpty: false, + }, + { + name: "no user message", + input: []byte(`{"messages": [{"role": "assistant", "content": "Hi"}]}`), + wantEmpty: true, + }, + { + name: "empty messages", + input: []byte(`{"messages": []}`), + wantEmpty: true, + }, + { + name: "no messages field", + input: []byte(`{}`), + wantEmpty: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := deriveSessionIDFromRequest(tt.input) + if tt.wantEmpty && result != "" { + t.Errorf("Expected empty session ID, got '%s'", result) + } + if !tt.wantEmpty && result == "" { + t.Error("Expected non-empty session ID") + } + }) + } +} + +func TestDeriveSessionIDFromRequest_Deterministic(t *testing.T) { + input := []byte(`{"messages": [{"role": "user", "content": "Same message"}]}`) + + id1 := deriveSessionIDFromRequest(input) + id2 := deriveSessionIDFromRequest(input) + + if id1 != id2 { + t.Errorf("Session ID should be deterministic: '%s' != '%s'", id1, id2) + } +} + +func TestDeriveSessionIDFromRequest_DifferentMessages(t *testing.T) { + input1 := []byte(`{"messages": [{"role": "user", "content": "Message A"}]}`) + input2 := []byte(`{"messages": [{"role": "user", "content": "Message B"}]}`) + + id1 := deriveSessionIDFromRequest(input1) + id2 := deriveSessionIDFromRequest(input2) + + if id1 == id2 { + t.Error("Different messages should produce different session IDs") + } +} From c1f8211acb64ffa473c1236592e462a35d7204a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8C=80=ED=9D=AC?= Date: Fri, 19 Dec 2025 11:12:16 +0900 Subject: [PATCH 07/14] fix: Normalize Bash tool args and add signature caching support Normalize Bash tool arguments by converting a "command" key into "cmd" using JSON-aware parsing, avoiding brittle string replacements that could corrupt values. Apply this conversion in both streaming and non-streaming response paths so bash-style tool calls are emitted with the expected "cmd" field. Add support for accumulating thinking text and carrying session identifiers to enable signature caching/restore for unsigned thinking blocks, improving handling of thinking-state continuity across requests/responses. Also perform small cleanups: import logging, tidy comments and test descriptions. These changes make tool-argument handling more robust and enable reliable signature restoration for thinking blocks. --- .../claude/antigravity_claude_request.go | 7 +-- .../claude/antigravity_claude_request_test.go | 4 +- .../claude/antigravity_claude_response.go | 48 +++++++++++++++++-- .../antigravity_claude_response_test.go | 2 +- internal/util/gemini_schema_test.go | 4 +- 5 files changed, 53 insertions(+), 12 deletions(-) diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index 4eaef1f6..fdc0f93e 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -14,6 +14,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/cache" "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -124,7 +125,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ signature = signatureResult.String() } - // P3: Try to restore signature from cache for unsigned thinking blocks + // Try to restore signature from cache for unsigned thinking blocks if !cache.HasValidSignature(signature) && sessionID != "" && thinkingText != "" { if cachedSig := cache.GetCachedSignature(sessionID, thinkingText); cachedSig != "" { signature = cachedSig @@ -132,7 +133,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ } } - // P2-A: Skip trailing unsigned thinking blocks on last assistant message + // Skip trailing unsigned thinking blocks on last assistant message isLastMessage := (i == numMessages-1) isLastContent := (j == numContents-1) isAssistant := (originalRole == "assistant") @@ -278,7 +279,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ out := `{"model":"","request":{"contents":[]}}` out, _ = sjson.Set(out, "model", modelName) - // P2-B: Inject interleaved thinking hint when both tools and thinking are active + // Inject interleaved thinking hint when both tools and thinking are active hasTools := toolDeclCount > 0 thinkingResult := gjson.GetBytes(rawJSON, "thinking") hasThinking := thinkingResult.Exists() && thinkingResult.IsObject() && thinkingResult.Get("type").String() == "enabled" diff --git a/internal/translator/antigravity/claude/antigravity_claude_request_test.go b/internal/translator/antigravity/claude/antigravity_claude_request_test.go index a5bfe49b..796ce0d3 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request_test.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request_test.go @@ -330,7 +330,7 @@ func TestConvertClaudeRequestToAntigravity_GenerationConfig(t *testing.T) { } // ============================================================================ -// P2-A: Trailing Unsigned Thinking Block Removal +// Trailing Unsigned Thinking Block Removal // ============================================================================ func TestConvertClaudeRequestToAntigravity_TrailingUnsignedThinking_Removed(t *testing.T) { @@ -435,7 +435,7 @@ func TestConvertClaudeRequestToAntigravity_MiddleUnsignedThinking_SentinelApplie } // ============================================================================ -// P2-B: Tool + Thinking System Hint Injection +// Tool + Thinking System Hint Injection // ============================================================================ func TestConvertClaudeRequestToAntigravity_ToolAndThinking_HintInjected(t *testing.T) { diff --git a/internal/translator/antigravity/claude/antigravity_claude_response.go b/internal/translator/antigravity/claude/antigravity_claude_response.go index d26a1c9f..939551ba 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_response.go +++ b/internal/translator/antigravity/claude/antigravity_claude_response.go @@ -41,7 +41,7 @@ type Params struct { HasToolUse bool // Indicates if tool use was observed in the stream HasContent bool // Tracks whether any content (text, thinking, or tool use) has been output - // P3: Signature caching support + // Signature caching support SessionID string // Session ID derived from request for signature caching CurrentThinkingText strings.Builder // Accumulates thinking text for signature caching } @@ -192,7 +192,7 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq output = output + fmt.Sprintf("data: %s\n\n\n", data) params.ResponseType = 2 // Set state to thinking params.HasContent = true - // P3: Start accumulating thinking text for signature caching + // Start accumulating thinking text for signature caching params.CurrentThinkingText.Reset() params.CurrentThinkingText.WriteString(partTextResult.String()) } @@ -276,8 +276,13 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq output = output + fmt.Sprintf("data: %s\n\n\n", data) if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() { + argsRaw := fcArgsResult.Raw + // Convert command → cmd for Bash tools using proper JSON parsing + if fcName == "Bash" || fcName == "bash" || fcName == "bash_20241022" { + argsRaw = convertBashCommandToCmdField(argsRaw) + } output = output + "event: content_block_delta\n" - data, _ = sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, params.ResponseIndex), "delta.partial_json", fcArgsResult.Raw) + data, _ = sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, params.ResponseIndex), "delta.partial_json", argsRaw) output = output + fmt.Sprintf("data: %s\n\n\n", data) } params.ResponseType = 3 @@ -365,6 +370,36 @@ func resolveStopReason(params *Params) string { return "end_turn" } +// convertBashCommandToCmdField converts "command" field to "cmd" field for Bash tools. +// Amp expects "cmd" but Gemini sends "command". This uses proper JSON parsing +// to avoid accidentally replacing "command" that appears in values. +func convertBashCommandToCmdField(argsRaw string) string { + // Only process valid JSON + if !gjson.Valid(argsRaw) { + return argsRaw + } + + // Check if "command" key exists and "cmd" doesn't + commandVal := gjson.Get(argsRaw, "command") + cmdVal := gjson.Get(argsRaw, "cmd") + + if commandVal.Exists() && !cmdVal.Exists() { + // Set "cmd" to the value of "command", preserve the raw value type + result, err := sjson.SetRaw(argsRaw, "cmd", commandVal.Raw) + if err != nil { + return argsRaw + } + // Delete "command" key + result, err = sjson.Delete(result, "command") + if err != nil { + return argsRaw + } + return result + } + + return argsRaw +} + // ConvertAntigravityResponseToClaudeNonStream converts a non-streaming Gemini CLI response to a non-streaming Claude response. // // Parameters: @@ -476,7 +511,12 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or toolBlock, _ = sjson.Set(toolBlock, "name", name) if args := functionCall.Get("args"); args.Exists() && args.Raw != "" && gjson.Valid(args.Raw) { - toolBlock, _ = sjson.SetRaw(toolBlock, "input", args.Raw) + argsRaw := args.Raw + // Convert command → cmd for Bash tools + if name == "Bash" || name == "bash" || name == "bash_20241022" { + argsRaw = convertBashCommandToCmdField(argsRaw) + } + toolBlock, _ = sjson.SetRaw(toolBlock, "input", argsRaw) } ensureContentArray() diff --git a/internal/translator/antigravity/claude/antigravity_claude_response_test.go b/internal/translator/antigravity/claude/antigravity_claude_response_test.go index 7ffd7666..4c2f31c1 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_response_test.go +++ b/internal/translator/antigravity/claude/antigravity_claude_response_test.go @@ -82,7 +82,7 @@ func TestConvertBashCommandToCmdField(t *testing.T) { } // ============================================================================ -// P3: Signature Caching Tests +// Signature Caching Tests // ============================================================================ func TestConvertAntigravityResponseToClaude_SessionIDDerived(t *testing.T) { diff --git a/internal/util/gemini_schema_test.go b/internal/util/gemini_schema_test.go index 0f9e3eba..69adbcdb 100644 --- a/internal/util/gemini_schema_test.go +++ b/internal/util/gemini_schema_test.go @@ -631,7 +631,7 @@ func compareJSON(t *testing.T, expectedJSON, actualJSON string) { } // ============================================================================ -// P0-1: Empty Schema Placeholder Tests +// Empty Schema Placeholder Tests // ============================================================================ func TestCleanJSONSchemaForAntigravity_EmptySchemaPlaceholder(t *testing.T) { @@ -732,7 +732,7 @@ func TestCleanJSONSchemaForAntigravity_EmptySchemaWithDescription(t *testing.T) } // ============================================================================ -// P0-2: Format field handling (ad-hoc patch removal) +// Format field handling (ad-hoc patch removal) // ============================================================================ func TestCleanJSONSchemaForAntigravity_FormatFieldRemoval(t *testing.T) { From 3275494fde6efb71a3b12ee66fa0b5f191f2d81f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8C=80=ED=9D=AC?= Date: Fri, 19 Dec 2025 13:09:36 +0900 Subject: [PATCH 08/14] refactor: Use helper to extract wrapped "thinking" text Improve robustness when handling "thinking" content by using a dedicated helper to extract the thinking text. This ensures wrapped or nested thinking objects are handled correctly instead of relying on a direct string extraction, reducing parsing errors for complex payloads. --- .../claude/antigravity_claude_request.go | 3 +- internal/util/thinking_text.go | 87 +++++++++++++++++++ litellm | 1 + opencode-antigravity-auth | 1 + opencode-google-antigravity-auth | 1 + 5 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 internal/util/thinking_text.go create mode 160000 litellm create mode 160000 opencode-antigravity-auth create mode 160000 opencode-google-antigravity-auth diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index fdc0f93e..fdfdf469 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -118,7 +118,8 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ contentResult := contentResults[j] contentTypeResult := contentResult.Get("type") if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "thinking" { - thinkingText := contentResult.Get("thinking").String() + // Use GetThinkingText to handle wrapped thinking objects + thinkingText := util.GetThinkingText(contentResult) signatureResult := contentResult.Get("signature") signature := "" if signatureResult.Exists() && signatureResult.String() != "" { diff --git a/internal/util/thinking_text.go b/internal/util/thinking_text.go new file mode 100644 index 00000000..c36d202d --- /dev/null +++ b/internal/util/thinking_text.go @@ -0,0 +1,87 @@ +package util + +import ( + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// GetThinkingText extracts the thinking text from a content part. +// Handles various formats: +// - Simple string: { "thinking": "text" } or { "text": "text" } +// - Wrapped object: { "thinking": { "text": "text", "cache_control": {...} } } +// - Gemini-style: { "thought": true, "text": "text" } +// Returns the extracted text string. +func GetThinkingText(part gjson.Result) string { + // Try direct text field first (Gemini-style) + if text := part.Get("text"); text.Exists() && text.Type == gjson.String { + return text.String() + } + + // Try thinking field + thinkingField := part.Get("thinking") + if !thinkingField.Exists() { + return "" + } + + // thinking is a string + if thinkingField.Type == gjson.String { + return thinkingField.String() + } + + // thinking is an object with inner text/thinking + if thinkingField.IsObject() { + if inner := thinkingField.Get("text"); inner.Exists() && inner.Type == gjson.String { + return inner.String() + } + if inner := thinkingField.Get("thinking"); inner.Exists() && inner.Type == gjson.String { + return inner.String() + } + } + + return "" +} + +// GetThinkingTextFromJSON extracts thinking text from a raw JSON string. +func GetThinkingTextFromJSON(jsonStr string) string { + return GetThinkingText(gjson.Parse(jsonStr)) +} + +// SanitizeThinkingPart normalizes a thinking part to a canonical form. +// Strips cache_control and other non-essential fields. +// Returns the sanitized part as JSON string. +func SanitizeThinkingPart(part gjson.Result) string { + // Gemini-style: { thought: true, text, thoughtSignature } + if part.Get("thought").Bool() { + result := `{"thought":true}` + if text := GetThinkingText(part); text != "" { + result, _ = sjson.Set(result, "text", text) + } + if sig := part.Get("thoughtSignature"); sig.Exists() && sig.Type == gjson.String { + result, _ = sjson.Set(result, "thoughtSignature", sig.String()) + } + return result + } + + // Anthropic-style: { type: "thinking", thinking, signature } + if part.Get("type").String() == "thinking" || part.Get("thinking").Exists() { + result := `{"type":"thinking"}` + if text := GetThinkingText(part); text != "" { + result, _ = sjson.Set(result, "thinking", text) + } + if sig := part.Get("signature"); sig.Exists() && sig.Type == gjson.String { + result, _ = sjson.Set(result, "signature", sig.String()) + } + return result + } + + // Not a thinking part, return as-is but strip cache_control + return StripCacheControl(part.Raw) +} + +// StripCacheControl removes cache_control and providerOptions from a JSON object. +func StripCacheControl(jsonStr string) string { + result := jsonStr + result, _ = sjson.Delete(result, "cache_control") + result, _ = sjson.Delete(result, "providerOptions") + return result +} diff --git a/litellm b/litellm new file mode 160000 index 00000000..0c48826c --- /dev/null +++ b/litellm @@ -0,0 +1 @@ +Subproject commit 0c48826cdc14a30953f55173c1eecdbfc859952d diff --git a/opencode-antigravity-auth b/opencode-antigravity-auth new file mode 160000 index 00000000..261a91f2 --- /dev/null +++ b/opencode-antigravity-auth @@ -0,0 +1 @@ +Subproject commit 261a91f21bd3bc1660168eb2b82301a6cf372e58 diff --git a/opencode-google-antigravity-auth b/opencode-google-antigravity-auth new file mode 160000 index 00000000..9f9493c7 --- /dev/null +++ b/opencode-google-antigravity-auth @@ -0,0 +1 @@ +Subproject commit 9f9493c730cbf0f17429e107a5bde00794752175 From e04b02113a2eaf39d20aecb8375c40f10a3de80f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8C=80=ED=9D=AC?= Date: Fri, 19 Dec 2025 13:14:51 +0900 Subject: [PATCH 09/14] refactor: Improve cache eviction ordering and clean up session ID usage Improve the cache eviction routine to sort entries by timestamp using the standard library sort routine (stable, clearer and faster than the prior manual selection/bubble logic), and remove a redundant request-derived session ID helper in favor of the centralized session ID function. Also drop now-unused crypto/encoding imports. This yields clearer, more maintainable eviction logic and removes duplicated/unused code and imports to reduce surface area and potential inconsistencies. --- internal/cache/signature_cache.go | 16 ++++++------ .../claude/antigravity_claude_response.go | 25 +------------------ 2 files changed, 8 insertions(+), 33 deletions(-) diff --git a/internal/cache/signature_cache.go b/internal/cache/signature_cache.go index 12f19cf0..c1326629 100644 --- a/internal/cache/signature_cache.go +++ b/internal/cache/signature_cache.go @@ -3,6 +3,7 @@ package cache import ( "crypto/sha256" "encoding/hex" + "sort" "sync" "time" ) @@ -89,20 +90,17 @@ func CacheSignature(sessionID, text, signature string) { ts time.Time }{key, entry.Timestamp}) } - // Simple approach: remove first quarter of entries + // Sort by timestamp (oldest first) using sort.Slice + sort.Slice(oldest, func(i, j int) bool { + return oldest[i].ts.Before(oldest[j].ts) + }) + toRemove := len(oldest) / 4 if toRemove < 1 { toRemove = 1 } - // Sort by timestamp (oldest first) - simple bubble for small N + for i := 0; i < toRemove; i++ { - minIdx := i - for j := i + 1; j < len(oldest); j++ { - if oldest[j].ts.Before(oldest[minIdx].ts) { - minIdx = j - } - } - oldest[i], oldest[minIdx] = oldest[minIdx], oldest[i] delete(sc.entries, oldest[i].key) } } diff --git a/internal/translator/antigravity/claude/antigravity_claude_response.go b/internal/translator/antigravity/claude/antigravity_claude_response.go index 939551ba..8f47b9bf 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_response.go +++ b/internal/translator/antigravity/claude/antigravity_claude_response.go @@ -9,8 +9,6 @@ package claude import ( "bytes" "context" - "crypto/sha256" - "encoding/hex" "fmt" "strings" "sync/atomic" @@ -46,27 +44,6 @@ type Params struct { CurrentThinkingText strings.Builder // Accumulates thinking text for signature caching } -// deriveSessionIDFromRequest generates a stable session ID from the request JSON. -func deriveSessionIDFromRequest(rawJSON []byte) string { - messages := gjson.GetBytes(rawJSON, "messages") - if !messages.IsArray() { - return "" - } - for _, msg := range messages.Array() { - if msg.Get("role").String() == "user" { - content := msg.Get("content").String() - if content == "" { - content = msg.Get("content.0.text").String() - } - if content != "" { - h := sha256.Sum256([]byte(content)) - return hex.EncodeToString(h[:16]) - } - } - } - return "" -} - // toolUseIDCounter provides a process-wide unique counter for tool use identifiers. var toolUseIDCounter uint64 @@ -92,7 +69,7 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq HasFirstResponse: false, ResponseType: 0, ResponseIndex: 0, - SessionID: deriveSessionIDFromRequest(originalRequestRawJSON), + SessionID: deriveSessionID(originalRequestRawJSON), } } From 9f9a4fc2af1088d0f5d130a8448b598b2f6493fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8C=80=ED=9D=AC?= Date: Sun, 21 Dec 2025 14:48:50 +0900 Subject: [PATCH 10/14] Remove unused submodules Removes two obsolete git submodules to clean up repository state and reduce maintenance overhead. This eliminates external references that are no longer needed, simplifying dependency management and repository maintenance going forward. --- litellm | 1 - opencode-google-antigravity-auth | 1 - 2 files changed, 2 deletions(-) delete mode 160000 litellm delete mode 160000 opencode-google-antigravity-auth diff --git a/litellm b/litellm deleted file mode 160000 index 0c48826c..00000000 --- a/litellm +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0c48826cdc14a30953f55173c1eecdbfc859952d diff --git a/opencode-google-antigravity-auth b/opencode-google-antigravity-auth deleted file mode 160000 index 9f9493c7..00000000 --- a/opencode-google-antigravity-auth +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 9f9493c730cbf0f17429e107a5bde00794752175 From 406a27271a39baed8e69eac83892ab2cc002d4d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8C=80=ED=9D=AC?= Date: Sun, 21 Dec 2025 14:54:49 +0900 Subject: [PATCH 11/14] Remove opencode-antigravity-auth submodule Remove the opencode-antigravity-auth submodule reference from the repository. Cleans up the project by eliminating an external submodule pointer that is no longer needed or maintained, reducing repository complexity and avoiding dangling submodule state. --- opencode-antigravity-auth | 1 - 1 file changed, 1 deletion(-) delete mode 160000 opencode-antigravity-auth diff --git a/opencode-antigravity-auth b/opencode-antigravity-auth deleted file mode 160000 index 261a91f2..00000000 --- a/opencode-antigravity-auth +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 261a91f21bd3bc1660168eb2b82301a6cf372e58 From 1e9e4a86a29f94c668488b99795097cae630953e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8C=80=ED=9D=AC?= Date: Sun, 21 Dec 2025 15:15:50 +0900 Subject: [PATCH 12/14] Improve thinking/tool signature handling for Claude and Gemini requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../runtime/executor/antigravity_executor.go | 51 +++++-- .../claude/antigravity_claude_request.go | 143 +++++++++++++++--- .../claude/antigravity_claude_request_test.go | 123 +++++++++++++-- .../claude/antigravity_claude_response.go | 44 +----- .../antigravity_claude_response_test.go | 83 +--------- .../gemini/antigravity_gemini_request.go | 28 +++- .../gemini/antigravity_gemini_request_test.go | 129 ++++++++++++++++ 7 files changed, 420 insertions(+), 181 deletions(-) create mode 100644 internal/translator/antigravity/gemini/antigravity_gemini_request_test.go diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 6be5bf46..ddfcfc3f 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -7,6 +7,8 @@ import ( "bufio" "bytes" "context" + "crypto/sha256" + "encoding/binary" "encoding/json" "fmt" "io" @@ -70,6 +72,10 @@ func (e *AntigravityExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Au // Execute performs a non-streaming request to the Antigravity API. func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { + if strings.Contains(req.Model, "claude") { + return e.executeClaudeNonStream(ctx, auth, req, opts) + } + token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth) if errToken != nil { return resp, errToken @@ -993,23 +999,21 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau payload = geminiToAntigravity(modelName, payload, projectID) payload, _ = sjson.SetBytes(payload, "model", alias2ModelName(modelName)) - // Apply schema processing for all Antigravity models (Claude, Gemini, GPT-OSS) - // Antigravity uses unified Gemini-style format with same schema restrictions - strJSON := string(payload) + if strings.Contains(modelName, "claude") { + strJSON := string(payload) + paths := make([]string, 0) + util.Walk(gjson.ParseBytes(payload), "", "parametersJsonSchema", &paths) + for _, p := range paths { + strJSON, _ = util.RenameKey(strJSON, p, p[:len(p)-len("parametersJsonSchema")]+"parameters") + } - // Rename parametersJsonSchema -> parameters (used by Claude translator) - paths := make([]string, 0) - util.Walk(gjson.ParseBytes(payload), "", "parametersJsonSchema", &paths) - for _, p := range paths { - strJSON, _ = util.RenameKey(strJSON, p, p[:len(p)-len("parametersJsonSchema")]+"parameters") + // Use the centralized schema cleaner to handle unsupported keywords, + // const->enum conversion, and flattening of types/anyOf. + strJSON = util.CleanJSONSchemaForAntigravity(strJSON) + + payload = []byte(strJSON) } - // Use the centralized schema cleaner to handle unsupported keywords, - // const->enum conversion, and flattening of types/anyOf. - strJSON = util.CleanJSONSchemaForAntigravity(strJSON) - - payload = []byte(strJSON) - httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), bytes.NewReader(payload)) if errReq != nil { return nil, errReq @@ -1187,7 +1191,7 @@ func geminiToAntigravity(modelName string, payload []byte, projectID string) []b template, _ = sjson.Set(template, "project", generateProjectID()) } template, _ = sjson.Set(template, "requestId", generateRequestID()) - template, _ = sjson.Set(template, "request.sessionId", generateSessionID()) + template, _ = sjson.Set(template, "request.sessionId", generateStableSessionID(payload)) template, _ = sjson.Delete(template, "request.safetySettings") template, _ = sjson.Set(template, "request.toolConfig.functionCallingConfig.mode", "VALIDATED") @@ -1227,6 +1231,23 @@ func generateSessionID() string { return "-" + strconv.FormatInt(n, 10) } +func generateStableSessionID(payload []byte) string { + contents := gjson.GetBytes(payload, "request.contents") + if contents.IsArray() { + for _, content := range contents.Array() { + if content.Get("role").String() == "user" { + text := content.Get("parts.0.text").String() + if text != "" { + h := sha256.Sum256([]byte(text)) + n := int64(binary.BigEndian.Uint64(h[:8])) & 0x7FFFFFFFFFFFFFFF + return "-" + strconv.FormatInt(n, 10) + } + } + } + } + return generateSessionID() +} + func generateProjectID() string { adjectives := []string{"useful", "bright", "swift", "calm", "bold"} nouns := []string{"fuze", "wave", "spark", "flow", "core"} diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index fdfdf469..d5845e63 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -19,8 +19,6 @@ import ( "github.com/tidwall/sjson" ) -const geminiCLIClaudeThoughtSignature = "skip_thought_signature_validator" - // deriveSessionID generates a stable session ID from the request. // Uses the hash of the first user message to identify the conversation. func deriveSessionID(rawJSON []byte) string { @@ -93,6 +91,12 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ // contents contentsJSON := "[]" hasContents := false + + // Track if we need to disable thinking (LiteLLM approach) + // If the last assistant message with tool_use has no valid thinking block before it, + // we need to disable thinkingConfig to avoid "Expected thinking but found tool_use" error + lastAssistantHasToolWithoutThinking := false + messagesResult := gjson.GetBytes(rawJSON, "messages") if messagesResult.IsArray() { messageResults := messagesResult.Array() @@ -114,6 +118,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ if contentsResult.IsArray() { contentResults := contentsResult.Array() numContents := len(contentResults) + var currentMessageThinkingSignature string for j := 0; j < numContents; j++ { contentResult := contentResults[j] contentTypeResult := contentResult.Get("type") @@ -121,36 +126,46 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ // Use GetThinkingText to handle wrapped thinking objects thinkingText := util.GetThinkingText(contentResult) signatureResult := contentResult.Get("signature") - signature := "" - if signatureResult.Exists() && signatureResult.String() != "" { - signature = signatureResult.String() - } + clientSignature := "" + if signatureResult.Exists() && signatureResult.String() != "" { + clientSignature = signatureResult.String() + } - // Try to restore signature from cache for unsigned thinking blocks - if !cache.HasValidSignature(signature) && sessionID != "" && thinkingText != "" { - if cachedSig := cache.GetCachedSignature(sessionID, thinkingText); cachedSig != "" { - signature = cachedSig - log.Debugf("Restored cached signature for thinking block") - } + // Always try cached signature first (more reliable than client-provided) + // Client may send stale or invalid signatures from different sessions + signature := "" + if sessionID != "" && thinkingText != "" { + if cachedSig := cache.GetCachedSignature(sessionID, thinkingText); cachedSig != "" { + signature = cachedSig + log.Debugf("Using cached signature for thinking block") } + } + + // Fallback to client signature only if cache miss and client signature is valid + if signature == "" && cache.HasValidSignature(clientSignature) { + signature = clientSignature + log.Debugf("Using client-provided signature for thinking block") + } + + // Store for subsequent tool_use in the same message + if cache.HasValidSignature(signature) { + currentMessageThinkingSignature = signature + } + // Skip trailing unsigned thinking blocks on last assistant message - isLastMessage := (i == numMessages-1) - isLastContent := (j == numContents-1) - isAssistant := (originalRole == "assistant") isUnsigned := !cache.HasValidSignature(signature) - if isLastMessage && isLastContent && isAssistant && isUnsigned { - // Skip this trailing unsigned thinking block + // If unsigned, skip entirely (don't convert to text) + // Claude requires assistant messages to start with thinking blocks when thinking is enabled + // Converting to text would break this requirement + if isUnsigned { + // TypeScript plugin approach: drop unsigned thinking blocks entirely + log.Debugf("Dropping unsigned thinking block (no valid signature)") continue } - // Apply sentinel for unsigned thinking blocks that are not trailing - // (includes empty string and short/invalid signatures < 50 chars) - if isUnsigned { - signature = geminiCLIClaudeThoughtSignature - } - + // Valid signature, send as thought block partJSON := `{}` partJSON, _ = sjson.Set(partJSON, "thought", true) if thinkingText != "" { @@ -168,6 +183,10 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ } clientContentJSON, _ = sjson.SetRaw(clientContentJSON, "parts.-1", partJSON) } else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_use" { + // NOTE: Do NOT inject dummy thinking blocks here. + // Antigravity API validates signatures, so dummy values are rejected. + // The TypeScript plugin removes unsigned thinking blocks instead of injecting dummies. + functionName := contentResult.Get("name").String() functionArgs := contentResult.Get("input").String() functionID := contentResult.Get("id").String() @@ -175,9 +194,18 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ argsResult := gjson.Parse(functionArgs) if argsResult.IsObject() { partJSON := `{}` - if !strings.Contains(modelName, "claude") { - partJSON, _ = sjson.Set(partJSON, "thoughtSignature", geminiCLIClaudeThoughtSignature) + + // Use skip_thought_signature_validator for tool calls without valid thinking signature + // This is the approach used in opencode-google-antigravity-auth for Gemini + // and also works for Claude through Antigravity API + const skipSentinel = "skip_thought_signature_validator" + if cache.HasValidSignature(currentMessageThinkingSignature) { + partJSON, _ = sjson.Set(partJSON, "thoughtSignature", currentMessageThinkingSignature) + } else { + // No valid signature - use skip sentinel to bypass validation + partJSON, _ = sjson.Set(partJSON, "thoughtSignature", skipSentinel) } + if functionID != "" { partJSON, _ = sjson.Set(partJSON, "functionCall.id", functionID) } @@ -239,6 +267,64 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ } } } + + // Reorder parts for 'model' role to ensure thinking block is first + if role == "model" { + partsResult := gjson.Get(clientContentJSON, "parts") + if partsResult.IsArray() { + parts := partsResult.Array() + var thinkingParts []gjson.Result + var otherParts []gjson.Result + for _, part := range parts { + if part.Get("thought").Bool() { + thinkingParts = append(thinkingParts, part) + } else { + otherParts = append(otherParts, part) + } + } + if len(thinkingParts) > 0 { + firstPartIsThinking := parts[0].Get("thought").Bool() + if !firstPartIsThinking || len(thinkingParts) > 1 { + var newParts []interface{} + for _, p := range thinkingParts { + newParts = append(newParts, p.Value()) + } + for _, p := range otherParts { + newParts = append(newParts, p.Value()) + } + clientContentJSON, _ = sjson.Set(clientContentJSON, "parts", newParts) + } + } + } + } + + // Check if this assistant message has tool_use without valid thinking + if role == "model" { + partsResult := gjson.Get(clientContentJSON, "parts") + if partsResult.IsArray() { + parts := partsResult.Array() + hasValidThinking := false + hasToolUse := false + + for _, part := range parts { + if part.Get("thought").Bool() { + hasValidThinking = true + } + if part.Get("functionCall").Exists() { + hasToolUse = true + } + } + + // If this message has tool_use but no valid thinking, mark it + // This will be used to disable thinking mode if needed + if hasToolUse && !hasValidThinking { + lastAssistantHasToolWithoutThinking = true + } else { + lastAssistantHasToolWithoutThinking = false + } + } + } + contentsJSON, _ = sjson.SetRaw(contentsJSON, "-1", clientContentJSON) hasContents = true } else if contentsResult.Type == gjson.String { @@ -333,6 +419,13 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ out, _ = sjson.Set(out, "request.generationConfig.maxOutputTokens", v.Num) } + // Note: We do NOT drop thinkingConfig here anymore. + // Instead, we: + // 1. Remove unsigned thinking blocks (done during message processing) + // 2. Add skip_thought_signature_validator to tool_use without valid thinking signature + // This approach keeps thinking mode enabled while handling the signature requirements. + _ = lastAssistantHasToolWithoutThinking // Variable is tracked but not used to drop thinkingConfig + outBytes := []byte(out) outBytes = common.AttachDefaultSafetySettings(outBytes, "request.safetySettings") diff --git a/internal/translator/antigravity/claude/antigravity_claude_request_test.go b/internal/translator/antigravity/claude/antigravity_claude_request_test.go index 796ce0d3..1d727c94 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request_test.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request_test.go @@ -105,6 +105,7 @@ func TestConvertClaudeRequestToAntigravity_ThinkingBlocks(t *testing.T) { } 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": [ @@ -121,11 +122,18 @@ func TestConvertClaudeRequestToAntigravity_ThinkingBlockWithoutSignature(t *test output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false) outputStr := string(output) - // Without signature, should use sentinel value - firstPart := gjson.Get(outputStr, "request.contents.0.parts.0") - if firstPart.Get("thoughtSignature").String() != geminiCLIClaudeThoughtSignature { - t.Errorf("Expected sentinel signature '%s', got '%s'", - geminiCLIClaudeThoughtSignature, firstPart.Get("thoughtSignature").String()) + // 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()) } } @@ -192,10 +200,16 @@ func TestConvertClaudeRequestToAntigravity_ToolUse(t *testing.T) { output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false) outputStr := string(output) - // Check function call conversion - funcCall := gjson.Get(outputStr, "request.contents.0.parts.0.functionCall") + // 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") + 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()) @@ -203,6 +217,78 @@ func TestConvertClaudeRequestToAntigravity_ToolUse(t *testing.T) { 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) { @@ -402,8 +488,8 @@ func TestConvertClaudeRequestToAntigravity_TrailingSignedThinking_Kept(t *testin } } -func TestConvertClaudeRequestToAntigravity_MiddleUnsignedThinking_SentinelApplied(t *testing.T) { - // Middle message has unsigned thinking - should use sentinel (existing behavior) +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": [ @@ -424,13 +510,18 @@ func TestConvertClaudeRequestToAntigravity_MiddleUnsignedThinking_SentinelApplie output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false) outputStr := string(output) - // Middle unsigned thinking should have sentinel applied - thinkingPart := gjson.Get(outputStr, "request.contents.0.parts.0") - if !thinkingPart.Get("thought").Bool() { - t.Error("Middle thinking block should be preserved with sentinel") + // 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)) } - if thinkingPart.Get("thoughtSignature").String() != geminiCLIClaudeThoughtSignature { - t.Errorf("Middle unsigned thinking should use sentinel signature, got: %s", thinkingPart.Get("thoughtSignature").String()) + + // 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()) } } diff --git a/internal/translator/antigravity/claude/antigravity_claude_response.go b/internal/translator/antigravity/claude/antigravity_claude_response.go index 8f47b9bf..ddda5ddb 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_response.go +++ b/internal/translator/antigravity/claude/antigravity_claude_response.go @@ -253,13 +253,8 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq output = output + fmt.Sprintf("data: %s\n\n\n", data) if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() { - argsRaw := fcArgsResult.Raw - // Convert command → cmd for Bash tools using proper JSON parsing - if fcName == "Bash" || fcName == "bash" || fcName == "bash_20241022" { - argsRaw = convertBashCommandToCmdField(argsRaw) - } output = output + "event: content_block_delta\n" - data, _ = sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, params.ResponseIndex), "delta.partial_json", argsRaw) + data, _ = sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, params.ResponseIndex), "delta.partial_json", fcArgsResult.Raw) output = output + fmt.Sprintf("data: %s\n\n\n", data) } params.ResponseType = 3 @@ -347,36 +342,6 @@ func resolveStopReason(params *Params) string { return "end_turn" } -// convertBashCommandToCmdField converts "command" field to "cmd" field for Bash tools. -// Amp expects "cmd" but Gemini sends "command". This uses proper JSON parsing -// to avoid accidentally replacing "command" that appears in values. -func convertBashCommandToCmdField(argsRaw string) string { - // Only process valid JSON - if !gjson.Valid(argsRaw) { - return argsRaw - } - - // Check if "command" key exists and "cmd" doesn't - commandVal := gjson.Get(argsRaw, "command") - cmdVal := gjson.Get(argsRaw, "cmd") - - if commandVal.Exists() && !cmdVal.Exists() { - // Set "cmd" to the value of "command", preserve the raw value type - result, err := sjson.SetRaw(argsRaw, "cmd", commandVal.Raw) - if err != nil { - return argsRaw - } - // Delete "command" key - result, err = sjson.Delete(result, "command") - if err != nil { - return argsRaw - } - return result - } - - return argsRaw -} - // ConvertAntigravityResponseToClaudeNonStream converts a non-streaming Gemini CLI response to a non-streaming Claude response. // // Parameters: @@ -488,12 +453,7 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or toolBlock, _ = sjson.Set(toolBlock, "name", name) if args := functionCall.Get("args"); args.Exists() && args.Raw != "" && gjson.Valid(args.Raw) { - argsRaw := args.Raw - // Convert command → cmd for Bash tools - if name == "Bash" || name == "bash" || name == "bash_20241022" { - argsRaw = convertBashCommandToCmdField(argsRaw) - } - toolBlock, _ = sjson.SetRaw(toolBlock, "input", argsRaw) + toolBlock, _ = sjson.SetRaw(toolBlock, "input", args.Raw) } ensureContentArray() diff --git a/internal/translator/antigravity/claude/antigravity_claude_response_test.go b/internal/translator/antigravity/claude/antigravity_claude_response_test.go index 4c2f31c1..afc3d937 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_response_test.go +++ b/internal/translator/antigravity/claude/antigravity_claude_response_test.go @@ -8,79 +8,6 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/cache" ) -func TestConvertBashCommandToCmdField(t *testing.T) { - tests := []struct { - name string - input string - expected string - }{ - { - name: "basic command to cmd conversion", - input: `{"command": "git diff"}`, - expected: `{"cmd":"git diff"}`, - }, - { - name: "already has cmd field - no change", - input: `{"cmd": "git diff"}`, - expected: `{"cmd": "git diff"}`, - }, - { - name: "both cmd and command - keep cmd only", - input: `{"command": "git diff", "cmd": "ls"}`, - expected: `{"command": "git diff", "cmd": "ls"}`, // no change when cmd exists - }, - { - name: "command with special characters in value", - input: `{"command": "echo \"command\": test"}`, - expected: `{"cmd":"echo \"command\": test"}`, - }, - { - name: "command with nested quotes", - input: `{"command": "bash -c 'echo \"hello\"'"}`, - expected: `{"cmd":"bash -c 'echo \"hello\"'"}`, - }, - { - name: "command with newlines", - input: `{"command": "echo hello\necho world"}`, - expected: `{"cmd":"echo hello\necho world"}`, - }, - { - name: "empty command value", - input: `{"command": ""}`, - expected: `{"cmd":""}`, - }, - { - name: "command with other fields - preserves them", - input: `{"command": "git diff", "timeout": 30}`, - expected: `{ "timeout": 30,"cmd":"git diff"}`, - }, - { - name: "invalid JSON - returns unchanged", - input: `{invalid json`, - expected: `{invalid json`, - }, - { - name: "empty object", - input: `{}`, - expected: `{}`, - }, - { - name: "no command field", - input: `{"restart": true}`, - expected: `{"restart": true}`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := convertBashCommandToCmdField(tt.input) - if result != tt.expected { - t.Errorf("convertBashCommandToCmdField(%q) = %q, want %q", tt.input, result, tt.expected) - } - }) - } -} - // ============================================================================ // Signature Caching Tests // ============================================================================ @@ -354,7 +281,7 @@ func TestDeriveSessionIDFromRequest(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := deriveSessionIDFromRequest(tt.input) + result := deriveSessionID(tt.input) if tt.wantEmpty && result != "" { t.Errorf("Expected empty session ID, got '%s'", result) } @@ -368,8 +295,8 @@ func TestDeriveSessionIDFromRequest(t *testing.T) { func TestDeriveSessionIDFromRequest_Deterministic(t *testing.T) { input := []byte(`{"messages": [{"role": "user", "content": "Same message"}]}`) - id1 := deriveSessionIDFromRequest(input) - id2 := deriveSessionIDFromRequest(input) + id1 := deriveSessionID(input) + id2 := deriveSessionID(input) if id1 != id2 { t.Errorf("Session ID should be deterministic: '%s' != '%s'", id1, id2) @@ -380,8 +307,8 @@ func TestDeriveSessionIDFromRequest_DifferentMessages(t *testing.T) { input1 := []byte(`{"messages": [{"role": "user", "content": "Message A"}]}`) input2 := []byte(`{"messages": [{"role": "user", "content": "Message B"}]}`) - id1 := deriveSessionIDFromRequest(input1) - id2 := deriveSessionIDFromRequest(input2) + id1 := deriveSessionID(input1) + id2 := deriveSessionID(input2) if id1 == id2 { t.Error("Different messages should produce different session IDs") diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_request.go b/internal/translator/antigravity/gemini/antigravity_gemini_request.go index e694b790..394cc05b 100644 --- a/internal/translator/antigravity/gemini/antigravity_gemini_request.go +++ b/internal/translator/antigravity/gemini/antigravity_gemini_request.go @@ -98,16 +98,34 @@ func ConvertGeminiRequestToAntigravity(_ string, inputRawJSON []byte, _ bool) [] } } - gjson.GetBytes(rawJSON, "request.contents").ForEach(func(key, content gjson.Result) bool { + // Gemini-specific handling: add skip_thought_signature_validator to functionCall parts + // and remove thinking blocks entirely (Gemini doesn't need to preserve them) + const skipSentinel = "skip_thought_signature_validator" + + gjson.GetBytes(rawJSON, "request.contents").ForEach(func(contentIdx, content gjson.Result) bool { if content.Get("role").String() == "model" { - content.Get("parts").ForEach(func(partKey, part gjson.Result) bool { + // First pass: collect indices of thinking parts to remove + var thinkingIndicesToRemove []int64 + content.Get("parts").ForEach(func(partIdx, part gjson.Result) bool { + // Mark thinking blocks for removal + if part.Get("thought").Bool() { + thinkingIndicesToRemove = append(thinkingIndicesToRemove, partIdx.Int()) + } + // Add skip sentinel to functionCall parts if part.Get("functionCall").Exists() { - rawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf("request.contents.%d.parts.%d.thoughtSignature", key.Int(), partKey.Int()), "skip_thought_signature_validator") - } else if part.Get("thoughtSignature").Exists() { - rawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf("request.contents.%d.parts.%d.thoughtSignature", key.Int(), partKey.Int()), "skip_thought_signature_validator") + existingSig := part.Get("thoughtSignature").String() + if existingSig == "" || len(existingSig) < 50 { + rawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf("request.contents.%d.parts.%d.thoughtSignature", contentIdx.Int(), partIdx.Int()), skipSentinel) + } } return true }) + + // Remove thinking blocks in reverse order to preserve indices + for i := len(thinkingIndicesToRemove) - 1; i >= 0; i-- { + idx := thinkingIndicesToRemove[i] + rawJSON, _ = sjson.DeleteBytes(rawJSON, fmt.Sprintf("request.contents.%d.parts.%d", contentIdx.Int(), idx)) + } } return true }) diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go b/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go new file mode 100644 index 00000000..58cffd69 --- /dev/null +++ b/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go @@ -0,0 +1,129 @@ +package gemini + +import ( + "fmt" + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertGeminiRequestToAntigravity_PreserveValidSignature(t *testing.T) { + // Valid signature on functionCall should be preserved + validSignature := "abc123validSignature1234567890123456789012345678901234567890" + inputJSON := []byte(fmt.Sprintf(`{ + "model": "gemini-3-pro-preview", + "contents": [ + { + "role": "model", + "parts": [ + {"functionCall": {"name": "test_tool", "args": {}}, "thoughtSignature": "%s"} + ] + } + ] + }`, validSignature)) + + output := ConvertGeminiRequestToAntigravity("gemini-3-pro-preview", inputJSON, false) + outputStr := string(output) + + // Check that valid thoughtSignature is preserved + parts := gjson.Get(outputStr, "request.contents.0.parts").Array() + if len(parts) != 1 { + t.Fatalf("Expected 1 part, got %d", len(parts)) + } + + sig := parts[0].Get("thoughtSignature").String() + if sig != validSignature { + t.Errorf("Expected thoughtSignature '%s', got '%s'", validSignature, sig) + } +} + +func TestConvertGeminiRequestToAntigravity_AddSkipSentinelToFunctionCall(t *testing.T) { + // functionCall without signature should get skip_thought_signature_validator + inputJSON := []byte(`{ + "model": "gemini-3-pro-preview", + "contents": [ + { + "role": "model", + "parts": [ + {"functionCall": {"name": "test_tool", "args": {}}} + ] + } + ] + }`) + + output := ConvertGeminiRequestToAntigravity("gemini-3-pro-preview", inputJSON, false) + outputStr := string(output) + + // Check that skip_thought_signature_validator is added to functionCall + sig := gjson.Get(outputStr, "request.contents.0.parts.0.thoughtSignature").String() + expectedSig := "skip_thought_signature_validator" + if sig != expectedSig { + t.Errorf("Expected skip sentinel '%s', got '%s'", expectedSig, sig) + } +} + +func TestConvertGeminiRequestToAntigravity_RemoveThinkingBlocks(t *testing.T) { + // Thinking blocks should be removed entirely for Gemini + validSignature := "abc123validSignature1234567890123456789012345678901234567890" + inputJSON := []byte(fmt.Sprintf(`{ + "model": "gemini-3-pro-preview", + "contents": [ + { + "role": "model", + "parts": [ + {"thought": true, "text": "Thinking...", "thoughtSignature": "%s"}, + {"text": "Here is my response"} + ] + } + ] + }`, validSignature)) + + output := ConvertGeminiRequestToAntigravity("gemini-3-pro-preview", inputJSON, false) + outputStr := string(output) + + // Check that thinking block is removed + 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 for Gemini") + } + if parts[0].Get("text").String() != "Here is my response" { + t.Errorf("Expected text 'Here is my response', got '%s'", parts[0].Get("text").String()) + } +} + +func TestConvertGeminiRequestToAntigravity_ParallelFunctionCalls(t *testing.T) { + // Multiple functionCalls should all get skip_thought_signature_validator + inputJSON := []byte(`{ + "model": "gemini-3-pro-preview", + "contents": [ + { + "role": "model", + "parts": [ + {"functionCall": {"name": "tool_one", "args": {"a": "1"}}}, + {"functionCall": {"name": "tool_two", "args": {"b": "2"}}} + ] + } + ] + }`) + + output := ConvertGeminiRequestToAntigravity("gemini-3-pro-preview", inputJSON, false) + outputStr := string(output) + + parts := gjson.Get(outputStr, "request.contents.0.parts").Array() + if len(parts) != 2 { + t.Fatalf("Expected 2 parts, got %d", len(parts)) + } + + expectedSig := "skip_thought_signature_validator" + for i, part := range parts { + sig := part.Get("thoughtSignature").String() + if sig != expectedSig { + t.Errorf("Part %d: Expected '%s', got '%s'", i, expectedSig, sig) + } + } +} From 4070c9de81c1378d4e488417eb46c1cb1f827cfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8C=80=ED=9D=AC?= Date: Sun, 21 Dec 2025 15:29:36 +0900 Subject: [PATCH 13/14] Remove interleaved-thinking header from requests Removes the addition of the "anthropic-beta: interleaved-thinking-2025-05-14" header for Claude thinking models when building HTTP requests. This prevents sending an experimental/feature flag header that is no longer required and avoids potential compatibility or routing issues with downstream services. Keeps request headers simpler and more standard. --- internal/runtime/executor/antigravity_executor.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index ddfcfc3f..38723e77 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -1021,12 +1021,6 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+token) httpReq.Header.Set("User-Agent", resolveUserAgent(auth)) - - // Add interleaved-thinking header for Claude thinking models - if util.IsClaudeThinkingModel(modelName) { - httpReq.Header.Set("anthropic-beta", "interleaved-thinking-2025-05-14") - } - if stream { httpReq.Header.Set("Accept", "text/event-stream") } else { From 7dc40ba6d4ebc019910951dc8f7a99953510cbef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8C=80=ED=9D=AC?= Date: Sun, 21 Dec 2025 17:16:40 +0900 Subject: [PATCH 14/14] Improve tool-call parsing, schema sanitization, and hint injection Improve parsing of tool call inputs and Antigravity compatibility to avoid invalid thinking/tool_use errors. - Parse tool call inputs robustly by accepting both object and JSON-string formats and only produce a functionCall part when valid args exist, reducing spurious or malformed parts. - Preserve the skip_thought_signature_validator approach for calls without a valid thinking signature but stop toggling/tracking a separate "disable thinking" flag; this prevents unnecessary removal of thinkingConfig. - Sanitize tool input schemas before attaching them to the Antigravity request to improve compatibility. - Append the interleaved-thinking hint as a new parts entry instead of overwriting/setting text directly, preserving structure. - Remove unused tracking logic and related comments to simplify flow. These changes reduce errors related to missing/invalid thinking signatures, improve schema compatibility, and make hint injection safer and more consistent. --- .../claude/antigravity_claude_request.go | 106 +++++++----------- 1 file changed, 41 insertions(+), 65 deletions(-) diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index d5845e63..8a54e739 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -92,11 +92,6 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ contentsJSON := "[]" hasContents := false - // Track if we need to disable thinking (LiteLLM approach) - // If the last assistant message with tool_use has no valid thinking block before it, - // we need to disable thinkingConfig to avoid "Expected thinking but found tool_use" error - lastAssistantHasToolWithoutThinking := false - messagesResult := gjson.GetBytes(rawJSON, "messages") if messagesResult.IsArray() { messageResults := messagesResult.Array() @@ -188,32 +183,42 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ // The TypeScript plugin removes unsigned thinking blocks instead of injecting dummies. functionName := contentResult.Get("name").String() - functionArgs := contentResult.Get("input").String() + argsResult := contentResult.Get("input") functionID := contentResult.Get("id").String() - if gjson.Valid(functionArgs) { - argsResult := gjson.Parse(functionArgs) - if argsResult.IsObject() { - partJSON := `{}` - // Use skip_thought_signature_validator for tool calls without valid thinking signature - // This is the approach used in opencode-google-antigravity-auth for Gemini - // and also works for Claude through Antigravity API - const skipSentinel = "skip_thought_signature_validator" - if cache.HasValidSignature(currentMessageThinkingSignature) { - partJSON, _ = sjson.Set(partJSON, "thoughtSignature", currentMessageThinkingSignature) - } else { - // No valid signature - use skip sentinel to bypass validation - partJSON, _ = sjson.Set(partJSON, "thoughtSignature", skipSentinel) - } - - if functionID != "" { - partJSON, _ = sjson.Set(partJSON, "functionCall.id", functionID) - } - partJSON, _ = sjson.Set(partJSON, "functionCall.name", functionName) - partJSON, _ = sjson.SetRaw(partJSON, "functionCall.args", argsResult.Raw) - clientContentJSON, _ = sjson.SetRaw(clientContentJSON, "parts.-1", partJSON) + // Handle both object and string input formats + var argsRaw string + if argsResult.IsObject() { + argsRaw = argsResult.Raw + } else if argsResult.Type == gjson.String { + // Input is a JSON string, parse and validate it + parsed := gjson.Parse(argsResult.String()) + if parsed.IsObject() { + argsRaw = parsed.Raw } } + + if argsRaw != "" { + partJSON := `{}` + + // Use skip_thought_signature_validator for tool calls without valid thinking signature + // This is the approach used in opencode-google-antigravity-auth for Gemini + // and also works for Claude through Antigravity API + const skipSentinel = "skip_thought_signature_validator" + if cache.HasValidSignature(currentMessageThinkingSignature) { + partJSON, _ = sjson.Set(partJSON, "thoughtSignature", currentMessageThinkingSignature) + } else { + // No valid signature - use skip sentinel to bypass validation + partJSON, _ = sjson.Set(partJSON, "thoughtSignature", skipSentinel) + } + + if functionID != "" { + partJSON, _ = sjson.Set(partJSON, "functionCall.id", functionID) + } + partJSON, _ = sjson.Set(partJSON, "functionCall.name", functionName) + partJSON, _ = sjson.SetRaw(partJSON, "functionCall.args", argsRaw) + clientContentJSON, _ = sjson.SetRaw(clientContentJSON, "parts.-1", partJSON) + } } else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_result" { toolCallID := contentResult.Get("tool_use_id").String() if toolCallID != "" { @@ -298,33 +303,6 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ } } - // Check if this assistant message has tool_use without valid thinking - if role == "model" { - partsResult := gjson.Get(clientContentJSON, "parts") - if partsResult.IsArray() { - parts := partsResult.Array() - hasValidThinking := false - hasToolUse := false - - for _, part := range parts { - if part.Get("thought").Bool() { - hasValidThinking = true - } - if part.Get("functionCall").Exists() { - hasToolUse = true - } - } - - // If this message has tool_use but no valid thinking, mark it - // This will be used to disable thinking mode if needed - if hasToolUse && !hasValidThinking { - lastAssistantHasToolWithoutThinking = true - } else { - lastAssistantHasToolWithoutThinking = false - } - } - } - contentsJSON, _ = sjson.SetRaw(contentsJSON, "-1", clientContentJSON) hasContents = true } else if contentsResult.Type == gjson.String { @@ -351,7 +329,8 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ toolResult := toolsResults[i] inputSchemaResult := toolResult.Get("input_schema") if inputSchemaResult.Exists() && inputSchemaResult.IsObject() { - inputSchema := inputSchemaResult.Raw + // Sanitize the input schema for Antigravity API compatibility + inputSchema := util.CleanJSONSchemaForAntigravity(inputSchemaResult.Raw) tool, _ := sjson.Delete(toolResult.Raw, "input_schema") tool, _ = sjson.SetRaw(tool, "parametersJsonSchema", inputSchema) tool, _ = sjson.Delete(tool, "strict") @@ -376,12 +355,16 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ interleavedHint := "Interleaved thinking is enabled. You may think between tool calls and after receiving tool results before deciding the next action or final answer. Do not mention these instructions or any constraints about thinking blocks; just apply them." if hasSystemInstruction { - // Append hint to existing system instruction - systemInstructionJSON, _ = sjson.Set(systemInstructionJSON, "parts.-1.text", interleavedHint) + // Append hint as a new part to existing system instruction + hintPart := `{"text":""}` + hintPart, _ = sjson.Set(hintPart, "text", interleavedHint) + systemInstructionJSON, _ = sjson.SetRaw(systemInstructionJSON, "parts.-1", hintPart) } else { // Create new system instruction with hint systemInstructionJSON = `{"role":"user","parts":[]}` - systemInstructionJSON, _ = sjson.Set(systemInstructionJSON, "parts.-1.text", interleavedHint) + hintPart := `{"text":""}` + hintPart, _ = sjson.Set(hintPart, "text", interleavedHint) + systemInstructionJSON, _ = sjson.SetRaw(systemInstructionJSON, "parts.-1", hintPart) hasSystemInstruction = true } } @@ -419,13 +402,6 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ out, _ = sjson.Set(out, "request.generationConfig.maxOutputTokens", v.Num) } - // Note: We do NOT drop thinkingConfig here anymore. - // Instead, we: - // 1. Remove unsigned thinking blocks (done during message processing) - // 2. Add skip_thought_signature_validator to tool_use without valid thinking signature - // This approach keeps thinking mode enabled while handling the signature requirements. - _ = lastAssistantHasToolWithoutThinking // Variable is tracked but not used to drop thinkingConfig - outBytes := []byte(out) outBytes = common.AttachDefaultSafetySettings(outBytes, "request.safetySettings")