History

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

Luis Pater committed 2 months ago

Commit 3db591e1 parent 8eb3ac22 files · +167 −4Browse files

Unified · Split

2 files
  1. internal/translator/gemini/claude/gemini_claude_response.go +25 −4
  2. internal/translator/gemini/claude/gemini_claude_response_test.go +142 −0
internal/translator/gemini/claude/gemini_claude_response.go+25−4
@@ -304,6 +304,7 @@ func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, origina · 303 unmodified lines
304304 parts := root.Get("candidates.0.content.parts")
305305 textBuilder := strings.Builder{}
306306 thinkingBuilder := strings.Builder{}
307+ var thinkingSignature string
307308 toolIDCounter := 0
308309 hasToolCall := false
309310 var blocks [][]byte
@@ -319,19 +320,39 @@ func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, origina · 9 unmodified lines
319320 }
320321
321322 flushThinking := func() {
322− if thinkingBuilder.Len() == 0 {
323+ if thinkingBuilder.Len() == 0 && thinkingSignature == "" {
323324 return
324325 }
325326 block := []byte(`{"type":"thinking","thinking":""}`)
326327 block, _ = sjson.SetBytes(block, "thinking", thinkingBuilder.String())
328+ if thinkingSignature != "" {
329+ block, _ = sjson.SetBytes(block, "signature", thinkingSignature)
330+ }
327331 blocks = append(blocks, block)
328332 thinkingBuilder.Reset()
333+ thinkingSignature = ""
329334 }
330335
331336 if parts.IsArray() {
332337 for _, part := range parts.Array() {
333− if text := part.Get("text"); text.Exists() && text.String() != "" {
334− if part.Get("thought").Bool() {
338+ thoughtSignatureResult := part.Get("thoughtSignature")
339+ if !thoughtSignatureResult.Exists() {
340+ thoughtSignatureResult = part.Get("thought_signature")
341+ }
342+ hasThoughtSignature := thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != ""
343+ if hasThoughtSignature {
344+ thinkingSignature = thoughtSignatureResult.String()
345+ }
346+
347+ text := part.Get("text")
348+ functionCall := part.Get("functionCall")
349+
350+ if hasThoughtSignature && (!text.Exists() || text.String() == "") && !functionCall.Exists() {
351+ continue
352+ }
353+
354+ if text.Exists() && text.String() != "" {
355+ if part.Get("thought").Bool() || hasThoughtSignature {
335356 flushText()
336357 thinkingBuilder.WriteString(text.String())
337358 continue
@@ -341,7 +362,7 @@ func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, origina · 3 unmodified lines
341362 continue
342363 }
343364
344− if functionCall := part.Get("functionCall"); functionCall.Exists() {
365+ if functionCall.Exists() {
345366 flushThinking()
346367 flushText()
347368 hasToolCall = true
internal/translator/gemini/claude/gemini_claude_response_test.go+142−0
@@ -5,6 +5,8 @@ import ( · 4 unmodified lines
55 "context"
66 "strings"
77 "testing"
8+
9+ "github.com/tidwall/gjson"
810 )
911
1012 func TestConvertGeminiResponseToClaude_SignatureOnlyPartDoesNotOpenEmptyTextBlock(t *testing.T) {
@@ -60,3 +62,143 @@ func TestConvertGeminiResponseToClaude_SignatureOnlyPartDoesNotOpenEmptyTextBloc · 49 unmodified lines
6062 t.Fatalf("DONE chunk must still emit message_stop after final events: %s", outputText)
6163 }
6264 }
65+
66+func TestConvertGeminiResponseToClaudeNonStream_PreservesThoughtSignature(t *testing.T) {
67+ requestJSON := []byte(`{"model":"gemini-2.5-pro","messages":[{"role":"user","content":"hi"}]}`)
68+ geminiResponse := []byte(`{
69+ "candidates": [{
70+ "content": {
71+ "parts": [
72+ {"text": "thinking step 1\n", "thought": true},
73+ {"text": "thinking step 2", "thought": true, "thoughtSignature": "sig-xyz-123"},
74+ {"text": "visible answer"}
75+ ]
76+ },
77+ "finishReason": "STOP"
78+ }],
79+ "usageMetadata": {
80+ "promptTokenCount": 10,
81+ "candidatesTokenCount": 5
82+ },
83+ "modelVersion": "gemini-2.5-pro",
84+ "responseId": "resp-non-stream"
85+ }`)
86+
87+ ctx := context.Background()
88+ output := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-2.5-pro", requestJSON, requestJSON, geminiResponse, nil)
89+ outputJSON := gjson.ParseBytes(output)
90+
91+ blocks := outputJSON.Get("content").Array()
92+ if len(blocks) != 2 {
93+ t.Fatalf("expected 2 content blocks (thinking + text), got %d: %s", len(blocks), string(output))
94+ }
95+
96+ thinkingBlock := blocks[0]
97+ if thinkingBlock.Get("type").String() != "thinking" {
98+ t.Fatalf("expected first block to be thinking, got %s", thinkingBlock.Get("type").String())
99+ }
100+ if thinkingBlock.Get("thinking").String() != "thinking step 1\nthinking step 2" {
101+ t.Fatalf("unexpected thinking content: %s", thinkingBlock.Get("thinking").String())
102+ }
103+ if thinkingBlock.Get("signature").String() != "sig-xyz-123" {
104+ t.Fatalf("expected signature 'sig-xyz-123', got %q. Output: %s", thinkingBlock.Get("signature").String(), string(output))
105+ }
106+
107+ textBlock := blocks[1]
108+ if textBlock.Get("type").String() != "text" || textBlock.Get("text").String() != "visible answer" {
109+ t.Fatalf("unexpected text block: %s", textBlock.Raw)
110+ }
111+}
112+
113+func TestConvertGeminiResponseToClaudeNonStream_PartWithThoughtSignatureWithoutThoughtBool(t *testing.T) {
114+ requestJSON := []byte(`{"model":"gemini-2.5-pro","messages":[{"role":"user","content":"hi"}]}`)
115+ geminiResponse := []byte(`{
116+ "candidates": [{
117+ "content": {
118+ "parts": [
119+ {"text": "inferred reasoning", "thought_signature": "sig-snake-case"},
120+ {"text": "final answer"}
121+ ]
122+ },
123+ "finishReason": "STOP"
124+ }],
125+ "usageMetadata": {
126+ "promptTokenCount": 10,
127+ "candidatesTokenCount": 5
128+ },
129+ "modelVersion": "gemini-2.5-pro",
130+ "responseId": "resp-non-stream-2"
131+ }`)
132+
133+ ctx := context.Background()
134+ output := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-2.5-pro", requestJSON, requestJSON, geminiResponse, nil)
135+ outputJSON := gjson.ParseBytes(output)
136+
137+ blocks := outputJSON.Get("content").Array()
138+ if len(blocks) != 2 {
139+ t.Fatalf("expected 2 content blocks (thinking + text), got %d: %s", len(blocks), string(output))
140+ }
141+
142+ thinkingBlock := blocks[0]
143+ if thinkingBlock.Get("type").String() != "thinking" {
144+ t.Fatalf("expected first block to be thinking, got %s", thinkingBlock.Get("type").String())
145+ }
146+ if thinkingBlock.Get("thinking").String() != "inferred reasoning" {
147+ t.Fatalf("unexpected thinking content: %s", thinkingBlock.Get("thinking").String())
148+ }
149+ if thinkingBlock.Get("signature").String() != "sig-snake-case" {
150+ t.Fatalf("expected signature 'sig-snake-case', got %q. Output: %s", thinkingBlock.Get("signature").String(), string(output))
151+ }
152+
153+ textBlock := blocks[1]
154+ if textBlock.Get("type").String() != "text" || textBlock.Get("text").String() != "final answer" {
155+ t.Fatalf("unexpected text block: %s", textBlock.Raw)
156+ }
157+}
158+
159+func TestConvertGeminiResponseToClaudeNonStream_TrailingSignatureOnlyPart(t *testing.T) {
160+ requestJSON := []byte(`{"model":"gemini-2.5-pro","messages":[{"role":"user","content":"hi"}]}`)
161+ geminiResponse := []byte(`{
162+ "candidates": [{
163+ "content": {
164+ "parts": [
165+ {"text": "thinking step 1\n", "thought": true},
166+ {"text": "", "thoughtSignature": "sig-trailing"},
167+ {"text": "visible answer"}
168+ ]
169+ },
170+ "finishReason": "STOP"
171+ }],
172+ "usageMetadata": {
173+ "promptTokenCount": 10,
174+ "candidatesTokenCount": 5
175+ },
176+ "modelVersion": "gemini-2.5-pro",
177+ "responseId": "resp-non-stream-trailing"
178+ }`)
179+
180+ ctx := context.Background()
181+ output := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-2.5-pro", requestJSON, requestJSON, geminiResponse, nil)
182+ outputJSON := gjson.ParseBytes(output)
183+
184+ blocks := outputJSON.Get("content").Array()
185+ if len(blocks) != 2 {
186+ t.Fatalf("expected 2 content blocks (thinking + text), got %d: %s", len(blocks), string(output))
187+ }
188+
189+ thinkingBlock := blocks[0]
190+ if thinkingBlock.Get("type").String() != "thinking" {
191+ t.Fatalf("expected first block to be thinking, got %s", thinkingBlock.Get("type").String())
192+ }
193+ if thinkingBlock.Get("thinking").String() != "thinking step 1\n" {
194+ t.Fatalf("unexpected thinking content: %s", thinkingBlock.Get("thinking").String())
195+ }
196+ if thinkingBlock.Get("signature").String() != "sig-trailing" {
197+ t.Fatalf("expected signature 'sig-trailing', got %q. Output: %s", thinkingBlock.Get("signature").String(), string(output))
198+ }
199+
200+ textBlock := blocks[1]
201+ if textBlock.Get("type").String() != "text" || textBlock.Get("text").String() != "visible answer" {
202+ t.Fatalf("unexpected text block: %s", textBlock.Raw)
203+ }
204+}