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
114114 # keepalive-seconds: 15 # Default: 0 (disabled). <= 0 disables keep-alives.
115115 # bootstrap-retries: 1 # Default: 0 (disabled). Retries before first byte is sent.
116116
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+
117127 # Gemini API keys
118128 # gemini-api-key:
119129 # - api-key: "AIzaSy...01"
internal/api/server.go+41−0
@@ -24,6 +24,7 @@ import ( · 23 unmodified lines
2424 "github.com/router-for-me/CLIProxyAPI/v6/internal/api/middleware"
2525 "github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules"
2626 ampmodule "github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules/amp"
27+ "github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
2728 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
2829 "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
2930 "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
261262 }
262263 managementasset.SetCurrentConfig(cfg)
263264 auth.SetQuotaCooldownDisabled(cfg.DisableCooling)
265+ applySignatureCacheConfig(nil, cfg)
264266 // Initialize management handler
265267 s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager)
266268 if optionState.localPassword != "" {
@@ -918,6 +920,8 @@ func (s *Server) UpdateClients(cfg *config.Config) { · 651 unmodified lines
918920 auth.SetQuotaCooldownDisabled(cfg.DisableCooling)
919921 }
920922
923+ applySignatureCacheConfig(oldCfg, cfg)
924+
921925 if s.handlers != nil && s.handlers.AuthManager != nil {
922926 s.handlers.AuthManager.SetRetryConfig(cfg.RequestRetry, time.Duration(cfg.MaxRetryInterval)*time.Second, cfg.MaxRetryCredentials)
923927 }
@@ -1056,3 +1060,40 @@ func AuthMiddleware(manager *sdkaccess.Manager) gin.HandlerFunc { · 132 unmodified lines
10561060 c.AbortWithStatusJSON(statusCode, gin.H{"error": err.Message})
10571061 }
10581062 }
1063+
1064+func configuredSignatureCacheEnabled(cfg *config.Config) bool {
1065+ if cfg != nil && cfg.AntigravitySignatureCacheEnabled != nil {
1066+ return *cfg.AntigravitySignatureCacheEnabled
1067+ }
1068+ return true
1069+}
1070+
1071+func 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+
1094+func 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
55 "encoding/hex"
66 "strings"
77 "sync"
8+ "sync/atomic"
89 "time"
10+
11+ log "github.com/sirupsen/logrus"
912 )
1013
1114 // SignatureEntry holds a cached thinking signature with timestamp
@@ -193,3 +196,39 @@ func GetModelGroup(modelName string) string { · 181 unmodified lines
193196 }
194197 return modelName
195198 }
199+
200+var signatureCacheEnabled atomic.Bool
201+var signatureBypassStrictMode atomic.Bool
202+
203+func init() {
204+ signatureCacheEnabled.Store(true)
205+ signatureBypassStrictMode.Store(false)
206+}
207+
208+// SetSignatureCacheEnabled switches Antigravity signature handling between cache mode and bypass mode.
209+func 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.
217+func SignatureCacheEnabled() bool {
218+ return signatureCacheEnabled.Load()
219+}
220+
221+// SetSignatureBypassStrictMode controls whether bypass mode uses strict protobuf-tree validation.
222+func 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.
232+func SignatureBypassStrictMode() bool {
233+ return signatureBypassStrictMode.Load()
234+}
internal/config/config.go+7−0
@@ -85,6 +85,13 @@ type Config struct { · 84 unmodified lines
8585 // WebsocketAuth enables or disables authentication for the WebSocket API.
8686 WebsocketAuth bool `yaml:"ws-auth" json:"ws-auth"`
8787
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+
8895 // GeminiKey defines Gemini API key configurations with optional routing overrides.
8996 GeminiKey []GeminiKey `yaml:"gemini-api-key" json:"gemini-api-key"`
9097
internal/runtime/executor/antigravity_executor.go+60−28
@@ -23,10 +23,12 @@ import ( · 22 unmodified lines
2323 "time"
2424
2525 "github.com/google/uuid"
26+ "github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
2627 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
2728 "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
2829 "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps"
2930 "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
31+ antigravityclaude "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/antigravity/claude"
3032 "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
3133 sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
3234 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
182184 return client
183185 }
184186
187+func 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+
185205 // Identifier returns the executor identifier.
186206 func (e *AntigravityExecutor) Identifier() string { return antigravityAuthType }
187207
@@ -664,14 +684,6 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au · 476 unmodified lines
664684 return e.executeClaudeNonStream(ctx, auth, req, opts)
665685 }
666686
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−
675687 reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
676688 defer reporter.TrackFailure(ctx, &err)
677689
@@ -683,6 +695,16 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au · 5 unmodified lines
683695 originalPayloadSource = opts.OriginalRequest
684696 }
685697 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+ }
686708 originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
687709 translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
688710
@@ -874,14 +896,6 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth * · 185 unmodified lines
874896 return resp, statusErr{code: http.StatusTooManyRequests, msg: fmt.Sprintf("auth in short cooldown, %s remaining", remaining), retryAfter: &d}
875897 }
876898
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−
885899 reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
886900 defer reporter.TrackFailure(ctx, &err)
887901
@@ -893,6 +907,16 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth * · 5 unmodified lines
893907 originalPayloadSource = opts.OriginalRequest
894908 }
895909 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+ }
896920 originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
897921 translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)
898922
@@ -1335,14 +1359,6 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya · 436 unmodified lines
13351359 return nil, statusErr{code: http.StatusTooManyRequests, msg: fmt.Sprintf("auth in short cooldown, %s remaining", remaining), retryAfter: &d}
13361360 }
13371361
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−
13461362 reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
13471363 defer reporter.TrackFailure(ctx, &err)
13481364
@@ -1354,6 +1370,16 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya · 5 unmodified lines
13541370 originalPayloadSource = opts.OriginalRequest
13551371 }
13561372 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+ }
13571383 originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
13581384 translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)
13591385
@@ -1593,6 +1619,16 @@ func (e *AntigravityExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Au · 233 unmodified lines
15931619 func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
15941620 baseModel := thinking.ParseSuffix(req.Model).ModelName
15951621
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+ }
15961632 token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth)
15971633 if errToken != nil {
15981634 return cliproxyexecutor.Response{}, errToken
@@ -1604,10 +1640,6 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut · 5 unmodified lines
16041640 return cliproxyexecutor.Response{}, statusErr{code: http.StatusUnauthorized, msg: "missing access token"}
16051641 }
16061642
1607− from := opts.SourceFormat
1608− to := sdktranslator.FromString("antigravity")
1609− respCtx := context.WithValue(ctx, "alt", opts.Alt)
1610−
16111643 // Prepare payload once (doesn't depend on baseURL)
16121644 payload := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
16131645
internal/runtime/executor/antigravity_executor_signature_test.go+157−0
@@ -0,0 +1,157 @@
1+package executor
2+
3+import (
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+
19+func testGeminiSignaturePayload() string {
20+ payload := append([]byte{0x0A}, bytes.Repeat([]byte{0x56}, 48)...)
21+ return base64.StdEncoding.EncodeToString(payload)
22+}
23+
24+func 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+
36+func 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+
51+func 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+
124+func 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+
143+func 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
1717 "github.com/tidwall/sjson"
1818 )
1919
20+func resolveThinkingSignature(modelName, thinkingText, rawSignature string) string {
21+ if cache.SignatureCacheEnabled() {
22+ return resolveCacheModeSignature(modelName, thinkingText, rawSignature)
23+ }
24+ return resolveBypassModeSignature(rawSignature)
25+}
26+
27+func 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+
52+func 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+
63+func hasResolvedThinkingSignature(modelName, signature string) bool {
64+ if cache.SignatureCacheEnabled() {
65+ return cache.HasValidSignature(modelName, signature)
66+ }
67+ return signature != ""
68+}
69+
2070 // ConvertClaudeRequestToAntigravity parses and transforms a Claude Code API request into Gemini CLI API format.
2171 // It extracts the model name, system instruction, message contents, and tool declarations
2272 // 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
101151 if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "thinking" {
102152 // Use GetThinkingText to handle wrapped thinking objects
103153 thinkingText := thinking.GetThinkingText(contentResult)
104−
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− }
154+ signature := resolveThinkingSignature(modelName, thinkingText, contentResult.Get("signature").String())
132155
133156 // Store for subsequent tool_use in the same message
134− if cache.HasValidSignature(modelName, signature) {
157+ if hasResolvedThinkingSignature(modelName, signature) {
135158 currentMessageThinkingSignature = signature
136159 }
137160
138− // Skip trailing unsigned thinking blocks on last assistant message
139− isUnsigned := !cache.HasValidSignature(modelName, signature)
161+ // Skip unsigned thinking blocks instead of converting them to text.
162+ isUnsigned := !hasResolvedThinkingSignature(modelName, signature)
140163
141164 // If unsigned, skip entirely (don't convert to text)
142165 // 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
198221 // This is the approach used in opencode-google-antigravity-auth for Gemini
199222 // and also works for Claude through Antigravity API
200223 const skipSentinel = "skip_thought_signature_validator"
201− if cache.HasValidSignature(modelName, currentMessageThinkingSignature) {
224+ if hasResolvedThinkingSignature(modelName, currentMessageThinkingSignature) {
202225 partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", currentMessageThinkingSignature)
203226 } else {
204227 // No valid signature - use skip sentinel to bypass validation
internal/translator/antigravity/claude/antigravity_claude_request_test.go+623−0
@@ -1,13 +1,97 @@
11 package claude
22
33 import (
4+ "bytes"
5+ "encoding/base64"
46 "strings"
57 "testing"
68
79 "github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
810 "github.com/tidwall/gjson"
11+ "google.golang.org/protobuf/encoding/protowire"
912 )
1013
14+func 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+
25+func testMinimalAnthropicSignature(t *testing.T) string {
26+ t.Helper()
27+
28+ payload := buildClaudeSignaturePayload(t, 12, nil, "", false)
29+ return base64.StdEncoding.EncodeToString(payload)
30+}
31+
32+func 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+
69+func uint64Ptr(v uint64) *uint64 {
70+ return &v
71+}
72+
73+func 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+
84+func 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+
1195 func TestConvertClaudeRequestToAntigravity_BasicStructure(t *testing.T) {
1296 inputJSON := []byte(`{
1397 "model": "claude-3-5-sonnet-20240620",
@@ -116,6 +200,545 @@ func TestConvertClaudeRequestToAntigravity_ThinkingBlocks(t *testing.T) { · 102 unmodified lines
116200 }
117201 }
118202
203+func 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+
224+func 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+
242+func 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+
263+func 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+
281+func 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+
304+func 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+
329+func 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+
351+func 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+
371+func 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+
399+func 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+
425+func 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+
442+func 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+
469+func 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+
492+func 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+
511+func 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+
552+func 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+
598+func 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+
620+func 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+
637+func 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+
670+func 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+
708+func 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+
119742 func TestConvertClaudeRequestToAntigravity_ThinkingBlockWithoutSignature(t *testing.T) {
120743 cache.ClearSignatureCache("")
121744
internal/translator/antigravity/claude/antigravity_claude_response.go+48−2
@@ -9,6 +9,7 @@ package claude · 8 unmodified lines
99 import (
1010 "bytes"
1111 "context"
12+ "encoding/base64"
1213 "fmt"
1314 "strings"
1415 "sync/atomic"
@@ -23,6 +24,33 @@ import ( · 8 unmodified lines
2324 "github.com/tidwall/sjson"
2425 )
2526
27+// decodeSignature decodes R... (2-layer Base64) to E... (1-layer Base64, Anthropic format).
28+// Returns empty string if decoding fails (skip invalid signatures).
29+func 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+
44+func 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+
2654 // Params holds parameters for response conversion and maintains state across streaming chunks.
2755 // This structure tracks the current state of the response translation process to ensure
2856 // proper sequencing of SSE events and transitions between different content types.
@@ -144,13 +172,30 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq · 115 unmodified lines
144172 if thoughtSignature := partResult.Get("thoughtSignature"); thoughtSignature.Exists() && thoughtSignature.String() != "" {
145173 // log.Debug("Branch: signature_delta")
146174
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+
147191 if params.CurrentThinkingText.Len() > 0 {
148192 cache.CacheSignature(modelName, params.CurrentThinkingText.String(), thoughtSignature.String())
149193 // log.Debugf("Cached signature for thinking block (textLen=%d)", params.CurrentThinkingText.Len())
150194 params.CurrentThinkingText.Reset()
151195 }
152196
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)
154199 appendEvent("content_block_delta", string(data))
155200 params.HasContent = true
156201 } 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
419464 block := []byte(`{"type":"thinking","thinking":""}`)
420465 block, _ = sjson.SetBytes(block, "thinking", thinkingBuilder.String())
421466 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)
423469 }
424470 responseJSON, _ = sjson.SetRawBytes(responseJSON, "content.-1", block)
425471 thinkingBuilder.Reset()
internal/translator/antigravity/claude/antigravity_claude_response_test.go+103−0
@@ -1,6 +1,7 @@
11 package claude
22
33 import (
4+ "bytes"
45 "context"
56 "strings"
67 "testing"
@@ -244,3 +245,105 @@ func TestConvertAntigravityResponseToClaude_MultipleThinkingBlocks(t *testing.T) · 237 unmodified lines
244245 t.Error("Second thinking block signature should be cached")
245246 }
246247 }
248+
249+func 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+
307+func 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.
48+package claude
49+
50+import (
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+
61+const maxBypassSignatureLen = 8192
62+
63+type 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+
75+func 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+
108+func 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+
142+func 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+
156+func validateSingleLayerSignature(sig string) error {
157+ return validateSingleLayerSignatureContent(sig, 1)
158+}
159+
160+func 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+
178+func 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+
192+func inspectSingleLayerSignature(sig string) (*claudeSignatureTree, error) {
193+ return inspectSingleLayerSignatureWithLayers(sig, 1)
194+}
195+
196+func 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+
207+func 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+
225+func 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+
332+func 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+
357+func 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+
377+func 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+
385+func 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+}