diff --git a/internal/translator/antigravity/claude/antigravity_claude_response.go b/internal/translator/antigravity/claude/antigravity_claude_response.go index 427551df..757ce31d 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_response.go +++ b/internal/translator/antigravity/claude/antigravity_claude_response.go @@ -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() != "" { diff --git a/internal/translator/antigravity/claude/antigravity_claude_response_test.go b/internal/translator/antigravity/claude/antigravity_claude_response_test.go index 1490ab3c..fe4cb31f 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_response_test.go +++ b/internal/translator/antigravity/claude/antigravity_claude_response_test.go @@ -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) + } +} diff --git a/internal/translator/gemini/claude/gemini_claude_response.go b/internal/translator/gemini/claude/gemini_claude_response.go index 797636d8..8f55bd66 100644 --- a/internal/translator/gemini/claude/gemini_claude_response.go +++ b/internal/translator/gemini/claude/gemini_claude_response.go @@ -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 } } diff --git a/internal/translator/gemini/claude/gemini_claude_response_test.go b/internal/translator/gemini/claude/gemini_claude_response_test.go new file mode 100644 index 00000000..3c4d4351 --- /dev/null +++ b/internal/translator/gemini/claude/gemini_claude_response_test.go @@ -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) + } +}