diff --git a/internal/translator/gemini/claude/gemini_claude_response.go b/internal/translator/gemini/claude/gemini_claude_response.go index ddded1fd8..d024d6f9a 100644 --- a/internal/translator/gemini/claude/gemini_claude_response.go +++ b/internal/translator/gemini/claude/gemini_claude_response.go @@ -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 diff --git a/internal/translator/gemini/claude/gemini_claude_response_test.go b/internal/translator/gemini/claude/gemini_claude_response_test.go index 3c4d43517..3a57f7914 100644 --- a/internal/translator/gemini/claude/gemini_claude_response_test.go +++ b/internal/translator/gemini/claude/gemini_claude_response_test.go @@ -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) + } +}