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
This commit is contained in:
Luis Pater
2026-10-06 15:43:01 +08:00
parent a0d31924e0
commit 68ea171da2
2 changed files with 544 additions and 95 deletions

View File

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

View File

@@ -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, &param), nil)
output = append(output, bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-3.5-flash-lite", requestJSON, requestJSON, []byte("[DONE]"), &param), 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, &param), nil)
output = append(output, bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-3.5-flash-lite", requestJSON, requestJSON, sigChunk, &param), nil)...)
output = append(output, bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-3.5-flash-lite", requestJSON, requestJSON, []byte("[DONE]"), &param), 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, &param), nil)
output = append(output, bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-3.5-flash-lite", requestJSON, requestJSON, []byte("[DONE]"), &param), 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, &param), nil)
output = append(output, bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-3.5-flash-lite", requestJSON, requestJSON, chunk2, &param), nil)...)
output = append(output, bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-3.5-flash-lite", requestJSON, requestJSON, []byte("[DONE]"), &param), 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, &param), nil)
output = append(output, bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-3.5-flash-lite", requestJSON, requestJSON, chunk2, &param), nil)...)
output = append(output, bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-3.5-flash-lite", requestJSON, requestJSON, []byte("[DONE]"), &param), 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)
}
}