fix(gemini): preserve Gemini thought signatures in non-stream Claude conversion

Closes: #5106
This commit is contained in:
Luis Pater
2026-08-21 03:04:12 +08:00
parent 8eb3ac2e03
commit 3db591eecd
2 changed files with 167 additions and 4 deletions

View File

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

View File

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