mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-03 04:50:52 +08:00
Merge pull request #611 from soilSpoon/feature/antigravity
feat(antigravity): Improve Claude model compatibility
This commit is contained in:
164
internal/cache/signature_cache.go
vendored
Normal file
164
internal/cache/signature_cache.go
vendored
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"sort"
|
||||||
|
"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})
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < toRemove; 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
|
||||||
|
}
|
||||||
216
internal/cache/signature_cache_test.go
vendored
Normal file
216
internal/cache/signature_cache_test.go
vendored
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -1010,7 +1012,7 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau
|
|||||||
|
|
||||||
// Use the centralized schema cleaner to handle unsupported keywords,
|
// Use the centralized schema cleaner to handle unsupported keywords,
|
||||||
// const->enum conversion, and flattening of types/anyOf.
|
// const->enum conversion, and flattening of types/anyOf.
|
||||||
strJSON = util.CleanJSONSchemaForGemini(strJSON)
|
strJSON = util.CleanJSONSchemaForAntigravity(strJSON)
|
||||||
|
|
||||||
payload = []byte(strJSON)
|
payload = []byte(strJSON)
|
||||||
}
|
}
|
||||||
@@ -1186,7 +1188,7 @@ func geminiToAntigravity(modelName string, payload []byte, projectID string) []b
|
|||||||
template, _ = sjson.Set(template, "project", generateProjectID())
|
template, _ = sjson.Set(template, "project", generateProjectID())
|
||||||
}
|
}
|
||||||
template, _ = sjson.Set(template, "requestId", generateRequestID())
|
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.Delete(template, "request.safetySettings")
|
||||||
template, _ = sjson.Set(template, "request.toolConfig.functionCallingConfig.mode", "VALIDATED")
|
template, _ = sjson.Set(template, "request.toolConfig.functionCallingConfig.mode", "VALIDATED")
|
||||||
@@ -1226,6 +1228,23 @@ func generateSessionID() string {
|
|||||||
return "-" + strconv.FormatInt(n, 10)
|
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 {
|
func generateProjectID() string {
|
||||||
adjectives := []string{"useful", "bright", "swift", "calm", "bold"}
|
adjectives := []string{"useful", "bright", "swift", "calm", "bold"}
|
||||||
nouns := []string{"fuze", "wave", "spark", "flow", "core"}
|
nouns := []string{"fuze", "wave", "spark", "flow", "core"}
|
||||||
|
|||||||
@@ -7,15 +7,40 @@ package claude
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"strings"
|
"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/translator/gemini/common"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
"github.com/tidwall/sjson"
|
"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 {
|
||||||
|
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.
|
// 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
|
// It extracts the model name, system instruction, message contents, and tool declarations
|
||||||
@@ -37,7 +62,9 @@ const geminiCLIClaudeThoughtSignature = "skip_thought_signature_validator"
|
|||||||
// - []byte: The transformed request data in Gemini CLI API format
|
// - []byte: The transformed request data in Gemini CLI API format
|
||||||
func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ bool) []byte {
|
func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ bool) []byte {
|
||||||
rawJSON := bytes.Clone(inputRawJSON)
|
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
|
// system instruction
|
||||||
systemInstructionJSON := ""
|
systemInstructionJSON := ""
|
||||||
@@ -64,16 +91,19 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
|||||||
// contents
|
// contents
|
||||||
contentsJSON := "[]"
|
contentsJSON := "[]"
|
||||||
hasContents := false
|
hasContents := false
|
||||||
|
|
||||||
messagesResult := gjson.GetBytes(rawJSON, "messages")
|
messagesResult := gjson.GetBytes(rawJSON, "messages")
|
||||||
if messagesResult.IsArray() {
|
if messagesResult.IsArray() {
|
||||||
messageResults := messagesResult.Array()
|
messageResults := messagesResult.Array()
|
||||||
for i := 0; i < len(messageResults); i++ {
|
numMessages := len(messageResults)
|
||||||
|
for i := 0; i < numMessages; i++ {
|
||||||
messageResult := messageResults[i]
|
messageResult := messageResults[i]
|
||||||
roleResult := messageResult.Get("role")
|
roleResult := messageResult.Get("role")
|
||||||
if roleResult.Type != gjson.String {
|
if roleResult.Type != gjson.String {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
role := roleResult.String()
|
originalRole := roleResult.String()
|
||||||
|
role := originalRole
|
||||||
if role == "assistant" {
|
if role == "assistant" {
|
||||||
role = "model"
|
role = "model"
|
||||||
}
|
}
|
||||||
@@ -82,20 +112,59 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
|||||||
contentsResult := messageResult.Get("content")
|
contentsResult := messageResult.Get("content")
|
||||||
if contentsResult.IsArray() {
|
if contentsResult.IsArray() {
|
||||||
contentResults := contentsResult.Array()
|
contentResults := contentsResult.Array()
|
||||||
for j := 0; j < len(contentResults); j++ {
|
numContents := len(contentResults)
|
||||||
|
var currentMessageThinkingSignature string
|
||||||
|
for j := 0; j < numContents; j++ {
|
||||||
contentResult := contentResults[j]
|
contentResult := contentResults[j]
|
||||||
contentTypeResult := contentResult.Get("type")
|
contentTypeResult := contentResult.Get("type")
|
||||||
if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "thinking" {
|
if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "thinking" {
|
||||||
prompt := contentResult.Get("thinking").String()
|
// Use GetThinkingText to handle wrapped thinking objects
|
||||||
|
thinkingText := util.GetThinkingText(contentResult)
|
||||||
signatureResult := contentResult.Get("signature")
|
signatureResult := contentResult.Get("signature")
|
||||||
signature := geminiCLIClaudeThoughtSignature
|
clientSignature := ""
|
||||||
if signatureResult.Exists() {
|
if signatureResult.Exists() && signatureResult.String() != "" {
|
||||||
signature = signatureResult.String()
|
clientSignature = signatureResult.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
isUnsigned := !cache.HasValidSignature(signature)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid signature, send as thought block
|
||||||
partJSON := `{}`
|
partJSON := `{}`
|
||||||
partJSON, _ = sjson.Set(partJSON, "thought", true)
|
partJSON, _ = sjson.Set(partJSON, "thought", true)
|
||||||
if prompt != "" {
|
if thinkingText != "" {
|
||||||
partJSON, _ = sjson.Set(partJSON, "text", prompt)
|
partJSON, _ = sjson.Set(partJSON, "text", thinkingText)
|
||||||
}
|
}
|
||||||
if signature != "" {
|
if signature != "" {
|
||||||
partJSON, _ = sjson.Set(partJSON, "thoughtSignature", signature)
|
partJSON, _ = sjson.Set(partJSON, "thoughtSignature", signature)
|
||||||
@@ -109,24 +178,47 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
|||||||
}
|
}
|
||||||
clientContentJSON, _ = sjson.SetRaw(clientContentJSON, "parts.-1", partJSON)
|
clientContentJSON, _ = sjson.SetRaw(clientContentJSON, "parts.-1", partJSON)
|
||||||
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_use" {
|
} 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()
|
functionName := contentResult.Get("name").String()
|
||||||
functionArgs := contentResult.Get("input").String()
|
argsResult := contentResult.Get("input")
|
||||||
functionID := contentResult.Get("id").String()
|
functionID := contentResult.Get("id").String()
|
||||||
if gjson.Valid(functionArgs) {
|
|
||||||
argsResult := gjson.Parse(functionArgs)
|
// Handle both object and string input formats
|
||||||
if argsResult.IsObject() {
|
var argsRaw string
|
||||||
partJSON := `{}`
|
if argsResult.IsObject() {
|
||||||
if !strings.Contains(modelName, "claude") {
|
argsRaw = argsResult.Raw
|
||||||
partJSON, _ = sjson.Set(partJSON, "thoughtSignature", geminiCLIClaudeThoughtSignature)
|
} else if argsResult.Type == gjson.String {
|
||||||
}
|
// Input is a JSON string, parse and validate it
|
||||||
if functionID != "" {
|
parsed := gjson.Parse(argsResult.String())
|
||||||
partJSON, _ = sjson.Set(partJSON, "functionCall.id", functionID)
|
if parsed.IsObject() {
|
||||||
}
|
argsRaw = parsed.Raw
|
||||||
partJSON, _ = sjson.Set(partJSON, "functionCall.name", functionName)
|
|
||||||
partJSON, _ = sjson.SetRaw(partJSON, "functionCall.args", argsResult.Raw)
|
|
||||||
clientContentJSON, _ = sjson.SetRaw(clientContentJSON, "parts.-1", partJSON)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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" {
|
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_result" {
|
||||||
toolCallID := contentResult.Get("tool_use_id").String()
|
toolCallID := contentResult.Get("tool_use_id").String()
|
||||||
if toolCallID != "" {
|
if toolCallID != "" {
|
||||||
@@ -180,6 +272,37 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
contentsJSON, _ = sjson.SetRaw(contentsJSON, "-1", clientContentJSON)
|
contentsJSON, _ = sjson.SetRaw(contentsJSON, "-1", clientContentJSON)
|
||||||
hasContents = true
|
hasContents = true
|
||||||
} else if contentsResult.Type == gjson.String {
|
} else if contentsResult.Type == gjson.String {
|
||||||
@@ -206,7 +329,8 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
|||||||
toolResult := toolsResults[i]
|
toolResult := toolsResults[i]
|
||||||
inputSchemaResult := toolResult.Get("input_schema")
|
inputSchemaResult := toolResult.Get("input_schema")
|
||||||
if inputSchemaResult.Exists() && inputSchemaResult.IsObject() {
|
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.Delete(toolResult.Raw, "input_schema")
|
||||||
tool, _ = sjson.SetRaw(tool, "parametersJsonSchema", inputSchema)
|
tool, _ = sjson.SetRaw(tool, "parametersJsonSchema", inputSchema)
|
||||||
tool, _ = sjson.Delete(tool, "strict")
|
tool, _ = sjson.Delete(tool, "strict")
|
||||||
@@ -222,6 +346,31 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
|||||||
// Build output Gemini CLI request JSON
|
// Build output Gemini CLI request JSON
|
||||||
out := `{"model":"","request":{"contents":[]}}`
|
out := `{"model":"","request":{"contents":[]}}`
|
||||||
out, _ = sjson.Set(out, "model", modelName)
|
out, _ = sjson.Set(out, "model", modelName)
|
||||||
|
|
||||||
|
// 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 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":[]}`
|
||||||
|
hintPart := `{"text":""}`
|
||||||
|
hintPart, _ = sjson.Set(hintPart, "text", interleavedHint)
|
||||||
|
systemInstructionJSON, _ = sjson.SetRaw(systemInstructionJSON, "parts.-1", hintPart)
|
||||||
|
hasSystemInstruction = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if hasSystemInstruction {
|
if hasSystemInstruction {
|
||||||
out, _ = sjson.SetRaw(out, "request.systemInstruction", systemInstructionJSON)
|
out, _ = sjson.SetRaw(out, "request.systemInstruction", systemInstructionJSON)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,658 @@
|
|||||||
|
package claude
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_BasicStructure(t *testing.T) {
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-3-5-sonnet-20240620",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "Hello"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"system": [
|
||||||
|
{"type": "text", "text": "You are helpful"}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// Check model
|
||||||
|
if gjson.Get(outputStr, "model").String() != "claude-sonnet-4-5" {
|
||||||
|
t.Errorf("Expected model 'claude-sonnet-4-5', got '%s'", gjson.Get(outputStr, "model").String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check contents exist
|
||||||
|
contents := gjson.Get(outputStr, "request.contents")
|
||||||
|
if !contents.Exists() || !contents.IsArray() {
|
||||||
|
t.Error("request.contents should exist and be an array")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check role mapping (assistant -> model)
|
||||||
|
firstContent := gjson.Get(outputStr, "request.contents.0")
|
||||||
|
if firstContent.Get("role").String() != "user" {
|
||||||
|
t.Errorf("Expected role 'user', got '%s'", firstContent.Get("role").String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check systemInstruction
|
||||||
|
sysInstruction := gjson.Get(outputStr, "request.systemInstruction")
|
||||||
|
if !sysInstruction.Exists() {
|
||||||
|
t.Error("systemInstruction should exist")
|
||||||
|
}
|
||||||
|
if sysInstruction.Get("parts.0.text").String() != "You are helpful" {
|
||||||
|
t.Error("systemInstruction text mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_RoleMapping(t *testing.T) {
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-3-5-sonnet-20240620",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": [{"type": "text", "text": "Hi"}]},
|
||||||
|
{"role": "assistant", "content": [{"type": "text", "text": "Hello"}]}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// assistant should be mapped to model
|
||||||
|
secondContent := gjson.Get(outputStr, "request.contents.1")
|
||||||
|
if secondContent.Get("role").String() != "model" {
|
||||||
|
t.Errorf("Expected role 'model' (mapped from 'assistant'), got '%s'", secondContent.Get("role").String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_ThinkingBlocks(t *testing.T) {
|
||||||
|
// Valid signature must be at least 50 characters
|
||||||
|
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": "Let me think...", "signature": "` + validSignature + `"},
|
||||||
|
{"type": "text", "text": "Answer"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// Check thinking block conversion
|
||||||
|
firstPart := gjson.Get(outputStr, "request.contents.0.parts.0")
|
||||||
|
if !firstPart.Get("thought").Bool() {
|
||||||
|
t.Error("thinking block should have thought: true")
|
||||||
|
}
|
||||||
|
if firstPart.Get("text").String() != "Let me think..." {
|
||||||
|
t.Error("thinking text mismatch")
|
||||||
|
}
|
||||||
|
if firstPart.Get("thoughtSignature").String() != validSignature {
|
||||||
|
t.Errorf("Expected thoughtSignature '%s', got '%s'", validSignature, firstPart.Get("thoughtSignature").String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_ThinkingBlockWithoutSignature(t *testing.T) {
|
||||||
|
// Unsigned thinking blocks should be removed entirely (not converted to text)
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": "Let me think..."},
|
||||||
|
{"type": "text", "text": "Answer"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// Without signature, thinking block should be removed (not converted to text)
|
||||||
|
parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
|
||||||
|
if len(parts) != 1 {
|
||||||
|
t.Fatalf("Expected 1 part (thinking removed), got %d", len(parts))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only text part should remain
|
||||||
|
if parts[0].Get("thought").Bool() {
|
||||||
|
t.Error("Thinking block should be removed, not preserved")
|
||||||
|
}
|
||||||
|
if parts[0].Get("text").String() != "Answer" {
|
||||||
|
t.Errorf("Expected text 'Answer', got '%s'", parts[0].Get("text").String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_ToolDeclarations(t *testing.T) {
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-3-5-sonnet-20240620",
|
||||||
|
"messages": [],
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "test_tool",
|
||||||
|
"description": "A test tool",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["name"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("gemini-1.5-pro", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// Check tools structure
|
||||||
|
tools := gjson.Get(outputStr, "request.tools")
|
||||||
|
if !tools.Exists() {
|
||||||
|
t.Error("Tools should exist in output")
|
||||||
|
}
|
||||||
|
|
||||||
|
funcDecl := gjson.Get(outputStr, "request.tools.0.functionDeclarations.0")
|
||||||
|
if funcDecl.Get("name").String() != "test_tool" {
|
||||||
|
t.Errorf("Expected tool name 'test_tool', got '%s'", funcDecl.Get("name").String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check input_schema renamed to parametersJsonSchema
|
||||||
|
if funcDecl.Get("parametersJsonSchema").Exists() {
|
||||||
|
t.Log("parametersJsonSchema exists (expected)")
|
||||||
|
}
|
||||||
|
if funcDecl.Get("input_schema").Exists() {
|
||||||
|
t.Error("input_schema should be removed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_ToolUse(t *testing.T) {
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-3-5-sonnet-20240620",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": "call_123",
|
||||||
|
"name": "get_weather",
|
||||||
|
"input": "{\"location\": \"Paris\"}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// Now we expect only 1 part (tool_use), no dummy thinking block injected
|
||||||
|
parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
|
||||||
|
if len(parts) != 1 {
|
||||||
|
t.Fatalf("Expected 1 part (tool only, no dummy injection), got %d", len(parts))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check function call conversion at parts[0]
|
||||||
|
funcCall := parts[0].Get("functionCall")
|
||||||
|
if !funcCall.Exists() {
|
||||||
|
t.Error("functionCall should exist at parts[0]")
|
||||||
|
}
|
||||||
|
if funcCall.Get("name").String() != "get_weather" {
|
||||||
|
t.Errorf("Expected function name 'get_weather', got '%s'", funcCall.Get("name").String())
|
||||||
|
}
|
||||||
|
if funcCall.Get("id").String() != "call_123" {
|
||||||
|
t.Errorf("Expected function id 'call_123', got '%s'", funcCall.Get("id").String())
|
||||||
|
}
|
||||||
|
// Verify skip_thought_signature_validator is added (bypass for tools without valid thinking)
|
||||||
|
expectedSig := "skip_thought_signature_validator"
|
||||||
|
actualSig := parts[0].Get("thoughtSignature").String()
|
||||||
|
if actualSig != expectedSig {
|
||||||
|
t.Errorf("Expected thoughtSignature '%s', got '%s'", expectedSig, actualSig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_ToolUse_WithSignature(t *testing.T) {
|
||||||
|
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": "Let me think...", "signature": "` + validSignature + `"},
|
||||||
|
{
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": "call_123",
|
||||||
|
"name": "get_weather",
|
||||||
|
"input": "{\"location\": \"Paris\"}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// Check function call has the signature from the preceding thinking block
|
||||||
|
part := gjson.Get(outputStr, "request.contents.0.parts.1")
|
||||||
|
if part.Get("functionCall.name").String() != "get_weather" {
|
||||||
|
t.Errorf("Expected functionCall, got %s", part.Raw)
|
||||||
|
}
|
||||||
|
if part.Get("thoughtSignature").String() != validSignature {
|
||||||
|
t.Errorf("Expected thoughtSignature '%s' on tool_use, got '%s'", validSignature, part.Get("thoughtSignature").String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_ReorderThinking(t *testing.T) {
|
||||||
|
// Case: text block followed by thinking block -> should be reordered to thinking first
|
||||||
|
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "Here is the plan."},
|
||||||
|
{"type": "thinking", "thinking": "Planning...", "signature": "` + validSignature + `"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// Verify order: Thinking block MUST be first
|
||||||
|
parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
|
||||||
|
if len(parts) != 2 {
|
||||||
|
t.Fatalf("Expected 2 parts, got %d", len(parts))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !parts[0].Get("thought").Bool() {
|
||||||
|
t.Error("First part should be thinking block after reordering")
|
||||||
|
}
|
||||||
|
if parts[1].Get("text").String() != "Here is the plan." {
|
||||||
|
t.Error("Second part should be text block")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_ToolResult(t *testing.T) {
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-3-5-sonnet-20240620",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": "get_weather-call-123",
|
||||||
|
"content": "22C sunny"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// Check function response conversion
|
||||||
|
funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
|
||||||
|
if !funcResp.Exists() {
|
||||||
|
t.Error("functionResponse should exist")
|
||||||
|
}
|
||||||
|
if funcResp.Get("id").String() != "get_weather-call-123" {
|
||||||
|
t.Errorf("Expected function id, got '%s'", funcResp.Get("id").String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_ThinkingConfig(t *testing.T) {
|
||||||
|
// Note: This test requires the model to be registered in the registry
|
||||||
|
// with Thinking metadata. If the registry is not populated in test environment,
|
||||||
|
// thinkingConfig won't be added. We'll test the basic structure only.
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
|
"messages": [],
|
||||||
|
"thinking": {
|
||||||
|
"type": "enabled",
|
||||||
|
"budget_tokens": 8000
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// Check thinking config conversion (only if model supports thinking in registry)
|
||||||
|
thinkingConfig := gjson.Get(outputStr, "request.generationConfig.thinkingConfig")
|
||||||
|
if thinkingConfig.Exists() {
|
||||||
|
if thinkingConfig.Get("thinkingBudget").Int() != 8000 {
|
||||||
|
t.Errorf("Expected thinkingBudget 8000, got %d", thinkingConfig.Get("thinkingBudget").Int())
|
||||||
|
}
|
||||||
|
if !thinkingConfig.Get("include_thoughts").Bool() {
|
||||||
|
t.Error("include_thoughts should be true")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Log("thinkingConfig not present - model may not be registered in test registry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_ImageContent(t *testing.T) {
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-3-5-sonnet-20240620",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"source": {
|
||||||
|
"type": "base64",
|
||||||
|
"media_type": "image/png",
|
||||||
|
"data": "iVBORw0KGgoAAAANSUhEUg=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// Check inline data conversion
|
||||||
|
inlineData := gjson.Get(outputStr, "request.contents.0.parts.0.inlineData")
|
||||||
|
if !inlineData.Exists() {
|
||||||
|
t.Error("inlineData should exist")
|
||||||
|
}
|
||||||
|
if inlineData.Get("mime_type").String() != "image/png" {
|
||||||
|
t.Error("mime_type mismatch")
|
||||||
|
}
|
||||||
|
if !strings.Contains(inlineData.Get("data").String(), "iVBORw0KGgo") {
|
||||||
|
t.Error("data mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_GenerationConfig(t *testing.T) {
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-3-5-sonnet-20240620",
|
||||||
|
"messages": [],
|
||||||
|
"temperature": 0.7,
|
||||||
|
"top_p": 0.9,
|
||||||
|
"top_k": 40,
|
||||||
|
"max_tokens": 2000
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
genConfig := gjson.Get(outputStr, "request.generationConfig")
|
||||||
|
if genConfig.Get("temperature").Float() != 0.7 {
|
||||||
|
t.Errorf("Expected temperature 0.7, got %f", genConfig.Get("temperature").Float())
|
||||||
|
}
|
||||||
|
if genConfig.Get("topP").Float() != 0.9 {
|
||||||
|
t.Errorf("Expected topP 0.9, got %f", genConfig.Get("topP").Float())
|
||||||
|
}
|
||||||
|
if genConfig.Get("topK").Float() != 40 {
|
||||||
|
t.Errorf("Expected topK 40, got %f", genConfig.Get("topK").Float())
|
||||||
|
}
|
||||||
|
if genConfig.Get("maxOutputTokens").Float() != 2000 {
|
||||||
|
t.Errorf("Expected maxOutputTokens 2000, got %f", genConfig.Get("maxOutputTokens").Float())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Trailing Unsigned Thinking Block Removal
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_TrailingUnsignedThinking_Removed(t *testing.T) {
|
||||||
|
// Last assistant message ends with unsigned thinking block - should be removed
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"type": "text", "text": "Hello"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "Here is my answer"},
|
||||||
|
{"type": "thinking", "thinking": "I should think more..."}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// The last part of the last assistant message should NOT be a thinking block
|
||||||
|
lastMessageParts := gjson.Get(outputStr, "request.contents.1.parts")
|
||||||
|
if !lastMessageParts.IsArray() {
|
||||||
|
t.Fatal("Last message should have parts array")
|
||||||
|
}
|
||||||
|
parts := lastMessageParts.Array()
|
||||||
|
if len(parts) == 0 {
|
||||||
|
t.Fatal("Last message should have at least one part")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The unsigned thinking should be removed, leaving only the text
|
||||||
|
lastPart := parts[len(parts)-1]
|
||||||
|
if lastPart.Get("thought").Bool() {
|
||||||
|
t.Error("Trailing unsigned thinking block should be removed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_TrailingSignedThinking_Kept(t *testing.T) {
|
||||||
|
// Last assistant message ends with signed thinking block - should be kept
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"type": "text", "text": "Hello"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "Here is my answer"},
|
||||||
|
{"type": "thinking", "thinking": "Valid thinking...", "signature": "abc123validSignature1234567890123456789012345678901234567890"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// The signed thinking block should be preserved
|
||||||
|
lastMessageParts := gjson.Get(outputStr, "request.contents.1.parts")
|
||||||
|
parts := lastMessageParts.Array()
|
||||||
|
if len(parts) < 2 {
|
||||||
|
t.Error("Signed thinking block should be preserved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_MiddleUnsignedThinking_Removed(t *testing.T) {
|
||||||
|
// Middle message has unsigned thinking - should be removed entirely
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": "Middle thinking..."},
|
||||||
|
{"type": "text", "text": "Answer"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"type": "text", "text": "Follow up"}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// Unsigned thinking should be removed entirely
|
||||||
|
parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
|
||||||
|
if len(parts) != 1 {
|
||||||
|
t.Fatalf("Expected 1 part (thinking removed), got %d", len(parts))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only text part should remain
|
||||||
|
if parts[0].Get("thought").Bool() {
|
||||||
|
t.Error("Thinking block should be removed, not preserved")
|
||||||
|
}
|
||||||
|
if parts[0].Get("text").String() != "Answer" {
|
||||||
|
t.Errorf("Expected text 'Answer', got '%s'", parts[0].Get("text").String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tool + Thinking System Hint Injection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_ToolAndThinking_HintInjected(t *testing.T) {
|
||||||
|
// When both tools and thinking are enabled, hint should be injected into system instruction
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
|
"messages": [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}],
|
||||||
|
"system": [{"type": "text", "text": "You are helpful."}],
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "get_weather",
|
||||||
|
"description": "Get weather",
|
||||||
|
"input_schema": {"type": "object", "properties": {"location": {"type": "string"}}}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thinking": {"type": "enabled", "budget_tokens": 8000}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// System instruction should contain the interleaved thinking hint
|
||||||
|
sysInstruction := gjson.Get(outputStr, "request.systemInstruction")
|
||||||
|
if !sysInstruction.Exists() {
|
||||||
|
t.Fatal("systemInstruction should exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if hint is appended
|
||||||
|
sysText := sysInstruction.Get("parts").Array()
|
||||||
|
found := false
|
||||||
|
for _, part := range sysText {
|
||||||
|
if strings.Contains(part.Get("text").String(), "Interleaved thinking is enabled") {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("Interleaved thinking hint should be injected when tools and thinking are both active, got: %v", sysInstruction.Raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_ToolsOnly_NoHint(t *testing.T) {
|
||||||
|
// When only tools are present (no thinking), hint should NOT be injected
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5",
|
||||||
|
"messages": [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}],
|
||||||
|
"system": [{"type": "text", "text": "You are helpful."}],
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "get_weather",
|
||||||
|
"description": "Get weather",
|
||||||
|
"input_schema": {"type": "object", "properties": {"location": {"type": "string"}}}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// System instruction should NOT contain the hint
|
||||||
|
sysInstruction := gjson.Get(outputStr, "request.systemInstruction")
|
||||||
|
if sysInstruction.Exists() {
|
||||||
|
for _, part := range sysInstruction.Get("parts").Array() {
|
||||||
|
if strings.Contains(part.Get("text").String(), "Interleaved thinking is enabled") {
|
||||||
|
t.Error("Hint should NOT be injected when only tools are present (no thinking)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_ThinkingOnly_NoHint(t *testing.T) {
|
||||||
|
// When only thinking is enabled (no tools), hint should NOT be injected
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
|
"messages": [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}],
|
||||||
|
"system": [{"type": "text", "text": "You are helpful."}],
|
||||||
|
"thinking": {"type": "enabled", "budget_tokens": 8000}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// System instruction should NOT contain the hint (no tools)
|
||||||
|
sysInstruction := gjson.Get(outputStr, "request.systemInstruction")
|
||||||
|
if sysInstruction.Exists() {
|
||||||
|
for _, part := range sysInstruction.Get("parts").Array() {
|
||||||
|
if strings.Contains(part.Get("text").String(), "Interleaved thinking is enabled") {
|
||||||
|
t.Error("Hint should NOT be injected when only thinking is present (no tools)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_ToolAndThinking_NoExistingSystem(t *testing.T) {
|
||||||
|
// When tools + thinking but no system instruction, should create one with hint
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
|
"messages": [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}],
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "get_weather",
|
||||||
|
"description": "Get weather",
|
||||||
|
"input_schema": {"type": "object", "properties": {"location": {"type": "string"}}}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thinking": {"type": "enabled", "budget_tokens": 8000}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// System instruction should be created with hint
|
||||||
|
sysInstruction := gjson.Get(outputStr, "request.systemInstruction")
|
||||||
|
if !sysInstruction.Exists() {
|
||||||
|
t.Fatal("systemInstruction should be created when tools + thinking are active")
|
||||||
|
}
|
||||||
|
|
||||||
|
sysText := sysInstruction.Get("parts").Array()
|
||||||
|
found := false
|
||||||
|
for _, part := range sysText {
|
||||||
|
if strings.Contains(part.Get("text").String(), "Interleaved thinking is enabled") {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("Interleaved thinking hint should be in created systemInstruction, got: %v", sysInstruction.Raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
)
|
)
|
||||||
@@ -35,6 +38,10 @@ type Params struct {
|
|||||||
HasSentFinalEvents bool // Indicates if final content/message events have been sent
|
HasSentFinalEvents bool // Indicates if final content/message events have been sent
|
||||||
HasToolUse bool // Indicates if tool use was observed in the stream
|
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
|
HasContent bool // Tracks whether any content (text, thinking, or tool use) has been output
|
||||||
|
|
||||||
|
// Signature caching support
|
||||||
|
SessionID string // Session ID derived from request for signature caching
|
||||||
|
CurrentThinkingText strings.Builder // Accumulates thinking text for signature caching
|
||||||
}
|
}
|
||||||
|
|
||||||
// toolUseIDCounter provides a process-wide unique counter for tool use identifiers.
|
// toolUseIDCounter provides a process-wide unique counter for tool use identifiers.
|
||||||
@@ -62,6 +69,7 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq
|
|||||||
HasFirstResponse: false,
|
HasFirstResponse: false,
|
||||||
ResponseType: 0,
|
ResponseType: 0,
|
||||||
ResponseIndex: 0,
|
ResponseIndex: 0,
|
||||||
|
SessionID: deriveSessionID(originalRequestRawJSON),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,11 +127,20 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq
|
|||||||
// Process thinking content (internal reasoning)
|
// Process thinking content (internal reasoning)
|
||||||
if partResult.Get("thought").Bool() {
|
if partResult.Get("thought").Bool() {
|
||||||
if thoughtSignature := partResult.Get("thoughtSignature"); thoughtSignature.Exists() && thoughtSignature.String() != "" {
|
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"
|
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())
|
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)
|
output = output + fmt.Sprintf("data: %s\n\n\n", data)
|
||||||
params.HasContent = true
|
params.HasContent = true
|
||||||
} else if params.ResponseType == 2 { // Continue existing thinking block if already in thinking state
|
} 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"
|
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())
|
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)
|
output = output + fmt.Sprintf("data: %s\n\n\n", data)
|
||||||
@@ -152,6 +169,9 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq
|
|||||||
output = output + fmt.Sprintf("data: %s\n\n\n", data)
|
output = output + fmt.Sprintf("data: %s\n\n\n", data)
|
||||||
params.ResponseType = 2 // Set state to thinking
|
params.ResponseType = 2 // Set state to thinking
|
||||||
params.HasContent = true
|
params.HasContent = true
|
||||||
|
// Start accumulating thinking text for signature caching
|
||||||
|
params.CurrentThinkingText.Reset()
|
||||||
|
params.CurrentThinkingText.WriteString(partTextResult.String())
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
finishReasonResult := gjson.GetBytes(rawJSON, "response.candidates.0.finishReason")
|
finishReasonResult := gjson.GetBytes(rawJSON, "response.candidates.0.finishReason")
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
package claude
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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 := deriveSessionID(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 := deriveSessionID(input)
|
||||||
|
id2 := deriveSessionID(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 := deriveSessionID(input1)
|
||||||
|
id2 := deriveSessionID(input2)
|
||||||
|
|
||||||
|
if id1 == id2 {
|
||||||
|
t.Error("Different messages should produce different session IDs")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" {
|
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() {
|
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")
|
existingSig := part.Get("thoughtSignature").String()
|
||||||
} else if part.Get("thoughtSignature").Exists() {
|
if existingSig == "" || len(existingSig) < 50 {
|
||||||
rawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf("request.contents.%d.parts.%d.thoughtSignature", key.Int(), partKey.Int()), "skip_thought_signature_validator")
|
rawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf("request.contents.%d.parts.%d.thoughtSignature", contentIdx.Int(), partIdx.Int()), skipSentinel)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true
|
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
|
return true
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
internal/util/claude_model.go
Normal file
10
internal/util/claude_model.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
41
internal/util/claude_model_test.go
Normal file
41
internal/util/claude_model_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,10 +12,10 @@ import (
|
|||||||
|
|
||||||
var gjsonPathKeyReplacer = strings.NewReplacer(".", "\\.", "*", "\\*", "?", "\\?")
|
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
|
// It handles unsupported keywords, type flattening, and schema simplification while preserving
|
||||||
// semantic information as description hints.
|
// semantic information as description hints.
|
||||||
func CleanJSONSchemaForGemini(jsonStr string) string {
|
func CleanJSONSchemaForAntigravity(jsonStr string) string {
|
||||||
// Phase 1: Convert and add hints
|
// Phase 1: Convert and add hints
|
||||||
jsonStr = convertRefsToHints(jsonStr)
|
jsonStr = convertRefsToHints(jsonStr)
|
||||||
jsonStr = convertConstToEnum(jsonStr)
|
jsonStr = convertConstToEnum(jsonStr)
|
||||||
@@ -32,6 +32,9 @@ func CleanJSONSchemaForGemini(jsonStr string) string {
|
|||||||
jsonStr = removeUnsupportedKeywords(jsonStr)
|
jsonStr = removeUnsupportedKeywords(jsonStr)
|
||||||
jsonStr = cleanupRequiredFields(jsonStr)
|
jsonStr = cleanupRequiredFields(jsonStr)
|
||||||
|
|
||||||
|
// Phase 4: Add placeholder for empty object schemas (Claude VALIDATED mode requirement)
|
||||||
|
jsonStr = addEmptySchemaPlaceholder(jsonStr)
|
||||||
|
|
||||||
return jsonStr
|
return jsonStr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +108,8 @@ func addAdditionalPropertiesHints(jsonStr string) string {
|
|||||||
|
|
||||||
var unsupportedConstraints = []string{
|
var unsupportedConstraints = []string{
|
||||||
"minLength", "maxLength", "exclusiveMinimum", "exclusiveMaximum",
|
"minLength", "maxLength", "exclusiveMinimum", "exclusiveMaximum",
|
||||||
"pattern", "minItems", "maxItems",
|
"pattern", "minItems", "maxItems", "format",
|
||||||
|
"default", "examples", // Claude rejects these in VALIDATED mode
|
||||||
}
|
}
|
||||||
|
|
||||||
func moveConstraintsToDescription(jsonStr string) string {
|
func moveConstraintsToDescription(jsonStr string) string {
|
||||||
@@ -339,6 +343,52 @@ func cleanupRequiredFields(jsonStr string) string {
|
|||||||
return jsonStr
|
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 ---
|
// --- Helpers ---
|
||||||
|
|
||||||
func findPaths(jsonStr, field string) []string {
|
func findPaths(jsonStr, field string) []string {
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCleanJSONSchemaForGemini_ConstToEnum(t *testing.T) {
|
func TestCleanJSONSchemaForAntigravity_ConstToEnum(t *testing.T) {
|
||||||
input := `{
|
input := `{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -28,11 +30,11 @@ func TestCleanJSONSchemaForGemini_ConstToEnum(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
result := CleanJSONSchemaForGemini(input)
|
result := CleanJSONSchemaForAntigravity(input)
|
||||||
compareJSON(t, expected, result)
|
compareJSON(t, expected, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanJSONSchemaForGemini_TypeFlattening_Nullable(t *testing.T) {
|
func TestCleanJSONSchemaForAntigravity_TypeFlattening_Nullable(t *testing.T) {
|
||||||
input := `{
|
input := `{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -60,11 +62,11 @@ func TestCleanJSONSchemaForGemini_TypeFlattening_Nullable(t *testing.T) {
|
|||||||
"required": ["other"]
|
"required": ["other"]
|
||||||
}`
|
}`
|
||||||
|
|
||||||
result := CleanJSONSchemaForGemini(input)
|
result := CleanJSONSchemaForAntigravity(input)
|
||||||
compareJSON(t, expected, result)
|
compareJSON(t, expected, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanJSONSchemaForGemini_ConstraintsToDescription(t *testing.T) {
|
func TestCleanJSONSchemaForAntigravity_ConstraintsToDescription(t *testing.T) {
|
||||||
input := `{
|
input := `{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"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
|
// minItems should be REMOVED and moved to description
|
||||||
if strings.Contains(result, `"minItems"`) {
|
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 := `{
|
input := `{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -131,11 +133,11 @@ func TestCleanJSONSchemaForGemini_AnyOfFlattening_SmartSelection(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
result := CleanJSONSchemaForGemini(input)
|
result := CleanJSONSchemaForAntigravity(input)
|
||||||
compareJSON(t, expected, result)
|
compareJSON(t, expected, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanJSONSchemaForGemini_OneOfFlattening(t *testing.T) {
|
func TestCleanJSONSchemaForAntigravity_OneOfFlattening(t *testing.T) {
|
||||||
input := `{
|
input := `{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -158,11 +160,11 @@ func TestCleanJSONSchemaForGemini_OneOfFlattening(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
result := CleanJSONSchemaForGemini(input)
|
result := CleanJSONSchemaForAntigravity(input)
|
||||||
compareJSON(t, expected, result)
|
compareJSON(t, expected, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanJSONSchemaForGemini_AllOfMerging(t *testing.T) {
|
func TestCleanJSONSchemaForAntigravity_AllOfMerging(t *testing.T) {
|
||||||
input := `{
|
input := `{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"allOf": [
|
"allOf": [
|
||||||
@@ -190,11 +192,11 @@ func TestCleanJSONSchemaForGemini_AllOfMerging(t *testing.T) {
|
|||||||
"required": ["a", "b"]
|
"required": ["a", "b"]
|
||||||
}`
|
}`
|
||||||
|
|
||||||
result := CleanJSONSchemaForGemini(input)
|
result := CleanJSONSchemaForAntigravity(input)
|
||||||
compareJSON(t, expected, result)
|
compareJSON(t, expected, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanJSONSchemaForGemini_RefHandling(t *testing.T) {
|
func TestCleanJSONSchemaForAntigravity_RefHandling(t *testing.T) {
|
||||||
input := `{
|
input := `{
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"User": {
|
"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 := `{
|
expected := `{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"customer": {
|
"customer": {
|
||||||
"type": "object",
|
"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)
|
compareJSON(t, expected, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanJSONSchemaForGemini_RefHandling_DescriptionEscaping(t *testing.T) {
|
func TestCleanJSONSchemaForAntigravity_RefHandling_DescriptionEscaping(t *testing.T) {
|
||||||
input := `{
|
input := `{
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"User": {
|
"User": {
|
||||||
@@ -243,21 +253,29 @@ func TestCleanJSONSchemaForGemini_RefHandling_DescriptionEscaping(t *testing.T)
|
|||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
|
// After $ref is converted, empty schema placeholder is also added
|
||||||
expected := `{
|
expected := `{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"customer": {
|
"customer": {
|
||||||
"type": "object",
|
"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)
|
compareJSON(t, expected, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanJSONSchemaForGemini_CyclicRefDefaults(t *testing.T) {
|
func TestCleanJSONSchemaForAntigravity_CyclicRefDefaults(t *testing.T) {
|
||||||
input := `{
|
input := `{
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"Node": {
|
"Node": {
|
||||||
@@ -270,7 +288,7 @@ func TestCleanJSONSchemaForGemini_CyclicRefDefaults(t *testing.T) {
|
|||||||
"$ref": "#/definitions/Node"
|
"$ref": "#/definitions/Node"
|
||||||
}`
|
}`
|
||||||
|
|
||||||
result := CleanJSONSchemaForGemini(input)
|
result := CleanJSONSchemaForAntigravity(input)
|
||||||
|
|
||||||
var resMap map[string]interface{}
|
var resMap map[string]interface{}
|
||||||
json.Unmarshal([]byte(result), &resMap)
|
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 := `{
|
input := `{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -304,11 +322,11 @@ func TestCleanJSONSchemaForGemini_RequiredCleanup(t *testing.T) {
|
|||||||
"required": ["a", "b"]
|
"required": ["a", "b"]
|
||||||
}`
|
}`
|
||||||
|
|
||||||
result := CleanJSONSchemaForGemini(input)
|
result := CleanJSONSchemaForAntigravity(input)
|
||||||
compareJSON(t, expected, result)
|
compareJSON(t, expected, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanJSONSchemaForGemini_AllOfMerging_DotKeys(t *testing.T) {
|
func TestCleanJSONSchemaForAntigravity_AllOfMerging_DotKeys(t *testing.T) {
|
||||||
input := `{
|
input := `{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"allOf": [
|
"allOf": [
|
||||||
@@ -336,11 +354,11 @@ func TestCleanJSONSchemaForGemini_AllOfMerging_DotKeys(t *testing.T) {
|
|||||||
"required": ["my.param", "b"]
|
"required": ["my.param", "b"]
|
||||||
}`
|
}`
|
||||||
|
|
||||||
result := CleanJSONSchemaForGemini(input)
|
result := CleanJSONSchemaForAntigravity(input)
|
||||||
compareJSON(t, expected, result)
|
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
|
// A tool has an argument named "pattern" - should NOT be treated as a constraint
|
||||||
input := `{
|
input := `{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -364,7 +382,7 @@ func TestCleanJSONSchemaForGemini_PropertyNameCollision(t *testing.T) {
|
|||||||
"required": ["pattern"]
|
"required": ["pattern"]
|
||||||
}`
|
}`
|
||||||
|
|
||||||
result := CleanJSONSchemaForGemini(input)
|
result := CleanJSONSchemaForAntigravity(input)
|
||||||
compareJSON(t, expected, result)
|
compareJSON(t, expected, result)
|
||||||
|
|
||||||
var resMap map[string]interface{}
|
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 := `{
|
input := `{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -389,7 +407,7 @@ func TestCleanJSONSchemaForGemini_DotKeys(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
result := CleanJSONSchemaForGemini(input)
|
result := CleanJSONSchemaForAntigravity(input)
|
||||||
|
|
||||||
var resMap map[string]interface{}
|
var resMap map[string]interface{}
|
||||||
if err := json.Unmarshal([]byte(result), &resMap); err != nil {
|
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 := `{
|
input := `{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -428,7 +446,7 @@ func TestCleanJSONSchemaForGemini_AnyOfAlternativeHints(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
result := CleanJSONSchemaForGemini(input)
|
result := CleanJSONSchemaForAntigravity(input)
|
||||||
|
|
||||||
if !strings.Contains(result, "Accepts:") {
|
if !strings.Contains(result, "Accepts:") {
|
||||||
t.Errorf("Expected alternative types hint, got: %s", result)
|
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 := `{
|
input := `{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -450,7 +468,7 @@ func TestCleanJSONSchemaForGemini_NullableHint(t *testing.T) {
|
|||||||
"required": ["name"]
|
"required": ["name"]
|
||||||
}`
|
}`
|
||||||
|
|
||||||
result := CleanJSONSchemaForGemini(input)
|
result := CleanJSONSchemaForAntigravity(input)
|
||||||
|
|
||||||
if !strings.Contains(result, "(nullable)") {
|
if !strings.Contains(result, "(nullable)") {
|
||||||
t.Errorf("Expected nullable hint, got: %s", result)
|
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 := `{
|
input := `{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -488,11 +506,11 @@ func TestCleanJSONSchemaForGemini_TypeFlattening_Nullable_DotKey(t *testing.T) {
|
|||||||
"required": ["other"]
|
"required": ["other"]
|
||||||
}`
|
}`
|
||||||
|
|
||||||
result := CleanJSONSchemaForGemini(input)
|
result := CleanJSONSchemaForAntigravity(input)
|
||||||
compareJSON(t, expected, result)
|
compareJSON(t, expected, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanJSONSchemaForGemini_EnumHint(t *testing.T) {
|
func TestCleanJSONSchemaForAntigravity_EnumHint(t *testing.T) {
|
||||||
input := `{
|
input := `{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -504,7 +522,7 @@ func TestCleanJSONSchemaForGemini_EnumHint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
result := CleanJSONSchemaForGemini(input)
|
result := CleanJSONSchemaForAntigravity(input)
|
||||||
|
|
||||||
if !strings.Contains(result, "Allowed:") {
|
if !strings.Contains(result, "Allowed:") {
|
||||||
t.Errorf("Expected enum values hint, got: %s", result)
|
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 := `{
|
input := `{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -523,14 +541,14 @@ func TestCleanJSONSchemaForGemini_AdditionalPropertiesHint(t *testing.T) {
|
|||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}`
|
}`
|
||||||
|
|
||||||
result := CleanJSONSchemaForGemini(input)
|
result := CleanJSONSchemaForAntigravity(input)
|
||||||
|
|
||||||
if !strings.Contains(result, "No extra properties allowed") {
|
if !strings.Contains(result, "No extra properties allowed") {
|
||||||
t.Errorf("Expected additionalProperties hint, got: %s", result)
|
t.Errorf("Expected additionalProperties hint, got: %s", result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanJSONSchemaForGemini_AnyOfFlattening_PreservesDescription(t *testing.T) {
|
func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_PreservesDescription(t *testing.T) {
|
||||||
input := `{
|
input := `{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -554,11 +572,11 @@ func TestCleanJSONSchemaForGemini_AnyOfFlattening_PreservesDescription(t *testin
|
|||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
result := CleanJSONSchemaForGemini(input)
|
result := CleanJSONSchemaForAntigravity(input)
|
||||||
compareJSON(t, expected, result)
|
compareJSON(t, expected, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanJSONSchemaForGemini_SingleEnumNoHint(t *testing.T) {
|
func TestCleanJSONSchemaForAntigravity_SingleEnumNoHint(t *testing.T) {
|
||||||
input := `{
|
input := `{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -569,14 +587,14 @@ func TestCleanJSONSchemaForGemini_SingleEnumNoHint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
result := CleanJSONSchemaForGemini(input)
|
result := CleanJSONSchemaForAntigravity(input)
|
||||||
|
|
||||||
if strings.Contains(result, "Allowed:") {
|
if strings.Contains(result, "Allowed:") {
|
||||||
t.Errorf("Single value enum should not add Allowed hint, got: %s", result)
|
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 := `{
|
input := `{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -586,7 +604,7 @@ func TestCleanJSONSchemaForGemini_MultipleNonNullTypes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
result := CleanJSONSchemaForGemini(input)
|
result := CleanJSONSchemaForAntigravity(input)
|
||||||
|
|
||||||
if !strings.Contains(result, "Accepts:") {
|
if !strings.Contains(result, "Accepts:") {
|
||||||
t.Errorf("Expected multiple types hint, got: %s", result)
|
t.Errorf("Expected multiple types hint, got: %s", result)
|
||||||
@@ -676,3 +694,190 @@ func compareJSON(t *testing.T, expectedJSON, actualJSON string) {
|
|||||||
t.Errorf("JSON mismatch:\nExpected:\n%s\n\nActual:\n%s", string(expBytes), string(actBytes))
|
t.Errorf("JSON mismatch:\nExpected:\n%s\n\nActual:\n%s", string(expBytes), string(actBytes))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
87
internal/util/thinking_text.go
Normal file
87
internal/util/thinking_text.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user