From 68ea171da27654de4e0dc789652bad13d96d810d Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Tue, 6 Oct 2026 15:43:01 +0800 Subject: [PATCH] fix(gemini): handle carrier thinking blocks for signed visible text and tool calls in Claude response translation - Emit carrier thinking blocks for thought signatures accompanying visible text or tool calls in both streaming and non-streaming responses - Ensure thinking content and visible text are properly segregated without misclassifying regular output as reasoning Closes: #6409 --- .../gemini/claude/gemini_claude_response.go | 251 ++++++----- .../claude/gemini_claude_response_test.go | 388 +++++++++++++++++- 2 files changed, 544 insertions(+), 95 deletions(-) diff --git a/internal/translator/gemini/claude/gemini_claude_response.go b/internal/translator/gemini/claude/gemini_claude_response.go index 8e812fd0a..8d52f9226 100644 --- a/internal/translator/gemini/claude/gemini_claude_response.go +++ b/internal/translator/gemini/claude/gemini_claude_response.go @@ -127,6 +127,25 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR (*param).(*Params).HasContent = true } + appendCarrierThinkingBlock := func(signature string) { + if signature == "" { + return + } + p := (*param).(*Params) + if p.ResponseType != 0 { + appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, p.ResponseIndex)) + p.ResponseIndex++ + p.ResponseType = 0 + } + appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"thinking","thinking":""}}`, p.ResponseIndex)) + data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, p.ResponseIndex)), "delta.signature", signature) + appendEvent("content_block_delta", string(data)) + appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, p.ResponseIndex)) + p.ResponseIndex++ + p.ResponseType = 0 + p.HasContent = true + } + // Initialize the streaming session with a message_start event // This is only sent for the very first response chunk if !(*param).(*Params).HasFirstResponse { @@ -161,118 +180,87 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR if !thoughtSignatureResult.Exists() { thoughtSignatureResult = partResult.Get("thought_signature") } - hasThoughtSignature := thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != "" + partSig := "" + if thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != "" { + partSig = thoughtSignatureResult.String() + } + hasThoughtSignature := partSig != "" + isThought := partResult.Get("thought").Bool() - if hasThoughtSignature && !partTextResult.Exists() && !functionCallResult.Exists() { - appendSignatureDelta(thoughtSignatureResult.String()) + if hasThoughtSignature && (!partTextResult.Exists() || partTextResult.String() == "") && !functionCallResult.Exists() { + if (*param).(*Params).ResponseType == 2 { + appendSignatureDelta(partSig) + continue + } + appendCarrierThinkingBlock(partSig) continue } - // Handle text content (both regular content and thinking) - if partTextResult.Exists() { - // Process thinking content (internal reasoning) - if partResult.Get("thought").Bool() || hasThoughtSignature { - if hasThoughtSignature && partTextResult.String() == "" { - appendSignatureDelta(thoughtSignatureResult.String()) - continue - } - // Continue existing thinking block + if isThought { + if hasThoughtSignature && (!partTextResult.Exists() || partTextResult.String() == "") { if (*param).(*Params).ResponseType == 2 { - data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, (*param).(*Params).ResponseIndex)), "delta.thinking", partTextResult.String()) - appendEvent("content_block_delta", string(data)) - (*param).(*Params).HasContent = true + appendSignatureDelta(partSig) } else { - // Transition from another state to thinking - // First, close any existing content block - if (*param).(*Params).ResponseType != 0 { - if (*param).(*Params).ResponseType == 2 { - // output = output + "event: content_block_delta\n" - // output = output + fmt.Sprintf(`data: {"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":null}}`, (*param).(*Params).ResponseIndex) - // output = output + "\n\n\n" - } - appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex)) - (*param).(*Params).ResponseIndex++ - } - - // Start a new thinking content block - appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"thinking","thinking":""}}`, (*param).(*Params).ResponseIndex)) - data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, (*param).(*Params).ResponseIndex)), "delta.thinking", partTextResult.String()) - appendEvent("content_block_delta", string(data)) - (*param).(*Params).ResponseType = 2 // Set state to thinking - (*param).(*Params).HasContent = true - } - appendSignatureDelta(thoughtSignatureResult.String()) - } else { - // Process regular text content (user-visible output) - // Continue existing text block - if (*param).(*Params).ResponseType == 1 { - data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, (*param).(*Params).ResponseIndex)), "delta.text", partTextResult.String()) - appendEvent("content_block_delta", string(data)) - (*param).(*Params).HasContent = true - } else { - // Transition from another state to text content - // First, close any existing content block - if (*param).(*Params).ResponseType != 0 { - if (*param).(*Params).ResponseType == 2 { - // output = output + "event: content_block_delta\n" - // output = output + fmt.Sprintf(`data: {"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":null}}`, (*param).(*Params).ResponseIndex) - // output = output + "\n\n\n" - } - appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex)) - (*param).(*Params).ResponseIndex++ - } - - // Start a new text content block - appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"text","text":""}}`, (*param).(*Params).ResponseIndex)) - data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, (*param).(*Params).ResponseIndex)), "delta.text", partTextResult.String()) - appendEvent("content_block_delta", string(data)) - (*param).(*Params).ResponseType = 1 // Set state to content - (*param).(*Params).HasContent = true + appendCarrierThinkingBlock(partSig) } + continue } - } else if functionCallResult.Exists() { - // Handle function/tool calls from the AI model - // This processes tool usage requests and formats them for Claude API compatibility + + partText := partTextResult.String() + if (*param).(*Params).ResponseType == 2 { + data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, (*param).(*Params).ResponseIndex)), "delta.thinking", partText) + appendEvent("content_block_delta", string(data)) + (*param).(*Params).HasContent = true + } else { + if (*param).(*Params).ResponseType != 0 { + appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex)) + (*param).(*Params).ResponseIndex++ + } + + appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"thinking","thinking":""}}`, (*param).(*Params).ResponseIndex)) + data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, (*param).(*Params).ResponseIndex)), "delta.thinking", partText) + appendEvent("content_block_delta", string(data)) + (*param).(*Params).ResponseType = 2 + (*param).(*Params).HasContent = true + } + if hasThoughtSignature { + appendSignatureDelta(partSig) + } + continue + } + + // From here on, !isThought (visible text, functionCall, or standalone signature) + if functionCallResult.Exists() { (*param).(*Params).SawToolCall = true upstreamToolName := functionCallResult.Get("name").String() upstreamToolName = util.RestoreSanitizedToolName((*param).(*Params).SanitizedNameMap, upstreamToolName) clientToolName := util.MapToolName((*param).(*Params).ToolNameMap, upstreamToolName) - // FIX: Handle streaming split/delta where name might be empty in subsequent chunks. - // If we are already in tool use mode and name is empty, treat as continuation (delta). if (*param).(*Params).ResponseType == 3 && upstreamToolName == "" { if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() { data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, (*param).(*Params).ResponseIndex)), "delta.partial_json", fcArgsResult.Raw) appendEvent("content_block_delta", string(data)) } - // Continue to next part without closing/opening logic + if hasThoughtSignature { + appendCarrierThinkingBlock(partSig) + } continue } - // Handle state transitions when switching to function calls - // Close any existing function call block first + if hasThoughtSignature { + appendCarrierThinkingBlock(partSig) + } + if (*param).(*Params).ResponseType == 3 { appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex)) (*param).(*Params).ResponseIndex++ (*param).(*Params).ResponseType = 0 } - - // Special handling for thinking state transition - if (*param).(*Params).ResponseType == 2 { - // output = output + "event: content_block_delta\n" - // output = output + fmt.Sprintf(`data: {"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":null}}`, (*param).(*Params).ResponseIndex) - // output = output + "\n\n\n" - } - - // Close any other existing content block if (*param).(*Params).ResponseType != 0 { appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex)) (*param).(*Params).ResponseIndex++ } - // Start a new tool use content block - // This creates the structure for a function call in Claude format - // Create the tool use block with unique ID and function details data := []byte(fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`, (*param).(*Params).ResponseIndex)) data, _ = sjson.SetBytes(data, "content_block.id", util.SanitizeClaudeToolID(fmt.Sprintf("%s-%d", upstreamToolName, atomic.AddUint64(&toolUseIDCounter, 1)))) data, _ = sjson.SetBytes(data, "content_block.name", clientToolName) @@ -284,6 +272,43 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR } (*param).(*Params).ResponseType = 3 (*param).(*Params).HasContent = true + continue + } + + if partTextResult.Exists() { + partText := partTextResult.String() + if hasThoughtSignature && partText == "" { + if (*param).(*Params).ResponseType == 2 { + appendSignatureDelta(partSig) + continue + } + appendCarrierThinkingBlock(partSig) + continue + } + if hasThoughtSignature { + appendCarrierThinkingBlock(partSig) + } + if (*param).(*Params).ResponseType == 1 { + data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, (*param).(*Params).ResponseIndex)), "delta.text", partText) + appendEvent("content_block_delta", string(data)) + (*param).(*Params).HasContent = true + } else { + if (*param).(*Params).ResponseType != 0 { + appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex)) + (*param).(*Params).ResponseIndex++ + } + appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"text","text":""}}`, (*param).(*Params).ResponseIndex)) + data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, (*param).(*Params).ResponseIndex)), "delta.text", partText) + appendEvent("content_block_delta", string(data)) + (*param).(*Params).ResponseType = 1 + (*param).(*Params).HasContent = true + } + continue + } + + if hasThoughtSignature { + appendCarrierThinkingBlock(partSig) + continue } } } @@ -405,38 +430,65 @@ func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, origina thinkingSignature = "" } + appendCarrierThinkingBlock := func(signature string) { + if signature == "" { + return + } + carrier := []byte(`{"type":"thinking","thinking":"","signature":""}`) + carrier, _ = sjson.SetBytes(carrier, "signature", signature) + blocks = append(blocks, carrier) + } + if parts.IsArray() { for _, part := range parts.Array() { thoughtSignatureResult := part.Get("thoughtSignature") if !thoughtSignatureResult.Exists() { thoughtSignatureResult = part.Get("thought_signature") } - hasThoughtSignature := thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != "" - if hasThoughtSignature { - thinkingSignature = thoughtSignatureResult.String() + partSig := "" + if thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != "" { + partSig = thoughtSignatureResult.String() } text := part.Get("text") functionCall := part.Get("functionCall") + isThought := part.Get("thought").Bool() - if hasThoughtSignature && (!text.Exists() || text.String() == "") && !functionCall.Exists() { + if isThought { + flushText() + if partSig != "" { + thinkingSignature = partSig + } + if text.Exists() && text.String() != "" { + thinkingBuilder.WriteString(text.String()) + } continue } - if text.Exists() && text.String() != "" { - if part.Get("thought").Bool() || hasThoughtSignature { - flushText() - thinkingBuilder.WriteString(text.String()) + // If this is a part without visible text or function call, and thinking is in progress: + // this signature belongs to the thinking block! + if (!text.Exists() || text.String() == "") && !functionCall.Exists() { + if thinkingBuilder.Len() > 0 && partSig != "" { + thinkingSignature = partSig + continue + } + if partSig != "" { + flushThinking() + flushText() + appendCarrierThinkingBlock(partSig) continue } - flushThinking() - textBuilder.WriteString(text.String()) continue } + // From here on, !isThought (visible text or functionCall) + flushThinking() + if functionCall.Exists() { - flushThinking() flushText() + if partSig != "" { + appendCarrierThinkingBlock(partSig) + } hasToolCall = true upstreamToolName := functionCall.Get("name").String() @@ -454,6 +506,21 @@ func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, origina blocks = append(blocks, toolBlock) continue } + + if text.Exists() && text.String() != "" { + if partSig != "" { + flushText() + appendCarrierThinkingBlock(partSig) + } + textBuilder.WriteString(text.String()) + continue + } + + if partSig != "" { + flushText() + appendCarrierThinkingBlock(partSig) + continue + } } } diff --git a/internal/translator/gemini/claude/gemini_claude_response_test.go b/internal/translator/gemini/claude/gemini_claude_response_test.go index 6241d6746..b729fe200 100644 --- a/internal/translator/gemini/claude/gemini_claude_response_test.go +++ b/internal/translator/gemini/claude/gemini_claude_response_test.go @@ -143,15 +143,15 @@ func TestConvertGeminiResponseToClaudeNonStream_PartWithThoughtSignatureWithoutT 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("thinking").String() != "" { + t.Fatalf("carrier thinking block must have empty thinking, got %q", 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" { + if textBlock.Get("type").String() != "text" || textBlock.Get("text").String() != "inferred reasoningfinal answer" { t.Fatalf("unexpected text block: %s", textBlock.Raw) } } @@ -479,3 +479,385 @@ func TestConvertGeminiResponseToClaudeNonStream_SafetyAndMalformedFunctionCallRe }) } } + +func TestConvertGeminiResponseToClaudeNonStream_Issue6409_SignedVisibleText(t *testing.T) { + requestJSON := []byte(`{"model":"gemini-3.5-flash-lite","messages":[{"role":"user","content":"hi"}]}`) + geminiResponse := []byte(`{ + "candidates": [{ + "content": { + "parts": [ + {"text": "ok", "thoughtSignature": "EmAKXgFpFH0Tb/MkBw="} + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0 + }], + "usageMetadata": { + "promptTokenCount": 5, + "candidatesTokenCount": 1 + }, + "modelVersion": "gemini-3.5-flash-lite", + "responseId": "5Qm-as2qBuLI-sAP76KVkAk" + }`) + + ctx := context.Background() + output := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-3.5-flash-lite", requestJSON, requestJSON, geminiResponse, nil) + outputJSON := gjson.ParseBytes(output) + + blocks := outputJSON.Get("content").Array() + if len(blocks) != 2 { + t.Fatalf("expected 2 content blocks (carrier thinking + text), got %d: %s", len(blocks), string(output)) + } + + thinkingBlock := blocks[0] + if thinkingBlock.Get("type").String() != "thinking" { + t.Fatalf("expected block 0 to be thinking carrier, got %s", thinkingBlock.Get("type").String()) + } + if got := thinkingBlock.Get("thinking").String(); got != "" { + t.Fatalf("carrier thinking block must have empty thinking, got %q", got) + } + if got := thinkingBlock.Get("signature").String(); got != "EmAKXgFpFH0Tb/MkBw=" { + t.Fatalf("expected signature 'EmAKXgFpFH0Tb/MkBw=', got %q", got) + } + + textBlock := blocks[1] + if textBlock.Get("type").String() != "text" || textBlock.Get("text").String() != "ok" { + t.Fatalf("expected visible text block with 'ok', got: %s", textBlock.Raw) + } +} + +func TestConvertGeminiResponseToClaudeNonStream_Issue6409_ThinkingFollowedBySignedVisibleText(t *testing.T) { + requestJSON := []byte(`{"model":"gemini-3.5-flash-lite","messages":[{"role":"user","content":"hi"}]}`) + geminiResponse := []byte(`{ + "candidates": [{ + "content": { + "parts": [ + {"text": "reasoning step", "thought": true, "thoughtSignature": "sig-think"}, + {"text": "final answer", "thoughtSignature": "sig-visible"} + ], + "role": "model" + }, + "finishReason": "STOP" + }], + "modelVersion": "gemini-3.5-flash-lite", + "responseId": "resp-mixed" + }`) + + ctx := context.Background() + output := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-3.5-flash-lite", requestJSON, requestJSON, geminiResponse, nil) + outputJSON := gjson.ParseBytes(output) + + blocks := outputJSON.Get("content").Array() + if len(blocks) != 3 { + t.Fatalf("expected 3 blocks (thinking, carrier thinking, text), got %d: %s", len(blocks), string(output)) + } + + if blocks[0].Get("type").String() != "thinking" || blocks[0].Get("thinking").String() != "reasoning step" || blocks[0].Get("signature").String() != "sig-think" { + t.Fatalf("block 0 must be thinking with its own signature, got: %s", blocks[0].Raw) + } + if blocks[1].Get("type").String() != "thinking" || blocks[1].Get("thinking").String() != "" || blocks[1].Get("signature").String() != "sig-visible" { + t.Fatalf("block 1 must be detached carrier thinking for visible text signature, got: %s", blocks[1].Raw) + } + if blocks[2].Get("type").String() != "text" || blocks[2].Get("text").String() != "final answer" { + t.Fatalf("block 2 must be text block with visible answer, got: %s", blocks[2].Raw) + } +} + +func TestConvertGeminiResponseToClaudeNonStream_Issue6409_ConsecutiveSignedVisibleTexts(t *testing.T) { + requestJSON := []byte(`{"model":"gemini-3.5-flash-lite","messages":[{"role":"user","content":"hi"}]}`) + geminiResponse := []byte(`{ + "candidates": [{ + "content": { + "parts": [ + {"text": "part A", "thoughtSignature": "sig-A"}, + {"text": "part B", "thoughtSignature": "sig-B"} + ], + "role": "model" + }, + "finishReason": "STOP" + }], + "modelVersion": "gemini-3.5-flash-lite", + "responseId": "resp-consecutive" + }`) + + ctx := context.Background() + output := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-3.5-flash-lite", requestJSON, requestJSON, geminiResponse, nil) + outputJSON := gjson.ParseBytes(output) + + blocks := outputJSON.Get("content").Array() + if len(blocks) != 4 { + t.Fatalf("expected 4 blocks (carrier A, text A, carrier B, text B), got %d: %s", len(blocks), string(output)) + } + + if blocks[0].Get("type").String() != "thinking" || blocks[0].Get("signature").String() != "sig-A" { + t.Fatalf("block 0 must be carrier for sig-A, got: %s", blocks[0].Raw) + } + if blocks[1].Get("type").String() != "text" || blocks[1].Get("text").String() != "part A" { + t.Fatalf("block 1 must be text part A, got: %s", blocks[1].Raw) + } + if blocks[2].Get("type").String() != "thinking" || blocks[2].Get("signature").String() != "sig-B" { + t.Fatalf("block 2 must be carrier for sig-B, got: %s", blocks[2].Raw) + } + if blocks[3].Get("type").String() != "text" || blocks[3].Get("text").String() != "part B" { + t.Fatalf("block 3 must be text part B, got: %s", blocks[3].Raw) + } +} + +func TestConvertGeminiResponseToClaudeStream_Issue6409_SignedVisibleText(t *testing.T) { + requestJSON := []byte(`{"model":"gemini-3.5-flash-lite","messages":[{"role":"user","content":"hi"}]}`) + chunk := []byte(`{ + "candidates": [{ + "content": { + "parts": [ + {"text": "ok", "thoughtSignature": "sig-stream-1"} + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0 + }], + "usageMetadata": { + "promptTokenCount": 5, + "candidatesTokenCount": 1 + }, + "modelVersion": "gemini-3.5-flash-lite", + "responseId": "resp-s1" + }`) + + var param any + ctx := context.Background() + output := bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-3.5-flash-lite", requestJSON, requestJSON, chunk, ¶m), nil) + output = append(output, bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-3.5-flash-lite", requestJSON, requestJSON, []byte("[DONE]"), ¶m), nil)...) + outputText := string(output) + + if strings.Contains(outputText, `"thinking_delta"`) { + t.Fatalf("signed visible text must not emit thinking_delta, got: %s", outputText) + } + + // Verify block 0 is carrier thinking + if !strings.Contains(outputText, `{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`) { + t.Fatalf("expected block 0 to be carrier thinking, got: %s", outputText) + } + if !strings.Contains(outputText, `{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig-stream-1"}}`) { + t.Fatalf("expected block 0 to have signature_delta, got: %s", outputText) + } + if !strings.Contains(outputText, `{"type":"content_block_stop","index":0}`) { + t.Fatalf("expected block 0 stop, got: %s", outputText) + } + + // Verify block 1 is visible text + if !strings.Contains(outputText, `{"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}`) { + t.Fatalf("expected block 1 to be text block, got: %s", outputText) + } + if !strings.Contains(outputText, `{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"ok"}}`) { + t.Fatalf("expected block 1 to have text_delta with 'ok', got: %s", outputText) + } + if !strings.Contains(outputText, `{"type":"content_block_stop","index":1}`) { + t.Fatalf("expected block 1 stop, got: %s", outputText) + } +} + +func TestConvertGeminiResponseToClaudeStream_Issue6409_SplitTrailingSignature(t *testing.T) { + requestJSON := []byte(`{"model":"gemini-3.5-flash-lite","messages":[{"role":"user","content":"hi"}]}`) + textChunk := []byte(`{ + "candidates": [{ + "content": { + "parts": [{"text": "ok"}] + } + }], + "modelVersion": "gemini-3.5-flash-lite", + "responseId": "resp-s2" + }`) + sigChunk := []byte(`{ + "candidates": [{ + "content": { + "parts": [{"text": "", "thoughtSignature": "sig-stream-trailing"}] + }, + "finishReason": "STOP" + }], + "usageMetadata": { + "promptTokenCount": 5, + "candidatesTokenCount": 1 + }, + "modelVersion": "gemini-3.5-flash-lite", + "responseId": "resp-s2" + }`) + + var param any + ctx := context.Background() + output := bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-3.5-flash-lite", requestJSON, requestJSON, textChunk, ¶m), nil) + output = append(output, bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-3.5-flash-lite", requestJSON, requestJSON, sigChunk, ¶m), nil)...) + output = append(output, bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-3.5-flash-lite", requestJSON, requestJSON, []byte("[DONE]"), ¶m), nil)...) + outputText := string(output) + + if strings.Contains(outputText, `"thinking_delta"`) { + t.Fatalf("unexpected thinking_delta in stream: %s", outputText) + } + + // Block 0: text block with "ok" + if !strings.Contains(outputText, `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`) { + t.Fatalf("expected block 0 text start, got: %s", outputText) + } + if !strings.Contains(outputText, `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}`) { + t.Fatalf("expected block 0 text delta, got: %s", outputText) + } + if !strings.Contains(outputText, `{"type":"content_block_stop","index":0}`) { + t.Fatalf("expected block 0 text stop, got: %s", outputText) + } + + // Block 1: trailing carrier thinking block + if !strings.Contains(outputText, `{"type":"content_block_start","index":1,"content_block":{"type":"thinking","thinking":""}}`) { + t.Fatalf("expected block 1 trailing carrier thinking, got: %s", outputText) + } + if !strings.Contains(outputText, `{"type":"content_block_delta","index":1,"delta":{"type":"signature_delta","signature":"sig-stream-trailing"}}`) { + t.Fatalf("expected block 1 signature_delta, got: %s", outputText) + } + if !strings.Contains(outputText, `{"type":"content_block_stop","index":1}`) { + t.Fatalf("expected block 1 stop, got: %s", outputText) + } +} + +func TestConvertGeminiResponseToClaudeStream_Issue6409_SignedFunctionCall(t *testing.T) { + requestJSON := []byte(`{"model":"gemini-3.5-flash-lite","messages":[{"role":"user","content":"hi"}]}`) + chunk := []byte(`{ + "candidates": [{ + "content": { + "parts": [ + {"thoughtSignature": "sig-fc-stream", "functionCall": {"name": "test_tool", "args": {}}} + ] + }, + "finishReason": "STOP" + }], + "usageMetadata": { + "promptTokenCount": 5, + "candidatesTokenCount": 1 + }, + "modelVersion": "gemini-3.5-flash-lite", + "responseId": "resp-fc" + }`) + + var param any + ctx := context.Background() + output := bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-3.5-flash-lite", requestJSON, requestJSON, chunk, ¶m), nil) + output = append(output, bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-3.5-flash-lite", requestJSON, requestJSON, []byte("[DONE]"), ¶m), nil)...) + outputText := string(output) + + // Block 0: carrier thinking + if !strings.Contains(outputText, `{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`) { + t.Fatalf("expected block 0 carrier thinking, got: %s", outputText) + } + if !strings.Contains(outputText, `{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig-fc-stream"}}`) { + t.Fatalf("expected block 0 signature_delta, got: %s", outputText) + } + if !strings.Contains(outputText, `{"type":"content_block_stop","index":0}`) { + t.Fatalf("expected block 0 stop, got: %s", outputText) + } + + // Block 1: tool_use + if !strings.Contains(outputText, `{"type":"content_block_start","index":1,"content_block":{"type":"tool_use"`) { + t.Fatalf("expected block 1 tool_use, got: %s", outputText) + } + if !strings.Contains(outputText, `{"type":"content_block_stop","index":1}`) { + t.Fatalf("expected block 1 tool_use stop, got: %s", outputText) + } +} + +func TestConvertGeminiResponseToClaudeStream_Issue6409_FunctionCallContinuationSignature(t *testing.T) { + requestJSON := []byte(`{"model":"gemini-3.5-flash-lite","messages":[{"role":"user","content":"hi"}]}`) + chunk1 := []byte(`{ + "candidates": [{ + "content": { + "parts": [ + {"functionCall": {"name": "test_tool", "args": {}}} + ] + } + }], + "modelVersion": "gemini-3.5-flash-lite", + "responseId": "resp-fc-cont" + }`) + chunk2 := []byte(`{ + "candidates": [{ + "content": { + "parts": [ + {"thoughtSignature": "sig-fc-cont-done", "functionCall": {"args": {"key":"val"}}} + ] + }, + "finishReason": "STOP" + }], + "modelVersion": "gemini-3.5-flash-lite", + "responseId": "resp-fc-cont" + }`) + + var param any + ctx := context.Background() + output := bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-3.5-flash-lite", requestJSON, requestJSON, chunk1, ¶m), nil) + output = append(output, bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-3.5-flash-lite", requestJSON, requestJSON, chunk2, ¶m), nil)...) + output = append(output, bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-3.5-flash-lite", requestJSON, requestJSON, []byte("[DONE]"), ¶m), nil)...) + outputText := string(output) + + if !strings.Contains(outputText, `"type":"input_json_delta"`) { + t.Fatalf("expected input_json_delta, got: %s", outputText) + } + if !strings.Contains(outputText, `"signature":"sig-fc-cont-done"`) { + t.Fatalf("expected signature_delta with 'sig-fc-cont-done', got: %s", outputText) + } +} + +func TestConvertGeminiResponseToClaudeStream_Issue6409_ThinkingFollowedBySignedVisibleText(t *testing.T) { + requestJSON := []byte(`{"model":"gemini-3.5-flash-lite","messages":[{"role":"user","content":"hi"}]}`) + chunk1 := []byte(`{ + "candidates": [{ + "content": { + "parts": [ + {"text": "thinking step", "thought": true, "thoughtSignature": "sig-stream-think"} + ] + } + }], + "modelVersion": "gemini-3.5-flash-lite", + "responseId": "resp-mixed-stream" + }`) + chunk2 := []byte(`{ + "candidates": [{ + "content": { + "parts": [ + {"text": "visible text", "thoughtSignature": "sig-stream-visible"} + ] + }, + "finishReason": "STOP" + }], + "modelVersion": "gemini-3.5-flash-lite", + "responseId": "resp-mixed-stream" + }`) + + var param any + ctx := context.Background() + output := bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-3.5-flash-lite", requestJSON, requestJSON, chunk1, ¶m), nil) + output = append(output, bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-3.5-flash-lite", requestJSON, requestJSON, chunk2, ¶m), nil)...) + output = append(output, bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-3.5-flash-lite", requestJSON, requestJSON, []byte("[DONE]"), ¶m), nil)...) + outputText := string(output) + + // Block 0: thinking with "sig-stream-think" + if !strings.Contains(outputText, `{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`) { + t.Fatalf("expected block 0 thinking start, got: %s", outputText) + } + if !strings.Contains(outputText, `{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig-stream-think"}}`) { + t.Fatalf("expected block 0 thinking signature, got: %s", outputText) + } + + // Block 1: detached carrier thinking for visible text signature "sig-stream-visible" + if !strings.Contains(outputText, `{"type":"content_block_start","index":1,"content_block":{"type":"thinking","thinking":""}}`) { + t.Fatalf("expected block 1 detached carrier thinking start, got: %s", outputText) + } + if !strings.Contains(outputText, `{"type":"content_block_delta","index":1,"delta":{"type":"signature_delta","signature":"sig-stream-visible"}}`) { + t.Fatalf("expected block 1 signature_delta 'sig-stream-visible', got: %s", outputText) + } + + // Block 2: text block with "visible text" + if !strings.Contains(outputText, `{"type":"content_block_start","index":2,"content_block":{"type":"text","text":""}}`) { + t.Fatalf("expected block 2 text start, got: %s", outputText) + } + if !strings.Contains(outputText, `{"type":"content_block_delta","index":2,"delta":{"type":"text_delta","text":"visible text"}}`) { + t.Fatalf("expected block 2 text_delta, got: %s", outputText) + } +}