mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-05-13 23:01:40 +00:00
Antigravity 的 Claude thinking signature 处理新增 cache/bypass 双模式, 并为 bypass 模式实现按 SIGNATURE-CHANNEL-SPEC.md 的签名校验。 新增 antigravity-signature-cache-enabled 配置项(默认 true): - cache mode(true):使用服务端缓存的签名,行为与原有逻辑完全一致 - bypass mode(false):直接使用客户端提供的签名,经过校验和归一化 支持配置热重载,运行时可切换模式。 校验流程: 1. 剥离历史 cache-mode 的 'modelGroup#' 前缀(如 claude#Exxxx → Exxxx) 2. 首字符必须为 'E'(单层编码)或 'R'(双层编码),否则拒绝 3. R 开头:base64 解码 → 内层必须以 'E' 开头 → 继续单层校验 4. E 开头:base64 解码 → 首字节必须为 0x12(Claude protobuf 标识) 5. 所有合法签名归一化为 R 形式(双层 base64)发往 Antigravity 后端 非法签名处理策略: - 非严格模式(默认):translator 静默丢弃无签名的 thinking block - 严格模式(antigravity-signature-bypass-strict: true): executor 层在请求发往上游前直接返回 HTTP 400 按 SIGNATURE-CHANNEL-SPEC.md 解析 Claude 签名的完整 protobuf 结构: - Top-level Field 2(容器)→ Field 1(渠道块) - 渠道块提取:channel_id (Field 1)、infrastructure (Field 2)、 model_text (Field 6)、field7 (Field 7) - 计算 routing_class、infrastructure_class、schema_features - 使用 google.golang.org/protobuf/encoding/protowire 解析 - resolveThinkingSignature 拆分为 resolveCacheModeSignature / resolveBypassModeSignature - hasResolvedThinkingSignature:mode-aware 签名有效性判断 (cache: len>=50 via HasValidSignature,bypass: non-empty) - validateAntigravityRequestSignatures:executor 预检, 仅在 bypass + strict 模式下拦截非法签名返回 400 - 响应侧签名缓存逻辑与 cache mode 集成 - Cache mode 行为完全保留:无 '#' 前缀的原生签名静默丢弃
350 lines
9.9 KiB
Go
350 lines
9.9 KiB
Go
package claude
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
|
|
)
|
|
|
|
// ============================================================================
|
|
// Signature Caching Tests
|
|
// ============================================================================
|
|
|
|
func TestConvertAntigravityResponseToClaude_ParamsInitialized(t *testing.T) {
|
|
cache.ClearSignatureCache("")
|
|
|
|
// Request with user message - should initialize params
|
|
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, ¶m)
|
|
|
|
params := param.(*Params)
|
|
if !params.HasFirstResponse {
|
|
t.Error("HasFirstResponse should be set after first chunk")
|
|
}
|
|
if params.CurrentThinkingText.Len() == 0 {
|
|
t.Error("Thinking text should be accumulated")
|
|
}
|
|
}
|
|
|
|
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, ¶m)
|
|
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, ¶m)
|
|
|
|
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(`{
|
|
"model": "claude-sonnet-4-5-thinking",
|
|
"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, ¶m)
|
|
params := param.(*Params)
|
|
thinkingText := params.CurrentThinkingText.String()
|
|
|
|
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, ¶m)
|
|
|
|
// Verify signature was cached
|
|
cachedSig := cache.GetCachedSignature("claude-sonnet-4-5-thinking", 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(`{
|
|
"model": "claude-sonnet-4-5-thinking",
|
|
"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, ¶m)
|
|
params := param.(*Params)
|
|
firstThinkingText := params.CurrentThinkingText.String()
|
|
|
|
ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, block1Sig, ¶m)
|
|
|
|
// Verify first signature cached
|
|
if cache.GetCachedSignature("claude-sonnet-4-5-thinking", 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, ¶m)
|
|
|
|
// Process second thinking block
|
|
ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, block2Thinking, ¶m)
|
|
secondThinkingText := params.CurrentThinkingText.String()
|
|
|
|
ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, block2Sig, ¶m)
|
|
|
|
// Verify second signature cached
|
|
if cache.GetCachedSignature("claude-sonnet-4-5-thinking", secondThinkingText) != validSig2 {
|
|
t.Error("Second thinking block signature should be cached")
|
|
}
|
|
}
|
|
|
|
func TestConvertAntigravityResponseToClaude_TextAndSignatureInSameChunk(t *testing.T) {
|
|
cache.ClearSignatureCache("")
|
|
|
|
requestJSON := []byte(`{
|
|
"model": "claude-sonnet-4-5-thinking",
|
|
"messages": [{"role": "user", "content": [{"type": "text", "text": "Test"}]}]
|
|
}`)
|
|
|
|
validSignature := "RtestSig1234567890123456789012345678901234567890123456789"
|
|
|
|
// Chunk 1: thinking text only (no signature)
|
|
chunk1 := []byte(`{
|
|
"response": {
|
|
"candidates": [{
|
|
"content": {
|
|
"parts": [{"text": "First part.", "thought": true}]
|
|
}
|
|
}]
|
|
}
|
|
}`)
|
|
|
|
// Chunk 2: thinking text AND signature in the same part
|
|
chunk2 := []byte(`{
|
|
"response": {
|
|
"candidates": [{
|
|
"content": {
|
|
"parts": [{"text": " Second part.", "thought": true, "thoughtSignature": "` + validSignature + `"}]
|
|
}
|
|
}]
|
|
}
|
|
}`)
|
|
|
|
var param any
|
|
ctx := context.Background()
|
|
|
|
result1 := ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, chunk1, ¶m)
|
|
result2 := ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, chunk2, ¶m)
|
|
|
|
allOutput := string(bytes.Join(result1, nil)) + string(bytes.Join(result2, nil))
|
|
|
|
// The text " Second part." must appear as a thinking_delta, not be silently dropped
|
|
if !strings.Contains(allOutput, "Second part.") {
|
|
t.Error("Text co-located with signature must be emitted as thinking_delta before the signature")
|
|
}
|
|
|
|
// The signature must also be emitted
|
|
if !strings.Contains(allOutput, "signature_delta") {
|
|
t.Error("Signature delta must still be emitted")
|
|
}
|
|
|
|
// Verify the cached signature covers the FULL text (both parts)
|
|
fullText := "First part. Second part."
|
|
cachedSig := cache.GetCachedSignature("claude-sonnet-4-5-thinking", fullText)
|
|
if cachedSig != validSignature {
|
|
t.Errorf("Cached signature should cover full text %q, got sig=%q", fullText, cachedSig)
|
|
}
|
|
}
|
|
|
|
func TestConvertAntigravityResponseToClaude_SignatureOnlyChunk(t *testing.T) {
|
|
cache.ClearSignatureCache("")
|
|
|
|
requestJSON := []byte(`{
|
|
"model": "claude-sonnet-4-5-thinking",
|
|
"messages": [{"role": "user", "content": [{"type": "text", "text": "Test"}]}]
|
|
}`)
|
|
|
|
validSignature := "RtestSig1234567890123456789012345678901234567890123456789"
|
|
|
|
// Chunk 1: thinking text
|
|
chunk1 := []byte(`{
|
|
"response": {
|
|
"candidates": [{
|
|
"content": {
|
|
"parts": [{"text": "Full thinking text.", "thought": true}]
|
|
}
|
|
}]
|
|
}
|
|
}`)
|
|
|
|
// Chunk 2: signature only (empty text) — the normal case
|
|
chunk2 := []byte(`{
|
|
"response": {
|
|
"candidates": [{
|
|
"content": {
|
|
"parts": [{"text": "", "thought": true, "thoughtSignature": "` + validSignature + `"}]
|
|
}
|
|
}]
|
|
}
|
|
}`)
|
|
|
|
var param any
|
|
ctx := context.Background()
|
|
|
|
ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, chunk1, ¶m)
|
|
ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, chunk2, ¶m)
|
|
|
|
cachedSig := cache.GetCachedSignature("claude-sonnet-4-5-thinking", "Full thinking text.")
|
|
if cachedSig != validSignature {
|
|
t.Errorf("Signature-only chunk should still cache correctly, got %q", cachedSig)
|
|
}
|
|
}
|