mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-17 21:19:36 +08:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user