diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index 0136c24a..e3ca6bae 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -84,18 +84,13 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ contentResult := contentResults[j] contentTypeResult := contentResult.Get("type") if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "thinking" { - // Claude "thinking" blocks are internal-only. They also require a valid provider signature - // when replayed as conversation history. Since we cannot mint signatures, only forward - // thinking blocks when the client provides a non-empty signature; otherwise, drop them. + prompt := contentResult.Get("thinking").String() signatureResult := contentResult.Get("signature") - if signatureResult.Type == gjson.String && signatureResult.String() != "" { - prompt := contentResult.Get("thinking").String() - clientContent.Parts = append(clientContent.Parts, client.Part{ - Text: prompt, - Thought: true, - ThoughtSignature: signatureResult.String(), - }) + signature := geminiCLIClaudeThoughtSignature + if signatureResult.Exists() { + signature = signatureResult.String() } + clientContent.Parts = append(clientContent.Parts, client.Part{Text: prompt, Thought: true, ThoughtSignature: signature}) } else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "text" { prompt := contentResult.Get("text").String() clientContent.Parts = append(clientContent.Parts, client.Part{Text: prompt}) @@ -147,9 +142,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ } } } - if len(clientContent.Parts) > 0 { - contents = append(contents, clientContent) - } + contents = append(contents, clientContent) } else if contentsResult.Type == gjson.String { prompt := contentsResult.String() contents = append(contents, client.Content{Role: role, Parts: []client.Part{{Text: prompt}}}) diff --git a/internal/translator/antigravity/claude/antigravity_claude_response.go b/internal/translator/antigravity/claude/antigravity_claude_response.go index 61489723..28785a8f 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_response.go +++ b/internal/translator/antigravity/claude/antigravity_claude_response.go @@ -114,54 +114,44 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq // Extract the different types of content from each part partTextResult := partResult.Get("text") functionCallResult := partResult.Get("functionCall") - thoughtSignatureResult := partResult.Get("thoughtSignature") - if !thoughtSignatureResult.Exists() { - thoughtSignatureResult = partResult.Get("thought_signature") - } - hasThoughtSignature := thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != "" - isThought := partResult.Get("thought").Bool() - - // Some Antigravity/Vertex Claude streams emit the thought signature as a standalone part - // (no text payload). Claude requires this signature to be replayed verbatim on subsequent turns. - if isThought && hasThoughtSignature && !partTextResult.Exists() && !functionCallResult.Exists() { - if params.ResponseType == 2 { - 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", thoughtSignatureResult.String()) - output = output + fmt.Sprintf("data: %s\n\n\n", data) - params.HasContent = true - } - continue - } // Handle text content (both regular content and thinking) if partTextResult.Exists() { // Process thinking content (internal reasoning) - if isThought { - // Ensure we have an open thinking block to attach thinking/signature deltas to. - if params.ResponseType != 2 { + if partResult.Get("thought").Bool() { + if thoughtSignature := partResult.Get("thoughtSignature"); thoughtSignature.Exists() && thoughtSignature.String() != "" { + output = output + "event: content_block_delta\n" + data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, params.ResponseIndex), "delta.signature", thoughtSignature.String()) + output = output + fmt.Sprintf("data: %s\n\n\n", data) + params.HasContent = true + } else if params.ResponseType == 2 { // Continue existing thinking block if already in thinking state + output = output + "event: content_block_delta\n" + data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, params.ResponseIndex), "delta.thinking", partTextResult.String()) + output = output + fmt.Sprintf("data: %s\n\n\n", data) + params.HasContent = true + } else { + // Transition from another state to thinking + // First, close any existing content block if params.ResponseType != 0 { + if params.ResponseType == 2 { + // output = output + "event: content_block_delta\n" + // output = output + fmt.Sprintf(`data: {"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":null}}`, params.ResponseIndex) + // output = output + "\n\n\n" + } output = output + "event: content_block_stop\n" output = output + fmt.Sprintf(`data: {"type":"content_block_stop","index":%d}`, params.ResponseIndex) output = output + "\n\n\n" params.ResponseIndex++ } + + // Start a new thinking content block output = output + "event: content_block_start\n" output = output + fmt.Sprintf(`data: {"type":"content_block_start","index":%d,"content_block":{"type":"thinking","thinking":""}}`, params.ResponseIndex) output = output + "\n\n\n" - params.ResponseType = 2 - } - - if partTextResult.String() != "" { output = output + "event: content_block_delta\n" data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, params.ResponseIndex), "delta.thinking", partTextResult.String()) output = output + fmt.Sprintf("data: %s\n\n\n", data) - params.HasContent = true - } - - if hasThoughtSignature { - 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", thoughtSignatureResult.String()) - output = output + fmt.Sprintf("data: %s\n\n\n", data) + params.ResponseType = 2 // Set state to thinking params.HasContent = true } } else { @@ -378,7 +368,6 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or var contentBlocks []interface{} textBuilder := strings.Builder{} thinkingBuilder := strings.Builder{} - thinkingSignature := "" toolIDCounter := 0 hasToolCall := false @@ -397,37 +386,19 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or if thinkingBuilder.Len() == 0 { return } - block := map[string]interface{}{ + contentBlocks = append(contentBlocks, map[string]interface{}{ "type": "thinking", "thinking": thinkingBuilder.String(), - } - if thinkingSignature != "" { - block["signature"] = thinkingSignature - } - contentBlocks = append(contentBlocks, block) + }) thinkingBuilder.Reset() - thinkingSignature = "" } if parts.IsArray() { for _, part := range parts.Array() { - thoughtSignatureResult := part.Get("thoughtSignature") - if !thoughtSignatureResult.Exists() { - thoughtSignatureResult = part.Get("thought_signature") - } - if part.Get("thought").Bool() && thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != "" && (!part.Get("text").Exists() || part.Get("text").String() == "") { - // Signature-only thought part (no text payload). - thinkingSignature = thoughtSignatureResult.String() - continue - } - if text := part.Get("text"); text.Exists() && text.String() != "" { if part.Get("thought").Bool() { flushText() thinkingBuilder.WriteString(text.String()) - if thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != "" { - thinkingSignature = thoughtSignatureResult.String() - } continue } flushThinking() diff --git a/test/antigravity_claude_signature_test.go b/test/antigravity_claude_signature_test.go deleted file mode 100644 index b605aa6f..00000000 --- a/test/antigravity_claude_signature_test.go +++ /dev/null @@ -1,109 +0,0 @@ -package test - -import ( - "context" - "strings" - "testing" - - agclaude "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/antigravity/claude" - "github.com/tidwall/gjson" -) - -func TestAntigravityClaudeRequest_DropsUnsignedThinkingBlocks(t *testing.T) { - model := "gemini-claude-sonnet-4-5-thinking" - input := []byte(`{ - "model":"` + model + `", - "messages":[ - {"role":"assistant","content":[{"type":"thinking","thinking":"secret without signature"}]}, - {"role":"user","content":[{"type":"text","text":"hi"}]} - ] -}`) - - out := agclaude.ConvertClaudeRequestToAntigravity(model, input, false) - contents := gjson.GetBytes(out, "request.contents") - if !contents.Exists() || !contents.IsArray() { - t.Fatalf("expected request.contents array, got: %s", string(out)) - } - if got := len(contents.Array()); got != 1 { - t.Fatalf("expected 1 content message after dropping unsigned thinking-only assistant message, got %d: %s", got, contents.Raw) - } - if role := contents.Array()[0].Get("role").String(); role != "user" { - t.Fatalf("expected remaining message role=user, got %q", role) - } -} - -func TestAntigravityClaudeStreamResponse_EmitsSignatureDeltaForStandaloneSignaturePart(t *testing.T) { - raw := []byte(`{ - "response":{ - "responseId":"resp_1", - "modelVersion":"claude-sonnet-4-5-thinking", - "candidates":[{ - "content":{"parts":[ - {"text":"THOUGHT","thought":true}, - {"thought":true,"thoughtSignature":"sig123"}, - {"text":"ANSWER","thought":false} - ]}, - "finishReason":"STOP" - }], - "usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"thoughtsTokenCount":1,"totalTokenCount":3} - } -}`) - - var param any - chunks := agclaude.ConvertAntigravityResponseToClaude(context.Background(), "", nil, nil, raw, ¶m) - joined := strings.Join(chunks, "") - if !strings.Contains(joined, `"type":"signature_delta"`) { - t.Fatalf("expected signature_delta in stream output, got: %s", joined) - } - if !strings.Contains(joined, `"signature":"sig123"`) { - t.Fatalf("expected signature sig123 in stream output, got: %s", joined) - } - // Signature delta must be attached to the thinking content block (index 0 in this minimal stream). - if !strings.Contains(joined, `{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig123"}}`) { - t.Fatalf("expected signature_delta to target thinking block index 0, got: %s", joined) - } -} - -func TestAntigravityClaudeNonStreamResponse_IncludesThinkingSignature(t *testing.T) { - raw := []byte(`{ - "response":{ - "responseId":"resp_1", - "modelVersion":"claude-sonnet-4-5-thinking", - "candidates":[{ - "content":{"parts":[ - {"text":"THOUGHT","thought":true}, - {"thought":true,"thoughtSignature":"sig123"}, - {"text":"ANSWER","thought":false} - ]}, - "finishReason":"STOP" - }], - "usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"thoughtsTokenCount":1,"totalTokenCount":3} - } -}`) - - out := agclaude.ConvertAntigravityResponseToClaudeNonStream(context.Background(), "", nil, nil, raw, nil) - if !gjson.Valid(out) { - t.Fatalf("expected valid JSON output, got: %s", out) - } - content := gjson.Get(out, "content") - if !content.Exists() || !content.IsArray() { - t.Fatalf("expected content array in output, got: %s", out) - } - - found := false - for _, block := range content.Array() { - if block.Get("type").String() != "thinking" { - continue - } - found = true - if got := block.Get("signature").String(); got != "sig123" { - t.Fatalf("expected thinking.signature=sig123, got %q (block=%s)", got, block.Raw) - } - if got := block.Get("thinking").String(); got != "THOUGHT" { - t.Fatalf("expected thinking.thinking=THOUGHT, got %q (block=%s)", got, block.Raw) - } - } - if !found { - t.Fatalf("expected a thinking block in output, got: %s", out) - } -}