From b3f72cef6565736ec97c254aa03ea2e93809aea4 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 22 Aug 2026 15:35:54 +0800 Subject: [PATCH] fix(gemini): normalize Claude thinking signatures in Gemini request and response conversion Closes: #5151 --- .../claude/gemini/claude_gemini_response.go | 37 +++++- .../gemini/claude_gemini_response_test.go | 113 ++++++++++++++++++ .../claude/gemini_claude_compat_test.go | 45 ++++++- .../gemini/claude/gemini_claude_request.go | 4 +- 4 files changed, 195 insertions(+), 4 deletions(-) diff --git a/internal/translator/claude/gemini/claude_gemini_response.go b/internal/translator/claude/gemini/claude_gemini_response.go index 1ff069ca8..0af5424bc 100644 --- a/internal/translator/claude/gemini/claude_gemini_response.go +++ b/internal/translator/claude/gemini/claude_gemini_response.go @@ -11,6 +11,7 @@ import ( "strings" "time" + sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -116,6 +117,13 @@ func ConvertClaudeResponseToGemini(_ context.Context, modelName string, original } (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs[idx] = toolID } + } else if cb.Get("type").String() == "thinking" { + if sig := cb.Get("signature"); sig.Exists() && sig.String() != "" { + thinkingPart := []byte(`{"thought":true,"thoughtSignature":""}`) + thinkingPart, _ = sjson.SetBytes(thinkingPart, "thoughtSignature", sigcompat.GeminiReplaySignatureOrBypass(sig.String(), sigcompat.SignatureBlockKindGeminiModelPart)) + template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", thinkingPart) + return [][]byte{template} + } } } return [][]byte{} @@ -140,6 +148,12 @@ func ConvertClaudeResponseToGemini(_ context.Context, modelName string, original thinkingPart, _ = sjson.SetBytes(thinkingPart, "text", text.String()) template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", thinkingPart) } + case "signature_delta": + if sig := delta.Get("signature"); sig.Exists() && sig.String() != "" { + thinkingPart := []byte(`{"thought":true,"thoughtSignature":""}`) + thinkingPart, _ = sjson.SetBytes(thinkingPart, "thoughtSignature", sigcompat.GeminiReplaySignatureOrBypass(sig.String(), sigcompat.SignatureBlockKindGeminiModelPart)) + template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", thinkingPart) + } case "input_json_delta": // Tool use input delta - accumulate partial_json by index for later assembly at content_block_stop idx := int(root.Get("index").Int()) @@ -376,6 +390,12 @@ func ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string, } newParam.ToolUseIDs[idx] = toolID } + } else if cb.Get("type").String() == "thinking" { + if sig := cb.Get("signature"); sig.Exists() && sig.String() != "" { + partJSON := []byte(`{"thought":true,"thoughtSignature":""}`) + partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", sigcompat.GeminiReplaySignatureOrBypass(sig.String(), sigcompat.SignatureBlockKindGeminiModelPart)) + allParts = append(allParts, partJSON) + } } } continue @@ -399,6 +419,12 @@ func ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string, partJSON, _ = sjson.SetBytes(partJSON, "text", text.String()) allParts = append(allParts, partJSON) } + case "signature_delta": + if sig := delta.Get("signature"); sig.Exists() && sig.String() != "" { + partJSON := []byte(`{"thought":true,"thoughtSignature":""}`) + partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", sigcompat.GeminiReplaySignatureOrBypass(sig.String(), sigcompat.SignatureBlockKindGeminiModelPart)) + allParts = append(allParts, partJSON) + } case "input_json_delta": // accumulate args partial_json for this index idx := int(root.Get("index").Int()) @@ -535,6 +561,7 @@ func consolidateParts(parts [][]byte) [][]byte { var consolidated [][]byte var currentTextPart strings.Builder var currentThoughtPart strings.Builder + var currentThoughtSignature string var hasText, hasThought bool flushText := func() { @@ -550,11 +577,15 @@ func consolidateParts(parts [][]byte) [][]byte { flushThought := func() { // Flush accumulated thinking content to the consolidated parts array - if hasThought && currentThoughtPart.Len() > 0 { + if hasThought && (currentThoughtPart.Len() > 0 || currentThoughtSignature != "") { thoughtPartJSON := []byte(`{"thought":true,"text":""}`) thoughtPartJSON, _ = sjson.SetBytes(thoughtPartJSON, "text", currentThoughtPart.String()) + if currentThoughtSignature != "" { + thoughtPartJSON, _ = sjson.SetBytes(thoughtPartJSON, "thoughtSignature", currentThoughtSignature) + } consolidated = append(consolidated, thoughtPartJSON) currentThoughtPart.Reset() + currentThoughtSignature = "" hasThought = false } } @@ -578,6 +609,10 @@ func consolidateParts(parts [][]byte) [][]byte { currentThoughtPart.WriteString(text.String()) hasThought = true } + if sig := part.Get("thoughtSignature"); sig.Exists() && sig.Type == gjson.String && sig.String() != "" { + currentThoughtSignature = sig.String() + hasThought = true + } } else if text := part.Get("text"); text.Exists() && text.Type == gjson.String { // This is a regular text part - flush any pending thought first flushThought() // Flush any pending thought first diff --git a/internal/translator/claude/gemini/claude_gemini_response_test.go b/internal/translator/claude/gemini/claude_gemini_response_test.go index 8fb6744c7..3e2a623fe 100644 --- a/internal/translator/claude/gemini/claude_gemini_response_test.go +++ b/internal/translator/claude/gemini/claude_gemini_response_test.go @@ -51,3 +51,116 @@ func TestConvertClaudeResponseToGeminiNonStreamPreservesToolUseID(t *testing.T) t.Fatalf("expected functionCall.id %q, got %q; chunk=%s", "toolu_gateway", got, string(out)) } } + +func TestConvertClaudeResponseToGemini_StreamThinkingSignature(t *testing.T) { + const validGeminiSignature = "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA" + + tests := []struct { + name string + signature string + wantSignature string + }{ + { + name: "foreign claude signature maps to bypass sentinel", + signature: "foreign_claude_sig_123", + wantSignature: "skip_thought_signature_validator", + }, + { + name: "preserves valid gemini signature", + signature: "gemini#" + validGeminiSignature, + wantSignature: validGeminiSignature, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + var param any + + chunks := [][]byte{ + []byte(`data: {"type":"message_start","message":{"id":"msg_123","model":"claude-3-7-sonnet-20250219"}}`), + []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`), + []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"thinking text"}}`), + []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"` + tt.signature + `"}}`), + []byte(`data: {"type":"content_block_stop","index":0}`), + []byte(`data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}`), + []byte(`data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"final answer"}}`), + []byte(`data: {"type":"content_block_stop","index":1}`), + []byte(`data: {"type":"message_stop"}`), + } + + var emittedParts []gjson.Result + for _, chunk := range chunks { + out := ConvertClaudeResponseToGemini(ctx, "gemini-2.5-pro", nil, nil, chunk, ¶m) + for _, c := range out { + parts := gjson.GetBytes(c, "candidates.0.content.parts").Array() + emittedParts = append(emittedParts, parts...) + } + } + + var foundSignature string + for _, p := range emittedParts { + if p.Get("thought").Bool() && p.Get("thoughtSignature").Exists() { + foundSignature = p.Get("thoughtSignature").String() + } + } + + if foundSignature != tt.wantSignature { + t.Fatalf("expected thoughtSignature %q, got %q", tt.wantSignature, foundSignature) + } + }) + } +} + +func TestConvertClaudeResponseToGeminiNonStream_ThinkingSignature(t *testing.T) { + const validGeminiSignature = "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA" + + tests := []struct { + name string + signature string + wantSignature string + }{ + { + name: "foreign claude signature maps to bypass sentinel", + signature: "foreign_claude_sig_123", + wantSignature: "skip_thought_signature_validator", + }, + { + name: "preserves valid gemini signature", + signature: "gemini#" + validGeminiSignature, + wantSignature: validGeminiSignature, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + raw := []byte(strings.Join([]string{ + `data: {"type":"message_start","message":{"id":"msg_123","model":"claude-3-7-sonnet-20250219"}}`, + `data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"thinking text"}}`, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"` + tt.signature + `"}}`, + `data: {"type":"content_block_stop","index":0}`, + `data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}`, + `data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"final answer"}}`, + `data: {"type":"content_block_stop","index":1}`, + `data: {"type":"message_stop"}`, + }, "\n")) + + out := ConvertClaudeResponseToGeminiNonStream(ctx, "gemini-2.5-pro", nil, nil, raw, nil) + + thoughtPart := gjson.GetBytes(out, "candidates.0.content.parts.0") + if !thoughtPart.Get("thought").Bool() || thoughtPart.Get("text").String() != "thinking text" { + t.Fatalf("expected thought part with text 'thinking text', got %s", thoughtPart.Raw) + } + if got := thoughtPart.Get("thoughtSignature").String(); got != tt.wantSignature { + t.Fatalf("expected thoughtSignature %q, got %q", tt.wantSignature, got) + } + + textPart := gjson.GetBytes(out, "candidates.0.content.parts.1") + if textPart.Get("text").String() != "final answer" { + t.Fatalf("expected text part 'final answer', got %s", textPart.Raw) + } + }) + } +} diff --git a/internal/translator/gemini/claude/gemini_claude_compat_test.go b/internal/translator/gemini/claude/gemini_claude_compat_test.go index a4ec625cf..0711050e3 100644 --- a/internal/translator/gemini/claude/gemini_claude_compat_test.go +++ b/internal/translator/gemini/claude/gemini_claude_compat_test.go @@ -3,9 +3,50 @@ package claude import ( "testing" + "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" "github.com/tidwall/gjson" ) +const capturedGeminiThinkingSignature = "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA" + +func TestConvertClaudeRequestToGeminiWithCompat_SignatureCompatibility(t *testing.T) { + tests := []struct { + name string + signature string + wantSignature string + }{ + { + name: "preserves valid gemini signature", + signature: "gemini#" + capturedGeminiThinkingSignature, + wantSignature: capturedGeminiThinkingSignature, + }, + { + name: "foreign claude signature maps to bypass sentinel", + signature: "claude#opaque-signature-12345", + wantSignature: signature.GeminiSkipThoughtSignatureValidator, + }, + { + name: "empty signature maps to bypass sentinel", + signature: "", + wantSignature: signature.GeminiSkipThoughtSignatureValidator, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"` + tt.signature + `"}]}]}`) + withCompat := ConvertClaudeRequestToGeminiWithCompat("deepseek-v4", payload, false) + part := gjson.GetBytes(withCompat, "contents.0.parts.0") + if !part.Get("thought").Bool() || part.Get("text").String() != "reason" { + t.Fatalf("compat translation missing thought part: %s", withCompat) + } + if got := part.Get("thoughtSignature").String(); got != tt.wantSignature { + t.Fatalf("thoughtSignature = %q, want %q; output: %s", got, tt.wantSignature, withCompat) + } + }) + } +} + func TestConvertClaudeRequestToGeminiWithCompatPreservesEmptyThinking(t *testing.T) { payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":""}]}]}`) @@ -19,7 +60,7 @@ func TestConvertClaudeRequestToGeminiWithCompatPreservesEmptyThinking(t *testing if !part.Get("thought").Bool() || part.Get("text").String() != "reason" { t.Fatalf("compat translation missing thought part: %s", withCompat) } - if !part.Get("thoughtSignature").Exists() || part.Get("thoughtSignature").String() != "" { - t.Fatalf("compat translation did not preserve empty signature: %s", withCompat) + if !part.Get("thoughtSignature").Exists() || part.Get("thoughtSignature").String() != signature.GeminiSkipThoughtSignatureValidator { + t.Fatalf("compat translation did not preserve bypass signature: %s", withCompat) } } diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index 39bf176d2..0cf9afe0f 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" @@ -119,7 +120,8 @@ func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool, } part := []byte(`{"text":"","thought":true,"thoughtSignature":""}`) part, _ = sjson.SetBytes(part, "text", contentResult.Get("thinking").String()) - part, _ = sjson.SetBytes(part, "thoughtSignature", contentResult.Get("signature").String()) + signature := sigcompat.GeminiReplaySignatureOrBypass(contentResult.Get("signature").String(), sigcompat.SignatureBlockKindGeminiModelPart) + part, _ = sjson.SetBytes(part, "thoughtSignature", signature) partItems = append(partItems, part) case "tool_use":