mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 06:35:00 +08:00
fix(gemini): normalize Claude thinking signatures in Gemini request and response conversion
Closes: #5151
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user