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
304 parts := root.Get("candidates.0.content.parts")304 parts := root.Get("candidates.0.content.parts")
305 textBuilder := strings.Builder{}305 textBuilder := strings.Builder{}
306 thinkingBuilder := strings.Builder{}306 thinkingBuilder := strings.Builder{}
307 var thinkingSignature string
307 toolIDCounter := 0308 toolIDCounter := 0
308 hasToolCall := false309 hasToolCall := false
309 var blocks [][]byte310 var blocks [][]byte
@@ -319,19 +320,39 @@ func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, origina · 9 unmodified lines
319 }320 }
320 321
321 flushThinking := func() {322 flushThinking := func() {
322 if thinkingBuilder.Len() == 0 {323 if thinkingBuilder.Len() == 0 && thinkingSignature == "" {
323 return324 return
324 }325 }
325 block := []byte(`{"type":"thinking","thinking":""}`)326 block := []byte(`{"type":"thinking","thinking":""}`)
326 block, _ = sjson.SetBytes(block, "thinking", thinkingBuilder.String())327 block, _ = sjson.SetBytes(block, "thinking", thinkingBuilder.String())
328 if thinkingSignature != "" {
329 block, _ = sjson.SetBytes(block, "signature", thinkingSignature)
330 }
327 blocks = append(blocks, block)331 blocks = append(blocks, block)
328 thinkingBuilder.Reset()332 thinkingBuilder.Reset()
333 thinkingSignature = ""
329 }334 }
330 335
331 if parts.IsArray() {336 if parts.IsArray() {
332 for _, part := range parts.Array() {337 for _, part := range parts.Array() {
333 if text := part.Get("text"); text.Exists() && text.String() != "" {338 thoughtSignatureResult := part.Get("thoughtSignature")
334 if part.Get("thought").Bool() {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 {
335 flushText()356 flushText()
336 thinkingBuilder.WriteString(text.String())357 thinkingBuilder.WriteString(text.String())
337 continue358 continue
@@ -341,7 +362,7 @@ func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, origina · 3 unmodified lines
341 continue362 continue
342 }363 }
343 364
344 if functionCall := part.Get("functionCall"); functionCall.Exists() {365 if functionCall.Exists() {
345 flushThinking()366 flushThinking()
346 flushText()367 flushText()
347 hasToolCall = true368 hasToolCall = true
internal/translator/gemini/claude/gemini_claude_response_test.go+142−0
@@ -5,6 +5,8 @@ import ( · 4 unmodified lines
5 "context"5 "context"
6 "strings"6 "strings"
7 "testing"7 "testing"
8
9 "github.com/tidwall/gjson"
8)10)
9 11
10func TestConvertGeminiResponseToClaude_SignatureOnlyPartDoesNotOpenEmptyTextBlock(t *testing.T) {12func TestConvertGeminiResponseToClaude_SignatureOnlyPartDoesNotOpenEmptyTextBlock(t *testing.T) {
@@ -60,3 +62,143 @@ func TestConvertGeminiResponseToClaude_SignatureOnlyPartDoesNotOpenEmptyTextBloc · 49 unmodified lines
60 t.Fatalf("DONE chunk must still emit message_stop after final events: %s", outputText)62 t.Fatalf("DONE chunk must still emit message_stop after final events: %s", outputText)
61 }63 }
62}64}
65
66func 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
113func 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
159func 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}