mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-05 23:50:29 +08:00
fix(gemini): preserve Gemini thought signatures in non-stream Claude conversion
Closes: #5106
This commit is contained in:
@@ -304,6 +304,7 @@ func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, origina
|
||||
parts := root.Get("candidates.0.content.parts")
|
||||
textBuilder := strings.Builder{}
|
||||
thinkingBuilder := strings.Builder{}
|
||||
var thinkingSignature string
|
||||
toolIDCounter := 0
|
||||
hasToolCall := false
|
||||
var blocks [][]byte
|
||||
@@ -319,19 +320,39 @@ func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, origina
|
||||
}
|
||||
|
||||
flushThinking := func() {
|
||||
if thinkingBuilder.Len() == 0 {
|
||||
if thinkingBuilder.Len() == 0 && thinkingSignature == "" {
|
||||
return
|
||||
}
|
||||
block := []byte(`{"type":"thinking","thinking":""}`)
|
||||
block, _ = sjson.SetBytes(block, "thinking", thinkingBuilder.String())
|
||||
if thinkingSignature != "" {
|
||||
block, _ = sjson.SetBytes(block, "signature", thinkingSignature)
|
||||
}
|
||||
blocks = append(blocks, block)
|
||||
thinkingBuilder.Reset()
|
||||
thinkingSignature = ""
|
||||
}
|
||||
|
||||
if parts.IsArray() {
|
||||
for _, part := range parts.Array() {
|
||||
if text := part.Get("text"); text.Exists() && text.String() != "" {
|
||||
if part.Get("thought").Bool() {
|
||||
thoughtSignatureResult := part.Get("thoughtSignature")
|
||||
if !thoughtSignatureResult.Exists() {
|
||||
thoughtSignatureResult = part.Get("thought_signature")
|
||||
}
|
||||
hasThoughtSignature := thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != ""
|
||||
if hasThoughtSignature {
|
||||
thinkingSignature = thoughtSignatureResult.String()
|
||||
}
|
||||
|
||||
text := part.Get("text")
|
||||
functionCall := part.Get("functionCall")
|
||||
|
||||
if hasThoughtSignature && (!text.Exists() || text.String() == "") && !functionCall.Exists() {
|
||||
continue
|
||||
}
|
||||
|
||||
if text.Exists() && text.String() != "" {
|
||||
if part.Get("thought").Bool() || hasThoughtSignature {
|
||||
flushText()
|
||||
thinkingBuilder.WriteString(text.String())
|
||||
continue
|
||||
@@ -341,7 +362,7 @@ func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, origina
|
||||
continue
|
||||
}
|
||||
|
||||
if functionCall := part.Get("functionCall"); functionCall.Exists() {
|
||||
if functionCall.Exists() {
|
||||
flushThinking()
|
||||
flushText()
|
||||
hasToolCall = true
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertGeminiResponseToClaude_SignatureOnlyPartDoesNotOpenEmptyTextBlock(t *testing.T) {
|
||||
@@ -60,3 +62,143 @@ func TestConvertGeminiResponseToClaude_SignatureOnlyPartDoesNotOpenEmptyTextBloc
|
||||
t.Fatalf("DONE chunk must still emit message_stop after final events: %s", outputText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiResponseToClaudeNonStream_PreservesThoughtSignature(t *testing.T) {
|
||||
requestJSON := []byte(`{"model":"gemini-2.5-pro","messages":[{"role":"user","content":"hi"}]}`)
|
||||
geminiResponse := []byte(`{
|
||||
"candidates": [{
|
||||
"content": {
|
||||
"parts": [
|
||||
{"text": "thinking step 1\n", "thought": true},
|
||||
{"text": "thinking step 2", "thought": true, "thoughtSignature": "sig-xyz-123"},
|
||||
{"text": "visible answer"}
|
||||
]
|
||||
},
|
||||
"finishReason": "STOP"
|
||||
}],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 10,
|
||||
"candidatesTokenCount": 5
|
||||
},
|
||||
"modelVersion": "gemini-2.5-pro",
|
||||
"responseId": "resp-non-stream"
|
||||
}`)
|
||||
|
||||
ctx := context.Background()
|
||||
output := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-2.5-pro", requestJSON, requestJSON, geminiResponse, nil)
|
||||
outputJSON := gjson.ParseBytes(output)
|
||||
|
||||
blocks := outputJSON.Get("content").Array()
|
||||
if len(blocks) != 2 {
|
||||
t.Fatalf("expected 2 content blocks (thinking + text), got %d: %s", len(blocks), string(output))
|
||||
}
|
||||
|
||||
thinkingBlock := blocks[0]
|
||||
if thinkingBlock.Get("type").String() != "thinking" {
|
||||
t.Fatalf("expected first block to be thinking, got %s", thinkingBlock.Get("type").String())
|
||||
}
|
||||
if thinkingBlock.Get("thinking").String() != "thinking step 1\nthinking step 2" {
|
||||
t.Fatalf("unexpected thinking content: %s", thinkingBlock.Get("thinking").String())
|
||||
}
|
||||
if thinkingBlock.Get("signature").String() != "sig-xyz-123" {
|
||||
t.Fatalf("expected signature 'sig-xyz-123', got %q. Output: %s", thinkingBlock.Get("signature").String(), string(output))
|
||||
}
|
||||
|
||||
textBlock := blocks[1]
|
||||
if textBlock.Get("type").String() != "text" || textBlock.Get("text").String() != "visible answer" {
|
||||
t.Fatalf("unexpected text block: %s", textBlock.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiResponseToClaudeNonStream_PartWithThoughtSignatureWithoutThoughtBool(t *testing.T) {
|
||||
requestJSON := []byte(`{"model":"gemini-2.5-pro","messages":[{"role":"user","content":"hi"}]}`)
|
||||
geminiResponse := []byte(`{
|
||||
"candidates": [{
|
||||
"content": {
|
||||
"parts": [
|
||||
{"text": "inferred reasoning", "thought_signature": "sig-snake-case"},
|
||||
{"text": "final answer"}
|
||||
]
|
||||
},
|
||||
"finishReason": "STOP"
|
||||
}],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 10,
|
||||
"candidatesTokenCount": 5
|
||||
},
|
||||
"modelVersion": "gemini-2.5-pro",
|
||||
"responseId": "resp-non-stream-2"
|
||||
}`)
|
||||
|
||||
ctx := context.Background()
|
||||
output := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-2.5-pro", requestJSON, requestJSON, geminiResponse, nil)
|
||||
outputJSON := gjson.ParseBytes(output)
|
||||
|
||||
blocks := outputJSON.Get("content").Array()
|
||||
if len(blocks) != 2 {
|
||||
t.Fatalf("expected 2 content blocks (thinking + text), got %d: %s", len(blocks), string(output))
|
||||
}
|
||||
|
||||
thinkingBlock := blocks[0]
|
||||
if thinkingBlock.Get("type").String() != "thinking" {
|
||||
t.Fatalf("expected first block to be thinking, got %s", thinkingBlock.Get("type").String())
|
||||
}
|
||||
if thinkingBlock.Get("thinking").String() != "inferred reasoning" {
|
||||
t.Fatalf("unexpected thinking content: %s", thinkingBlock.Get("thinking").String())
|
||||
}
|
||||
if thinkingBlock.Get("signature").String() != "sig-snake-case" {
|
||||
t.Fatalf("expected signature 'sig-snake-case', got %q. Output: %s", thinkingBlock.Get("signature").String(), string(output))
|
||||
}
|
||||
|
||||
textBlock := blocks[1]
|
||||
if textBlock.Get("type").String() != "text" || textBlock.Get("text").String() != "final answer" {
|
||||
t.Fatalf("unexpected text block: %s", textBlock.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiResponseToClaudeNonStream_TrailingSignatureOnlyPart(t *testing.T) {
|
||||
requestJSON := []byte(`{"model":"gemini-2.5-pro","messages":[{"role":"user","content":"hi"}]}`)
|
||||
geminiResponse := []byte(`{
|
||||
"candidates": [{
|
||||
"content": {
|
||||
"parts": [
|
||||
{"text": "thinking step 1\n", "thought": true},
|
||||
{"text": "", "thoughtSignature": "sig-trailing"},
|
||||
{"text": "visible answer"}
|
||||
]
|
||||
},
|
||||
"finishReason": "STOP"
|
||||
}],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 10,
|
||||
"candidatesTokenCount": 5
|
||||
},
|
||||
"modelVersion": "gemini-2.5-pro",
|
||||
"responseId": "resp-non-stream-trailing"
|
||||
}`)
|
||||
|
||||
ctx := context.Background()
|
||||
output := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-2.5-pro", requestJSON, requestJSON, geminiResponse, nil)
|
||||
outputJSON := gjson.ParseBytes(output)
|
||||
|
||||
blocks := outputJSON.Get("content").Array()
|
||||
if len(blocks) != 2 {
|
||||
t.Fatalf("expected 2 content blocks (thinking + text), got %d: %s", len(blocks), string(output))
|
||||
}
|
||||
|
||||
thinkingBlock := blocks[0]
|
||||
if thinkingBlock.Get("type").String() != "thinking" {
|
||||
t.Fatalf("expected first block to be thinking, got %s", thinkingBlock.Get("type").String())
|
||||
}
|
||||
if thinkingBlock.Get("thinking").String() != "thinking step 1\n" {
|
||||
t.Fatalf("unexpected thinking content: %s", thinkingBlock.Get("thinking").String())
|
||||
}
|
||||
if thinkingBlock.Get("signature").String() != "sig-trailing" {
|
||||
t.Fatalf("expected signature 'sig-trailing', got %q. Output: %s", thinkingBlock.Get("signature").String(), string(output))
|
||||
}
|
||||
|
||||
textBlock := blocks[1]
|
||||
if textBlock.Get("type").String() != "text" || textBlock.Get("text").String() != "visible answer" {
|
||||
t.Fatalf("unexpected text block: %s", textBlock.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user