From b602eae215f718a334e4b27d970d6e24a69a1b62 Mon Sep 17 00:00:00 2001 From: kz <2514883828@qq.com> Date: Wed, 17 Dec 2025 02:28:58 +0800 Subject: [PATCH] Fix antigravity Claude thinking signature handling --- .../claude/antigravity_claude_request.go | 19 ++- .../claude/antigravity_claude_response.go | 77 +++++++++---- test/antigravity_claude_signature_test.go | 109 ++++++++++++++++++ 3 files changed, 175 insertions(+), 30 deletions(-) create mode 100644 test/antigravity_claude_signature_test.go diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index ef9d1d09..10377dc2 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -84,13 +84,18 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ contentResult := contentResults[j] contentTypeResult := contentResult.Get("type") if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "thinking" { - prompt := contentResult.Get("thinking").String() + // 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. signatureResult := contentResult.Get("signature") - signature := geminiCLIClaudeThoughtSignature - if signatureResult.Exists() { - signature = signatureResult.String() + 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(), + }) } - 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}) @@ -134,7 +139,9 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ } } } - contents = append(contents, clientContent) + if len(clientContent.Parts) > 0 { + 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 28785a8f..61489723 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_response.go +++ b/internal/translator/antigravity/claude/antigravity_claude_response.go @@ -114,44 +114,54 @@ 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 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 isThought { + // Ensure we have an open thinking block to attach thinking/signature deltas to. + if params.ResponseType != 2 { 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.ResponseType = 2 // Set state to thinking + 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.HasContent = true } } else { @@ -368,6 +378,7 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or var contentBlocks []interface{} textBuilder := strings.Builder{} thinkingBuilder := strings.Builder{} + thinkingSignature := "" toolIDCounter := 0 hasToolCall := false @@ -386,19 +397,37 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or if thinkingBuilder.Len() == 0 { return } - contentBlocks = append(contentBlocks, map[string]interface{}{ + block := 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 new file mode 100644 index 00000000..b605aa6f --- /dev/null +++ b/test/antigravity_claude_signature_test.go @@ -0,0 +1,109 @@ +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) + } +}