Files
CLIProxyAPI/internal/translator/antigravity/claude/antigravity_claude_response_test.go
이대희 1e9e4a86a2 Improve thinking/tool signature handling for Claude and Gemini requests
Prefer cached signatures and avoid injecting dummy thinking blocks; instead remove unsigned thinking blocks and add a skip sentinel for tool calls without a valid signature. Generate stable session IDs from the first user message, apply schema cleaning only for Claude models, and reorder thinking parts so thinking appears first. For Gemini, remove thinking blocks and attach a skip sentinel to function calls. Simplify response handling by passing raw function args through (remove special Bash conversion). Update and add tests to reflect the new behavior.

These changes prevent rejected dummy signatures, improve compatibility with Antigravity’s signature validation, provide more stable session IDs for conversation grouping, and make request/response translation more robust.
2025-12-21 15:15:50 +09:00

317 lines
8.4 KiB
Go

package claude
import (
"context"
"strings"
"testing"
"github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
)
// ============================================================================
// Signature Caching Tests
// ============================================================================
func TestConvertAntigravityResponseToClaude_SessionIDDerived(t *testing.T) {
cache.ClearSignatureCache("")
// Request with user message - should derive session ID
requestJSON := []byte(`{
"messages": [
{"role": "user", "content": [{"type": "text", "text": "Hello world"}]}
]
}`)
// First response chunk with thinking
responseJSON := []byte(`{
"response": {
"candidates": [{
"content": {
"parts": [{"text": "Let me think...", "thought": true}]
}
}]
}
}`)
var param any
ctx := context.Background()
ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, responseJSON, &param)
// Verify session ID was set
params := param.(*Params)
if params.SessionID == "" {
t.Error("SessionID should be derived from request")
}
}
func TestConvertAntigravityResponseToClaude_ThinkingTextAccumulated(t *testing.T) {
cache.ClearSignatureCache("")
requestJSON := []byte(`{
"messages": [{"role": "user", "content": [{"type": "text", "text": "Test"}]}]
}`)
// First thinking chunk
chunk1 := []byte(`{
"response": {
"candidates": [{
"content": {
"parts": [{"text": "First part of thinking...", "thought": true}]
}
}]
}
}`)
// Second thinking chunk (continuation)
chunk2 := []byte(`{
"response": {
"candidates": [{
"content": {
"parts": [{"text": " Second part of thinking...", "thought": true}]
}
}]
}
}`)
var param any
ctx := context.Background()
// Process first chunk - starts new thinking block
ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, chunk1, &param)
params := param.(*Params)
if params.CurrentThinkingText.Len() == 0 {
t.Error("Thinking text should be accumulated after first chunk")
}
// Process second chunk - continues thinking block
ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, chunk2, &param)
text := params.CurrentThinkingText.String()
if !strings.Contains(text, "First part") || !strings.Contains(text, "Second part") {
t.Errorf("Thinking text should accumulate both parts, got: %s", text)
}
}
func TestConvertAntigravityResponseToClaude_SignatureCached(t *testing.T) {
cache.ClearSignatureCache("")
requestJSON := []byte(`{
"messages": [{"role": "user", "content": [{"type": "text", "text": "Cache test"}]}]
}`)
// Thinking chunk
thinkingChunk := []byte(`{
"response": {
"candidates": [{
"content": {
"parts": [{"text": "My thinking process here", "thought": true}]
}
}]
}
}`)
// Signature chunk
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
signatureChunk := []byte(`{
"response": {
"candidates": [{
"content": {
"parts": [{"text": "", "thought": true, "thoughtSignature": "` + validSignature + `"}]
}
}]
}
}`)
var param any
ctx := context.Background()
// Process thinking chunk
ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, thinkingChunk, &param)
params := param.(*Params)
sessionID := params.SessionID
thinkingText := params.CurrentThinkingText.String()
if sessionID == "" {
t.Fatal("SessionID should be set")
}
if thinkingText == "" {
t.Fatal("Thinking text should be accumulated")
}
// Process signature chunk - should cache the signature
ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, signatureChunk, &param)
// Verify signature was cached
cachedSig := cache.GetCachedSignature(sessionID, thinkingText)
if cachedSig != validSignature {
t.Errorf("Expected cached signature '%s', got '%s'", validSignature, cachedSig)
}
// Verify thinking text was reset after caching
if params.CurrentThinkingText.Len() != 0 {
t.Error("Thinking text should be reset after signature is cached")
}
}
func TestConvertAntigravityResponseToClaude_MultipleThinkingBlocks(t *testing.T) {
cache.ClearSignatureCache("")
requestJSON := []byte(`{
"messages": [{"role": "user", "content": [{"type": "text", "text": "Multi block test"}]}]
}`)
validSig1 := "signature1_12345678901234567890123456789012345678901234567"
validSig2 := "signature2_12345678901234567890123456789012345678901234567"
// First thinking block with signature
block1Thinking := []byte(`{
"response": {
"candidates": [{
"content": {
"parts": [{"text": "First thinking block", "thought": true}]
}
}]
}
}`)
block1Sig := []byte(`{
"response": {
"candidates": [{
"content": {
"parts": [{"text": "", "thought": true, "thoughtSignature": "` + validSig1 + `"}]
}
}]
}
}`)
// Text content (breaks thinking)
textBlock := []byte(`{
"response": {
"candidates": [{
"content": {
"parts": [{"text": "Regular text output"}]
}
}]
}
}`)
// Second thinking block with signature
block2Thinking := []byte(`{
"response": {
"candidates": [{
"content": {
"parts": [{"text": "Second thinking block", "thought": true}]
}
}]
}
}`)
block2Sig := []byte(`{
"response": {
"candidates": [{
"content": {
"parts": [{"text": "", "thought": true, "thoughtSignature": "` + validSig2 + `"}]
}
}]
}
}`)
var param any
ctx := context.Background()
// Process first thinking block
ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, block1Thinking, &param)
params := param.(*Params)
sessionID := params.SessionID
firstThinkingText := params.CurrentThinkingText.String()
ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, block1Sig, &param)
// Verify first signature cached
if cache.GetCachedSignature(sessionID, firstThinkingText) != validSig1 {
t.Error("First thinking block signature should be cached")
}
// Process text (transitions out of thinking)
ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, textBlock, &param)
// Process second thinking block
ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, block2Thinking, &param)
secondThinkingText := params.CurrentThinkingText.String()
ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, block2Sig, &param)
// Verify second signature cached
if cache.GetCachedSignature(sessionID, secondThinkingText) != validSig2 {
t.Error("Second thinking block signature should be cached")
}
}
func TestDeriveSessionIDFromRequest(t *testing.T) {
tests := []struct {
name string
input []byte
wantEmpty bool
}{
{
name: "valid user message",
input: []byte(`{"messages": [{"role": "user", "content": "Hello"}]}`),
wantEmpty: false,
},
{
name: "user message with content array",
input: []byte(`{"messages": [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}]}`),
wantEmpty: false,
},
{
name: "no user message",
input: []byte(`{"messages": [{"role": "assistant", "content": "Hi"}]}`),
wantEmpty: true,
},
{
name: "empty messages",
input: []byte(`{"messages": []}`),
wantEmpty: true,
},
{
name: "no messages field",
input: []byte(`{}`),
wantEmpty: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := deriveSessionID(tt.input)
if tt.wantEmpty && result != "" {
t.Errorf("Expected empty session ID, got '%s'", result)
}
if !tt.wantEmpty && result == "" {
t.Error("Expected non-empty session ID")
}
})
}
}
func TestDeriveSessionIDFromRequest_Deterministic(t *testing.T) {
input := []byte(`{"messages": [{"role": "user", "content": "Same message"}]}`)
id1 := deriveSessionID(input)
id2 := deriveSessionID(input)
if id1 != id2 {
t.Errorf("Session ID should be deterministic: '%s' != '%s'", id1, id2)
}
}
func TestDeriveSessionIDFromRequest_DifferentMessages(t *testing.T) {
input1 := []byte(`{"messages": [{"role": "user", "content": "Message A"}]}`)
input2 := []byte(`{"messages": [{"role": "user", "content": "Message B"}]}`)
id1 := deriveSessionID(input1)
id2 := deriveSessionID(input2)
if id1 == id2 {
t.Error("Different messages should produce different session IDs")
}
}