History

Merge pull request #2412 from sususu98/feat/signature-cache-toggle

Luis Pater committed 7 months ago

Commit 39dc4552 parents 30e94b6 38f0ae511 files · +1534 −62Browse files

Unified · Split

11 files
  1. config.example.yaml +10 −0
  2. internal/api/server.go +41 −0
  3. internal/cache/signature_cache.go +39 −0
  4. internal/config/config.go +7 −0
  5. internal/runtime/executor/antigravity_executor.go +60 −28
  6. internal/runtime/executor/antigravity_executor_signature_test.go +157 −0
  7. internal/translator/antigravity/claude/antigravity_claude_request.go +55 −32
  8. internal/translator/antigravity/claude/antigravity_claude_request_test.go +623 −0
  9. internal/translator/antigravity/claude/antigravity_claude_response.go +48 −2
  10. internal/translator/antigravity/claude/antigravity_claude_response_test.go +103 −0
  11. internal/translator/antigravity/claude/signature_validation.go +391 −0
config.example.yaml+10−0
@@ -114,6 +114,16 @@ nonstream-keepalive-interval: 0 · 113 unmodified lines
114# keepalive-seconds: 15 # Default: 0 (disabled). <= 0 disables keep-alives.114# keepalive-seconds: 15 # Default: 0 (disabled). <= 0 disables keep-alives.
115# bootstrap-retries: 1 # Default: 0 (disabled). Retries before first byte is sent.115# bootstrap-retries: 1 # Default: 0 (disabled). Retries before first byte is sent.
116 116
117# Signature cache validation for thinking blocks (Antigravity/Claude).
118# When true (default), cached signatures are preferred and validated.
119# When false, client signatures are used directly after normalization (bypass mode for testing).
120# antigravity-signature-cache-enabled: true
121
122# Bypass mode signature validation strictness (only applies when signature cache is disabled).
123# When true, validates full Claude protobuf tree (Field 2 -> Field 1 structure).
124# When false (default), only checks R/E prefix + base64 + first byte 0x12.
125# antigravity-signature-bypass-strict: false
126
117# Gemini API keys127# Gemini API keys
118# gemini-api-key:128# gemini-api-key:
119# - api-key: "AIzaSy...01"129# - api-key: "AIzaSy...01"
internal/api/server.go+41−0
@@ -24,6 +24,7 @@ import ( · 23 unmodified lines
24 "github.com/router-for-me/CLIProxyAPI/v6/internal/api/middleware"24 "github.com/router-for-me/CLIProxyAPI/v6/internal/api/middleware"
25 "github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules"25 "github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules"
26 ampmodule "github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules/amp"26 ampmodule "github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules/amp"
27 "github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
27 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"28 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
28 "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"29 "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
29 "github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset"30 "github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset"
@@ -261,6 +262,7 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk · 231 unmodified lines
261 }262 }
262 managementasset.SetCurrentConfig(cfg)263 managementasset.SetCurrentConfig(cfg)
263 auth.SetQuotaCooldownDisabled(cfg.DisableCooling)264 auth.SetQuotaCooldownDisabled(cfg.DisableCooling)
265 applySignatureCacheConfig(nil, cfg)
264 // Initialize management handler266 // Initialize management handler
265 s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager)267 s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager)
266 if optionState.localPassword != "" {268 if optionState.localPassword != "" {
@@ -918,6 +920,8 @@ func (s *Server) UpdateClients(cfg *config.Config) { · 651 unmodified lines
918 auth.SetQuotaCooldownDisabled(cfg.DisableCooling)920 auth.SetQuotaCooldownDisabled(cfg.DisableCooling)
919 }921 }
920 922
923 applySignatureCacheConfig(oldCfg, cfg)
924
921 if s.handlers != nil && s.handlers.AuthManager != nil {925 if s.handlers != nil && s.handlers.AuthManager != nil {
922 s.handlers.AuthManager.SetRetryConfig(cfg.RequestRetry, time.Duration(cfg.MaxRetryInterval)*time.Second, cfg.MaxRetryCredentials)926 s.handlers.AuthManager.SetRetryConfig(cfg.RequestRetry, time.Duration(cfg.MaxRetryInterval)*time.Second, cfg.MaxRetryCredentials)
923 }927 }
@@ -1056,3 +1060,40 @@ func AuthMiddleware(manager *sdkaccess.Manager) gin.HandlerFunc { · 132 unmodified lines
1056 c.AbortWithStatusJSON(statusCode, gin.H{"error": err.Message})1060 c.AbortWithStatusJSON(statusCode, gin.H{"error": err.Message})
1057 }1061 }
1058}1062}
1063
1064func configuredSignatureCacheEnabled(cfg *config.Config) bool {
1065 if cfg != nil && cfg.AntigravitySignatureCacheEnabled != nil {
1066 return *cfg.AntigravitySignatureCacheEnabled
1067 }
1068 return true
1069}
1070
1071func applySignatureCacheConfig(oldCfg, cfg *config.Config) {
1072 newVal := configuredSignatureCacheEnabled(cfg)
1073 newStrict := configuredSignatureBypassStrict(cfg)
1074 if oldCfg == nil {
1075 cache.SetSignatureCacheEnabled(newVal)
1076 cache.SetSignatureBypassStrictMode(newStrict)
1077 log.Debugf("antigravity_signature_cache_enabled toggled to %t", newVal)
1078 return
1079 }
1080
1081 oldVal := configuredSignatureCacheEnabled(oldCfg)
1082 if oldVal != newVal {
1083 cache.SetSignatureCacheEnabled(newVal)
1084 log.Debugf("antigravity_signature_cache_enabled updated from %t to %t", oldVal, newVal)
1085 }
1086
1087 oldStrict := configuredSignatureBypassStrict(oldCfg)
1088 if oldStrict != newStrict {
1089 cache.SetSignatureBypassStrictMode(newStrict)
1090 log.Debugf("antigravity_signature_bypass_strict updated from %t to %t", oldStrict, newStrict)
1091 }
1092}
1093
1094func configuredSignatureBypassStrict(cfg *config.Config) bool {
1095 if cfg != nil && cfg.AntigravitySignatureBypassStrict != nil {
1096 return *cfg.AntigravitySignatureBypassStrict
1097 }
1098 return false
1099}
internal/cache/signature_cache.go+39−0
@@ -5,7 +5,10 @@ import ( · 4 unmodified lines
5 "encoding/hex"5 "encoding/hex"
6 "strings"6 "strings"
7 "sync"7 "sync"
8 "sync/atomic"
8 "time"9 "time"
10
11 log "github.com/sirupsen/logrus"
9)12)
10 13
11// SignatureEntry holds a cached thinking signature with timestamp14// SignatureEntry holds a cached thinking signature with timestamp
@@ -193,3 +196,39 @@ func GetModelGroup(modelName string) string { · 181 unmodified lines
193 }196 }
194 return modelName197 return modelName
195}198}
199
200var signatureCacheEnabled atomic.Bool
201var signatureBypassStrictMode atomic.Bool
202
203func init() {
204 signatureCacheEnabled.Store(true)
205 signatureBypassStrictMode.Store(false)
206}
207
208// SetSignatureCacheEnabled switches Antigravity signature handling between cache mode and bypass mode.
209func SetSignatureCacheEnabled(enabled bool) {
210 signatureCacheEnabled.Store(enabled)
211 if !enabled {
212 log.Warn("antigravity signature cache DISABLED - bypass mode active, cached signatures will not be used for request translation")
213 }
214}
215
216// SignatureCacheEnabled returns whether signature cache validation is enabled.
217func SignatureCacheEnabled() bool {
218 return signatureCacheEnabled.Load()
219}
220
221// SetSignatureBypassStrictMode controls whether bypass mode uses strict protobuf-tree validation.
222func SetSignatureBypassStrictMode(strict bool) {
223 signatureBypassStrictMode.Store(strict)
224 if strict {
225 log.Info("antigravity bypass signature validation: strict mode (protobuf tree)")
226 } else {
227 log.Info("antigravity bypass signature validation: basic mode (R/E + 0x12)")
228 }
229}
230
231// SignatureBypassStrictMode returns whether bypass mode uses strict protobuf-tree validation.
232func SignatureBypassStrictMode() bool {
233 return signatureBypassStrictMode.Load()
234}
internal/config/config.go+7−0
@@ -85,6 +85,13 @@ type Config struct { · 84 unmodified lines
85 // WebsocketAuth enables or disables authentication for the WebSocket API.85 // WebsocketAuth enables or disables authentication for the WebSocket API.
86 WebsocketAuth bool `yaml:"ws-auth" json:"ws-auth"`86 WebsocketAuth bool `yaml:"ws-auth" json:"ws-auth"`
87 87
88 // AntigravitySignatureCacheEnabled controls whether signature cache validation is enabled for thinking blocks.
89 // When true (default), cached signatures are preferred and validated.
90 // When false, client signatures are used directly after normalization (bypass mode).
91 AntigravitySignatureCacheEnabled *bool `yaml:"antigravity-signature-cache-enabled,omitempty" json:"antigravity-signature-cache-enabled,omitempty"`
92
93 AntigravitySignatureBypassStrict *bool `yaml:"antigravity-signature-bypass-strict,omitempty" json:"antigravity-signature-bypass-strict,omitempty"`
94
88 // GeminiKey defines Gemini API key configurations with optional routing overrides.95 // GeminiKey defines Gemini API key configurations with optional routing overrides.
89 GeminiKey []GeminiKey `yaml:"gemini-api-key" json:"gemini-api-key"`96 GeminiKey []GeminiKey `yaml:"gemini-api-key" json:"gemini-api-key"`
90 97
internal/runtime/executor/antigravity_executor.go+60−28
@@ -23,10 +23,12 @@ import ( · 22 unmodified lines
23 "time"23 "time"
24 24
25 "github.com/google/uuid"25 "github.com/google/uuid"
26 "github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
26 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"27 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
27 "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"28 "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
28 "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps"29 "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps"
29 "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"30 "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
31 antigravityclaude "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/antigravity/claude"
30 "github.com/router-for-me/CLIProxyAPI/v6/internal/util"32 "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
31 sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"33 sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
32 cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"34 cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
@@ -182,6 +184,24 @@ func newAntigravityHTTPClient(ctx context.Context, cfg *config.Config, auth *cli · 149 unmodified lines
182 return client184 return client
183}185}
184 186
187func validateAntigravityRequestSignatures(from sdktranslator.Format, rawJSON []byte) error {
188 if from.String() != "claude" {
189 return nil
190 }
191 if cache.SignatureCacheEnabled() {
192 return nil
193 }
194 if !cache.SignatureBypassStrictMode() {
195 // Non-strict bypass: let the translator handle invalid signatures
196 // by dropping unsigned thinking blocks silently (no 400).
197 return nil
198 }
199 if err := antigravityclaude.ValidateClaudeBypassSignatures(rawJSON); err != nil {
200 return statusErr{code: http.StatusBadRequest, msg: err.Error()}
201 }
202 return nil
203}
204
185// Identifier returns the executor identifier.205// Identifier returns the executor identifier.
186func (e *AntigravityExecutor) Identifier() string { return antigravityAuthType }206func (e *AntigravityExecutor) Identifier() string { return antigravityAuthType }
187 207
@@ -664,14 +684,6 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au · 476 unmodified lines
664 return e.executeClaudeNonStream(ctx, auth, req, opts)684 return e.executeClaudeNonStream(ctx, auth, req, opts)
665 }685 }
666 686
667 token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth)
668 if errToken != nil {
669 return resp, errToken
670 }
671 if updatedAuth != nil {
672 auth = updatedAuth
673 }
674
675 reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)687 reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
676 defer reporter.TrackFailure(ctx, &err)688 defer reporter.TrackFailure(ctx, &err)
677 689
@@ -683,6 +695,16 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au · 5 unmodified lines
683 originalPayloadSource = opts.OriginalRequest695 originalPayloadSource = opts.OriginalRequest
684 }696 }
685 originalPayload := originalPayloadSource697 originalPayload := originalPayloadSource
698 if errValidate := validateAntigravityRequestSignatures(from, originalPayload); errValidate != nil {
699 return resp, errValidate
700 }
701 token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth)
702 if errToken != nil {
703 return resp, errToken
704 }
705 if updatedAuth != nil {
706 auth = updatedAuth
707 }
686 originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)708 originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
687 translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)709 translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
688 710
@@ -874,14 +896,6 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth * · 185 unmodified lines
874 return resp, statusErr{code: http.StatusTooManyRequests, msg: fmt.Sprintf("auth in short cooldown, %s remaining", remaining), retryAfter: &d}896 return resp, statusErr{code: http.StatusTooManyRequests, msg: fmt.Sprintf("auth in short cooldown, %s remaining", remaining), retryAfter: &d}
875 }897 }
876 898
877 token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth)
878 if errToken != nil {
879 return resp, errToken
880 }
881 if updatedAuth != nil {
882 auth = updatedAuth
883 }
884
885 reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)899 reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
886 defer reporter.TrackFailure(ctx, &err)900 defer reporter.TrackFailure(ctx, &err)
887 901
@@ -893,6 +907,16 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth * · 5 unmodified lines
893 originalPayloadSource = opts.OriginalRequest907 originalPayloadSource = opts.OriginalRequest
894 }908 }
895 originalPayload := originalPayloadSource909 originalPayload := originalPayloadSource
910 if errValidate := validateAntigravityRequestSignatures(from, originalPayload); errValidate != nil {
911 return resp, errValidate
912 }
913 token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth)
914 if errToken != nil {
915 return resp, errToken
916 }
917 if updatedAuth != nil {
918 auth = updatedAuth
919 }
896 originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)920 originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
897 translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)921 translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)
898 922
@@ -1335,14 +1359,6 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya · 436 unmodified lines
1335 return nil, statusErr{code: http.StatusTooManyRequests, msg: fmt.Sprintf("auth in short cooldown, %s remaining", remaining), retryAfter: &d}1359 return nil, statusErr{code: http.StatusTooManyRequests, msg: fmt.Sprintf("auth in short cooldown, %s remaining", remaining), retryAfter: &d}
1336 }1360 }
1337 1361
1338 token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth)
1339 if errToken != nil {
1340 return nil, errToken
1341 }
1342 if updatedAuth != nil {
1343 auth = updatedAuth
1344 }
1345
1346 reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)1362 reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
1347 defer reporter.TrackFailure(ctx, &err)1363 defer reporter.TrackFailure(ctx, &err)
1348 1364
@@ -1354,6 +1370,16 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya · 5 unmodified lines
1354 originalPayloadSource = opts.OriginalRequest1370 originalPayloadSource = opts.OriginalRequest
1355 }1371 }
1356 originalPayload := originalPayloadSource1372 originalPayload := originalPayloadSource
1373 if errValidate := validateAntigravityRequestSignatures(from, originalPayload); errValidate != nil {
1374 return nil, errValidate
1375 }
1376 token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth)
1377 if errToken != nil {
1378 return nil, errToken
1379 }
1380 if updatedAuth != nil {
1381 auth = updatedAuth
1382 }
1357 originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)1383 originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
1358 translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)1384 translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)
1359 1385
@@ -1593,6 +1619,16 @@ func (e *AntigravityExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Au · 233 unmodified lines
1593func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {1619func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
1594 baseModel := thinking.ParseSuffix(req.Model).ModelName1620 baseModel := thinking.ParseSuffix(req.Model).ModelName
1595 1621
1622 from := opts.SourceFormat
1623 to := sdktranslator.FromString("antigravity")
1624 respCtx := context.WithValue(ctx, "alt", opts.Alt)
1625 originalPayloadSource := req.Payload
1626 if len(opts.OriginalRequest) > 0 {
1627 originalPayloadSource = opts.OriginalRequest
1628 }
1629 if errValidate := validateAntigravityRequestSignatures(from, originalPayloadSource); errValidate != nil {
1630 return cliproxyexecutor.Response{}, errValidate
1631 }
1596 token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth)1632 token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth)
1597 if errToken != nil {1633 if errToken != nil {
1598 return cliproxyexecutor.Response{}, errToken1634 return cliproxyexecutor.Response{}, errToken
@@ -1604,10 +1640,6 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut · 5 unmodified lines
1604 return cliproxyexecutor.Response{}, statusErr{code: http.StatusUnauthorized, msg: "missing access token"}1640 return cliproxyexecutor.Response{}, statusErr{code: http.StatusUnauthorized, msg: "missing access token"}
1605 }1641 }
1606 1642
1607 from := opts.SourceFormat
1608 to := sdktranslator.FromString("antigravity")
1609 respCtx := context.WithValue(ctx, "alt", opts.Alt)
1610
1611 // Prepare payload once (doesn't depend on baseURL)1643 // Prepare payload once (doesn't depend on baseURL)
1612 payload := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)1644 payload := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
1613 1645
internal/runtime/executor/antigravity_executor_signature_test.go+157−0
@@ -0,0 +1,157 @@
1package executor
2
3import (
4 "bytes"
5 "context"
6 "encoding/base64"
7 "net/http"
8 "net/http/httptest"
9 "sync/atomic"
10 "testing"
11 "time"
12
13 "github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
14 cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
15 cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
16 sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
17)
18
19func testGeminiSignaturePayload() string {
20 payload := append([]byte{0x0A}, bytes.Repeat([]byte{0x56}, 48)...)
21 return base64.StdEncoding.EncodeToString(payload)
22}
23
24func testAntigravityAuth(baseURL string) *cliproxyauth.Auth {
25 return &cliproxyauth.Auth{
26 Attributes: map[string]string{
27 "base_url": baseURL,
28 },
29 Metadata: map[string]any{
30 "access_token": "token-123",
31 "expired": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
32 },
33 }
34}
35
36func invalidClaudeThinkingPayload() []byte {
37 return []byte(`{
38 "model": "claude-sonnet-4-5-thinking",
39 "messages": [
40 {
41 "role": "assistant",
42 "content": [
43 {"type": "thinking", "thinking": "bad", "signature": "` + testGeminiSignaturePayload() + `"},
44 {"type": "text", "text": "hello"}
45 ]
46 }
47 ]
48 }`)
49}
50
51func TestAntigravityExecutor_StrictBypassRejectsInvalidSignature(t *testing.T) {
52 previousCache := cache.SignatureCacheEnabled()
53 previousStrict := cache.SignatureBypassStrictMode()
54 cache.SetSignatureCacheEnabled(false)
55 cache.SetSignatureBypassStrictMode(true)
56 t.Cleanup(func() {
57 cache.SetSignatureCacheEnabled(previousCache)
58 cache.SetSignatureBypassStrictMode(previousStrict)
59 })
60
61 var hits atomic.Int32
62 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
63 hits.Add(1)
64 w.WriteHeader(http.StatusOK)
65 _, _ = w.Write([]byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"ok"}]}}]}}`))
66 }))
67 defer server.Close()
68
69 executor := NewAntigravityExecutor(nil)
70 auth := testAntigravityAuth(server.URL)
71 payload := invalidClaudeThinkingPayload()
72 opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude"), OriginalRequest: payload}
73 req := cliproxyexecutor.Request{Model: "claude-sonnet-4-5-thinking", Payload: payload}
74
75 tests := []struct {
76 name string
77 invoke func() error
78 }{
79 {
80 name: "execute",
81 invoke: func() error {
82 _, err := executor.Execute(context.Background(), auth, req, opts)
83 return err
84 },
85 },
86 {
87 name: "stream",
88 invoke: func() error {
89 _, err := executor.ExecuteStream(context.Background(), auth, req, cliproxyexecutor.Options{SourceFormat: opts.SourceFormat, OriginalRequest: payload, Stream: true})
90 return err
91 },
92 },
93 {
94 name: "count tokens",
95 invoke: func() error {
96 _, err := executor.CountTokens(context.Background(), auth, req, opts)
97 return err
98 },
99 },
100 }
101
102 for _, tt := range tests {
103 tt := tt
104 t.Run(tt.name, func(t *testing.T) {
105 err := tt.invoke()
106 if err == nil {
107 t.Fatal("expected invalid signature to return an error")
108 }
109 statusProvider, ok := err.(interface{ StatusCode() int })
110 if !ok {
111 t.Fatalf("expected status error, got %T: %v", err, err)
112 }
113 if statusProvider.StatusCode() != http.StatusBadRequest {
114 t.Fatalf("status = %d, want %d", statusProvider.StatusCode(), http.StatusBadRequest)
115 }
116 })
117 }
118
119 if got := hits.Load(); got != 0 {
120 t.Fatalf("expected invalid signature to be rejected before upstream request, got %d upstream hits", got)
121 }
122}
123
124func TestAntigravityExecutor_NonStrictBypassSkipsPrecheck(t *testing.T) {
125 previousCache := cache.SignatureCacheEnabled()
126 previousStrict := cache.SignatureBypassStrictMode()
127 cache.SetSignatureCacheEnabled(false)
128 cache.SetSignatureBypassStrictMode(false)
129 t.Cleanup(func() {
130 cache.SetSignatureCacheEnabled(previousCache)
131 cache.SetSignatureBypassStrictMode(previousStrict)
132 })
133
134 payload := invalidClaudeThinkingPayload()
135 from := sdktranslator.FromString("claude")
136
137 err := validateAntigravityRequestSignatures(from, payload)
138 if err != nil {
139 t.Fatalf("non-strict bypass should skip precheck, got: %v", err)
140 }
141}
142
143func TestAntigravityExecutor_CacheModeSkipsPrecheck(t *testing.T) {
144 previous := cache.SignatureCacheEnabled()
145 cache.SetSignatureCacheEnabled(true)
146 t.Cleanup(func() {
147 cache.SetSignatureCacheEnabled(previous)
148 })
149
150 payload := invalidClaudeThinkingPayload()
151 from := sdktranslator.FromString("claude")
152
153 err := validateAntigravityRequestSignatures(from, payload)
154 if err != nil {
155 t.Fatalf("cache mode should skip precheck, got: %v", err)
156 }
157}
internal/translator/antigravity/claude/antigravity_claude_request.go+55−32
@@ -17,6 +17,56 @@ import ( · 16 unmodified lines
17 "github.com/tidwall/sjson"17 "github.com/tidwall/sjson"
18)18)
19 19
20func resolveThinkingSignature(modelName, thinkingText, rawSignature string) string {
21 if cache.SignatureCacheEnabled() {
22 return resolveCacheModeSignature(modelName, thinkingText, rawSignature)
23 }
24 return resolveBypassModeSignature(rawSignature)
25}
26
27func resolveCacheModeSignature(modelName, thinkingText, rawSignature string) string {
28 if thinkingText != "" {
29 if cachedSig := cache.GetCachedSignature(modelName, thinkingText); cachedSig != "" {
30 return cachedSig
31 }
32 }
33
34 if rawSignature == "" {
35 return ""
36 }
37
38 clientSignature := ""
39 arrayClientSignatures := strings.SplitN(rawSignature, "#", 2)
40 if len(arrayClientSignatures) == 2 {
41 if cache.GetModelGroup(modelName) == arrayClientSignatures[0] {
42 clientSignature = arrayClientSignatures[1]
43 }
44 }
45 if cache.HasValidSignature(modelName, clientSignature) {
46 return clientSignature
47 }
48
49 return ""
50}
51
52func resolveBypassModeSignature(rawSignature string) string {
53 if rawSignature == "" {
54 return ""
55 }
56 normalized, err := normalizeClaudeBypassSignature(rawSignature)
57 if err != nil {
58 return ""
59 }
60 return normalized
61}
62
63func hasResolvedThinkingSignature(modelName, signature string) bool {
64 if cache.SignatureCacheEnabled() {
65 return cache.HasValidSignature(modelName, signature)
66 }
67 return signature != ""
68}
69
20// ConvertClaudeRequestToAntigravity parses and transforms a Claude Code API request into Gemini CLI API format.70// ConvertClaudeRequestToAntigravity parses and transforms a Claude Code API request into Gemini CLI API format.
21// It extracts the model name, system instruction, message contents, and tool declarations71// It extracts the model name, system instruction, message contents, and tool declarations
22// from the raw JSON request and returns them in the format expected by the Gemini CLI API.72// from the raw JSON request and returns them in the format expected by the Gemini CLI API.
@@ -101,42 +151,15 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ · 78 unmodified lines
101 if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "thinking" {151 if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "thinking" {
102 // Use GetThinkingText to handle wrapped thinking objects152 // Use GetThinkingText to handle wrapped thinking objects
103 thinkingText := thinking.GetThinkingText(contentResult)153 thinkingText := thinking.GetThinkingText(contentResult)
104 154 signature := resolveThinkingSignature(modelName, thinkingText, contentResult.Get("signature").String())
105 // Always try cached signature first (more reliable than client-provided)
106 // Client may send stale or invalid signatures from different sessions
107 signature := ""
108 if thinkingText != "" {
109 if cachedSig := cache.GetCachedSignature(modelName, thinkingText); cachedSig != "" {
110 signature = cachedSig
111 // log.Debugf("Using cached signature for thinking block")
112 }
113 }
114
115 // Fallback to client signature only if cache miss and client signature is valid
116 if signature == "" {
117 signatureResult := contentResult.Get("signature")
118 clientSignature := ""
119 if signatureResult.Exists() && signatureResult.String() != "" {
120 arrayClientSignatures := strings.SplitN(signatureResult.String(), "#", 2)
121 if len(arrayClientSignatures) == 2 {
122 if cache.GetModelGroup(modelName) == arrayClientSignatures[0] {
123 clientSignature = arrayClientSignatures[1]
124 }
125 }
126 }
127 if cache.HasValidSignature(modelName, clientSignature) {
128 signature = clientSignature
129 }
130 // log.Debugf("Using client-provided signature for thinking block")
131 }
132 155
133 // Store for subsequent tool_use in the same message156 // Store for subsequent tool_use in the same message
134 if cache.HasValidSignature(modelName, signature) {157 if hasResolvedThinkingSignature(modelName, signature) {
135 currentMessageThinkingSignature = signature158 currentMessageThinkingSignature = signature
136 }159 }
137 160
138 // Skip trailing unsigned thinking blocks on last assistant message161 // Skip unsigned thinking blocks instead of converting them to text.
139 isUnsigned := !cache.HasValidSignature(modelName, signature)162 isUnsigned := !hasResolvedThinkingSignature(modelName, signature)
140 163
141 // If unsigned, skip entirely (don't convert to text)164 // If unsigned, skip entirely (don't convert to text)
142 // Claude requires assistant messages to start with thinking blocks when thinking is enabled165 // Claude requires assistant messages to start with thinking blocks when thinking is enabled
@@ -198,7 +221,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ · 55 unmodified lines
198 // This is the approach used in opencode-google-antigravity-auth for Gemini221 // This is the approach used in opencode-google-antigravity-auth for Gemini
199 // and also works for Claude through Antigravity API222 // and also works for Claude through Antigravity API
200 const skipSentinel = "skip_thought_signature_validator"223 const skipSentinel = "skip_thought_signature_validator"
201 if cache.HasValidSignature(modelName, currentMessageThinkingSignature) {224 if hasResolvedThinkingSignature(modelName, currentMessageThinkingSignature) {
202 partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", currentMessageThinkingSignature)225 partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", currentMessageThinkingSignature)
203 } else {226 } else {
204 // No valid signature - use skip sentinel to bypass validation227 // No valid signature - use skip sentinel to bypass validation
internal/translator/antigravity/claude/antigravity_claude_request_test.go+623−0
@@ -1,13 +1,97 @@
1package claude1package claude
2 2
3import (3import (
4 "bytes"
5 "encoding/base64"
4 "strings"6 "strings"
5 "testing"7 "testing"
6 8
7 "github.com/router-for-me/CLIProxyAPI/v6/internal/cache"9 "github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
8 "github.com/tidwall/gjson"10 "github.com/tidwall/gjson"
11 "google.golang.org/protobuf/encoding/protowire"
9)12)
10 13
14func testAnthropicNativeSignature(t *testing.T) string {
15 t.Helper()
16
17 payload := buildClaudeSignaturePayload(t, 12, uint64Ptr(2), "claude-sonnet-4-6", true)
18 signature := base64.StdEncoding.EncodeToString(payload)
19 if len(signature) < cache.MinValidSignatureLen {
20 t.Fatalf("test signature too short: %d", len(signature))
21 }
22 return signature
23}
24
25func testMinimalAnthropicSignature(t *testing.T) string {
26 t.Helper()
27
28 payload := buildClaudeSignaturePayload(t, 12, nil, "", false)
29 return base64.StdEncoding.EncodeToString(payload)
30}
31
32func buildClaudeSignaturePayload(t *testing.T, channelID uint64, field2 *uint64, modelText string, includeField7 bool) []byte {
33 t.Helper()
34
35 channelBlock := []byte{}
36 channelBlock = protowire.AppendTag(channelBlock, 1, protowire.VarintType)
37 channelBlock = protowire.AppendVarint(channelBlock, channelID)
38 if field2 != nil {
39 channelBlock = protowire.AppendTag(channelBlock, 2, protowire.VarintType)
40 channelBlock = protowire.AppendVarint(channelBlock, *field2)
41 }
42 if modelText != "" {
43 channelBlock = protowire.AppendTag(channelBlock, 6, protowire.BytesType)
44 channelBlock = protowire.AppendString(channelBlock, modelText)
45 }
46 if includeField7 {
47 channelBlock = protowire.AppendTag(channelBlock, 7, protowire.VarintType)
48 channelBlock = protowire.AppendVarint(channelBlock, 0)
49 }
50
51 container := []byte{}
52 container = protowire.AppendTag(container, 1, protowire.BytesType)
53 container = protowire.AppendBytes(container, channelBlock)
54 container = protowire.AppendTag(container, 2, protowire.BytesType)
55 container = protowire.AppendBytes(container, bytes.Repeat([]byte{0x11}, 12))
56 container = protowire.AppendTag(container, 3, protowire.BytesType)
57 container = protowire.AppendBytes(container, bytes.Repeat([]byte{0x22}, 12))
58 container = protowire.AppendTag(container, 4, protowire.BytesType)
59 container = protowire.AppendBytes(container, bytes.Repeat([]byte{0x33}, 48))
60
61 payload := []byte{}
62 payload = protowire.AppendTag(payload, 2, protowire.BytesType)
63 payload = protowire.AppendBytes(payload, container)
64 payload = protowire.AppendTag(payload, 3, protowire.VarintType)
65 payload = protowire.AppendVarint(payload, 1)
66 return payload
67}
68
69func uint64Ptr(v uint64) *uint64 {
70 return &v
71}
72
73func testNonAnthropicRawSignature(t *testing.T) string {
74 t.Helper()
75
76 payload := bytes.Repeat([]byte{0x34}, 48)
77 signature := base64.StdEncoding.EncodeToString(payload)
78 if len(signature) < cache.MinValidSignatureLen {
79 t.Fatalf("test signature too short: %d", len(signature))
80 }
81 return signature
82}
83
84func testGeminiRawSignature(t *testing.T) string {
85 t.Helper()
86
87 payload := append([]byte{0x0A}, bytes.Repeat([]byte{0x56}, 48)...)
88 signature := base64.StdEncoding.EncodeToString(payload)
89 if len(signature) < cache.MinValidSignatureLen {
90 t.Fatalf("test signature too short: %d", len(signature))
91 }
92 return signature
93}
94
11func TestConvertClaudeRequestToAntigravity_BasicStructure(t *testing.T) {95func TestConvertClaudeRequestToAntigravity_BasicStructure(t *testing.T) {
12 inputJSON := []byte(`{96 inputJSON := []byte(`{
13 "model": "claude-3-5-sonnet-20240620",97 "model": "claude-3-5-sonnet-20240620",
@@ -116,6 +200,545 @@ func TestConvertClaudeRequestToAntigravity_ThinkingBlocks(t *testing.T) { · 102 unmodified lines
116 }200 }
117}201}
118 202
203func TestValidateBypassMode_AcceptsClaudeSingleAndDoubleLayer(t *testing.T) {
204 rawSignature := testAnthropicNativeSignature(t)
205 doubleEncoded := base64.StdEncoding.EncodeToString([]byte(rawSignature))
206
207 inputJSON := []byte(`{
208 "messages": [
209 {
210 "role": "assistant",
211 "content": [
212 {"type": "thinking", "thinking": "one", "signature": "` + rawSignature + `"},
213 {"type": "thinking", "thinking": "two", "signature": "claude#` + doubleEncoded + `"}
214 ]
215 }
216 ]
217 }`)
218
219 if err := ValidateClaudeBypassSignatures(inputJSON); err != nil {
220 t.Fatalf("ValidateBypassModeSignatures returned error: %v", err)
221 }
222}
223
224func TestValidateBypassMode_RejectsGeminiSignature(t *testing.T) {
225 inputJSON := []byte(`{
226 "messages": [
227 {
228 "role": "assistant",
229 "content": [
230 {"type": "thinking", "thinking": "one", "signature": "` + testGeminiRawSignature(t) + `"}
231 ]
232 }
233 ]
234 }`)
235
236 err := ValidateClaudeBypassSignatures(inputJSON)
237 if err == nil {
238 t.Fatal("expected Gemini signature to be rejected")
239 }
240}
241
242func TestValidateBypassMode_RejectsMissingSignature(t *testing.T) {
243 inputJSON := []byte(`{
244 "messages": [
245 {
246 "role": "assistant",
247 "content": [
248 {"type": "thinking", "thinking": "one"}
249 ]
250 }
251 ]
252 }`)
253
254 err := ValidateClaudeBypassSignatures(inputJSON)
255 if err == nil {
256 t.Fatal("expected missing signature to be rejected")
257 }
258 if !strings.Contains(err.Error(), "missing thinking signature") {
259 t.Fatalf("expected missing signature message, got: %v", err)
260 }
261}
262
263func TestValidateBypassMode_RejectsNonREPrefix(t *testing.T) {
264 inputJSON := []byte(`{
265 "messages": [
266 {
267 "role": "assistant",
268 "content": [
269 {"type": "thinking", "thinking": "one", "signature": "` + testNonAnthropicRawSignature(t) + `"}
270 ]
271 }
272 ]
273 }`)
274
275 err := ValidateClaudeBypassSignatures(inputJSON)
276 if err == nil {
277 t.Fatal("expected non-R/E signature to be rejected")
278 }
279}
280
281func TestValidateBypassMode_RejectsEPrefixWrongFirstByte(t *testing.T) {
282 t.Parallel()
283 payload := append([]byte{0x10}, bytes.Repeat([]byte{0x34}, 48)...)
284 sig := base64.StdEncoding.EncodeToString(payload)
285 if sig[0] != 'E' {
286 t.Fatalf("test setup: expected E prefix, got %c", sig[0])
287 }
288
289 inputJSON := []byte(`{
290 "messages": [{"role": "assistant", "content": [
291 {"type": "thinking", "thinking": "t", "signature": "` + sig + `"}
292 ]}]
293 }`)
294
295 err := ValidateClaudeBypassSignatures(inputJSON)
296 if err == nil {
297 t.Fatal("expected E-prefix with wrong first byte (0x10) to be rejected")
298 }
299 if !strings.Contains(err.Error(), "0x10") {
300 t.Fatalf("expected error to mention 0x10, got: %v", err)
301 }
302}
303
304func TestValidateBypassMode_RejectsTopLevel12WithoutClaudeTree(t *testing.T) {
305 previous := cache.SignatureBypassStrictMode()
306 cache.SetSignatureBypassStrictMode(true)
307 t.Cleanup(func() {
308 cache.SetSignatureBypassStrictMode(previous)
309 })
310
311 payload := append([]byte{0x12}, bytes.Repeat([]byte{0x34}, 48)...)
312 sig := base64.StdEncoding.EncodeToString(payload)
313
314 inputJSON := []byte(`{
315 "messages": [{"role": "assistant", "content": [
316 {"type": "thinking", "thinking": "t", "signature": "` + sig + `"}
317 ]}]
318 }`)
319
320 err := ValidateClaudeBypassSignatures(inputJSON)
321 if err == nil {
322 t.Fatal("expected non-Claude protobuf tree to be rejected in strict mode")
323 }
324 if !strings.Contains(err.Error(), "malformed protobuf") && !strings.Contains(err.Error(), "Field 2") {
325 t.Fatalf("expected protobuf tree error, got: %v", err)
326 }
327}
328
329func TestValidateBypassMode_NonStrictAccepts12WithoutClaudeTree(t *testing.T) {
330 previous := cache.SignatureBypassStrictMode()
331 cache.SetSignatureBypassStrictMode(false)
332 t.Cleanup(func() {
333 cache.SetSignatureBypassStrictMode(previous)
334 })
335
336 payload := append([]byte{0x12}, bytes.Repeat([]byte{0x34}, 48)...)
337 sig := base64.StdEncoding.EncodeToString(payload)
338
339 inputJSON := []byte(`{
340 "messages": [{"role": "assistant", "content": [
341 {"type": "thinking", "thinking": "t", "signature": "` + sig + `"}
342 ]}]
343 }`)
344
345 err := ValidateClaudeBypassSignatures(inputJSON)
346 if err != nil {
347 t.Fatalf("non-strict mode should accept 0x12 without protobuf tree, got: %v", err)
348 }
349}
350
351func TestValidateBypassMode_RejectsRPrefixInnerNotE(t *testing.T) {
352 t.Parallel()
353 inner := "F" + strings.Repeat("a", 60)
354 outer := base64.StdEncoding.EncodeToString([]byte(inner))
355 if outer[0] != 'R' {
356 t.Fatalf("test setup: expected R prefix, got %c", outer[0])
357 }
358
359 inputJSON := []byte(`{
360 "messages": [{"role": "assistant", "content": [
361 {"type": "thinking", "thinking": "t", "signature": "` + outer + `"}
362 ]}]
363 }`)
364
365 err := ValidateClaudeBypassSignatures(inputJSON)
366 if err == nil {
367 t.Fatal("expected R-prefix with non-E inner to be rejected")
368 }
369}
370
371func TestValidateBypassMode_RejectsInvalidBase64(t *testing.T) {
372 t.Parallel()
373 tests := []struct {
374 name string
375 sig string
376 }{
377 {"E invalid", "E!!!invalid!!!"},
378 {"R invalid", "R$$$invalid$$$"},
379 }
380 for _, tt := range tests {
381 tt := tt
382 t.Run(tt.name, func(t *testing.T) {
383 inputJSON := []byte(`{
384 "messages": [{"role": "assistant", "content": [
385 {"type": "thinking", "thinking": "t", "signature": "` + tt.sig + `"}
386 ]}]
387 }`)
388 err := ValidateClaudeBypassSignatures(inputJSON)
389 if err == nil {
390 t.Fatal("expected invalid base64 to be rejected")
391 }
392 if !strings.Contains(err.Error(), "base64") {
393 t.Fatalf("expected base64 error, got: %v", err)
394 }
395 })
396 }
397}
398
399func TestValidateBypassMode_RejectsPrefixStrippedToEmpty(t *testing.T) {
400 t.Parallel()
401 tests := []struct {
402 name string
403 sig string
404 }{
405 {"prefix only", "claude#"},
406 {"prefix with spaces", "claude# "},
407 {"hash only", "#"},
408 }
409 for _, tt := range tests {
410 tt := tt
411 t.Run(tt.name, func(t *testing.T) {
412 inputJSON := []byte(`{
413 "messages": [{"role": "assistant", "content": [
414 {"type": "thinking", "thinking": "t", "signature": "` + tt.sig + `"}
415 ]}]
416 }`)
417 err := ValidateClaudeBypassSignatures(inputJSON)
418 if err == nil {
419 t.Fatal("expected prefix-only signature to be rejected")
420 }
421 })
422 }
423}
424
425func TestValidateBypassMode_HandlesMultipleHashMarks(t *testing.T) {
426 t.Parallel()
427 rawSignature := testAnthropicNativeSignature(t)
428 sig := "claude#" + rawSignature + "#extra"
429
430 inputJSON := []byte(`{
431 "messages": [{"role": "assistant", "content": [
432 {"type": "thinking", "thinking": "t", "signature": "` + sig + `"}
433 ]}]
434 }`)
435
436 err := ValidateClaudeBypassSignatures(inputJSON)
437 if err == nil {
438 t.Fatal("expected signature with trailing # to be rejected (invalid base64)")
439 }
440}
441
442func TestValidateBypassMode_HandlesWhitespace(t *testing.T) {
443 t.Parallel()
444 rawSignature := testAnthropicNativeSignature(t)
445 tests := []struct {
446 name string
447 sig string
448 }{
449 {"leading space", " " + rawSignature},
450 {"trailing space", rawSignature + " "},
451 {"both spaces", " " + rawSignature + " "},
452 {"leading tab", "\t" + rawSignature},
453 }
454 for _, tt := range tests {
455 tt := tt
456 t.Run(tt.name, func(t *testing.T) {
457 inputJSON := []byte(`{
458 "messages": [{"role": "assistant", "content": [
459 {"type": "thinking", "thinking": "t", "signature": "` + tt.sig + `"}
460 ]}]
461 }`)
462 if err := ValidateClaudeBypassSignatures(inputJSON); err != nil {
463 t.Fatalf("expected whitespace-padded signature to be accepted, got: %v", err)
464 }
465 })
466 }
467}
468
469func TestValidateBypassMode_RejectsOversizedSignature(t *testing.T) {
470 t.Parallel()
471 payload := append([]byte{0x12}, bytes.Repeat([]byte{0x34}, maxBypassSignatureLen)...)
472 sig := base64.StdEncoding.EncodeToString(payload)
473 if len(sig) <= maxBypassSignatureLen {
474 t.Fatalf("test setup: signature should exceed max length, got %d", len(sig))
475 }
476
477 inputJSON := []byte(`{
478 "messages": [{"role": "assistant", "content": [
479 {"type": "thinking", "thinking": "t", "signature": "` + sig + `"}
480 ]}]
481 }`)
482
483 err := ValidateClaudeBypassSignatures(inputJSON)
484 if err == nil {
485 t.Fatal("expected oversized signature to be rejected")
486 }
487 if !strings.Contains(err.Error(), "maximum length") {
488 t.Fatalf("expected length error, got: %v", err)
489 }
490}
491
492func TestResolveBypassModeSignature_TrimsWhitespace(t *testing.T) {
493 previous := cache.SignatureCacheEnabled()
494 cache.SetSignatureCacheEnabled(false)
495 t.Cleanup(func() {
496 cache.SetSignatureCacheEnabled(previous)
497 })
498
499 rawSignature := testAnthropicNativeSignature(t)
500 expected := resolveBypassModeSignature(rawSignature)
501 if expected == "" {
502 t.Fatal("test setup: expected non-empty normalized signature")
503 }
504
505 got := resolveBypassModeSignature(rawSignature + " ")
506 if got != expected {
507 t.Fatalf("expected trailing whitespace to be trimmed:\n got: %q\n want: %q", got, expected)
508 }
509}
510
511func TestConvertClaudeRequestToAntigravity_BypassModeNormalizesESignature(t *testing.T) {
512 cache.ClearSignatureCache("")
513 previous := cache.SignatureCacheEnabled()
514 cache.SetSignatureCacheEnabled(false)
515 t.Cleanup(func() {
516 cache.SetSignatureCacheEnabled(previous)
517 cache.ClearSignatureCache("")
518 })
519
520 thinkingText := "Let me think..."
521 cachedSignature := "cachedSignature1234567890123456789012345678901234567890123"
522 rawSignature := testAnthropicNativeSignature(t)
523 expectedSignature := base64.StdEncoding.EncodeToString([]byte(rawSignature))
524
525 cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, cachedSignature)
526
527 inputJSON := []byte(`{
528 "model": "claude-sonnet-4-5-thinking",
529 "messages": [
530 {
531 "role": "assistant",
532 "content": [
533 {"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + rawSignature + `"},
534 {"type": "text", "text": "Answer"}
535 ]
536 }
537 ]
538 }`)
539
540 output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
541 outputStr := string(output)
542
543 part := gjson.Get(outputStr, "request.contents.0.parts.0")
544 if part.Get("thoughtSignature").String() != expectedSignature {
545 t.Fatalf("Expected bypass-mode signature '%s', got '%s'", expectedSignature, part.Get("thoughtSignature").String())
546 }
547 if part.Get("thoughtSignature").String() == cachedSignature {
548 t.Fatal("Bypass mode should not reuse cached signature")
549 }
550}
551
552func TestConvertClaudeRequestToAntigravity_BypassModePreservesShortValidSignature(t *testing.T) {
553 cache.ClearSignatureCache("")
554 previous := cache.SignatureCacheEnabled()
555 cache.SetSignatureCacheEnabled(false)
556 t.Cleanup(func() {
557 cache.SetSignatureCacheEnabled(previous)
558 cache.ClearSignatureCache("")
559 })
560
561 rawSignature := testMinimalAnthropicSignature(t)
562 expectedSignature := base64.StdEncoding.EncodeToString([]byte(rawSignature))
563 inputJSON := []byte(`{
564 "model": "claude-sonnet-4-5-thinking",
565 "messages": [
566 {
567 "role": "assistant",
568 "content": [
569 {"type": "thinking", "thinking": "tiny", "signature": "` + rawSignature + `"},
570 {"type": "text", "text": "Answer"}
571 ]
572 }
573 ]
574 }`)
575
576 output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
577 parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
578 if len(parts) != 2 {
579 t.Fatalf("expected thinking part to be preserved in bypass mode, got %d parts", len(parts))
580 }
581 if parts[0].Get("thoughtSignature").String() != expectedSignature {
582 t.Fatalf("expected normalized short signature %q, got %q", expectedSignature, parts[0].Get("thoughtSignature").String())
583 }
584 if !parts[0].Get("thought").Bool() {
585 t.Fatalf("expected first part to remain a thought block, got %s", parts[0].Raw)
586 }
587 if parts[1].Get("text").String() != "Answer" {
588 t.Fatalf("expected trailing text part, got %s", parts[1].Raw)
589 }
590 if thoughtSig := gjson.GetBytes(output, "request.contents.0.parts.1.thoughtSignature").String(); thoughtSig != "" {
591 t.Fatalf("expected plain text part to have no thought signature, got %q", thoughtSig)
592 }
593 if functionSig := gjson.GetBytes(output, "request.contents.0.parts.0.functionCall.thoughtSignature").String(); functionSig != "" {
594 t.Fatalf("unexpected functionCall payload in thinking part: %q", functionSig)
595 }
596}
597
598func TestInspectClaudeSignaturePayload_ExtractsSpecTree(t *testing.T) {
599 t.Parallel()
600 payload := buildClaudeSignaturePayload(t, 12, uint64Ptr(2), "claude-sonnet-4-6", true)
601
602 tree, err := inspectClaudeSignaturePayload(payload, 1)
603 if err != nil {
604 t.Fatalf("expected structured Claude payload to parse, got: %v", err)
605 }
606 if tree.RoutingClass != "routing_class_12" {
607 t.Fatalf("routing_class = %q, want routing_class_12", tree.RoutingClass)
608 }
609 if tree.InfrastructureClass != "infra_google" {
610 t.Fatalf("infrastructure_class = %q, want infra_google", tree.InfrastructureClass)
611 }
612 if tree.SchemaFeatures != "extended_model_tagged_schema" {
613 t.Fatalf("schema_features = %q, want extended_model_tagged_schema", tree.SchemaFeatures)
614 }
615 if tree.ModelText != "claude-sonnet-4-6" {
616 t.Fatalf("model_text = %q, want claude-sonnet-4-6", tree.ModelText)
617 }
618}
619
620func TestInspectDoubleLayerSignature_TracksEncodingLayers(t *testing.T) {
621 t.Parallel()
622 inner := base64.StdEncoding.EncodeToString(buildClaudeSignaturePayload(t, 11, uint64Ptr(2), "", false))
623 outer := base64.StdEncoding.EncodeToString([]byte(inner))
624
625 tree, err := inspectDoubleLayerSignature(outer)
626 if err != nil {
627 t.Fatalf("expected double-layer Claude signature to parse, got: %v", err)
628 }
629 if tree.EncodingLayers != 2 {
630 t.Fatalf("encoding_layers = %d, want 2", tree.EncodingLayers)
631 }
632 if tree.LegacyRouteHint != "legacy_vertex_direct" {
633 t.Fatalf("legacy_route_hint = %q, want legacy_vertex_direct", tree.LegacyRouteHint)
634 }
635}
636
637func TestConvertClaudeRequestToAntigravity_CacheModeDropsRawSignature(t *testing.T) {
638 cache.ClearSignatureCache("")
639 previous := cache.SignatureCacheEnabled()
640 cache.SetSignatureCacheEnabled(true)
641 t.Cleanup(func() {
642 cache.SetSignatureCacheEnabled(previous)
643 cache.ClearSignatureCache("")
644 })
645
646 rawSignature := testAnthropicNativeSignature(t)
647 inputJSON := []byte(`{
648 "model": "claude-sonnet-4-5-thinking",
649 "messages": [
650 {
651 "role": "assistant",
652 "content": [
653 {"type": "thinking", "thinking": "Let me think...", "signature": "` + rawSignature + `"},
654 {"type": "text", "text": "Answer"}
655 ]
656 }
657 ]
658 }`)
659
660 output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
661 parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
662 if len(parts) != 1 {
663 t.Fatalf("Expected raw signature thinking block to be dropped in cache mode, got %d parts", len(parts))
664 }
665 if parts[0].Get("text").String() != "Answer" {
666 t.Fatalf("Expected remaining text part, got %s", parts[0].Raw)
667 }
668}
669
670func TestConvertClaudeRequestToAntigravity_BypassModeDropsInvalidSignature(t *testing.T) {
671 cache.ClearSignatureCache("")
672 previous := cache.SignatureCacheEnabled()
673 cache.SetSignatureCacheEnabled(false)
674 t.Cleanup(func() {
675 cache.SetSignatureCacheEnabled(previous)
676 cache.ClearSignatureCache("")
677 })
678
679 invalidRawSignature := testNonAnthropicRawSignature(t)
680 inputJSON := []byte(`{
681 "model": "claude-sonnet-4-5-thinking",
682 "messages": [
683 {
684 "role": "assistant",
685 "content": [
686 {"type": "thinking", "thinking": "Let me think...", "signature": "` + invalidRawSignature + `"},
687 {"type": "text", "text": "Answer"}
688 ]
689 }
690 ]
691 }`)
692
693 output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
694 outputStr := string(output)
695
696 parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
697 if len(parts) != 1 {
698 t.Fatalf("Expected invalid thinking block to be removed, got %d parts", len(parts))
699 }
700 if parts[0].Get("text").String() != "Answer" {
701 t.Fatalf("Expected remaining text part, got %s", parts[0].Raw)
702 }
703 if parts[0].Get("thought").Bool() {
704 t.Fatal("Invalid raw signature should not preserve thinking block")
705 }
706}
707
708func TestConvertClaudeRequestToAntigravity_BypassModeDropsGeminiSignature(t *testing.T) {
709 cache.ClearSignatureCache("")
710 previous := cache.SignatureCacheEnabled()
711 cache.SetSignatureCacheEnabled(false)
712 t.Cleanup(func() {
713 cache.SetSignatureCacheEnabled(previous)
714 cache.ClearSignatureCache("")
715 })
716
717 geminiPayload := append([]byte{0x0A}, bytes.Repeat([]byte{0x56}, 48)...)
718 geminiSig := base64.StdEncoding.EncodeToString(geminiPayload)
719 inputJSON := []byte(`{
720 "model": "claude-sonnet-4-5-thinking",
721 "messages": [
722 {
723 "role": "assistant",
724 "content": [
725 {"type": "thinking", "thinking": "hmm", "signature": "` + geminiSig + `"},
726 {"type": "text", "text": "Answer"}
727 ]
728 }
729 ]
730 }`)
731
732 output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
733 parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
734 if len(parts) != 1 {
735 t.Fatalf("expected Gemini-signed thinking block to be dropped, got %d parts", len(parts))
736 }
737 if parts[0].Get("text").String() != "Answer" {
738 t.Fatalf("expected remaining text part, got %s", parts[0].Raw)
739 }
740}
741
119func TestConvertClaudeRequestToAntigravity_ThinkingBlockWithoutSignature(t *testing.T) {742func TestConvertClaudeRequestToAntigravity_ThinkingBlockWithoutSignature(t *testing.T) {
120 cache.ClearSignatureCache("")743 cache.ClearSignatureCache("")
121 744
internal/translator/antigravity/claude/antigravity_claude_response.go+48−2
@@ -9,6 +9,7 @@ package claude · 8 unmodified lines
9import (9import (
10 "bytes"10 "bytes"
11 "context"11 "context"
12 "encoding/base64"
12 "fmt"13 "fmt"
13 "strings"14 "strings"
14 "sync/atomic"15 "sync/atomic"
@@ -23,6 +24,33 @@ import ( · 8 unmodified lines
23 "github.com/tidwall/sjson"24 "github.com/tidwall/sjson"
24)25)
25 26
27// decodeSignature decodes R... (2-layer Base64) to E... (1-layer Base64, Anthropic format).
28// Returns empty string if decoding fails (skip invalid signatures).
29func decodeSignature(signature string) string {
30 if signature == "" {
31 return signature
32 }
33 if strings.HasPrefix(signature, "R") {
34 decoded, err := base64.StdEncoding.DecodeString(signature)
35 if err != nil {
36 log.Warnf("antigravity claude response: failed to decode signature, skipping")
37 return ""
38 }
39 return string(decoded)
40 }
41 return signature
42}
43
44func formatClaudeSignatureValue(modelName, signature string) string {
45 if cache.SignatureCacheEnabled() {
46 return fmt.Sprintf("%s#%s", cache.GetModelGroup(modelName), signature)
47 }
48 if cache.GetModelGroup(modelName) == "claude" {
49 return decodeSignature(signature)
50 }
51 return signature
52}
53
26// Params holds parameters for response conversion and maintains state across streaming chunks.54// Params holds parameters for response conversion and maintains state across streaming chunks.
27// This structure tracks the current state of the response translation process to ensure55// This structure tracks the current state of the response translation process to ensure
28// proper sequencing of SSE events and transitions between different content types.56// proper sequencing of SSE events and transitions between different content types.
@@ -144,13 +172,30 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq · 115 unmodified lines
144 if thoughtSignature := partResult.Get("thoughtSignature"); thoughtSignature.Exists() && thoughtSignature.String() != "" {172 if thoughtSignature := partResult.Get("thoughtSignature"); thoughtSignature.Exists() && thoughtSignature.String() != "" {
145 // log.Debug("Branch: signature_delta")173 // log.Debug("Branch: signature_delta")
146 174
175 // Flush co-located text before emitting the signature
176 if partText := partTextResult.String(); partText != "" {
177 if params.ResponseType != 2 {
178 if params.ResponseType != 0 {
179 appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, params.ResponseIndex))
180 params.ResponseIndex++
181 }
182 appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"thinking","thinking":""}}`, params.ResponseIndex))
183 params.ResponseType = 2
184 params.CurrentThinkingText.Reset()
185 }
186 params.CurrentThinkingText.WriteString(partText)
187 data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, params.ResponseIndex)), "delta.thinking", partText)
188 appendEvent("content_block_delta", string(data))
189 }
190
147 if params.CurrentThinkingText.Len() > 0 {191 if params.CurrentThinkingText.Len() > 0 {
148 cache.CacheSignature(modelName, params.CurrentThinkingText.String(), thoughtSignature.String())192 cache.CacheSignature(modelName, params.CurrentThinkingText.String(), thoughtSignature.String())
149 // log.Debugf("Cached signature for thinking block (textLen=%d)", params.CurrentThinkingText.Len())193 // log.Debugf("Cached signature for thinking block (textLen=%d)", params.CurrentThinkingText.Len())
150 params.CurrentThinkingText.Reset()194 params.CurrentThinkingText.Reset()
151 }195 }
152 196
153 data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, params.ResponseIndex)), "delta.signature", fmt.Sprintf("%s#%s", cache.GetModelGroup(modelName), thoughtSignature.String()))197 sigValue := formatClaudeSignatureValue(modelName, thoughtSignature.String())
198 data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, params.ResponseIndex)), "delta.signature", sigValue)
154 appendEvent("content_block_delta", string(data))199 appendEvent("content_block_delta", string(data))
155 params.HasContent = true200 params.HasContent = true
156 } else if params.ResponseType == 2 { // Continue existing thinking block if already in thinking state201 } else if params.ResponseType == 2 { // Continue existing thinking block if already in thinking state
@@ -419,7 +464,8 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or · 262 unmodified lines
419 block := []byte(`{"type":"thinking","thinking":""}`)464 block := []byte(`{"type":"thinking","thinking":""}`)
420 block, _ = sjson.SetBytes(block, "thinking", thinkingBuilder.String())465 block, _ = sjson.SetBytes(block, "thinking", thinkingBuilder.String())
421 if thinkingSignature != "" {466 if thinkingSignature != "" {
422 block, _ = sjson.SetBytes(block, "signature", fmt.Sprintf("%s#%s", cache.GetModelGroup(modelName), thinkingSignature))467 sigValue := formatClaudeSignatureValue(modelName, thinkingSignature)
468 block, _ = sjson.SetBytes(block, "signature", sigValue)
423 }469 }
424 responseJSON, _ = sjson.SetRawBytes(responseJSON, "content.-1", block)470 responseJSON, _ = sjson.SetRawBytes(responseJSON, "content.-1", block)
425 thinkingBuilder.Reset()471 thinkingBuilder.Reset()
internal/translator/antigravity/claude/antigravity_claude_response_test.go+103−0
@@ -1,6 +1,7 @@
1package claude1package claude
2 2
3import (3import (
4 "bytes"
4 "context"5 "context"
5 "strings"6 "strings"
6 "testing"7 "testing"
@@ -244,3 +245,105 @@ func TestConvertAntigravityResponseToClaude_MultipleThinkingBlocks(t *testing.T) · 237 unmodified lines
244 t.Error("Second thinking block signature should be cached")245 t.Error("Second thinking block signature should be cached")
245 }246 }
246}247}
248
249func TestConvertAntigravityResponseToClaude_TextAndSignatureInSameChunk(t *testing.T) {
250 cache.ClearSignatureCache("")
251
252 requestJSON := []byte(`{
253 "model": "claude-sonnet-4-5-thinking",
254 "messages": [{"role": "user", "content": [{"type": "text", "text": "Test"}]}]
255 }`)
256
257 validSignature := "RtestSig1234567890123456789012345678901234567890123456789"
258
259 // Chunk 1: thinking text only (no signature)
260 chunk1 := []byte(`{
261 "response": {
262 "candidates": [{
263 "content": {
264 "parts": [{"text": "First part.", "thought": true}]
265 }
266 }]
267 }
268 }`)
269
270 // Chunk 2: thinking text AND signature in the same part
271 chunk2 := []byte(`{
272 "response": {
273 "candidates": [{
274 "content": {
275 "parts": [{"text": " Second part.", "thought": true, "thoughtSignature": "` + validSignature + `"}]
276 }
277 }]
278 }
279 }`)
280
281 var param any
282 ctx := context.Background()
283
284 result1 := ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, chunk1, &param)
285 result2 := ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, chunk2, &param)
286
287 allOutput := string(bytes.Join(result1, nil)) + string(bytes.Join(result2, nil))
288
289 // The text " Second part." must appear as a thinking_delta, not be silently dropped
290 if !strings.Contains(allOutput, "Second part.") {
291 t.Error("Text co-located with signature must be emitted as thinking_delta before the signature")
292 }
293
294 // The signature must also be emitted
295 if !strings.Contains(allOutput, "signature_delta") {
296 t.Error("Signature delta must still be emitted")
297 }
298
299 // Verify the cached signature covers the FULL text (both parts)
300 fullText := "First part. Second part."
301 cachedSig := cache.GetCachedSignature("claude-sonnet-4-5-thinking", fullText)
302 if cachedSig != validSignature {
303 t.Errorf("Cached signature should cover full text %q, got sig=%q", fullText, cachedSig)
304 }
305}
306
307func TestConvertAntigravityResponseToClaude_SignatureOnlyChunk(t *testing.T) {
308 cache.ClearSignatureCache("")
309
310 requestJSON := []byte(`{
311 "model": "claude-sonnet-4-5-thinking",
312 "messages": [{"role": "user", "content": [{"type": "text", "text": "Test"}]}]
313 }`)
314
315 validSignature := "RtestSig1234567890123456789012345678901234567890123456789"
316
317 // Chunk 1: thinking text
318 chunk1 := []byte(`{
319 "response": {
320 "candidates": [{
321 "content": {
322 "parts": [{"text": "Full thinking text.", "thought": true}]
323 }
324 }]
325 }
326 }`)
327
328 // Chunk 2: signature only (empty text) — the normal case
329 chunk2 := []byte(`{
330 "response": {
331 "candidates": [{
332 "content": {
333 "parts": [{"text": "", "thought": true, "thoughtSignature": "` + validSignature + `"}]
334 }
335 }]
336 }
337 }`)
338
339 var param any
340 ctx := context.Background()
341
342 ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, chunk1, &param)
343 ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, chunk2, &param)
344
345 cachedSig := cache.GetCachedSignature("claude-sonnet-4-5-thinking", "Full thinking text.")
346 if cachedSig != validSignature {
347 t.Errorf("Signature-only chunk should still cache correctly, got %q", cachedSig)
348 }
349}
internal/translator/antigravity/claude/signature_validation.go+391−0
@@ -0,0 +1,391 @@
1// Claude thinking signature validation for Antigravity bypass mode.
2//
3// Spec reference: SIGNATURE-CHANNEL-SPEC.md
4//
5// # Encoding Detection (Spec §3)
6//
7// Claude signatures use base64 encoding in one or two layers. The raw string's
8// first character determines the encoding depth — this is mathematically equivalent
9// to the spec's "decode first, check byte" approach:
10//
11// - 'E' prefix → single-layer: payload[0]==0x12, first 6 bits = 000100 = base64 index 4 = 'E'
12// - 'R' prefix → double-layer: inner[0]=='E' (0x45), first 6 bits = 010001 = base64 index 17 = 'R'
13//
14// All valid signatures are normalized to R-form (double-layer base64) before
15// sending to the Antigravity backend.
16//
17// # Protobuf Structure (Spec §4.1, §4.2) — strict mode only
18//
19// After base64 decoding to raw bytes (first byte must be 0x12):
20//
21// Top-level protobuf
22// ├── Field 2 (bytes): container ← extractBytesField(payload, 2)
23// │ ├── Field 1 (bytes): channel block ← extractBytesField(container, 1)
24// │ │ ├── Field 1 (varint): channel_id [required] → routing_class (11 | 12)
25// │ │ ├── Field 2 (varint): infra [optional] → infrastructure_class (aws=1 | google=2)
26// │ │ ├── Field 3 (varint): version=2 [skipped]
27// │ │ ├── Field 5 (bytes): ECDSA sig [skipped, per Spec §11]
28// │ │ ├── Field 6 (bytes): model_text [optional] → schema_features
29// │ │ └── Field 7 (varint): unknown [optional] → schema_features
30// │ ├── Field 2 (bytes): nonce 12B [skipped]
31// │ ├── Field 3 (bytes): session 12B [skipped]
32// │ ├── Field 4 (bytes): SHA-384 48B [skipped]
33// │ └── Field 5 (bytes): metadata [skipped, per Spec §11]
34// └── Field 3 (varint): =1 [skipped]
35//
36// # Output Dimensions (Spec §8)
37//
38// routing_class: routing_class_11 | routing_class_12 | unknown
39// infrastructure_class: infra_default (absent) | infra_aws (1) | infra_google (2) | infra_unknown
40// schema_features: compact_schema (len 70-72, no f6/f7) | extended_model_tagged_schema (f6 exists) | unknown
41// legacy_route_hint: only for ch=11 — legacy_default_group | legacy_aws_group | legacy_vertex_direct/proxy
42//
43// # Compatibility
44//
45// Verified against all confirmed spec samples (Anthropic Max 20x, Azure, Vertex,
46// Bedrock) and legacy ch=11 signatures. Both single-layer (E) and double-layer (R)
47// encodings are supported. Historical cache-mode 'modelGroup#' prefixes are stripped.
48package claude
49
50import (
51 "encoding/base64"
52 "fmt"
53 "strings"
54 "unicode/utf8"
55
56 "github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
57 "github.com/tidwall/gjson"
58 "google.golang.org/protobuf/encoding/protowire"
59)
60
61const maxBypassSignatureLen = 8192
62
63type claudeSignatureTree struct {
64 EncodingLayers int
65 ChannelID uint64
66 Field2 *uint64
67 RoutingClass string
68 InfrastructureClass string
69 SchemaFeatures string
70 ModelText string
71 LegacyRouteHint string
72 HasField7 bool
73}
74
75func ValidateClaudeBypassSignatures(inputRawJSON []byte) error {
76 messages := gjson.GetBytes(inputRawJSON, "messages")
77 if !messages.IsArray() {
78 return nil
79 }
80
81 messageResults := messages.Array()
82 for i := 0; i < len(messageResults); i++ {
83 contentResults := messageResults[i].Get("content")
84 if !contentResults.IsArray() {
85 continue
86 }
87 parts := contentResults.Array()
88 for j := 0; j < len(parts); j++ {
89 part := parts[j]
90 if part.Get("type").String() != "thinking" {
91 continue
92 }
93
94 rawSignature := strings.TrimSpace(part.Get("signature").String())
95 if rawSignature == "" {
96 return fmt.Errorf("messages[%d].content[%d]: missing thinking signature", i, j)
97 }
98
99 if _, err := normalizeClaudeBypassSignature(rawSignature); err != nil {
100 return fmt.Errorf("messages[%d].content[%d]: %w", i, j, err)
101 }
102 }
103 }
104
105 return nil
106}
107
108func normalizeClaudeBypassSignature(rawSignature string) (string, error) {
109 sig := strings.TrimSpace(rawSignature)
110 if sig == "" {
111 return "", fmt.Errorf("empty signature")
112 }
113
114 if idx := strings.IndexByte(sig, '#'); idx >= 0 {
115 sig = strings.TrimSpace(sig[idx+1:])
116 }
117
118 if sig == "" {
119 return "", fmt.Errorf("empty signature after stripping prefix")
120 }
121
122 if len(sig) > maxBypassSignatureLen {
123 return "", fmt.Errorf("signature exceeds maximum length (%d bytes)", maxBypassSignatureLen)
124 }
125
126 switch sig[0] {
127 case 'R':
128 if err := validateDoubleLayerSignature(sig); err != nil {
129 return "", err
130 }
131 return sig, nil
132 case 'E':
133 if err := validateSingleLayerSignature(sig); err != nil {
134 return "", err
135 }
136 return base64.StdEncoding.EncodeToString([]byte(sig)), nil
137 default:
138 return "", fmt.Errorf("invalid signature: expected 'E' or 'R' prefix, got %q", string(sig[0]))
139 }
140}
141
142func validateDoubleLayerSignature(sig string) error {
143 decoded, err := base64.StdEncoding.DecodeString(sig)
144 if err != nil {
145 return fmt.Errorf("invalid double-layer signature: base64 decode failed: %w", err)
146 }
147 if len(decoded) == 0 {
148 return fmt.Errorf("invalid double-layer signature: empty after decode")
149 }
150 if decoded[0] != 'E' {
151 return fmt.Errorf("invalid double-layer signature: inner does not start with 'E', got 0x%02x", decoded[0])
152 }
153 return validateSingleLayerSignatureContent(string(decoded), 2)
154}
155
156func validateSingleLayerSignature(sig string) error {
157 return validateSingleLayerSignatureContent(sig, 1)
158}
159
160func validateSingleLayerSignatureContent(sig string, encodingLayers int) error {
161 decoded, err := base64.StdEncoding.DecodeString(sig)
162 if err != nil {
163 return fmt.Errorf("invalid single-layer signature: base64 decode failed: %w", err)
164 }
165 if len(decoded) == 0 {
166 return fmt.Errorf("invalid single-layer signature: empty after decode")
167 }
168 if decoded[0] != 0x12 {
169 return fmt.Errorf("invalid Claude signature: expected first byte 0x12, got 0x%02x", decoded[0])
170 }
171 if !cache.SignatureBypassStrictMode() {
172 return nil
173 }
174 _, err = inspectClaudeSignaturePayload(decoded, encodingLayers)
175 return err
176}
177
178func inspectDoubleLayerSignature(sig string) (*claudeSignatureTree, error) {
179 decoded, err := base64.StdEncoding.DecodeString(sig)
180 if err != nil {
181 return nil, fmt.Errorf("invalid double-layer signature: base64 decode failed: %w", err)
182 }
183 if len(decoded) == 0 {
184 return nil, fmt.Errorf("invalid double-layer signature: empty after decode")
185 }
186 if decoded[0] != 'E' {
187 return nil, fmt.Errorf("invalid double-layer signature: inner does not start with 'E', got 0x%02x", decoded[0])
188 }
189 return inspectSingleLayerSignatureWithLayers(string(decoded), 2)
190}
191
192func inspectSingleLayerSignature(sig string) (*claudeSignatureTree, error) {
193 return inspectSingleLayerSignatureWithLayers(sig, 1)
194}
195
196func inspectSingleLayerSignatureWithLayers(sig string, encodingLayers int) (*claudeSignatureTree, error) {
197 decoded, err := base64.StdEncoding.DecodeString(sig)
198 if err != nil {
199 return nil, fmt.Errorf("invalid single-layer signature: base64 decode failed: %w", err)
200 }
201 if len(decoded) == 0 {
202 return nil, fmt.Errorf("invalid single-layer signature: empty after decode")
203 }
204 return inspectClaudeSignaturePayload(decoded, encodingLayers)
205}
206
207func inspectClaudeSignaturePayload(payload []byte, encodingLayers int) (*claudeSignatureTree, error) {
208 if len(payload) == 0 {
209 return nil, fmt.Errorf("invalid Claude signature: empty payload")
210 }
211 if payload[0] != 0x12 {
212 return nil, fmt.Errorf("invalid Claude signature: expected first byte 0x12, got 0x%02x", payload[0])
213 }
214 container, err := extractBytesField(payload, 2, "top-level protobuf")
215 if err != nil {
216 return nil, err
217 }
218 channelBlock, err := extractBytesField(container, 1, "Claude Field 2 container")
219 if err != nil {
220 return nil, err
221 }
222 return inspectClaudeChannelBlock(channelBlock, encodingLayers)
223}
224
225func inspectClaudeChannelBlock(channelBlock []byte, encodingLayers int) (*claudeSignatureTree, error) {
226 tree := &claudeSignatureTree{
227 EncodingLayers: encodingLayers,
228 RoutingClass: "unknown",
229 InfrastructureClass: "infra_unknown",
230 SchemaFeatures: "unknown_schema_features",
231 }
232 haveChannelID := false
233 hasField6 := false
234 hasField7 := false
235
236 err := walkProtobufFields(channelBlock, func(num protowire.Number, typ protowire.Type, raw []byte) error {
237 switch num {
238 case 1:
239 if typ != protowire.VarintType {
240 return fmt.Errorf("invalid Claude signature: Field 2.1.1 channel_id must be varint")
241 }
242 channelID, err := decodeVarintField(raw, "Field 2.1.1 channel_id")
243 if err != nil {
244 return err
245 }
246 tree.ChannelID = channelID
247 haveChannelID = true
248 case 2:
249 if typ != protowire.VarintType {
250 return fmt.Errorf("invalid Claude signature: Field 2.1.2 field2 must be varint")
251 }
252 field2, err := decodeVarintField(raw, "Field 2.1.2 field2")
253 if err != nil {
254 return err
255 }
256 tree.Field2 = &field2
257 case 6:
258 if typ != protowire.BytesType {
259 return fmt.Errorf("invalid Claude signature: Field 2.1.6 model_text must be bytes")
260 }
261 modelBytes, err := decodeBytesField(raw, "Field 2.1.6 model_text")
262 if err != nil {
263 return err
264 }
265 if !utf8.Valid(modelBytes) {
266 return fmt.Errorf("invalid Claude signature: Field 2.1.6 model_text is not valid UTF-8")
267 }
268 tree.ModelText = string(modelBytes)
269 hasField6 = true
270 case 7:
271 if typ != protowire.VarintType {
272 return fmt.Errorf("invalid Claude signature: Field 2.1.7 must be varint")
273 }
274 if _, err := decodeVarintField(raw, "Field 2.1.7"); err != nil {
275 return err
276 }
277 hasField7 = true
278 tree.HasField7 = true
279 }
280 return nil
281 })
282 if err != nil {
283 return nil, err
284 }
285 if !haveChannelID {
286 return nil, fmt.Errorf("invalid Claude signature: missing Field 2.1.1 channel_id")
287 }
288
289 switch tree.ChannelID {
290 case 11:
291 tree.RoutingClass = "routing_class_11"
292 case 12:
293 tree.RoutingClass = "routing_class_12"
294 }
295
296 if tree.Field2 == nil {
297 tree.InfrastructureClass = "infra_default"
298 } else {
299 switch *tree.Field2 {
300 case 1:
301 tree.InfrastructureClass = "infra_aws"
302 case 2:
303 tree.InfrastructureClass = "infra_google"
304 default:
305 tree.InfrastructureClass = "infra_unknown"
306 }
307 }
308
309 switch {
310 case hasField6:
311 tree.SchemaFeatures = "extended_model_tagged_schema"
312 case !hasField6 && !hasField7 && len(channelBlock) >= 70 && len(channelBlock) <= 72:
313 tree.SchemaFeatures = "compact_schema"
314 }
315
316 if tree.ChannelID == 11 {
317 switch {
318 case tree.Field2 == nil:
319 tree.LegacyRouteHint = "legacy_default_group"
320 case *tree.Field2 == 1:
321 tree.LegacyRouteHint = "legacy_aws_group"
322 case *tree.Field2 == 2 && tree.EncodingLayers == 2:
323 tree.LegacyRouteHint = "legacy_vertex_direct"
324 case *tree.Field2 == 2 && tree.EncodingLayers == 1:
325 tree.LegacyRouteHint = "legacy_vertex_proxy"
326 }
327 }
328
329 return tree, nil
330}
331
332func extractBytesField(msg []byte, fieldNum protowire.Number, scope string) ([]byte, error) {
333 var value []byte
334 err := walkProtobufFields(msg, func(num protowire.Number, typ protowire.Type, raw []byte) error {
335 if num != fieldNum {
336 return nil
337 }
338 if typ != protowire.BytesType {
339 return fmt.Errorf("invalid Claude signature: %s field %d must be bytes", scope, fieldNum)
340 }
341 bytesValue, err := decodeBytesField(raw, fmt.Sprintf("%s field %d", scope, fieldNum))
342 if err != nil {
343 return err
344 }
345 value = bytesValue
346 return nil
347 })
348 if err != nil {
349 return nil, err
350 }
351 if value == nil {
352 return nil, fmt.Errorf("invalid Claude signature: missing %s field %d", scope, fieldNum)
353 }
354 return value, nil
355}
356
357func walkProtobufFields(msg []byte, visit func(num protowire.Number, typ protowire.Type, raw []byte) error) error {
358 for offset := 0; offset < len(msg); {
359 num, typ, n := protowire.ConsumeTag(msg[offset:])
360 if n < 0 {
361 return fmt.Errorf("invalid Claude signature: malformed protobuf tag: %w", protowire.ParseError(n))
362 }
363 offset += n
364 valueLen := protowire.ConsumeFieldValue(num, typ, msg[offset:])
365 if valueLen < 0 {
366 return fmt.Errorf("invalid Claude signature: malformed protobuf field %d: %w", num, protowire.ParseError(valueLen))
367 }
368 fieldRaw := msg[offset : offset+valueLen]
369 if err := visit(num, typ, fieldRaw); err != nil {
370 return err
371 }
372 offset += valueLen
373 }
374 return nil
375}
376
377func decodeVarintField(raw []byte, label string) (uint64, error) {
378 value, n := protowire.ConsumeVarint(raw)
379 if n < 0 {
380 return 0, fmt.Errorf("invalid Claude signature: failed to decode %s: %w", label, protowire.ParseError(n))
381 }
382 return value, nil
383}
384
385func decodeBytesField(raw []byte, label string) ([]byte, error) {
386 value, n := protowire.ConsumeBytes(raw)
387 if n < 0 {
388 return nil, fmt.Errorf("invalid Claude signature: failed to decode %s: %w", label, protowire.ParseError(n))
389 }
390 return value, nil
391}