fix(antigravity): prevent corrupted thought signature when switching models

When switching from Claude models (e.g., Opus 4.5) to Gemini models
(e.g., Flash) mid-conversation via Antigravity OAuth, the client-provided
thinking signatures from Claude would cause "Corrupted thought signature"
errors since they are incompatible with Gemini API.

Changes:
- Remove fallback to client-provided signatures in thinking block handling
- Only use cached signatures (from same-session Gemini responses)
- Skip thinking blocks without valid cached signatures
- tool_use blocks continue to use skip_thought_signature_validator when
  no valid signature is available

This ensures cross-model switching works correctly while preserving
signature validation for same-model conversations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
adrenjc
2026-01-13 18:24:05 +08:00
parent 43652d044c
commit 5977af96a0
2 changed files with 61 additions and 21 deletions

View File

@@ -123,11 +123,6 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "thinking" { if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "thinking" {
// Use GetThinkingText to handle wrapped thinking objects // Use GetThinkingText to handle wrapped thinking objects
thinkingText := util.GetThinkingText(contentResult) thinkingText := util.GetThinkingText(contentResult)
signatureResult := contentResult.Get("signature")
clientSignature := ""
if signatureResult.Exists() && signatureResult.String() != "" {
clientSignature = signatureResult.String()
}
// Always try cached signature first (more reliable than client-provided) // Always try cached signature first (more reliable than client-provided)
// Client may send stale or invalid signatures from different sessions // Client may send stale or invalid signatures from different sessions
@@ -139,11 +134,11 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
} }
} }
// Fallback to client signature only if cache miss and client signature is valid // NOTE: We do NOT fallback to client signature anymore.
if signature == "" && cache.HasValidSignature(clientSignature) { // Client signatures from Claude models are incompatible with Antigravity/Gemini API.
signature = clientSignature // When switching between models (e.g., Claude Opus -> Gemini Flash), the Claude
// log.Debugf("Using client-provided signature for thinking block") // signatures will cause "Corrupted thought signature" errors.
} // If we have no cached signature, the thinking block will be skipped below.
// Store for subsequent tool_use in the same message // Store for subsequent tool_use in the same message
if cache.HasValidSignature(signature) { if cache.HasValidSignature(signature) {

View File

@@ -4,6 +4,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
) )
@@ -75,28 +76,42 @@ func TestConvertClaudeRequestToAntigravity_RoleMapping(t *testing.T) {
func TestConvertClaudeRequestToAntigravity_ThinkingBlocks(t *testing.T) { func TestConvertClaudeRequestToAntigravity_ThinkingBlocks(t *testing.T) {
// Valid signature must be at least 50 characters // Valid signature must be at least 50 characters
validSignature := "abc123validSignature1234567890123456789012345678901234567890" validSignature := "abc123validSignature1234567890123456789012345678901234567890"
thinkingText := "Let me think..."
// Pre-cache the signature (simulating a response from the same session)
// The session ID is derived from the first user message hash
// Since there's no user message in this test, we need to add one
inputJSON := []byte(`{ inputJSON := []byte(`{
"model": "claude-sonnet-4-5-thinking", "model": "claude-sonnet-4-5-thinking",
"messages": [ "messages": [
{
"role": "user",
"content": [{"type": "text", "text": "Test user message"}]
},
{ {
"role": "assistant", "role": "assistant",
"content": [ "content": [
{"type": "thinking", "thinking": "Let me think...", "signature": "` + validSignature + `"}, {"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + validSignature + `"},
{"type": "text", "text": "Answer"} {"type": "text", "text": "Answer"}
] ]
} }
] ]
}`) }`)
// Derive session ID and cache the signature
sessionID := deriveSessionID(inputJSON)
cache.CacheSignature(sessionID, thinkingText, validSignature)
defer cache.ClearSignatureCache(sessionID)
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false) output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
outputStr := string(output) outputStr := string(output)
// Check thinking block conversion // Check thinking block conversion (now in contents.1 due to user message)
firstPart := gjson.Get(outputStr, "request.contents.0.parts.0") firstPart := gjson.Get(outputStr, "request.contents.1.parts.0")
if !firstPart.Get("thought").Bool() { if !firstPart.Get("thought").Bool() {
t.Error("thinking block should have thought: true") t.Error("thinking block should have thought: true")
} }
if firstPart.Get("text").String() != "Let me think..." { if firstPart.Get("text").String() != thinkingText {
t.Error("thinking text mismatch") t.Error("thinking text mismatch")
} }
if firstPart.Get("thoughtSignature").String() != validSignature { if firstPart.Get("thoughtSignature").String() != validSignature {
@@ -227,13 +242,19 @@ func TestConvertClaudeRequestToAntigravity_ToolUse(t *testing.T) {
func TestConvertClaudeRequestToAntigravity_ToolUse_WithSignature(t *testing.T) { func TestConvertClaudeRequestToAntigravity_ToolUse_WithSignature(t *testing.T) {
validSignature := "abc123validSignature1234567890123456789012345678901234567890" validSignature := "abc123validSignature1234567890123456789012345678901234567890"
thinkingText := "Let me think..."
inputJSON := []byte(`{ inputJSON := []byte(`{
"model": "claude-sonnet-4-5-thinking", "model": "claude-sonnet-4-5-thinking",
"messages": [ "messages": [
{
"role": "user",
"content": [{"type": "text", "text": "Test user message"}]
},
{ {
"role": "assistant", "role": "assistant",
"content": [ "content": [
{"type": "thinking", "thinking": "Let me think...", "signature": "` + validSignature + `"}, {"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + validSignature + `"},
{ {
"type": "tool_use", "type": "tool_use",
"id": "call_123", "id": "call_123",
@@ -245,11 +266,16 @@ func TestConvertClaudeRequestToAntigravity_ToolUse_WithSignature(t *testing.T) {
] ]
}`) }`)
// Derive session ID and cache the signature
sessionID := deriveSessionID(inputJSON)
cache.CacheSignature(sessionID, thinkingText, validSignature)
defer cache.ClearSignatureCache(sessionID)
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false) output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
outputStr := string(output) outputStr := string(output)
// Check function call has the signature from the preceding thinking block // Check function call has the signature from the preceding thinking block (now in contents.1)
part := gjson.Get(outputStr, "request.contents.0.parts.1") part := gjson.Get(outputStr, "request.contents.1.parts.1")
if part.Get("functionCall.name").String() != "get_weather" { if part.Get("functionCall.name").String() != "get_weather" {
t.Errorf("Expected functionCall, got %s", part.Raw) t.Errorf("Expected functionCall, got %s", part.Raw)
} }
@@ -261,24 +287,35 @@ func TestConvertClaudeRequestToAntigravity_ToolUse_WithSignature(t *testing.T) {
func TestConvertClaudeRequestToAntigravity_ReorderThinking(t *testing.T) { func TestConvertClaudeRequestToAntigravity_ReorderThinking(t *testing.T) {
// Case: text block followed by thinking block -> should be reordered to thinking first // Case: text block followed by thinking block -> should be reordered to thinking first
validSignature := "abc123validSignature1234567890123456789012345678901234567890" validSignature := "abc123validSignature1234567890123456789012345678901234567890"
thinkingText := "Planning..."
inputJSON := []byte(`{ inputJSON := []byte(`{
"model": "claude-sonnet-4-5-thinking", "model": "claude-sonnet-4-5-thinking",
"messages": [ "messages": [
{
"role": "user",
"content": [{"type": "text", "text": "Test user message"}]
},
{ {
"role": "assistant", "role": "assistant",
"content": [ "content": [
{"type": "text", "text": "Here is the plan."}, {"type": "text", "text": "Here is the plan."},
{"type": "thinking", "thinking": "Planning...", "signature": "` + validSignature + `"} {"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + validSignature + `"}
] ]
} }
] ]
}`) }`)
// Derive session ID and cache the signature
sessionID := deriveSessionID(inputJSON)
cache.CacheSignature(sessionID, thinkingText, validSignature)
defer cache.ClearSignatureCache(sessionID)
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false) output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
outputStr := string(output) outputStr := string(output)
// Verify order: Thinking block MUST be first // Verify order: Thinking block MUST be first (now in contents.1 due to user message)
parts := gjson.Get(outputStr, "request.contents.0.parts").Array() parts := gjson.Get(outputStr, "request.contents.1.parts").Array()
if len(parts) != 2 { if len(parts) != 2 {
t.Fatalf("Expected 2 parts, got %d", len(parts)) t.Fatalf("Expected 2 parts, got %d", len(parts))
} }
@@ -460,6 +497,9 @@ func TestConvertClaudeRequestToAntigravity_TrailingUnsignedThinking_Removed(t *t
func TestConvertClaudeRequestToAntigravity_TrailingSignedThinking_Kept(t *testing.T) { func TestConvertClaudeRequestToAntigravity_TrailingSignedThinking_Kept(t *testing.T) {
// Last assistant message ends with signed thinking block - should be kept // Last assistant message ends with signed thinking block - should be kept
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
thinkingText := "Valid thinking..."
inputJSON := []byte(`{ inputJSON := []byte(`{
"model": "claude-sonnet-4-5-thinking", "model": "claude-sonnet-4-5-thinking",
"messages": [ "messages": [
@@ -471,12 +511,17 @@ func TestConvertClaudeRequestToAntigravity_TrailingSignedThinking_Kept(t *testin
"role": "assistant", "role": "assistant",
"content": [ "content": [
{"type": "text", "text": "Here is my answer"}, {"type": "text", "text": "Here is my answer"},
{"type": "thinking", "thinking": "Valid thinking...", "signature": "abc123validSignature1234567890123456789012345678901234567890"} {"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + validSignature + `"}
] ]
} }
] ]
}`) }`)
// Derive session ID and cache the signature
sessionID := deriveSessionID(inputJSON)
cache.CacheSignature(sessionID, thinkingText, validSignature)
defer cache.ClearSignatureCache(sessionID)
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false) output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
outputStr := string(output) outputStr := string(output)