fix(gemini): normalize Claude thinking signatures in Gemini request and response conversion

Closes: #5151
This commit is contained in:
Luis Pater
2026-08-22 15:35:54 +08:00
parent 65071f7c47
commit b3f72cef65
4 changed files with 195 additions and 4 deletions

View File

@@ -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

View File

@@ -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, &param)
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)
}
})
}
}

View File

@@ -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)
}
}

View File

@@ -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":