Improve thinking/tool signature handling for Claude and Gemini requests

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

These changes prevent rejected dummy signatures, improve compatibility with Antigravity’s signature validation, provide more stable session IDs for conversation grouping, and make request/response translation more robust.
This commit is contained in:
이대희
2025-12-21 15:15:50 +09:00
parent 406a27271a
commit 1e9e4a86a2
7 changed files with 420 additions and 181 deletions

View File

@@ -19,8 +19,6 @@ import (
"github.com/tidwall/sjson"
)
const geminiCLIClaudeThoughtSignature = "skip_thought_signature_validator"
// deriveSessionID generates a stable session ID from the request.
// Uses the hash of the first user message to identify the conversation.
func deriveSessionID(rawJSON []byte) string {
@@ -93,6 +91,12 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
// contents
contentsJSON := "[]"
hasContents := false
// Track if we need to disable thinking (LiteLLM approach)
// If the last assistant message with tool_use has no valid thinking block before it,
// we need to disable thinkingConfig to avoid "Expected thinking but found tool_use" error
lastAssistantHasToolWithoutThinking := false
messagesResult := gjson.GetBytes(rawJSON, "messages")
if messagesResult.IsArray() {
messageResults := messagesResult.Array()
@@ -114,6 +118,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
if contentsResult.IsArray() {
contentResults := contentsResult.Array()
numContents := len(contentResults)
var currentMessageThinkingSignature string
for j := 0; j < numContents; j++ {
contentResult := contentResults[j]
contentTypeResult := contentResult.Get("type")
@@ -121,36 +126,46 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
// Use GetThinkingText to handle wrapped thinking objects
thinkingText := util.GetThinkingText(contentResult)
signatureResult := contentResult.Get("signature")
signature := ""
if signatureResult.Exists() && signatureResult.String() != "" {
signature = signatureResult.String()
}
clientSignature := ""
if signatureResult.Exists() && signatureResult.String() != "" {
clientSignature = signatureResult.String()
}
// Try to restore signature from cache for unsigned thinking blocks
if !cache.HasValidSignature(signature) && sessionID != "" && thinkingText != "" {
if cachedSig := cache.GetCachedSignature(sessionID, thinkingText); cachedSig != "" {
signature = cachedSig
log.Debugf("Restored cached signature for thinking block")
}
// Always try cached signature first (more reliable than client-provided)
// Client may send stale or invalid signatures from different sessions
signature := ""
if sessionID != "" && thinkingText != "" {
if cachedSig := cache.GetCachedSignature(sessionID, thinkingText); cachedSig != "" {
signature = cachedSig
log.Debugf("Using cached signature for thinking block")
}
}
// Fallback to client signature only if cache miss and client signature is valid
if signature == "" && cache.HasValidSignature(clientSignature) {
signature = clientSignature
log.Debugf("Using client-provided signature for thinking block")
}
// Store for subsequent tool_use in the same message
if cache.HasValidSignature(signature) {
currentMessageThinkingSignature = signature
}
// Skip trailing unsigned thinking blocks on last assistant message
isLastMessage := (i == numMessages-1)
isLastContent := (j == numContents-1)
isAssistant := (originalRole == "assistant")
isUnsigned := !cache.HasValidSignature(signature)
if isLastMessage && isLastContent && isAssistant && isUnsigned {
// Skip this trailing unsigned thinking block
// If unsigned, skip entirely (don't convert to text)
// Claude requires assistant messages to start with thinking blocks when thinking is enabled
// Converting to text would break this requirement
if isUnsigned {
// TypeScript plugin approach: drop unsigned thinking blocks entirely
log.Debugf("Dropping unsigned thinking block (no valid signature)")
continue
}
// Apply sentinel for unsigned thinking blocks that are not trailing
// (includes empty string and short/invalid signatures < 50 chars)
if isUnsigned {
signature = geminiCLIClaudeThoughtSignature
}
// Valid signature, send as thought block
partJSON := `{}`
partJSON, _ = sjson.Set(partJSON, "thought", true)
if thinkingText != "" {
@@ -168,6 +183,10 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
}
clientContentJSON, _ = sjson.SetRaw(clientContentJSON, "parts.-1", partJSON)
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_use" {
// NOTE: Do NOT inject dummy thinking blocks here.
// Antigravity API validates signatures, so dummy values are rejected.
// The TypeScript plugin removes unsigned thinking blocks instead of injecting dummies.
functionName := contentResult.Get("name").String()
functionArgs := contentResult.Get("input").String()
functionID := contentResult.Get("id").String()
@@ -175,9 +194,18 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
argsResult := gjson.Parse(functionArgs)
if argsResult.IsObject() {
partJSON := `{}`
if !strings.Contains(modelName, "claude") {
partJSON, _ = sjson.Set(partJSON, "thoughtSignature", geminiCLIClaudeThoughtSignature)
// Use skip_thought_signature_validator for tool calls without valid thinking signature
// This is the approach used in opencode-google-antigravity-auth for Gemini
// and also works for Claude through Antigravity API
const skipSentinel = "skip_thought_signature_validator"
if cache.HasValidSignature(currentMessageThinkingSignature) {
partJSON, _ = sjson.Set(partJSON, "thoughtSignature", currentMessageThinkingSignature)
} else {
// No valid signature - use skip sentinel to bypass validation
partJSON, _ = sjson.Set(partJSON, "thoughtSignature", skipSentinel)
}
if functionID != "" {
partJSON, _ = sjson.Set(partJSON, "functionCall.id", functionID)
}
@@ -239,6 +267,64 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
}
}
}
// Reorder parts for 'model' role to ensure thinking block is first
if role == "model" {
partsResult := gjson.Get(clientContentJSON, "parts")
if partsResult.IsArray() {
parts := partsResult.Array()
var thinkingParts []gjson.Result
var otherParts []gjson.Result
for _, part := range parts {
if part.Get("thought").Bool() {
thinkingParts = append(thinkingParts, part)
} else {
otherParts = append(otherParts, part)
}
}
if len(thinkingParts) > 0 {
firstPartIsThinking := parts[0].Get("thought").Bool()
if !firstPartIsThinking || len(thinkingParts) > 1 {
var newParts []interface{}
for _, p := range thinkingParts {
newParts = append(newParts, p.Value())
}
for _, p := range otherParts {
newParts = append(newParts, p.Value())
}
clientContentJSON, _ = sjson.Set(clientContentJSON, "parts", newParts)
}
}
}
}
// Check if this assistant message has tool_use without valid thinking
if role == "model" {
partsResult := gjson.Get(clientContentJSON, "parts")
if partsResult.IsArray() {
parts := partsResult.Array()
hasValidThinking := false
hasToolUse := false
for _, part := range parts {
if part.Get("thought").Bool() {
hasValidThinking = true
}
if part.Get("functionCall").Exists() {
hasToolUse = true
}
}
// If this message has tool_use but no valid thinking, mark it
// This will be used to disable thinking mode if needed
if hasToolUse && !hasValidThinking {
lastAssistantHasToolWithoutThinking = true
} else {
lastAssistantHasToolWithoutThinking = false
}
}
}
contentsJSON, _ = sjson.SetRaw(contentsJSON, "-1", clientContentJSON)
hasContents = true
} else if contentsResult.Type == gjson.String {
@@ -333,6 +419,13 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
out, _ = sjson.Set(out, "request.generationConfig.maxOutputTokens", v.Num)
}
// Note: We do NOT drop thinkingConfig here anymore.
// Instead, we:
// 1. Remove unsigned thinking blocks (done during message processing)
// 2. Add skip_thought_signature_validator to tool_use without valid thinking signature
// This approach keeps thinking mode enabled while handling the signature requirements.
_ = lastAssistantHasToolWithoutThinking // Variable is tracked but not used to drop thinkingConfig
outBytes := []byte(out)
outBytes = common.AttachDefaultSafetySettings(outBytes, "request.safetySettings")

View File

@@ -105,6 +105,7 @@ func TestConvertClaudeRequestToAntigravity_ThinkingBlocks(t *testing.T) {
}
func TestConvertClaudeRequestToAntigravity_ThinkingBlockWithoutSignature(t *testing.T) {
// Unsigned thinking blocks should be removed entirely (not converted to text)
inputJSON := []byte(`{
"model": "claude-sonnet-4-5-thinking",
"messages": [
@@ -121,11 +122,18 @@ func TestConvertClaudeRequestToAntigravity_ThinkingBlockWithoutSignature(t *test
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
outputStr := string(output)
// Without signature, should use sentinel value
firstPart := gjson.Get(outputStr, "request.contents.0.parts.0")
if firstPart.Get("thoughtSignature").String() != geminiCLIClaudeThoughtSignature {
t.Errorf("Expected sentinel signature '%s', got '%s'",
geminiCLIClaudeThoughtSignature, firstPart.Get("thoughtSignature").String())
// Without signature, thinking block should be removed (not converted to text)
parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
if len(parts) != 1 {
t.Fatalf("Expected 1 part (thinking removed), got %d", len(parts))
}
// Only text part should remain
if parts[0].Get("thought").Bool() {
t.Error("Thinking block should be removed, not preserved")
}
if parts[0].Get("text").String() != "Answer" {
t.Errorf("Expected text 'Answer', got '%s'", parts[0].Get("text").String())
}
}
@@ -192,10 +200,16 @@ func TestConvertClaudeRequestToAntigravity_ToolUse(t *testing.T) {
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
outputStr := string(output)
// Check function call conversion
funcCall := gjson.Get(outputStr, "request.contents.0.parts.0.functionCall")
// Now we expect only 1 part (tool_use), no dummy thinking block injected
parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
if len(parts) != 1 {
t.Fatalf("Expected 1 part (tool only, no dummy injection), got %d", len(parts))
}
// Check function call conversion at parts[0]
funcCall := parts[0].Get("functionCall")
if !funcCall.Exists() {
t.Error("functionCall should exist")
t.Error("functionCall should exist at parts[0]")
}
if funcCall.Get("name").String() != "get_weather" {
t.Errorf("Expected function name 'get_weather', got '%s'", funcCall.Get("name").String())
@@ -203,6 +217,78 @@ func TestConvertClaudeRequestToAntigravity_ToolUse(t *testing.T) {
if funcCall.Get("id").String() != "call_123" {
t.Errorf("Expected function id 'call_123', got '%s'", funcCall.Get("id").String())
}
// Verify skip_thought_signature_validator is added (bypass for tools without valid thinking)
expectedSig := "skip_thought_signature_validator"
actualSig := parts[0].Get("thoughtSignature").String()
if actualSig != expectedSig {
t.Errorf("Expected thoughtSignature '%s', got '%s'", expectedSig, actualSig)
}
}
func TestConvertClaudeRequestToAntigravity_ToolUse_WithSignature(t *testing.T) {
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
inputJSON := []byte(`{
"model": "claude-sonnet-4-5-thinking",
"messages": [
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "Let me think...", "signature": "` + validSignature + `"},
{
"type": "tool_use",
"id": "call_123",
"name": "get_weather",
"input": "{\"location\": \"Paris\"}"
}
]
}
]
}`)
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
outputStr := string(output)
// Check function call has the signature from the preceding thinking block
part := gjson.Get(outputStr, "request.contents.0.parts.1")
if part.Get("functionCall.name").String() != "get_weather" {
t.Errorf("Expected functionCall, got %s", part.Raw)
}
if part.Get("thoughtSignature").String() != validSignature {
t.Errorf("Expected thoughtSignature '%s' on tool_use, got '%s'", validSignature, part.Get("thoughtSignature").String())
}
}
func TestConvertClaudeRequestToAntigravity_ReorderThinking(t *testing.T) {
// Case: text block followed by thinking block -> should be reordered to thinking first
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
inputJSON := []byte(`{
"model": "claude-sonnet-4-5-thinking",
"messages": [
{
"role": "assistant",
"content": [
{"type": "text", "text": "Here is the plan."},
{"type": "thinking", "thinking": "Planning...", "signature": "` + validSignature + `"}
]
}
]
}`)
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
outputStr := string(output)
// Verify order: Thinking block MUST be first
parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
if len(parts) != 2 {
t.Fatalf("Expected 2 parts, got %d", len(parts))
}
if !parts[0].Get("thought").Bool() {
t.Error("First part should be thinking block after reordering")
}
if parts[1].Get("text").String() != "Here is the plan." {
t.Error("Second part should be text block")
}
}
func TestConvertClaudeRequestToAntigravity_ToolResult(t *testing.T) {
@@ -402,8 +488,8 @@ func TestConvertClaudeRequestToAntigravity_TrailingSignedThinking_Kept(t *testin
}
}
func TestConvertClaudeRequestToAntigravity_MiddleUnsignedThinking_SentinelApplied(t *testing.T) {
// Middle message has unsigned thinking - should use sentinel (existing behavior)
func TestConvertClaudeRequestToAntigravity_MiddleUnsignedThinking_Removed(t *testing.T) {
// Middle message has unsigned thinking - should be removed entirely
inputJSON := []byte(`{
"model": "claude-sonnet-4-5-thinking",
"messages": [
@@ -424,13 +510,18 @@ func TestConvertClaudeRequestToAntigravity_MiddleUnsignedThinking_SentinelApplie
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
outputStr := string(output)
// Middle unsigned thinking should have sentinel applied
thinkingPart := gjson.Get(outputStr, "request.contents.0.parts.0")
if !thinkingPart.Get("thought").Bool() {
t.Error("Middle thinking block should be preserved with sentinel")
// Unsigned thinking should be removed entirely
parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
if len(parts) != 1 {
t.Fatalf("Expected 1 part (thinking removed), got %d", len(parts))
}
if thinkingPart.Get("thoughtSignature").String() != geminiCLIClaudeThoughtSignature {
t.Errorf("Middle unsigned thinking should use sentinel signature, got: %s", thinkingPart.Get("thoughtSignature").String())
// Only text part should remain
if parts[0].Get("thought").Bool() {
t.Error("Thinking block should be removed, not preserved")
}
if parts[0].Get("text").String() != "Answer" {
t.Errorf("Expected text 'Answer', got '%s'", parts[0].Get("text").String())
}
}

View File

@@ -253,13 +253,8 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq
output = output + fmt.Sprintf("data: %s\n\n\n", data)
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
argsRaw := fcArgsResult.Raw
// Convert command → cmd for Bash tools using proper JSON parsing
if fcName == "Bash" || fcName == "bash" || fcName == "bash_20241022" {
argsRaw = convertBashCommandToCmdField(argsRaw)
}
output = output + "event: content_block_delta\n"
data, _ = sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, params.ResponseIndex), "delta.partial_json", argsRaw)
data, _ = sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, params.ResponseIndex), "delta.partial_json", fcArgsResult.Raw)
output = output + fmt.Sprintf("data: %s\n\n\n", data)
}
params.ResponseType = 3
@@ -347,36 +342,6 @@ func resolveStopReason(params *Params) string {
return "end_turn"
}
// convertBashCommandToCmdField converts "command" field to "cmd" field for Bash tools.
// Amp expects "cmd" but Gemini sends "command". This uses proper JSON parsing
// to avoid accidentally replacing "command" that appears in values.
func convertBashCommandToCmdField(argsRaw string) string {
// Only process valid JSON
if !gjson.Valid(argsRaw) {
return argsRaw
}
// Check if "command" key exists and "cmd" doesn't
commandVal := gjson.Get(argsRaw, "command")
cmdVal := gjson.Get(argsRaw, "cmd")
if commandVal.Exists() && !cmdVal.Exists() {
// Set "cmd" to the value of "command", preserve the raw value type
result, err := sjson.SetRaw(argsRaw, "cmd", commandVal.Raw)
if err != nil {
return argsRaw
}
// Delete "command" key
result, err = sjson.Delete(result, "command")
if err != nil {
return argsRaw
}
return result
}
return argsRaw
}
// ConvertAntigravityResponseToClaudeNonStream converts a non-streaming Gemini CLI response to a non-streaming Claude response.
//
// Parameters:
@@ -488,12 +453,7 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or
toolBlock, _ = sjson.Set(toolBlock, "name", name)
if args := functionCall.Get("args"); args.Exists() && args.Raw != "" && gjson.Valid(args.Raw) {
argsRaw := args.Raw
// Convert command → cmd for Bash tools
if name == "Bash" || name == "bash" || name == "bash_20241022" {
argsRaw = convertBashCommandToCmdField(argsRaw)
}
toolBlock, _ = sjson.SetRaw(toolBlock, "input", argsRaw)
toolBlock, _ = sjson.SetRaw(toolBlock, "input", args.Raw)
}
ensureContentArray()

View File

@@ -8,79 +8,6 @@ import (
"github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
)
func TestConvertBashCommandToCmdField(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "basic command to cmd conversion",
input: `{"command": "git diff"}`,
expected: `{"cmd":"git diff"}`,
},
{
name: "already has cmd field - no change",
input: `{"cmd": "git diff"}`,
expected: `{"cmd": "git diff"}`,
},
{
name: "both cmd and command - keep cmd only",
input: `{"command": "git diff", "cmd": "ls"}`,
expected: `{"command": "git diff", "cmd": "ls"}`, // no change when cmd exists
},
{
name: "command with special characters in value",
input: `{"command": "echo \"command\": test"}`,
expected: `{"cmd":"echo \"command\": test"}`,
},
{
name: "command with nested quotes",
input: `{"command": "bash -c 'echo \"hello\"'"}`,
expected: `{"cmd":"bash -c 'echo \"hello\"'"}`,
},
{
name: "command with newlines",
input: `{"command": "echo hello\necho world"}`,
expected: `{"cmd":"echo hello\necho world"}`,
},
{
name: "empty command value",
input: `{"command": ""}`,
expected: `{"cmd":""}`,
},
{
name: "command with other fields - preserves them",
input: `{"command": "git diff", "timeout": 30}`,
expected: `{ "timeout": 30,"cmd":"git diff"}`,
},
{
name: "invalid JSON - returns unchanged",
input: `{invalid json`,
expected: `{invalid json`,
},
{
name: "empty object",
input: `{}`,
expected: `{}`,
},
{
name: "no command field",
input: `{"restart": true}`,
expected: `{"restart": true}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := convertBashCommandToCmdField(tt.input)
if result != tt.expected {
t.Errorf("convertBashCommandToCmdField(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
// ============================================================================
// Signature Caching Tests
// ============================================================================
@@ -354,7 +281,7 @@ func TestDeriveSessionIDFromRequest(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := deriveSessionIDFromRequest(tt.input)
result := deriveSessionID(tt.input)
if tt.wantEmpty && result != "" {
t.Errorf("Expected empty session ID, got '%s'", result)
}
@@ -368,8 +295,8 @@ func TestDeriveSessionIDFromRequest(t *testing.T) {
func TestDeriveSessionIDFromRequest_Deterministic(t *testing.T) {
input := []byte(`{"messages": [{"role": "user", "content": "Same message"}]}`)
id1 := deriveSessionIDFromRequest(input)
id2 := deriveSessionIDFromRequest(input)
id1 := deriveSessionID(input)
id2 := deriveSessionID(input)
if id1 != id2 {
t.Errorf("Session ID should be deterministic: '%s' != '%s'", id1, id2)
@@ -380,8 +307,8 @@ func TestDeriveSessionIDFromRequest_DifferentMessages(t *testing.T) {
input1 := []byte(`{"messages": [{"role": "user", "content": "Message A"}]}`)
input2 := []byte(`{"messages": [{"role": "user", "content": "Message B"}]}`)
id1 := deriveSessionIDFromRequest(input1)
id2 := deriveSessionIDFromRequest(input2)
id1 := deriveSessionID(input1)
id2 := deriveSessionID(input2)
if id1 == id2 {
t.Error("Different messages should produce different session IDs")