feat(translator): implement signature delta handling and enhance chunk processing in Gemini and Antigravity translators

- Added support for processing `signature-only` chunks without opening empty text blocks.
- Refactored chunk handling logic to include `signature_delta` events for improved fidelity.
- Unified logic for emitting final message states, including `message_delta` and `message_stop`, to ensure consistent behavior across different scenarios.
- Enhanced caching mechanism for thought signatures.
This commit is contained in:
Luis Pater
2026-06-08 11:30:17 +08:00
parent d9a0c9bdc7
commit ec672446d1
4 changed files with 258 additions and 39 deletions
@@ -125,6 +125,19 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq
appendEvent := func(event, payload string) {
output = translatorcommon.AppendSSEEventString(output, event, payload, 3)
}
appendThinkingSignature := func(signature string) {
if signature == "" || params.ResponseType != 2 {
return
}
if params.CurrentThinkingText.Len() > 0 {
cache.CacheSignature(modelName, params.CurrentThinkingText.String(), signature)
params.CurrentThinkingText.Reset()
}
sigValue := formatClaudeSignatureValue(modelName, signature)
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, params.ResponseIndex)), "delta.signature", sigValue)
appendEvent("content_block_delta", string(data))
params.HasContent = true
}
// Initialize the streaming session with a message_start event
// This is only sent for the very first response chunk to establish the streaming session
@@ -164,12 +177,22 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq
// Extract the different types of content from each part
partTextResult := partResult.Get("text")
functionCallResult := partResult.Get("functionCall")
thoughtSignatureResult := partResult.Get("thoughtSignature")
if !thoughtSignatureResult.Exists() {
thoughtSignatureResult = partResult.Get("thought_signature")
}
hasThoughtSignature := thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != "" && !functionCallResult.Exists()
if hasThoughtSignature && !partTextResult.Exists() {
appendThinkingSignature(thoughtSignatureResult.String())
continue
}
// Handle text content (both regular content and thinking)
if partTextResult.Exists() {
// Process thinking content (internal reasoning)
if partResult.Get("thought").Bool() {
if thoughtSignature := partResult.Get("thoughtSignature"); thoughtSignature.Exists() && thoughtSignature.String() != "" {
if partResult.Get("thought").Bool() || hasThoughtSignature {
if hasThoughtSignature {
// log.Debug("Branch: signature_delta")
// Flush co-located text before emitting the signature
@@ -188,16 +211,7 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq
appendEvent("content_block_delta", string(data))
}
if params.CurrentThinkingText.Len() > 0 {
cache.CacheSignature(modelName, params.CurrentThinkingText.String(), thoughtSignature.String())
// log.Debugf("Cached signature for thinking block (textLen=%d)", params.CurrentThinkingText.Len())
params.CurrentThinkingText.Reset()
}
sigValue := formatClaudeSignatureValue(modelName, thoughtSignature.String())
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, params.ResponseIndex)), "delta.signature", sigValue)
appendEvent("content_block_delta", string(data))
params.HasContent = true
appendThinkingSignature(thoughtSignatureResult.String())
} else if params.ResponseType == 2 { // Continue existing thinking block if already in thinking state
params.CurrentThinkingText.WriteString(partTextResult.String())
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, params.ResponseIndex)), "delta.thinking", partTextResult.String())
@@ -474,15 +488,14 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or
if parts.IsArray() {
for _, part := range parts.Array() {
isThought := part.Get("thought").Bool()
if isThought {
sig := part.Get("thoughtSignature")
if !sig.Exists() {
sig = part.Get("thought_signature")
}
if sig.Exists() && sig.String() != "" {
thinkingSignature = sig.String()
}
sig := part.Get("thoughtSignature")
if !sig.Exists() {
sig = part.Get("thought_signature")
}
hasThoughtSignature := sig.Exists() && sig.String() != "" && !part.Get("functionCall").Exists()
isThought := part.Get("thought").Bool() || hasThoughtSignature
if hasThoughtSignature {
thinkingSignature = sig.String()
}
if text := part.Get("text"); text.Exists() && text.String() != "" {
@@ -7,6 +7,7 @@ import (
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/cache"
"github.com/tidwall/gjson"
)
// ============================================================================
@@ -347,3 +348,119 @@ func TestConvertAntigravityResponseToClaude_SignatureOnlyChunk(t *testing.T) {
t.Errorf("Signature-only chunk should still cache correctly, got %q", cachedSig)
}
}
func TestConvertAntigravityResponseToClaude_SignatureOnlyChunkWithoutThoughtFlag(t *testing.T) {
cache.ClearSignatureCache("")
requestJSON := []byte(`{
"model": "claude-sonnet-4-5-thinking",
"messages": [{"role": "user", "content": [{"type": "text", "text": "Test"}]}]
}`)
validSignature := "RtestSig1234567890123456789012345678901234567890123456789"
chunk1 := []byte(`{
"response": {
"candidates": [{
"content": {
"parts": [{"text": "Full thinking text.", "thought": true}]
}
}],
"modelVersion": "claude-sonnet-4-5-thinking",
"responseId": "resp-test"
}
}`)
chunk2 := []byte(`{
"response": {
"candidates": [{
"content": {
"parts": [{"text": "", "thoughtSignature": "` + validSignature + `"}]
},
"finishReason": "STOP"
}],
"usageMetadata": {
"promptTokenCount": 10,
"thoughtsTokenCount": 2,
"totalTokenCount": 12
},
"modelVersion": "claude-sonnet-4-5-thinking",
"responseId": "resp-test"
}
}`)
var param any
ctx := context.Background()
output := bytes.Join(ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, chunk1, &param), nil)
output = append(output, bytes.Join(ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, chunk2, &param), nil)...)
output = append(output, bytes.Join(ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, []byte("[DONE]"), &param), nil)...)
outputText := string(output)
if strings.Contains(outputText, `"content_block":{"type":"text"`) {
t.Fatalf("signature-only part must not open an empty text block: %s", outputText)
}
if strings.Contains(outputText, `"type":"content_block_stop","index":1`) {
t.Fatalf("signature-only part must not produce a stop for unopened index 1: %s", outputText)
}
if !strings.Contains(outputText, `"type":"signature_delta"`) {
t.Fatalf("signature-only part must be emitted as a thinking signature delta: %s", outputText)
}
if got := strings.Count(outputText, `"type":"content_block_stop","index":0`); got != 1 {
t.Fatalf("expected exactly one stop for thinking index 0, got %d: %s", got, outputText)
}
if !strings.Contains(outputText, `"type":"message_delta"`) || !strings.Contains(outputText, `"output_tokens":2`) {
t.Fatalf("finish chunk without candidatesTokenCount must still emit final message_delta: %s", outputText)
}
if !strings.Contains(outputText, `"type":"message_stop"`) {
t.Fatalf("DONE chunk must still emit message_stop after final events: %s", outputText)
}
cachedSig := cache.GetCachedSignature("claude-sonnet-4-5-thinking", "Full thinking text.")
if cachedSig != validSignature {
t.Fatalf("signature-only chunk without thought flag should still cache correctly, got %q", cachedSig)
}
}
func TestConvertAntigravityResponseToClaudeNonStream_SignatureOnlyPartWithoutThoughtFlag(t *testing.T) {
previousCache := cache.SignatureCacheEnabled()
cache.SetSignatureCacheEnabled(false)
defer cache.SetSignatureCacheEnabled(previousCache)
requestJSON := []byte(`{"model":"claude-sonnet-4-5-thinking"}`)
validSignature := "EtestSig1234567890123456789012345678901234567890123456789"
responseJSON := []byte(`{
"response": {
"candidates": [{
"content": {
"parts": [
{"text": "Full thinking text.", "thought": true},
{"text": "", "thoughtSignature": "` + validSignature + `"}
]
},
"finishReason": "STOP"
}],
"usageMetadata": {
"promptTokenCount": 10,
"thoughtsTokenCount": 2,
"totalTokenCount": 12
},
"modelVersion": "claude-sonnet-4-5-thinking",
"responseId": "resp-test"
}
}`)
output := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "claude-sonnet-4-5-thinking", requestJSON, requestJSON, responseJSON, nil)
if got := gjson.GetBytes(output, "content.#").Int(); got != 1 {
t.Fatalf("expected exactly one content block, got %d: %s", got, output)
}
if got := gjson.GetBytes(output, "content.0.type").String(); got != "thinking" {
t.Fatalf("expected thinking content block, got %q: %s", got, output)
}
if got := gjson.GetBytes(output, "content.0.thinking").String(); got != "Full thinking text." {
t.Fatalf("unexpected thinking text %q: %s", got, output)
}
if got := gjson.GetBytes(output, "content.0.signature").String(); got != validSignature {
t.Fatalf("expected signature %q, got %q: %s", validSignature, got, output)
}
}
@@ -29,6 +29,7 @@ type Params struct {
ToolNameMap map[string]string
SanitizedNameMap map[string]string
SawToolCall bool
HasFinalEvents bool
}
// toolUseIDCounter provides a process-wide unique counter for tool use identifiers.
@@ -75,6 +76,14 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR
appendEvent := func(event, payload string) {
output = translatorcommon.AppendSSEEventString(output, event, payload, 3)
}
appendSignatureDelta := func(signature string) {
if signature == "" || (*param).(*Params).ResponseType != 2 {
return
}
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, (*param).(*Params).ResponseIndex)), "delta.signature", signature)
appendEvent("content_block_delta", string(data))
(*param).(*Params).HasContent = true
}
// Initialize the streaming session with a message_start event
// This is only sent for the very first response chunk
@@ -106,11 +115,25 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR
// Extract the different types of content from each part
partTextResult := partResult.Get("text")
functionCallResult := partResult.Get("functionCall")
thoughtSignatureResult := partResult.Get("thoughtSignature")
if !thoughtSignatureResult.Exists() {
thoughtSignatureResult = partResult.Get("thought_signature")
}
hasThoughtSignature := thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != ""
if hasThoughtSignature && !partTextResult.Exists() && !functionCallResult.Exists() {
appendSignatureDelta(thoughtSignatureResult.String())
continue
}
// Handle text content (both regular content and thinking)
if partTextResult.Exists() {
// Process thinking content (internal reasoning)
if partResult.Get("thought").Bool() {
if partResult.Get("thought").Bool() || hasThoughtSignature {
if hasThoughtSignature && partTextResult.String() == "" {
appendSignatureDelta(thoughtSignatureResult.String())
continue
}
// Continue existing thinking block
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())
@@ -136,6 +159,7 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR
(*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
@@ -223,25 +247,28 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR
}
usageResult := gjson.GetBytes(rawJSON, "usageMetadata")
if usageResult.Exists() && bytes.Contains(rawJSON, []byte(`"finishReason"`)) {
if candidatesTokenCountResult := usageResult.Get("candidatesTokenCount"); candidatesTokenCountResult.Exists() {
// Only send final events if we have actually output content
if (*param).(*Params).HasContent {
if usageResult.Exists() && bytes.Contains(rawJSON, []byte(`"finishReason"`)) && !(*param).(*Params).HasFinalEvents {
// Only send final events if we have actually output content
if (*param).(*Params).HasContent {
if (*param).(*Params).ResponseType != 0 {
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex))
template := []byte(`{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
if (*param).(*Params).SawToolCall {
template = []byte(`{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
} else if finish := gjson.GetBytes(rawJSON, "candidates.0.finishReason"); finish.Exists() && finish.String() == "MAX_TOKENS" {
template = []byte(`{"type":"message_delta","delta":{"stop_reason":"max_tokens","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
}
thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
template, _ = sjson.SetBytes(template, "usage.output_tokens", candidatesTokenCountResult.Int()+thoughtsTokenCount)
template, _ = sjson.SetBytes(template, "usage.input_tokens", usageResult.Get("promptTokenCount").Int())
appendEvent("message_delta", string(template))
(*param).(*Params).ResponseType = 0
}
template := []byte(`{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
if (*param).(*Params).SawToolCall {
template = []byte(`{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
} else if finish := gjson.GetBytes(rawJSON, "candidates.0.finishReason"); finish.Exists() && finish.String() == "MAX_TOKENS" {
template = []byte(`{"type":"message_delta","delta":{"stop_reason":"max_tokens","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
}
thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
candidatesTokenCount := usageResult.Get("candidatesTokenCount").Int()
template, _ = sjson.SetBytes(template, "usage.output_tokens", candidatesTokenCount+thoughtsTokenCount)
template, _ = sjson.SetBytes(template, "usage.input_tokens", usageResult.Get("promptTokenCount").Int())
appendEvent("message_delta", string(template))
(*param).(*Params).HasFinalEvents = true
}
}
@@ -0,0 +1,62 @@
package claude
import (
"bytes"
"context"
"strings"
"testing"
)
func TestConvertGeminiResponseToClaude_SignatureOnlyPartDoesNotOpenEmptyTextBlock(t *testing.T) {
requestJSON := []byte(`{"model":"gemini-test","messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`)
thinkingChunk := []byte(`{
"candidates": [{
"content": {
"parts": [{"text": "thinking text", "thought": true}]
}
}],
"modelVersion": "gemini-test",
"responseId": "resp-test"
}`)
signatureChunk := []byte(`{
"candidates": [{
"content": {
"parts": [{"text": "", "thoughtSignature": "sig-test"}]
},
"finishReason": "STOP"
}],
"usageMetadata": {
"promptTokenCount": 10,
"thoughtsTokenCount": 2,
"totalTokenCount": 12
},
"modelVersion": "gemini-test",
"responseId": "resp-test"
}`)
var param any
ctx := context.Background()
output := bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-test", requestJSON, requestJSON, thinkingChunk, &param), nil)
output = append(output, bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-test", requestJSON, requestJSON, signatureChunk, &param), nil)...)
output = append(output, bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-test", requestJSON, requestJSON, []byte("[DONE]"), &param), nil)...)
outputText := string(output)
if strings.Contains(outputText, `"content_block":{"type":"text"`) {
t.Fatalf("signature-only part must not open an empty text block: %s", outputText)
}
if strings.Contains(outputText, `"type":"content_block_stop","index":1`) {
t.Fatalf("signature-only part must not produce a stop for unopened index 1: %s", outputText)
}
if !strings.Contains(outputText, `"type":"signature_delta"`) || !strings.Contains(outputText, `"signature":"sig-test"`) {
t.Fatalf("signature-only part must be emitted as a thinking signature delta: %s", outputText)
}
if got := strings.Count(outputText, `"type":"content_block_stop","index":0`); got != 1 {
t.Fatalf("expected exactly one stop for thinking index 0, got %d: %s", got, outputText)
}
if !strings.Contains(outputText, `"type":"message_delta"`) || !strings.Contains(outputText, `"output_tokens":2`) {
t.Fatalf("finish chunk without candidatesTokenCount must still emit final message_delta: %s", outputText)
}
if !strings.Contains(outputText, `"type":"message_stop"`) {
t.Fatalf("DONE chunk must still emit message_stop after final events: %s", outputText)
}
}