diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 6be5bf46..ddfcfc3f 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -7,6 +7,8 @@ import ( "bufio" "bytes" "context" + "crypto/sha256" + "encoding/binary" "encoding/json" "fmt" "io" @@ -70,6 +72,10 @@ func (e *AntigravityExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Au // Execute performs a non-streaming request to the Antigravity API. func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { + if strings.Contains(req.Model, "claude") { + return e.executeClaudeNonStream(ctx, auth, req, opts) + } + token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth) if errToken != nil { return resp, errToken @@ -993,23 +999,21 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau payload = geminiToAntigravity(modelName, payload, projectID) payload, _ = sjson.SetBytes(payload, "model", alias2ModelName(modelName)) - // Apply schema processing for all Antigravity models (Claude, Gemini, GPT-OSS) - // Antigravity uses unified Gemini-style format with same schema restrictions - strJSON := string(payload) + if strings.Contains(modelName, "claude") { + strJSON := string(payload) + paths := make([]string, 0) + util.Walk(gjson.ParseBytes(payload), "", "parametersJsonSchema", &paths) + for _, p := range paths { + strJSON, _ = util.RenameKey(strJSON, p, p[:len(p)-len("parametersJsonSchema")]+"parameters") + } - // Rename parametersJsonSchema -> parameters (used by Claude translator) - paths := make([]string, 0) - util.Walk(gjson.ParseBytes(payload), "", "parametersJsonSchema", &paths) - for _, p := range paths { - strJSON, _ = util.RenameKey(strJSON, p, p[:len(p)-len("parametersJsonSchema")]+"parameters") + // Use the centralized schema cleaner to handle unsupported keywords, + // const->enum conversion, and flattening of types/anyOf. + strJSON = util.CleanJSONSchemaForAntigravity(strJSON) + + payload = []byte(strJSON) } - // Use the centralized schema cleaner to handle unsupported keywords, - // const->enum conversion, and flattening of types/anyOf. - strJSON = util.CleanJSONSchemaForAntigravity(strJSON) - - payload = []byte(strJSON) - httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), bytes.NewReader(payload)) if errReq != nil { return nil, errReq @@ -1187,7 +1191,7 @@ func geminiToAntigravity(modelName string, payload []byte, projectID string) []b template, _ = sjson.Set(template, "project", generateProjectID()) } template, _ = sjson.Set(template, "requestId", generateRequestID()) - template, _ = sjson.Set(template, "request.sessionId", generateSessionID()) + template, _ = sjson.Set(template, "request.sessionId", generateStableSessionID(payload)) template, _ = sjson.Delete(template, "request.safetySettings") template, _ = sjson.Set(template, "request.toolConfig.functionCallingConfig.mode", "VALIDATED") @@ -1227,6 +1231,23 @@ func generateSessionID() string { return "-" + strconv.FormatInt(n, 10) } +func generateStableSessionID(payload []byte) string { + contents := gjson.GetBytes(payload, "request.contents") + if contents.IsArray() { + for _, content := range contents.Array() { + if content.Get("role").String() == "user" { + text := content.Get("parts.0.text").String() + if text != "" { + h := sha256.Sum256([]byte(text)) + n := int64(binary.BigEndian.Uint64(h[:8])) & 0x7FFFFFFFFFFFFFFF + return "-" + strconv.FormatInt(n, 10) + } + } + } + } + return generateSessionID() +} + func generateProjectID() string { adjectives := []string{"useful", "bright", "swift", "calm", "bold"} nouns := []string{"fuze", "wave", "spark", "flow", "core"} diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index fdfdf469..d5845e63 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -19,8 +19,6 @@ import ( "github.com/tidwall/sjson" ) -const geminiCLIClaudeThoughtSignature = "skip_thought_signature_validator" - // deriveSessionID generates a stable session ID from the request. // Uses the hash of the first user message to identify the conversation. func deriveSessionID(rawJSON []byte) string { @@ -93,6 +91,12 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ // contents contentsJSON := "[]" hasContents := false + + // Track if we need to disable thinking (LiteLLM approach) + // If the last assistant message with tool_use has no valid thinking block before it, + // we need to disable thinkingConfig to avoid "Expected thinking but found tool_use" error + lastAssistantHasToolWithoutThinking := false + messagesResult := gjson.GetBytes(rawJSON, "messages") if messagesResult.IsArray() { messageResults := messagesResult.Array() @@ -114,6 +118,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ if contentsResult.IsArray() { contentResults := contentsResult.Array() numContents := len(contentResults) + var currentMessageThinkingSignature string for j := 0; j < numContents; j++ { contentResult := contentResults[j] contentTypeResult := contentResult.Get("type") @@ -121,36 +126,46 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ // Use GetThinkingText to handle wrapped thinking objects thinkingText := util.GetThinkingText(contentResult) signatureResult := contentResult.Get("signature") - signature := "" - if signatureResult.Exists() && signatureResult.String() != "" { - signature = signatureResult.String() - } + clientSignature := "" + if signatureResult.Exists() && signatureResult.String() != "" { + clientSignature = signatureResult.String() + } - // Try to restore signature from cache for unsigned thinking blocks - if !cache.HasValidSignature(signature) && sessionID != "" && thinkingText != "" { - if cachedSig := cache.GetCachedSignature(sessionID, thinkingText); cachedSig != "" { - signature = cachedSig - log.Debugf("Restored cached signature for thinking block") - } + // Always try cached signature first (more reliable than client-provided) + // Client may send stale or invalid signatures from different sessions + signature := "" + if sessionID != "" && thinkingText != "" { + if cachedSig := cache.GetCachedSignature(sessionID, thinkingText); cachedSig != "" { + signature = cachedSig + log.Debugf("Using cached signature for thinking block") } + } + + // Fallback to client signature only if cache miss and client signature is valid + if signature == "" && cache.HasValidSignature(clientSignature) { + signature = clientSignature + log.Debugf("Using client-provided signature for thinking block") + } + + // Store for subsequent tool_use in the same message + if cache.HasValidSignature(signature) { + currentMessageThinkingSignature = signature + } + // Skip trailing unsigned thinking blocks on last assistant message - isLastMessage := (i == numMessages-1) - isLastContent := (j == numContents-1) - isAssistant := (originalRole == "assistant") isUnsigned := !cache.HasValidSignature(signature) - if isLastMessage && isLastContent && isAssistant && isUnsigned { - // Skip this trailing unsigned thinking block + // If unsigned, skip entirely (don't convert to text) + // Claude requires assistant messages to start with thinking blocks when thinking is enabled + // Converting to text would break this requirement + if isUnsigned { + // TypeScript plugin approach: drop unsigned thinking blocks entirely + log.Debugf("Dropping unsigned thinking block (no valid signature)") continue } - // Apply sentinel for unsigned thinking blocks that are not trailing - // (includes empty string and short/invalid signatures < 50 chars) - if isUnsigned { - signature = geminiCLIClaudeThoughtSignature - } - + // Valid signature, send as thought block partJSON := `{}` partJSON, _ = sjson.Set(partJSON, "thought", true) if thinkingText != "" { @@ -168,6 +183,10 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ } clientContentJSON, _ = sjson.SetRaw(clientContentJSON, "parts.-1", partJSON) } else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_use" { + // NOTE: Do NOT inject dummy thinking blocks here. + // Antigravity API validates signatures, so dummy values are rejected. + // The TypeScript plugin removes unsigned thinking blocks instead of injecting dummies. + functionName := contentResult.Get("name").String() functionArgs := contentResult.Get("input").String() functionID := contentResult.Get("id").String() @@ -175,9 +194,18 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ argsResult := gjson.Parse(functionArgs) if argsResult.IsObject() { partJSON := `{}` - if !strings.Contains(modelName, "claude") { - partJSON, _ = sjson.Set(partJSON, "thoughtSignature", geminiCLIClaudeThoughtSignature) + + // Use skip_thought_signature_validator for tool calls without valid thinking signature + // This is the approach used in opencode-google-antigravity-auth for Gemini + // and also works for Claude through Antigravity API + const skipSentinel = "skip_thought_signature_validator" + if cache.HasValidSignature(currentMessageThinkingSignature) { + partJSON, _ = sjson.Set(partJSON, "thoughtSignature", currentMessageThinkingSignature) + } else { + // No valid signature - use skip sentinel to bypass validation + partJSON, _ = sjson.Set(partJSON, "thoughtSignature", skipSentinel) } + if functionID != "" { partJSON, _ = sjson.Set(partJSON, "functionCall.id", functionID) } @@ -239,6 +267,64 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ } } } + + // Reorder parts for 'model' role to ensure thinking block is first + if role == "model" { + partsResult := gjson.Get(clientContentJSON, "parts") + if partsResult.IsArray() { + parts := partsResult.Array() + var thinkingParts []gjson.Result + var otherParts []gjson.Result + for _, part := range parts { + if part.Get("thought").Bool() { + thinkingParts = append(thinkingParts, part) + } else { + otherParts = append(otherParts, part) + } + } + if len(thinkingParts) > 0 { + firstPartIsThinking := parts[0].Get("thought").Bool() + if !firstPartIsThinking || len(thinkingParts) > 1 { + var newParts []interface{} + for _, p := range thinkingParts { + newParts = append(newParts, p.Value()) + } + for _, p := range otherParts { + newParts = append(newParts, p.Value()) + } + clientContentJSON, _ = sjson.Set(clientContentJSON, "parts", newParts) + } + } + } + } + + // Check if this assistant message has tool_use without valid thinking + if role == "model" { + partsResult := gjson.Get(clientContentJSON, "parts") + if partsResult.IsArray() { + parts := partsResult.Array() + hasValidThinking := false + hasToolUse := false + + for _, part := range parts { + if part.Get("thought").Bool() { + hasValidThinking = true + } + if part.Get("functionCall").Exists() { + hasToolUse = true + } + } + + // If this message has tool_use but no valid thinking, mark it + // This will be used to disable thinking mode if needed + if hasToolUse && !hasValidThinking { + lastAssistantHasToolWithoutThinking = true + } else { + lastAssistantHasToolWithoutThinking = false + } + } + } + contentsJSON, _ = sjson.SetRaw(contentsJSON, "-1", clientContentJSON) hasContents = true } else if contentsResult.Type == gjson.String { @@ -333,6 +419,13 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ out, _ = sjson.Set(out, "request.generationConfig.maxOutputTokens", v.Num) } + // Note: We do NOT drop thinkingConfig here anymore. + // Instead, we: + // 1. Remove unsigned thinking blocks (done during message processing) + // 2. Add skip_thought_signature_validator to tool_use without valid thinking signature + // This approach keeps thinking mode enabled while handling the signature requirements. + _ = lastAssistantHasToolWithoutThinking // Variable is tracked but not used to drop thinkingConfig + outBytes := []byte(out) outBytes = common.AttachDefaultSafetySettings(outBytes, "request.safetySettings") diff --git a/internal/translator/antigravity/claude/antigravity_claude_request_test.go b/internal/translator/antigravity/claude/antigravity_claude_request_test.go index 796ce0d3..1d727c94 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request_test.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request_test.go @@ -105,6 +105,7 @@ func TestConvertClaudeRequestToAntigravity_ThinkingBlocks(t *testing.T) { } func TestConvertClaudeRequestToAntigravity_ThinkingBlockWithoutSignature(t *testing.T) { + // Unsigned thinking blocks should be removed entirely (not converted to text) inputJSON := []byte(`{ "model": "claude-sonnet-4-5-thinking", "messages": [ @@ -121,11 +122,18 @@ func TestConvertClaudeRequestToAntigravity_ThinkingBlockWithoutSignature(t *test output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false) outputStr := string(output) - // Without signature, should use sentinel value - firstPart := gjson.Get(outputStr, "request.contents.0.parts.0") - if firstPart.Get("thoughtSignature").String() != geminiCLIClaudeThoughtSignature { - t.Errorf("Expected sentinel signature '%s', got '%s'", - geminiCLIClaudeThoughtSignature, firstPart.Get("thoughtSignature").String()) + // Without signature, thinking block should be removed (not converted to text) + parts := gjson.Get(outputStr, "request.contents.0.parts").Array() + if len(parts) != 1 { + t.Fatalf("Expected 1 part (thinking removed), got %d", len(parts)) + } + + // Only text part should remain + if parts[0].Get("thought").Bool() { + t.Error("Thinking block should be removed, not preserved") + } + if parts[0].Get("text").String() != "Answer" { + t.Errorf("Expected text 'Answer', got '%s'", parts[0].Get("text").String()) } } @@ -192,10 +200,16 @@ func TestConvertClaudeRequestToAntigravity_ToolUse(t *testing.T) { output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false) outputStr := string(output) - // Check function call conversion - funcCall := gjson.Get(outputStr, "request.contents.0.parts.0.functionCall") + // Now we expect only 1 part (tool_use), no dummy thinking block injected + parts := gjson.Get(outputStr, "request.contents.0.parts").Array() + if len(parts) != 1 { + t.Fatalf("Expected 1 part (tool only, no dummy injection), got %d", len(parts)) + } + + // Check function call conversion at parts[0] + funcCall := parts[0].Get("functionCall") if !funcCall.Exists() { - t.Error("functionCall should exist") + t.Error("functionCall should exist at parts[0]") } if funcCall.Get("name").String() != "get_weather" { t.Errorf("Expected function name 'get_weather', got '%s'", funcCall.Get("name").String()) @@ -203,6 +217,78 @@ func TestConvertClaudeRequestToAntigravity_ToolUse(t *testing.T) { if funcCall.Get("id").String() != "call_123" { t.Errorf("Expected function id 'call_123', got '%s'", funcCall.Get("id").String()) } + // Verify skip_thought_signature_validator is added (bypass for tools without valid thinking) + expectedSig := "skip_thought_signature_validator" + actualSig := parts[0].Get("thoughtSignature").String() + if actualSig != expectedSig { + t.Errorf("Expected thoughtSignature '%s', got '%s'", expectedSig, actualSig) + } +} + +func TestConvertClaudeRequestToAntigravity_ToolUse_WithSignature(t *testing.T) { + validSignature := "abc123validSignature1234567890123456789012345678901234567890" + inputJSON := []byte(`{ + "model": "claude-sonnet-4-5-thinking", + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Let me think...", "signature": "` + validSignature + `"}, + { + "type": "tool_use", + "id": "call_123", + "name": "get_weather", + "input": "{\"location\": \"Paris\"}" + } + ] + } + ] + }`) + + output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false) + outputStr := string(output) + + // Check function call has the signature from the preceding thinking block + part := gjson.Get(outputStr, "request.contents.0.parts.1") + if part.Get("functionCall.name").String() != "get_weather" { + t.Errorf("Expected functionCall, got %s", part.Raw) + } + if part.Get("thoughtSignature").String() != validSignature { + t.Errorf("Expected thoughtSignature '%s' on tool_use, got '%s'", validSignature, part.Get("thoughtSignature").String()) + } +} + +func TestConvertClaudeRequestToAntigravity_ReorderThinking(t *testing.T) { + // Case: text block followed by thinking block -> should be reordered to thinking first + validSignature := "abc123validSignature1234567890123456789012345678901234567890" + inputJSON := []byte(`{ + "model": "claude-sonnet-4-5-thinking", + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "text", "text": "Here is the plan."}, + {"type": "thinking", "thinking": "Planning...", "signature": "` + validSignature + `"} + ] + } + ] + }`) + + output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false) + outputStr := string(output) + + // Verify order: Thinking block MUST be first + parts := gjson.Get(outputStr, "request.contents.0.parts").Array() + if len(parts) != 2 { + t.Fatalf("Expected 2 parts, got %d", len(parts)) + } + + if !parts[0].Get("thought").Bool() { + t.Error("First part should be thinking block after reordering") + } + if parts[1].Get("text").String() != "Here is the plan." { + t.Error("Second part should be text block") + } } func TestConvertClaudeRequestToAntigravity_ToolResult(t *testing.T) { @@ -402,8 +488,8 @@ func TestConvertClaudeRequestToAntigravity_TrailingSignedThinking_Kept(t *testin } } -func TestConvertClaudeRequestToAntigravity_MiddleUnsignedThinking_SentinelApplied(t *testing.T) { - // Middle message has unsigned thinking - should use sentinel (existing behavior) +func TestConvertClaudeRequestToAntigravity_MiddleUnsignedThinking_Removed(t *testing.T) { + // Middle message has unsigned thinking - should be removed entirely inputJSON := []byte(`{ "model": "claude-sonnet-4-5-thinking", "messages": [ @@ -424,13 +510,18 @@ func TestConvertClaudeRequestToAntigravity_MiddleUnsignedThinking_SentinelApplie output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false) outputStr := string(output) - // Middle unsigned thinking should have sentinel applied - thinkingPart := gjson.Get(outputStr, "request.contents.0.parts.0") - if !thinkingPart.Get("thought").Bool() { - t.Error("Middle thinking block should be preserved with sentinel") + // Unsigned thinking should be removed entirely + parts := gjson.Get(outputStr, "request.contents.0.parts").Array() + if len(parts) != 1 { + t.Fatalf("Expected 1 part (thinking removed), got %d", len(parts)) } - if thinkingPart.Get("thoughtSignature").String() != geminiCLIClaudeThoughtSignature { - t.Errorf("Middle unsigned thinking should use sentinel signature, got: %s", thinkingPart.Get("thoughtSignature").String()) + + // Only text part should remain + if parts[0].Get("thought").Bool() { + t.Error("Thinking block should be removed, not preserved") + } + if parts[0].Get("text").String() != "Answer" { + t.Errorf("Expected text 'Answer', got '%s'", parts[0].Get("text").String()) } } diff --git a/internal/translator/antigravity/claude/antigravity_claude_response.go b/internal/translator/antigravity/claude/antigravity_claude_response.go index 8f47b9bf..ddda5ddb 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_response.go +++ b/internal/translator/antigravity/claude/antigravity_claude_response.go @@ -253,13 +253,8 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq output = output + fmt.Sprintf("data: %s\n\n\n", data) if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() { - argsRaw := fcArgsResult.Raw - // Convert command → cmd for Bash tools using proper JSON parsing - if fcName == "Bash" || fcName == "bash" || fcName == "bash_20241022" { - argsRaw = convertBashCommandToCmdField(argsRaw) - } output = output + "event: content_block_delta\n" - data, _ = sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, params.ResponseIndex), "delta.partial_json", argsRaw) + data, _ = sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, params.ResponseIndex), "delta.partial_json", fcArgsResult.Raw) output = output + fmt.Sprintf("data: %s\n\n\n", data) } params.ResponseType = 3 @@ -347,36 +342,6 @@ func resolveStopReason(params *Params) string { return "end_turn" } -// convertBashCommandToCmdField converts "command" field to "cmd" field for Bash tools. -// Amp expects "cmd" but Gemini sends "command". This uses proper JSON parsing -// to avoid accidentally replacing "command" that appears in values. -func convertBashCommandToCmdField(argsRaw string) string { - // Only process valid JSON - if !gjson.Valid(argsRaw) { - return argsRaw - } - - // Check if "command" key exists and "cmd" doesn't - commandVal := gjson.Get(argsRaw, "command") - cmdVal := gjson.Get(argsRaw, "cmd") - - if commandVal.Exists() && !cmdVal.Exists() { - // Set "cmd" to the value of "command", preserve the raw value type - result, err := sjson.SetRaw(argsRaw, "cmd", commandVal.Raw) - if err != nil { - return argsRaw - } - // Delete "command" key - result, err = sjson.Delete(result, "command") - if err != nil { - return argsRaw - } - return result - } - - return argsRaw -} - // ConvertAntigravityResponseToClaudeNonStream converts a non-streaming Gemini CLI response to a non-streaming Claude response. // // Parameters: @@ -488,12 +453,7 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or toolBlock, _ = sjson.Set(toolBlock, "name", name) if args := functionCall.Get("args"); args.Exists() && args.Raw != "" && gjson.Valid(args.Raw) { - argsRaw := args.Raw - // Convert command → cmd for Bash tools - if name == "Bash" || name == "bash" || name == "bash_20241022" { - argsRaw = convertBashCommandToCmdField(argsRaw) - } - toolBlock, _ = sjson.SetRaw(toolBlock, "input", argsRaw) + toolBlock, _ = sjson.SetRaw(toolBlock, "input", args.Raw) } ensureContentArray() diff --git a/internal/translator/antigravity/claude/antigravity_claude_response_test.go b/internal/translator/antigravity/claude/antigravity_claude_response_test.go index 4c2f31c1..afc3d937 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_response_test.go +++ b/internal/translator/antigravity/claude/antigravity_claude_response_test.go @@ -8,79 +8,6 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/cache" ) -func TestConvertBashCommandToCmdField(t *testing.T) { - tests := []struct { - name string - input string - expected string - }{ - { - name: "basic command to cmd conversion", - input: `{"command": "git diff"}`, - expected: `{"cmd":"git diff"}`, - }, - { - name: "already has cmd field - no change", - input: `{"cmd": "git diff"}`, - expected: `{"cmd": "git diff"}`, - }, - { - name: "both cmd and command - keep cmd only", - input: `{"command": "git diff", "cmd": "ls"}`, - expected: `{"command": "git diff", "cmd": "ls"}`, // no change when cmd exists - }, - { - name: "command with special characters in value", - input: `{"command": "echo \"command\": test"}`, - expected: `{"cmd":"echo \"command\": test"}`, - }, - { - name: "command with nested quotes", - input: `{"command": "bash -c 'echo \"hello\"'"}`, - expected: `{"cmd":"bash -c 'echo \"hello\"'"}`, - }, - { - name: "command with newlines", - input: `{"command": "echo hello\necho world"}`, - expected: `{"cmd":"echo hello\necho world"}`, - }, - { - name: "empty command value", - input: `{"command": ""}`, - expected: `{"cmd":""}`, - }, - { - name: "command with other fields - preserves them", - input: `{"command": "git diff", "timeout": 30}`, - expected: `{ "timeout": 30,"cmd":"git diff"}`, - }, - { - name: "invalid JSON - returns unchanged", - input: `{invalid json`, - expected: `{invalid json`, - }, - { - name: "empty object", - input: `{}`, - expected: `{}`, - }, - { - name: "no command field", - input: `{"restart": true}`, - expected: `{"restart": true}`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := convertBashCommandToCmdField(tt.input) - if result != tt.expected { - t.Errorf("convertBashCommandToCmdField(%q) = %q, want %q", tt.input, result, tt.expected) - } - }) - } -} - // ============================================================================ // Signature Caching Tests // ============================================================================ @@ -354,7 +281,7 @@ func TestDeriveSessionIDFromRequest(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := deriveSessionIDFromRequest(tt.input) + result := deriveSessionID(tt.input) if tt.wantEmpty && result != "" { t.Errorf("Expected empty session ID, got '%s'", result) } @@ -368,8 +295,8 @@ func TestDeriveSessionIDFromRequest(t *testing.T) { func TestDeriveSessionIDFromRequest_Deterministic(t *testing.T) { input := []byte(`{"messages": [{"role": "user", "content": "Same message"}]}`) - id1 := deriveSessionIDFromRequest(input) - id2 := deriveSessionIDFromRequest(input) + id1 := deriveSessionID(input) + id2 := deriveSessionID(input) if id1 != id2 { t.Errorf("Session ID should be deterministic: '%s' != '%s'", id1, id2) @@ -380,8 +307,8 @@ func TestDeriveSessionIDFromRequest_DifferentMessages(t *testing.T) { input1 := []byte(`{"messages": [{"role": "user", "content": "Message A"}]}`) input2 := []byte(`{"messages": [{"role": "user", "content": "Message B"}]}`) - id1 := deriveSessionIDFromRequest(input1) - id2 := deriveSessionIDFromRequest(input2) + id1 := deriveSessionID(input1) + id2 := deriveSessionID(input2) if id1 == id2 { t.Error("Different messages should produce different session IDs") diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_request.go b/internal/translator/antigravity/gemini/antigravity_gemini_request.go index e694b790..394cc05b 100644 --- a/internal/translator/antigravity/gemini/antigravity_gemini_request.go +++ b/internal/translator/antigravity/gemini/antigravity_gemini_request.go @@ -98,16 +98,34 @@ func ConvertGeminiRequestToAntigravity(_ string, inputRawJSON []byte, _ bool) [] } } - gjson.GetBytes(rawJSON, "request.contents").ForEach(func(key, content gjson.Result) bool { + // Gemini-specific handling: add skip_thought_signature_validator to functionCall parts + // and remove thinking blocks entirely (Gemini doesn't need to preserve them) + const skipSentinel = "skip_thought_signature_validator" + + gjson.GetBytes(rawJSON, "request.contents").ForEach(func(contentIdx, content gjson.Result) bool { if content.Get("role").String() == "model" { - content.Get("parts").ForEach(func(partKey, part gjson.Result) bool { + // First pass: collect indices of thinking parts to remove + var thinkingIndicesToRemove []int64 + content.Get("parts").ForEach(func(partIdx, part gjson.Result) bool { + // Mark thinking blocks for removal + if part.Get("thought").Bool() { + thinkingIndicesToRemove = append(thinkingIndicesToRemove, partIdx.Int()) + } + // Add skip sentinel to functionCall parts if part.Get("functionCall").Exists() { - rawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf("request.contents.%d.parts.%d.thoughtSignature", key.Int(), partKey.Int()), "skip_thought_signature_validator") - } else if part.Get("thoughtSignature").Exists() { - rawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf("request.contents.%d.parts.%d.thoughtSignature", key.Int(), partKey.Int()), "skip_thought_signature_validator") + existingSig := part.Get("thoughtSignature").String() + if existingSig == "" || len(existingSig) < 50 { + rawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf("request.contents.%d.parts.%d.thoughtSignature", contentIdx.Int(), partIdx.Int()), skipSentinel) + } } return true }) + + // Remove thinking blocks in reverse order to preserve indices + for i := len(thinkingIndicesToRemove) - 1; i >= 0; i-- { + idx := thinkingIndicesToRemove[i] + rawJSON, _ = sjson.DeleteBytes(rawJSON, fmt.Sprintf("request.contents.%d.parts.%d", contentIdx.Int(), idx)) + } } return true }) diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go b/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go new file mode 100644 index 00000000..58cffd69 --- /dev/null +++ b/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go @@ -0,0 +1,129 @@ +package gemini + +import ( + "fmt" + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertGeminiRequestToAntigravity_PreserveValidSignature(t *testing.T) { + // Valid signature on functionCall should be preserved + validSignature := "abc123validSignature1234567890123456789012345678901234567890" + inputJSON := []byte(fmt.Sprintf(`{ + "model": "gemini-3-pro-preview", + "contents": [ + { + "role": "model", + "parts": [ + {"functionCall": {"name": "test_tool", "args": {}}, "thoughtSignature": "%s"} + ] + } + ] + }`, validSignature)) + + output := ConvertGeminiRequestToAntigravity("gemini-3-pro-preview", inputJSON, false) + outputStr := string(output) + + // Check that valid thoughtSignature is preserved + parts := gjson.Get(outputStr, "request.contents.0.parts").Array() + if len(parts) != 1 { + t.Fatalf("Expected 1 part, got %d", len(parts)) + } + + sig := parts[0].Get("thoughtSignature").String() + if sig != validSignature { + t.Errorf("Expected thoughtSignature '%s', got '%s'", validSignature, sig) + } +} + +func TestConvertGeminiRequestToAntigravity_AddSkipSentinelToFunctionCall(t *testing.T) { + // functionCall without signature should get skip_thought_signature_validator + inputJSON := []byte(`{ + "model": "gemini-3-pro-preview", + "contents": [ + { + "role": "model", + "parts": [ + {"functionCall": {"name": "test_tool", "args": {}}} + ] + } + ] + }`) + + output := ConvertGeminiRequestToAntigravity("gemini-3-pro-preview", inputJSON, false) + outputStr := string(output) + + // Check that skip_thought_signature_validator is added to functionCall + sig := gjson.Get(outputStr, "request.contents.0.parts.0.thoughtSignature").String() + expectedSig := "skip_thought_signature_validator" + if sig != expectedSig { + t.Errorf("Expected skip sentinel '%s', got '%s'", expectedSig, sig) + } +} + +func TestConvertGeminiRequestToAntigravity_RemoveThinkingBlocks(t *testing.T) { + // Thinking blocks should be removed entirely for Gemini + validSignature := "abc123validSignature1234567890123456789012345678901234567890" + inputJSON := []byte(fmt.Sprintf(`{ + "model": "gemini-3-pro-preview", + "contents": [ + { + "role": "model", + "parts": [ + {"thought": true, "text": "Thinking...", "thoughtSignature": "%s"}, + {"text": "Here is my response"} + ] + } + ] + }`, validSignature)) + + output := ConvertGeminiRequestToAntigravity("gemini-3-pro-preview", inputJSON, false) + outputStr := string(output) + + // Check that thinking block is removed + parts := gjson.Get(outputStr, "request.contents.0.parts").Array() + if len(parts) != 1 { + t.Fatalf("Expected 1 part (thinking removed), got %d", len(parts)) + } + + // Only text part should remain + if parts[0].Get("thought").Bool() { + t.Error("Thinking block should be removed for Gemini") + } + if parts[0].Get("text").String() != "Here is my response" { + t.Errorf("Expected text 'Here is my response', got '%s'", parts[0].Get("text").String()) + } +} + +func TestConvertGeminiRequestToAntigravity_ParallelFunctionCalls(t *testing.T) { + // Multiple functionCalls should all get skip_thought_signature_validator + inputJSON := []byte(`{ + "model": "gemini-3-pro-preview", + "contents": [ + { + "role": "model", + "parts": [ + {"functionCall": {"name": "tool_one", "args": {"a": "1"}}}, + {"functionCall": {"name": "tool_two", "args": {"b": "2"}}} + ] + } + ] + }`) + + output := ConvertGeminiRequestToAntigravity("gemini-3-pro-preview", inputJSON, false) + outputStr := string(output) + + parts := gjson.Get(outputStr, "request.contents.0.parts").Array() + if len(parts) != 2 { + t.Fatalf("Expected 2 parts, got %d", len(parts)) + } + + expectedSig := "skip_thought_signature_validator" + for i, part := range parts { + sig := part.Get("thoughtSignature").String() + if sig != expectedSig { + t.Errorf("Part %d: Expected '%s', got '%s'", i, expectedSig, sig) + } + } +}