mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-08 17:36:47 +08:00
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:
@@ -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, ¶m), nil)
|
||||
output = append(output, bytes.Join(ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, chunk2, ¶m), nil)...)
|
||||
output = append(output, bytes.Join(ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, []byte("[DONE]"), ¶m), 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, ¶m), nil)
|
||||
output = append(output, bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-test", requestJSON, requestJSON, signatureChunk, ¶m), nil)...)
|
||||
output = append(output, bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-test", requestJSON, requestJSON, []byte("[DONE]"), ¶m), 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user