mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-05-07 02:35:51 +08:00
feat(antigravity): configurable signature cache with bypass-mode validation
Antigravity 的 Claude thinking signature 处理新增 cache/bypass 双模式, 并为 bypass 模式实现按 SIGNATURE-CHANNEL-SPEC.md 的签名校验。 新增 antigravity-signature-cache-enabled 配置项(默认 true): - cache mode(true):使用服务端缓存的签名,行为与原有逻辑完全一致 - bypass mode(false):直接使用客户端提供的签名,经过校验和归一化 支持配置热重载,运行时可切换模式。 校验流程: 1. 剥离历史 cache-mode 的 'modelGroup#' 前缀(如 claude#Exxxx → Exxxx) 2. 首字符必须为 'E'(单层编码)或 'R'(双层编码),否则拒绝 3. R 开头:base64 解码 → 内层必须以 'E' 开头 → 继续单层校验 4. E 开头:base64 解码 → 首字节必须为 0x12(Claude protobuf 标识) 5. 所有合法签名归一化为 R 形式(双层 base64)发往 Antigravity 后端 非法签名处理策略: - 非严格模式(默认):translator 静默丢弃无签名的 thinking block - 严格模式(antigravity-signature-bypass-strict: true): executor 层在请求发往上游前直接返回 HTTP 400 按 SIGNATURE-CHANNEL-SPEC.md 解析 Claude 签名的完整 protobuf 结构: - Top-level Field 2(容器)→ Field 1(渠道块) - 渠道块提取:channel_id (Field 1)、infrastructure (Field 2)、 model_text (Field 6)、field7 (Field 7) - 计算 routing_class、infrastructure_class、schema_features - 使用 google.golang.org/protobuf/encoding/protowire 解析 - resolveThinkingSignature 拆分为 resolveCacheModeSignature / resolveBypassModeSignature - hasResolvedThinkingSignature:mode-aware 签名有效性判断 (cache: len>=50 via HasValidSignature,bypass: non-empty) - validateAntigravityRequestSignatures:executor 预检, 仅在 bypass + strict 模式下拦截非法签名返回 400 - 响应侧签名缓存逻辑与 cache mode 集成 - Cache mode 行为完全保留:无 '#' 前缀的原生签名静默丢弃
This commit is contained in:
@@ -115,6 +115,16 @@ nonstream-keepalive-interval: 0
|
|||||||
# keepalive-seconds: 15 # Default: 0 (disabled). <= 0 disables keep-alives.
|
# keepalive-seconds: 15 # Default: 0 (disabled). <= 0 disables keep-alives.
|
||||||
# bootstrap-retries: 1 # Default: 0 (disabled). Retries before first byte is sent.
|
# bootstrap-retries: 1 # Default: 0 (disabled). Retries before first byte is sent.
|
||||||
|
|
||||||
|
# Signature cache validation for thinking blocks (Antigravity/Claude).
|
||||||
|
# When true (default), cached signatures are preferred and validated.
|
||||||
|
# When false, client signatures are used directly after normalization (bypass mode for testing).
|
||||||
|
# antigravity-signature-cache-enabled: true
|
||||||
|
|
||||||
|
# Bypass mode signature validation strictness (only applies when signature cache is disabled).
|
||||||
|
# When true, validates full Claude protobuf tree (Field 2 -> Field 1 structure).
|
||||||
|
# When false (default), only checks R/E prefix + base64 + first byte 0x12.
|
||||||
|
# antigravity-signature-bypass-strict: false
|
||||||
|
|
||||||
# Gemini API keys
|
# Gemini API keys
|
||||||
# gemini-api-key:
|
# gemini-api-key:
|
||||||
# - api-key: "AIzaSy...01"
|
# - api-key: "AIzaSy...01"
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/middleware"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/middleware"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules"
|
||||||
ampmodule "github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules/amp"
|
ampmodule "github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules/amp"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset"
|
||||||
@@ -261,6 +262,7 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
|
|||||||
}
|
}
|
||||||
managementasset.SetCurrentConfig(cfg)
|
managementasset.SetCurrentConfig(cfg)
|
||||||
auth.SetQuotaCooldownDisabled(cfg.DisableCooling)
|
auth.SetQuotaCooldownDisabled(cfg.DisableCooling)
|
||||||
|
applySignatureCacheConfig(nil, cfg)
|
||||||
// Initialize management handler
|
// Initialize management handler
|
||||||
s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager)
|
s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager)
|
||||||
if optionState.localPassword != "" {
|
if optionState.localPassword != "" {
|
||||||
@@ -918,6 +920,8 @@ func (s *Server) UpdateClients(cfg *config.Config) {
|
|||||||
auth.SetQuotaCooldownDisabled(cfg.DisableCooling)
|
auth.SetQuotaCooldownDisabled(cfg.DisableCooling)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applySignatureCacheConfig(oldCfg, cfg)
|
||||||
|
|
||||||
if s.handlers != nil && s.handlers.AuthManager != nil {
|
if s.handlers != nil && s.handlers.AuthManager != nil {
|
||||||
s.handlers.AuthManager.SetRetryConfig(cfg.RequestRetry, time.Duration(cfg.MaxRetryInterval)*time.Second, cfg.MaxRetryCredentials)
|
s.handlers.AuthManager.SetRetryConfig(cfg.RequestRetry, time.Duration(cfg.MaxRetryInterval)*time.Second, cfg.MaxRetryCredentials)
|
||||||
}
|
}
|
||||||
@@ -1056,3 +1060,40 @@ func AuthMiddleware(manager *sdkaccess.Manager) gin.HandlerFunc {
|
|||||||
c.AbortWithStatusJSON(statusCode, gin.H{"error": err.Message})
|
c.AbortWithStatusJSON(statusCode, gin.H{"error": err.Message})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func configuredSignatureCacheEnabled(cfg *config.Config) bool {
|
||||||
|
if cfg != nil && cfg.AntigravitySignatureCacheEnabled != nil {
|
||||||
|
return *cfg.AntigravitySignatureCacheEnabled
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func applySignatureCacheConfig(oldCfg, cfg *config.Config) {
|
||||||
|
newVal := configuredSignatureCacheEnabled(cfg)
|
||||||
|
newStrict := configuredSignatureBypassStrict(cfg)
|
||||||
|
if oldCfg == nil {
|
||||||
|
cache.SetSignatureCacheEnabled(newVal)
|
||||||
|
cache.SetSignatureBypassStrictMode(newStrict)
|
||||||
|
log.Debugf("antigravity_signature_cache_enabled toggled to %t", newVal)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
oldVal := configuredSignatureCacheEnabled(oldCfg)
|
||||||
|
if oldVal != newVal {
|
||||||
|
cache.SetSignatureCacheEnabled(newVal)
|
||||||
|
log.Debugf("antigravity_signature_cache_enabled updated from %t to %t", oldVal, newVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldStrict := configuredSignatureBypassStrict(oldCfg)
|
||||||
|
if oldStrict != newStrict {
|
||||||
|
cache.SetSignatureBypassStrictMode(newStrict)
|
||||||
|
log.Debugf("antigravity_signature_bypass_strict updated from %t to %t", oldStrict, newStrict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configuredSignatureBypassStrict(cfg *config.Config) bool {
|
||||||
|
if cfg != nil && cfg.AntigravitySignatureBypassStrict != nil {
|
||||||
|
return *cfg.AntigravitySignatureBypassStrict
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
39
internal/cache/signature_cache.go
vendored
39
internal/cache/signature_cache.go
vendored
@@ -5,7 +5,10 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SignatureEntry holds a cached thinking signature with timestamp
|
// SignatureEntry holds a cached thinking signature with timestamp
|
||||||
@@ -193,3 +196,39 @@ func GetModelGroup(modelName string) string {
|
|||||||
}
|
}
|
||||||
return modelName
|
return modelName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var signatureCacheEnabled atomic.Bool
|
||||||
|
var signatureBypassStrictMode atomic.Bool
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
signatureCacheEnabled.Store(true)
|
||||||
|
signatureBypassStrictMode.Store(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSignatureCacheEnabled switches Antigravity signature handling between cache mode and bypass mode.
|
||||||
|
func SetSignatureCacheEnabled(enabled bool) {
|
||||||
|
signatureCacheEnabled.Store(enabled)
|
||||||
|
if !enabled {
|
||||||
|
log.Warn("antigravity signature cache DISABLED - bypass mode active, cached signatures will not be used for request translation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignatureCacheEnabled returns whether signature cache validation is enabled.
|
||||||
|
func SignatureCacheEnabled() bool {
|
||||||
|
return signatureCacheEnabled.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSignatureBypassStrictMode controls whether bypass mode uses strict protobuf-tree validation.
|
||||||
|
func SetSignatureBypassStrictMode(strict bool) {
|
||||||
|
signatureBypassStrictMode.Store(strict)
|
||||||
|
if strict {
|
||||||
|
log.Info("antigravity bypass signature validation: strict mode (protobuf tree)")
|
||||||
|
} else {
|
||||||
|
log.Info("antigravity bypass signature validation: basic mode (R/E + 0x12)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignatureBypassStrictMode returns whether bypass mode uses strict protobuf-tree validation.
|
||||||
|
func SignatureBypassStrictMode() bool {
|
||||||
|
return signatureBypassStrictMode.Load()
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,6 +85,13 @@ type Config struct {
|
|||||||
// WebsocketAuth enables or disables authentication for the WebSocket API.
|
// WebsocketAuth enables or disables authentication for the WebSocket API.
|
||||||
WebsocketAuth bool `yaml:"ws-auth" json:"ws-auth"`
|
WebsocketAuth bool `yaml:"ws-auth" json:"ws-auth"`
|
||||||
|
|
||||||
|
// AntigravitySignatureCacheEnabled controls whether signature cache validation is enabled for thinking blocks.
|
||||||
|
// When true (default), cached signatures are preferred and validated.
|
||||||
|
// When false, client signatures are used directly after normalization (bypass mode).
|
||||||
|
AntigravitySignatureCacheEnabled *bool `yaml:"antigravity-signature-cache-enabled,omitempty" json:"antigravity-signature-cache-enabled,omitempty"`
|
||||||
|
|
||||||
|
AntigravitySignatureBypassStrict *bool `yaml:"antigravity-signature-bypass-strict,omitempty" json:"antigravity-signature-bypass-strict,omitempty"`
|
||||||
|
|
||||||
// GeminiKey defines Gemini API key configurations with optional routing overrides.
|
// GeminiKey defines Gemini API key configurations with optional routing overrides.
|
||||||
GeminiKey []GeminiKey `yaml:"gemini-api-key" json:"gemini-api-key"`
|
GeminiKey []GeminiKey `yaml:"gemini-api-key" json:"gemini-api-key"`
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||||
|
antigravityclaude "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/antigravity/claude"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
@@ -158,6 +160,24 @@ func newAntigravityHTTPClient(ctx context.Context, cfg *config.Config, auth *cli
|
|||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateAntigravityRequestSignatures(from sdktranslator.Format, rawJSON []byte) error {
|
||||||
|
if from.String() != "claude" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if cache.SignatureCacheEnabled() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !cache.SignatureBypassStrictMode() {
|
||||||
|
// Non-strict bypass: let the translator handle invalid signatures
|
||||||
|
// by dropping unsigned thinking blocks silently (no 400).
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := antigravityclaude.ValidateClaudeBypassSignatures(rawJSON); err != nil {
|
||||||
|
return statusErr{code: http.StatusBadRequest, msg: err.Error()}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Identifier returns the executor identifier.
|
// Identifier returns the executor identifier.
|
||||||
func (e *AntigravityExecutor) Identifier() string { return antigravityAuthType }
|
func (e *AntigravityExecutor) Identifier() string { return antigravityAuthType }
|
||||||
|
|
||||||
@@ -479,14 +499,6 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au
|
|||||||
return e.executeClaudeNonStream(ctx, auth, req, opts)
|
return e.executeClaudeNonStream(ctx, auth, req, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth)
|
|
||||||
if errToken != nil {
|
|
||||||
return resp, errToken
|
|
||||||
}
|
|
||||||
if updatedAuth != nil {
|
|
||||||
auth = updatedAuth
|
|
||||||
}
|
|
||||||
|
|
||||||
reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
defer reporter.TrackFailure(ctx, &err)
|
defer reporter.TrackFailure(ctx, &err)
|
||||||
|
|
||||||
@@ -498,6 +510,16 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au
|
|||||||
originalPayloadSource = opts.OriginalRequest
|
originalPayloadSource = opts.OriginalRequest
|
||||||
}
|
}
|
||||||
originalPayload := originalPayloadSource
|
originalPayload := originalPayloadSource
|
||||||
|
if errValidate := validateAntigravityRequestSignatures(from, originalPayload); errValidate != nil {
|
||||||
|
return resp, errValidate
|
||||||
|
}
|
||||||
|
token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth)
|
||||||
|
if errToken != nil {
|
||||||
|
return resp, errToken
|
||||||
|
}
|
||||||
|
if updatedAuth != nil {
|
||||||
|
auth = updatedAuth
|
||||||
|
}
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
|
||||||
translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
|
translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
|
||||||
|
|
||||||
@@ -655,14 +677,6 @@ attemptLoop:
|
|||||||
func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
||||||
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth)
|
|
||||||
if errToken != nil {
|
|
||||||
return resp, errToken
|
|
||||||
}
|
|
||||||
if updatedAuth != nil {
|
|
||||||
auth = updatedAuth
|
|
||||||
}
|
|
||||||
|
|
||||||
reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
defer reporter.TrackFailure(ctx, &err)
|
defer reporter.TrackFailure(ctx, &err)
|
||||||
|
|
||||||
@@ -674,6 +688,16 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *
|
|||||||
originalPayloadSource = opts.OriginalRequest
|
originalPayloadSource = opts.OriginalRequest
|
||||||
}
|
}
|
||||||
originalPayload := originalPayloadSource
|
originalPayload := originalPayloadSource
|
||||||
|
if errValidate := validateAntigravityRequestSignatures(from, originalPayload); errValidate != nil {
|
||||||
|
return resp, errValidate
|
||||||
|
}
|
||||||
|
token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth)
|
||||||
|
if errToken != nil {
|
||||||
|
return resp, errToken
|
||||||
|
}
|
||||||
|
if updatedAuth != nil {
|
||||||
|
auth = updatedAuth
|
||||||
|
}
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
|
||||||
translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)
|
translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)
|
||||||
|
|
||||||
@@ -1080,14 +1104,6 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
|
|||||||
|
|
||||||
ctx = context.WithValue(ctx, "alt", "")
|
ctx = context.WithValue(ctx, "alt", "")
|
||||||
|
|
||||||
token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth)
|
|
||||||
if errToken != nil {
|
|
||||||
return nil, errToken
|
|
||||||
}
|
|
||||||
if updatedAuth != nil {
|
|
||||||
auth = updatedAuth
|
|
||||||
}
|
|
||||||
|
|
||||||
reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||||
defer reporter.TrackFailure(ctx, &err)
|
defer reporter.TrackFailure(ctx, &err)
|
||||||
|
|
||||||
@@ -1099,6 +1115,16 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
|
|||||||
originalPayloadSource = opts.OriginalRequest
|
originalPayloadSource = opts.OriginalRequest
|
||||||
}
|
}
|
||||||
originalPayload := originalPayloadSource
|
originalPayload := originalPayloadSource
|
||||||
|
if errValidate := validateAntigravityRequestSignatures(from, originalPayload); errValidate != nil {
|
||||||
|
return nil, errValidate
|
||||||
|
}
|
||||||
|
token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth)
|
||||||
|
if errToken != nil {
|
||||||
|
return nil, errToken
|
||||||
|
}
|
||||||
|
if updatedAuth != nil {
|
||||||
|
auth = updatedAuth
|
||||||
|
}
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
|
||||||
translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)
|
translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)
|
||||||
|
|
||||||
@@ -1307,6 +1333,16 @@ func (e *AntigravityExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Au
|
|||||||
func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
||||||
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||||
|
|
||||||
|
from := opts.SourceFormat
|
||||||
|
to := sdktranslator.FromString("antigravity")
|
||||||
|
respCtx := context.WithValue(ctx, "alt", opts.Alt)
|
||||||
|
originalPayloadSource := req.Payload
|
||||||
|
if len(opts.OriginalRequest) > 0 {
|
||||||
|
originalPayloadSource = opts.OriginalRequest
|
||||||
|
}
|
||||||
|
if errValidate := validateAntigravityRequestSignatures(from, originalPayloadSource); errValidate != nil {
|
||||||
|
return cliproxyexecutor.Response{}, errValidate
|
||||||
|
}
|
||||||
token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth)
|
token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth)
|
||||||
if errToken != nil {
|
if errToken != nil {
|
||||||
return cliproxyexecutor.Response{}, errToken
|
return cliproxyexecutor.Response{}, errToken
|
||||||
@@ -1318,10 +1354,6 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut
|
|||||||
return cliproxyexecutor.Response{}, statusErr{code: http.StatusUnauthorized, msg: "missing access token"}
|
return cliproxyexecutor.Response{}, statusErr{code: http.StatusUnauthorized, msg: "missing access token"}
|
||||||
}
|
}
|
||||||
|
|
||||||
from := opts.SourceFormat
|
|
||||||
to := sdktranslator.FromString("antigravity")
|
|
||||||
respCtx := context.WithValue(ctx, "alt", opts.Alt)
|
|
||||||
|
|
||||||
// Prepare payload once (doesn't depend on baseURL)
|
// Prepare payload once (doesn't depend on baseURL)
|
||||||
payload := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
|
payload := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
|
||||||
|
|
||||||
|
|||||||
157
internal/runtime/executor/antigravity_executor_signature_test.go
Normal file
157
internal/runtime/executor/antigravity_executor_signature_test.go
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
|
||||||
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testGeminiSignaturePayload() string {
|
||||||
|
payload := append([]byte{0x0A}, bytes.Repeat([]byte{0x56}, 48)...)
|
||||||
|
return base64.StdEncoding.EncodeToString(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAntigravityAuth(baseURL string) *cliproxyauth.Auth {
|
||||||
|
return &cliproxyauth.Auth{
|
||||||
|
Attributes: map[string]string{
|
||||||
|
"base_url": baseURL,
|
||||||
|
},
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"access_token": "token-123",
|
||||||
|
"expired": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidClaudeThinkingPayload() []byte {
|
||||||
|
return []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": "bad", "signature": "` + testGeminiSignaturePayload() + `"},
|
||||||
|
{"type": "text", "text": "hello"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntigravityExecutor_StrictBypassRejectsInvalidSignature(t *testing.T) {
|
||||||
|
previousCache := cache.SignatureCacheEnabled()
|
||||||
|
previousStrict := cache.SignatureBypassStrictMode()
|
||||||
|
cache.SetSignatureCacheEnabled(false)
|
||||||
|
cache.SetSignatureBypassStrictMode(true)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cache.SetSignatureCacheEnabled(previousCache)
|
||||||
|
cache.SetSignatureBypassStrictMode(previousStrict)
|
||||||
|
})
|
||||||
|
|
||||||
|
var hits atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"ok"}]}}]}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
executor := NewAntigravityExecutor(nil)
|
||||||
|
auth := testAntigravityAuth(server.URL)
|
||||||
|
payload := invalidClaudeThinkingPayload()
|
||||||
|
opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude"), OriginalRequest: payload}
|
||||||
|
req := cliproxyexecutor.Request{Model: "claude-sonnet-4-5-thinking", Payload: payload}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
invoke func() error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "execute",
|
||||||
|
invoke: func() error {
|
||||||
|
_, err := executor.Execute(context.Background(), auth, req, opts)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "stream",
|
||||||
|
invoke: func() error {
|
||||||
|
_, err := executor.ExecuteStream(context.Background(), auth, req, cliproxyexecutor.Options{SourceFormat: opts.SourceFormat, OriginalRequest: payload, Stream: true})
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "count tokens",
|
||||||
|
invoke: func() error {
|
||||||
|
_, err := executor.CountTokens(context.Background(), auth, req, opts)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := tt.invoke()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected invalid signature to return an error")
|
||||||
|
}
|
||||||
|
statusProvider, ok := err.(interface{ StatusCode() int })
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected status error, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if statusProvider.StatusCode() != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d", statusProvider.StatusCode(), http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := hits.Load(); got != 0 {
|
||||||
|
t.Fatalf("expected invalid signature to be rejected before upstream request, got %d upstream hits", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntigravityExecutor_NonStrictBypassSkipsPrecheck(t *testing.T) {
|
||||||
|
previousCache := cache.SignatureCacheEnabled()
|
||||||
|
previousStrict := cache.SignatureBypassStrictMode()
|
||||||
|
cache.SetSignatureCacheEnabled(false)
|
||||||
|
cache.SetSignatureBypassStrictMode(false)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cache.SetSignatureCacheEnabled(previousCache)
|
||||||
|
cache.SetSignatureBypassStrictMode(previousStrict)
|
||||||
|
})
|
||||||
|
|
||||||
|
payload := invalidClaudeThinkingPayload()
|
||||||
|
from := sdktranslator.FromString("claude")
|
||||||
|
|
||||||
|
err := validateAntigravityRequestSignatures(from, payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("non-strict bypass should skip precheck, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntigravityExecutor_CacheModeSkipsPrecheck(t *testing.T) {
|
||||||
|
previous := cache.SignatureCacheEnabled()
|
||||||
|
cache.SetSignatureCacheEnabled(true)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cache.SetSignatureCacheEnabled(previous)
|
||||||
|
})
|
||||||
|
|
||||||
|
payload := invalidClaudeThinkingPayload()
|
||||||
|
from := sdktranslator.FromString("claude")
|
||||||
|
|
||||||
|
err := validateAntigravityRequestSignatures(from, payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cache mode should skip precheck, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,56 @@ import (
|
|||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func resolveThinkingSignature(modelName, thinkingText, rawSignature string) string {
|
||||||
|
if cache.SignatureCacheEnabled() {
|
||||||
|
return resolveCacheModeSignature(modelName, thinkingText, rawSignature)
|
||||||
|
}
|
||||||
|
return resolveBypassModeSignature(rawSignature)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveCacheModeSignature(modelName, thinkingText, rawSignature string) string {
|
||||||
|
if thinkingText != "" {
|
||||||
|
if cachedSig := cache.GetCachedSignature(modelName, thinkingText); cachedSig != "" {
|
||||||
|
return cachedSig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rawSignature == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
clientSignature := ""
|
||||||
|
arrayClientSignatures := strings.SplitN(rawSignature, "#", 2)
|
||||||
|
if len(arrayClientSignatures) == 2 {
|
||||||
|
if cache.GetModelGroup(modelName) == arrayClientSignatures[0] {
|
||||||
|
clientSignature = arrayClientSignatures[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cache.HasValidSignature(modelName, clientSignature) {
|
||||||
|
return clientSignature
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveBypassModeSignature(rawSignature string) string {
|
||||||
|
if rawSignature == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
normalized, err := normalizeClaudeBypassSignature(rawSignature)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasResolvedThinkingSignature(modelName, signature string) bool {
|
||||||
|
if cache.SignatureCacheEnabled() {
|
||||||
|
return cache.HasValidSignature(modelName, signature)
|
||||||
|
}
|
||||||
|
return signature != ""
|
||||||
|
}
|
||||||
|
|
||||||
// ConvertClaudeRequestToAntigravity parses and transforms a Claude Code API request into Gemini CLI API format.
|
// ConvertClaudeRequestToAntigravity parses and transforms a Claude Code API request into Gemini CLI API format.
|
||||||
// It extracts the model name, system instruction, message contents, and tool declarations
|
// It extracts the model name, system instruction, message contents, and tool declarations
|
||||||
// from the raw JSON request and returns them in the format expected by the Gemini CLI API.
|
// 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, _
|
|||||||
if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "thinking" {
|
if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "thinking" {
|
||||||
// Use GetThinkingText to handle wrapped thinking objects
|
// Use GetThinkingText to handle wrapped thinking objects
|
||||||
thinkingText := thinking.GetThinkingText(contentResult)
|
thinkingText := thinking.GetThinkingText(contentResult)
|
||||||
|
signature := resolveThinkingSignature(modelName, thinkingText, contentResult.Get("signature").String())
|
||||||
// Always try cached signature first (more reliable than client-provided)
|
|
||||||
// Client may send stale or invalid signatures from different sessions
|
|
||||||
signature := ""
|
|
||||||
if thinkingText != "" {
|
|
||||||
if cachedSig := cache.GetCachedSignature(modelName, thinkingText); cachedSig != "" {
|
|
||||||
signature = cachedSig
|
|
||||||
// log.Debugf("Using cached signature for thinking block")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to client signature only if cache miss and client signature is valid
|
|
||||||
if signature == "" {
|
|
||||||
signatureResult := contentResult.Get("signature")
|
|
||||||
clientSignature := ""
|
|
||||||
if signatureResult.Exists() && signatureResult.String() != "" {
|
|
||||||
arrayClientSignatures := strings.SplitN(signatureResult.String(), "#", 2)
|
|
||||||
if len(arrayClientSignatures) == 2 {
|
|
||||||
if cache.GetModelGroup(modelName) == arrayClientSignatures[0] {
|
|
||||||
clientSignature = arrayClientSignatures[1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if cache.HasValidSignature(modelName, clientSignature) {
|
|
||||||
signature = clientSignature
|
|
||||||
}
|
|
||||||
// log.Debugf("Using client-provided signature for thinking block")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store for subsequent tool_use in the same message
|
// Store for subsequent tool_use in the same message
|
||||||
if cache.HasValidSignature(modelName, signature) {
|
if hasResolvedThinkingSignature(modelName, signature) {
|
||||||
currentMessageThinkingSignature = signature
|
currentMessageThinkingSignature = signature
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip trailing unsigned thinking blocks on last assistant message
|
// Skip unsigned thinking blocks instead of converting them to text.
|
||||||
isUnsigned := !cache.HasValidSignature(modelName, signature)
|
isUnsigned := !hasResolvedThinkingSignature(modelName, signature)
|
||||||
|
|
||||||
// If unsigned, skip entirely (don't convert to text)
|
// If unsigned, skip entirely (don't convert to text)
|
||||||
// Claude requires assistant messages to start with thinking blocks when thinking is enabled
|
// Claude requires assistant messages to start with thinking blocks when thinking is enabled
|
||||||
@@ -198,7 +221,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
|||||||
// This is the approach used in opencode-google-antigravity-auth for Gemini
|
// This is the approach used in opencode-google-antigravity-auth for Gemini
|
||||||
// and also works for Claude through Antigravity API
|
// and also works for Claude through Antigravity API
|
||||||
const skipSentinel = "skip_thought_signature_validator"
|
const skipSentinel = "skip_thought_signature_validator"
|
||||||
if cache.HasValidSignature(modelName, currentMessageThinkingSignature) {
|
if hasResolvedThinkingSignature(modelName, currentMessageThinkingSignature) {
|
||||||
partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", currentMessageThinkingSignature)
|
partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", currentMessageThinkingSignature)
|
||||||
} else {
|
} else {
|
||||||
// No valid signature - use skip sentinel to bypass validation
|
// No valid signature - use skip sentinel to bypass validation
|
||||||
|
|||||||
@@ -1,13 +1,97 @@
|
|||||||
package claude
|
package claude
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
|
"google.golang.org/protobuf/encoding/protowire"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func testAnthropicNativeSignature(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
payload := buildClaudeSignaturePayload(t, 12, uint64Ptr(2), "claude-sonnet-4-6", true)
|
||||||
|
signature := base64.StdEncoding.EncodeToString(payload)
|
||||||
|
if len(signature) < cache.MinValidSignatureLen {
|
||||||
|
t.Fatalf("test signature too short: %d", len(signature))
|
||||||
|
}
|
||||||
|
return signature
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMinimalAnthropicSignature(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
payload := buildClaudeSignaturePayload(t, 12, nil, "", false)
|
||||||
|
return base64.StdEncoding.EncodeToString(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildClaudeSignaturePayload(t *testing.T, channelID uint64, field2 *uint64, modelText string, includeField7 bool) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
channelBlock := []byte{}
|
||||||
|
channelBlock = protowire.AppendTag(channelBlock, 1, protowire.VarintType)
|
||||||
|
channelBlock = protowire.AppendVarint(channelBlock, channelID)
|
||||||
|
if field2 != nil {
|
||||||
|
channelBlock = protowire.AppendTag(channelBlock, 2, protowire.VarintType)
|
||||||
|
channelBlock = protowire.AppendVarint(channelBlock, *field2)
|
||||||
|
}
|
||||||
|
if modelText != "" {
|
||||||
|
channelBlock = protowire.AppendTag(channelBlock, 6, protowire.BytesType)
|
||||||
|
channelBlock = protowire.AppendString(channelBlock, modelText)
|
||||||
|
}
|
||||||
|
if includeField7 {
|
||||||
|
channelBlock = protowire.AppendTag(channelBlock, 7, protowire.VarintType)
|
||||||
|
channelBlock = protowire.AppendVarint(channelBlock, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
container := []byte{}
|
||||||
|
container = protowire.AppendTag(container, 1, protowire.BytesType)
|
||||||
|
container = protowire.AppendBytes(container, channelBlock)
|
||||||
|
container = protowire.AppendTag(container, 2, protowire.BytesType)
|
||||||
|
container = protowire.AppendBytes(container, bytes.Repeat([]byte{0x11}, 12))
|
||||||
|
container = protowire.AppendTag(container, 3, protowire.BytesType)
|
||||||
|
container = protowire.AppendBytes(container, bytes.Repeat([]byte{0x22}, 12))
|
||||||
|
container = protowire.AppendTag(container, 4, protowire.BytesType)
|
||||||
|
container = protowire.AppendBytes(container, bytes.Repeat([]byte{0x33}, 48))
|
||||||
|
|
||||||
|
payload := []byte{}
|
||||||
|
payload = protowire.AppendTag(payload, 2, protowire.BytesType)
|
||||||
|
payload = protowire.AppendBytes(payload, container)
|
||||||
|
payload = protowire.AppendTag(payload, 3, protowire.VarintType)
|
||||||
|
payload = protowire.AppendVarint(payload, 1)
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func uint64Ptr(v uint64) *uint64 {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNonAnthropicRawSignature(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
payload := bytes.Repeat([]byte{0x34}, 48)
|
||||||
|
signature := base64.StdEncoding.EncodeToString(payload)
|
||||||
|
if len(signature) < cache.MinValidSignatureLen {
|
||||||
|
t.Fatalf("test signature too short: %d", len(signature))
|
||||||
|
}
|
||||||
|
return signature
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGeminiRawSignature(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
payload := append([]byte{0x0A}, bytes.Repeat([]byte{0x56}, 48)...)
|
||||||
|
signature := base64.StdEncoding.EncodeToString(payload)
|
||||||
|
if len(signature) < cache.MinValidSignatureLen {
|
||||||
|
t.Fatalf("test signature too short: %d", len(signature))
|
||||||
|
}
|
||||||
|
return signature
|
||||||
|
}
|
||||||
|
|
||||||
func TestConvertClaudeRequestToAntigravity_BasicStructure(t *testing.T) {
|
func TestConvertClaudeRequestToAntigravity_BasicStructure(t *testing.T) {
|
||||||
inputJSON := []byte(`{
|
inputJSON := []byte(`{
|
||||||
"model": "claude-3-5-sonnet-20240620",
|
"model": "claude-3-5-sonnet-20240620",
|
||||||
@@ -116,6 +200,545 @@ func TestConvertClaudeRequestToAntigravity_ThinkingBlocks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateBypassMode_AcceptsClaudeSingleAndDoubleLayer(t *testing.T) {
|
||||||
|
rawSignature := testAnthropicNativeSignature(t)
|
||||||
|
doubleEncoded := base64.StdEncoding.EncodeToString([]byte(rawSignature))
|
||||||
|
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": "one", "signature": "` + rawSignature + `"},
|
||||||
|
{"type": "thinking", "thinking": "two", "signature": "claude#` + doubleEncoded + `"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
if err := ValidateClaudeBypassSignatures(inputJSON); err != nil {
|
||||||
|
t.Fatalf("ValidateBypassModeSignatures returned error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateBypassMode_RejectsGeminiSignature(t *testing.T) {
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": "one", "signature": "` + testGeminiRawSignature(t) + `"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
err := ValidateClaudeBypassSignatures(inputJSON)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected Gemini signature to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateBypassMode_RejectsMissingSignature(t *testing.T) {
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": "one"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
err := ValidateClaudeBypassSignatures(inputJSON)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected missing signature to be rejected")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "missing thinking signature") {
|
||||||
|
t.Fatalf("expected missing signature message, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateBypassMode_RejectsNonREPrefix(t *testing.T) {
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": "one", "signature": "` + testNonAnthropicRawSignature(t) + `"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
err := ValidateClaudeBypassSignatures(inputJSON)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected non-R/E signature to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateBypassMode_RejectsEPrefixWrongFirstByte(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
payload := append([]byte{0x10}, bytes.Repeat([]byte{0x34}, 48)...)
|
||||||
|
sig := base64.StdEncoding.EncodeToString(payload)
|
||||||
|
if sig[0] != 'E' {
|
||||||
|
t.Fatalf("test setup: expected E prefix, got %c", sig[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"messages": [{"role": "assistant", "content": [
|
||||||
|
{"type": "thinking", "thinking": "t", "signature": "` + sig + `"}
|
||||||
|
]}]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
err := ValidateClaudeBypassSignatures(inputJSON)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected E-prefix with wrong first byte (0x10) to be rejected")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "0x10") {
|
||||||
|
t.Fatalf("expected error to mention 0x10, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateBypassMode_RejectsTopLevel12WithoutClaudeTree(t *testing.T) {
|
||||||
|
previous := cache.SignatureBypassStrictMode()
|
||||||
|
cache.SetSignatureBypassStrictMode(true)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cache.SetSignatureBypassStrictMode(previous)
|
||||||
|
})
|
||||||
|
|
||||||
|
payload := append([]byte{0x12}, bytes.Repeat([]byte{0x34}, 48)...)
|
||||||
|
sig := base64.StdEncoding.EncodeToString(payload)
|
||||||
|
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"messages": [{"role": "assistant", "content": [
|
||||||
|
{"type": "thinking", "thinking": "t", "signature": "` + sig + `"}
|
||||||
|
]}]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
err := ValidateClaudeBypassSignatures(inputJSON)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected non-Claude protobuf tree to be rejected in strict mode")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "malformed protobuf") && !strings.Contains(err.Error(), "Field 2") {
|
||||||
|
t.Fatalf("expected protobuf tree error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateBypassMode_NonStrictAccepts12WithoutClaudeTree(t *testing.T) {
|
||||||
|
previous := cache.SignatureBypassStrictMode()
|
||||||
|
cache.SetSignatureBypassStrictMode(false)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cache.SetSignatureBypassStrictMode(previous)
|
||||||
|
})
|
||||||
|
|
||||||
|
payload := append([]byte{0x12}, bytes.Repeat([]byte{0x34}, 48)...)
|
||||||
|
sig := base64.StdEncoding.EncodeToString(payload)
|
||||||
|
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"messages": [{"role": "assistant", "content": [
|
||||||
|
{"type": "thinking", "thinking": "t", "signature": "` + sig + `"}
|
||||||
|
]}]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
err := ValidateClaudeBypassSignatures(inputJSON)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("non-strict mode should accept 0x12 without protobuf tree, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateBypassMode_RejectsRPrefixInnerNotE(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
inner := "F" + strings.Repeat("a", 60)
|
||||||
|
outer := base64.StdEncoding.EncodeToString([]byte(inner))
|
||||||
|
if outer[0] != 'R' {
|
||||||
|
t.Fatalf("test setup: expected R prefix, got %c", outer[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"messages": [{"role": "assistant", "content": [
|
||||||
|
{"type": "thinking", "thinking": "t", "signature": "` + outer + `"}
|
||||||
|
]}]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
err := ValidateClaudeBypassSignatures(inputJSON)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected R-prefix with non-E inner to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateBypassMode_RejectsInvalidBase64(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sig string
|
||||||
|
}{
|
||||||
|
{"E invalid", "E!!!invalid!!!"},
|
||||||
|
{"R invalid", "R$$$invalid$$$"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"messages": [{"role": "assistant", "content": [
|
||||||
|
{"type": "thinking", "thinking": "t", "signature": "` + tt.sig + `"}
|
||||||
|
]}]
|
||||||
|
}`)
|
||||||
|
err := ValidateClaudeBypassSignatures(inputJSON)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected invalid base64 to be rejected")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "base64") {
|
||||||
|
t.Fatalf("expected base64 error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateBypassMode_RejectsPrefixStrippedToEmpty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sig string
|
||||||
|
}{
|
||||||
|
{"prefix only", "claude#"},
|
||||||
|
{"prefix with spaces", "claude# "},
|
||||||
|
{"hash only", "#"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"messages": [{"role": "assistant", "content": [
|
||||||
|
{"type": "thinking", "thinking": "t", "signature": "` + tt.sig + `"}
|
||||||
|
]}]
|
||||||
|
}`)
|
||||||
|
err := ValidateClaudeBypassSignatures(inputJSON)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected prefix-only signature to be rejected")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateBypassMode_HandlesMultipleHashMarks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
rawSignature := testAnthropicNativeSignature(t)
|
||||||
|
sig := "claude#" + rawSignature + "#extra"
|
||||||
|
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"messages": [{"role": "assistant", "content": [
|
||||||
|
{"type": "thinking", "thinking": "t", "signature": "` + sig + `"}
|
||||||
|
]}]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
err := ValidateClaudeBypassSignatures(inputJSON)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected signature with trailing # to be rejected (invalid base64)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateBypassMode_HandlesWhitespace(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
rawSignature := testAnthropicNativeSignature(t)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sig string
|
||||||
|
}{
|
||||||
|
{"leading space", " " + rawSignature},
|
||||||
|
{"trailing space", rawSignature + " "},
|
||||||
|
{"both spaces", " " + rawSignature + " "},
|
||||||
|
{"leading tab", "\t" + rawSignature},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"messages": [{"role": "assistant", "content": [
|
||||||
|
{"type": "thinking", "thinking": "t", "signature": "` + tt.sig + `"}
|
||||||
|
]}]
|
||||||
|
}`)
|
||||||
|
if err := ValidateClaudeBypassSignatures(inputJSON); err != nil {
|
||||||
|
t.Fatalf("expected whitespace-padded signature to be accepted, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateBypassMode_RejectsOversizedSignature(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
payload := append([]byte{0x12}, bytes.Repeat([]byte{0x34}, maxBypassSignatureLen)...)
|
||||||
|
sig := base64.StdEncoding.EncodeToString(payload)
|
||||||
|
if len(sig) <= maxBypassSignatureLen {
|
||||||
|
t.Fatalf("test setup: signature should exceed max length, got %d", len(sig))
|
||||||
|
}
|
||||||
|
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"messages": [{"role": "assistant", "content": [
|
||||||
|
{"type": "thinking", "thinking": "t", "signature": "` + sig + `"}
|
||||||
|
]}]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
err := ValidateClaudeBypassSignatures(inputJSON)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected oversized signature to be rejected")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "maximum length") {
|
||||||
|
t.Fatalf("expected length error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveBypassModeSignature_TrimsWhitespace(t *testing.T) {
|
||||||
|
previous := cache.SignatureCacheEnabled()
|
||||||
|
cache.SetSignatureCacheEnabled(false)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cache.SetSignatureCacheEnabled(previous)
|
||||||
|
})
|
||||||
|
|
||||||
|
rawSignature := testAnthropicNativeSignature(t)
|
||||||
|
expected := resolveBypassModeSignature(rawSignature)
|
||||||
|
if expected == "" {
|
||||||
|
t.Fatal("test setup: expected non-empty normalized signature")
|
||||||
|
}
|
||||||
|
|
||||||
|
got := resolveBypassModeSignature(rawSignature + " ")
|
||||||
|
if got != expected {
|
||||||
|
t.Fatalf("expected trailing whitespace to be trimmed:\n got: %q\n want: %q", got, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_BypassModeNormalizesESignature(t *testing.T) {
|
||||||
|
cache.ClearSignatureCache("")
|
||||||
|
previous := cache.SignatureCacheEnabled()
|
||||||
|
cache.SetSignatureCacheEnabled(false)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cache.SetSignatureCacheEnabled(previous)
|
||||||
|
cache.ClearSignatureCache("")
|
||||||
|
})
|
||||||
|
|
||||||
|
thinkingText := "Let me think..."
|
||||||
|
cachedSignature := "cachedSignature1234567890123456789012345678901234567890123"
|
||||||
|
rawSignature := testAnthropicNativeSignature(t)
|
||||||
|
expectedSignature := base64.StdEncoding.EncodeToString([]byte(rawSignature))
|
||||||
|
|
||||||
|
cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, cachedSignature)
|
||||||
|
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + rawSignature + `"},
|
||||||
|
{"type": "text", "text": "Answer"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
part := gjson.Get(outputStr, "request.contents.0.parts.0")
|
||||||
|
if part.Get("thoughtSignature").String() != expectedSignature {
|
||||||
|
t.Fatalf("Expected bypass-mode signature '%s', got '%s'", expectedSignature, part.Get("thoughtSignature").String())
|
||||||
|
}
|
||||||
|
if part.Get("thoughtSignature").String() == cachedSignature {
|
||||||
|
t.Fatal("Bypass mode should not reuse cached signature")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_BypassModePreservesShortValidSignature(t *testing.T) {
|
||||||
|
cache.ClearSignatureCache("")
|
||||||
|
previous := cache.SignatureCacheEnabled()
|
||||||
|
cache.SetSignatureCacheEnabled(false)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cache.SetSignatureCacheEnabled(previous)
|
||||||
|
cache.ClearSignatureCache("")
|
||||||
|
})
|
||||||
|
|
||||||
|
rawSignature := testMinimalAnthropicSignature(t)
|
||||||
|
expectedSignature := base64.StdEncoding.EncodeToString([]byte(rawSignature))
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": "tiny", "signature": "` + rawSignature + `"},
|
||||||
|
{"type": "text", "text": "Answer"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
||||||
|
parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
|
||||||
|
if len(parts) != 2 {
|
||||||
|
t.Fatalf("expected thinking part to be preserved in bypass mode, got %d parts", len(parts))
|
||||||
|
}
|
||||||
|
if parts[0].Get("thoughtSignature").String() != expectedSignature {
|
||||||
|
t.Fatalf("expected normalized short signature %q, got %q", expectedSignature, parts[0].Get("thoughtSignature").String())
|
||||||
|
}
|
||||||
|
if !parts[0].Get("thought").Bool() {
|
||||||
|
t.Fatalf("expected first part to remain a thought block, got %s", parts[0].Raw)
|
||||||
|
}
|
||||||
|
if parts[1].Get("text").String() != "Answer" {
|
||||||
|
t.Fatalf("expected trailing text part, got %s", parts[1].Raw)
|
||||||
|
}
|
||||||
|
if thoughtSig := gjson.GetBytes(output, "request.contents.0.parts.1.thoughtSignature").String(); thoughtSig != "" {
|
||||||
|
t.Fatalf("expected plain text part to have no thought signature, got %q", thoughtSig)
|
||||||
|
}
|
||||||
|
if functionSig := gjson.GetBytes(output, "request.contents.0.parts.0.functionCall.thoughtSignature").String(); functionSig != "" {
|
||||||
|
t.Fatalf("unexpected functionCall payload in thinking part: %q", functionSig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspectClaudeSignaturePayload_ExtractsSpecTree(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
payload := buildClaudeSignaturePayload(t, 12, uint64Ptr(2), "claude-sonnet-4-6", true)
|
||||||
|
|
||||||
|
tree, err := inspectClaudeSignaturePayload(payload, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected structured Claude payload to parse, got: %v", err)
|
||||||
|
}
|
||||||
|
if tree.RoutingClass != "routing_class_12" {
|
||||||
|
t.Fatalf("routing_class = %q, want routing_class_12", tree.RoutingClass)
|
||||||
|
}
|
||||||
|
if tree.InfrastructureClass != "infra_google" {
|
||||||
|
t.Fatalf("infrastructure_class = %q, want infra_google", tree.InfrastructureClass)
|
||||||
|
}
|
||||||
|
if tree.SchemaFeatures != "extended_model_tagged_schema" {
|
||||||
|
t.Fatalf("schema_features = %q, want extended_model_tagged_schema", tree.SchemaFeatures)
|
||||||
|
}
|
||||||
|
if tree.ModelText != "claude-sonnet-4-6" {
|
||||||
|
t.Fatalf("model_text = %q, want claude-sonnet-4-6", tree.ModelText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspectDoubleLayerSignature_TracksEncodingLayers(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
inner := base64.StdEncoding.EncodeToString(buildClaudeSignaturePayload(t, 11, uint64Ptr(2), "", false))
|
||||||
|
outer := base64.StdEncoding.EncodeToString([]byte(inner))
|
||||||
|
|
||||||
|
tree, err := inspectDoubleLayerSignature(outer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected double-layer Claude signature to parse, got: %v", err)
|
||||||
|
}
|
||||||
|
if tree.EncodingLayers != 2 {
|
||||||
|
t.Fatalf("encoding_layers = %d, want 2", tree.EncodingLayers)
|
||||||
|
}
|
||||||
|
if tree.LegacyRouteHint != "legacy_vertex_direct" {
|
||||||
|
t.Fatalf("legacy_route_hint = %q, want legacy_vertex_direct", tree.LegacyRouteHint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_CacheModeDropsRawSignature(t *testing.T) {
|
||||||
|
cache.ClearSignatureCache("")
|
||||||
|
previous := cache.SignatureCacheEnabled()
|
||||||
|
cache.SetSignatureCacheEnabled(true)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cache.SetSignatureCacheEnabled(previous)
|
||||||
|
cache.ClearSignatureCache("")
|
||||||
|
})
|
||||||
|
|
||||||
|
rawSignature := testAnthropicNativeSignature(t)
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": "Let me think...", "signature": "` + rawSignature + `"},
|
||||||
|
{"type": "text", "text": "Answer"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
||||||
|
parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
|
||||||
|
if len(parts) != 1 {
|
||||||
|
t.Fatalf("Expected raw signature thinking block to be dropped in cache mode, got %d parts", len(parts))
|
||||||
|
}
|
||||||
|
if parts[0].Get("text").String() != "Answer" {
|
||||||
|
t.Fatalf("Expected remaining text part, got %s", parts[0].Raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_BypassModeDropsInvalidSignature(t *testing.T) {
|
||||||
|
cache.ClearSignatureCache("")
|
||||||
|
previous := cache.SignatureCacheEnabled()
|
||||||
|
cache.SetSignatureCacheEnabled(false)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cache.SetSignatureCacheEnabled(previous)
|
||||||
|
cache.ClearSignatureCache("")
|
||||||
|
})
|
||||||
|
|
||||||
|
invalidRawSignature := testNonAnthropicRawSignature(t)
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": "Let me think...", "signature": "` + invalidRawSignature + `"},
|
||||||
|
{"type": "text", "text": "Answer"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
|
||||||
|
if len(parts) != 1 {
|
||||||
|
t.Fatalf("Expected invalid thinking block to be removed, got %d parts", len(parts))
|
||||||
|
}
|
||||||
|
if parts[0].Get("text").String() != "Answer" {
|
||||||
|
t.Fatalf("Expected remaining text part, got %s", parts[0].Raw)
|
||||||
|
}
|
||||||
|
if parts[0].Get("thought").Bool() {
|
||||||
|
t.Fatal("Invalid raw signature should not preserve thinking block")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_BypassModeDropsGeminiSignature(t *testing.T) {
|
||||||
|
cache.ClearSignatureCache("")
|
||||||
|
previous := cache.SignatureCacheEnabled()
|
||||||
|
cache.SetSignatureCacheEnabled(false)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cache.SetSignatureCacheEnabled(previous)
|
||||||
|
cache.ClearSignatureCache("")
|
||||||
|
})
|
||||||
|
|
||||||
|
geminiPayload := append([]byte{0x0A}, bytes.Repeat([]byte{0x56}, 48)...)
|
||||||
|
geminiSig := base64.StdEncoding.EncodeToString(geminiPayload)
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": "hmm", "signature": "` + geminiSig + `"},
|
||||||
|
{"type": "text", "text": "Answer"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
||||||
|
parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
|
||||||
|
if len(parts) != 1 {
|
||||||
|
t.Fatalf("expected Gemini-signed thinking block to be dropped, got %d parts", len(parts))
|
||||||
|
}
|
||||||
|
if parts[0].Get("text").String() != "Answer" {
|
||||||
|
t.Fatalf("expected remaining text part, got %s", parts[0].Raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConvertClaudeRequestToAntigravity_ThinkingBlockWithoutSignature(t *testing.T) {
|
func TestConvertClaudeRequestToAntigravity_ThinkingBlockWithoutSignature(t *testing.T) {
|
||||||
cache.ClearSignatureCache("")
|
cache.ClearSignatureCache("")
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ package claude
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -23,6 +24,33 @@ import (
|
|||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// decodeSignature decodes R... (2-layer Base64) to E... (1-layer Base64, Anthropic format).
|
||||||
|
// Returns empty string if decoding fails (skip invalid signatures).
|
||||||
|
func decodeSignature(signature string) string {
|
||||||
|
if signature == "" {
|
||||||
|
return signature
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(signature, "R") {
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(signature)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("antigravity claude response: failed to decode signature, skipping")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(decoded)
|
||||||
|
}
|
||||||
|
return signature
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatClaudeSignatureValue(modelName, signature string) string {
|
||||||
|
if cache.SignatureCacheEnabled() {
|
||||||
|
return fmt.Sprintf("%s#%s", cache.GetModelGroup(modelName), signature)
|
||||||
|
}
|
||||||
|
if cache.GetModelGroup(modelName) == "claude" {
|
||||||
|
return decodeSignature(signature)
|
||||||
|
}
|
||||||
|
return signature
|
||||||
|
}
|
||||||
|
|
||||||
// Params holds parameters for response conversion and maintains state across streaming chunks.
|
// Params holds parameters for response conversion and maintains state across streaming chunks.
|
||||||
// This structure tracks the current state of the response translation process to ensure
|
// This structure tracks the current state of the response translation process to ensure
|
||||||
// proper sequencing of SSE events and transitions between different content types.
|
// proper sequencing of SSE events and transitions between different content types.
|
||||||
@@ -144,13 +172,30 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq
|
|||||||
if thoughtSignature := partResult.Get("thoughtSignature"); thoughtSignature.Exists() && thoughtSignature.String() != "" {
|
if thoughtSignature := partResult.Get("thoughtSignature"); thoughtSignature.Exists() && thoughtSignature.String() != "" {
|
||||||
// log.Debug("Branch: signature_delta")
|
// log.Debug("Branch: signature_delta")
|
||||||
|
|
||||||
|
// Flush co-located text before emitting the signature
|
||||||
|
if partText := partTextResult.String(); partText != "" {
|
||||||
|
if params.ResponseType != 2 {
|
||||||
|
if params.ResponseType != 0 {
|
||||||
|
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, params.ResponseIndex))
|
||||||
|
params.ResponseIndex++
|
||||||
|
}
|
||||||
|
appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"thinking","thinking":""}}`, params.ResponseIndex))
|
||||||
|
params.ResponseType = 2
|
||||||
|
params.CurrentThinkingText.Reset()
|
||||||
|
}
|
||||||
|
params.CurrentThinkingText.WriteString(partText)
|
||||||
|
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, params.ResponseIndex)), "delta.thinking", partText)
|
||||||
|
appendEvent("content_block_delta", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
if params.CurrentThinkingText.Len() > 0 {
|
if params.CurrentThinkingText.Len() > 0 {
|
||||||
cache.CacheSignature(modelName, params.CurrentThinkingText.String(), thoughtSignature.String())
|
cache.CacheSignature(modelName, params.CurrentThinkingText.String(), thoughtSignature.String())
|
||||||
// log.Debugf("Cached signature for thinking block (textLen=%d)", params.CurrentThinkingText.Len())
|
// log.Debugf("Cached signature for thinking block (textLen=%d)", params.CurrentThinkingText.Len())
|
||||||
params.CurrentThinkingText.Reset()
|
params.CurrentThinkingText.Reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
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()))
|
sigValue := formatClaudeSignatureValue(modelName, thoughtSignature.String())
|
||||||
|
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, params.ResponseIndex)), "delta.signature", sigValue)
|
||||||
appendEvent("content_block_delta", string(data))
|
appendEvent("content_block_delta", string(data))
|
||||||
params.HasContent = true
|
params.HasContent = true
|
||||||
} else if params.ResponseType == 2 { // Continue existing thinking block if already in thinking state
|
} else if params.ResponseType == 2 { // Continue existing thinking block if already in thinking state
|
||||||
@@ -419,7 +464,8 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or
|
|||||||
block := []byte(`{"type":"thinking","thinking":""}`)
|
block := []byte(`{"type":"thinking","thinking":""}`)
|
||||||
block, _ = sjson.SetBytes(block, "thinking", thinkingBuilder.String())
|
block, _ = sjson.SetBytes(block, "thinking", thinkingBuilder.String())
|
||||||
if thinkingSignature != "" {
|
if thinkingSignature != "" {
|
||||||
block, _ = sjson.SetBytes(block, "signature", fmt.Sprintf("%s#%s", cache.GetModelGroup(modelName), thinkingSignature))
|
sigValue := formatClaudeSignatureValue(modelName, thinkingSignature)
|
||||||
|
block, _ = sjson.SetBytes(block, "signature", sigValue)
|
||||||
}
|
}
|
||||||
responseJSON, _ = sjson.SetRawBytes(responseJSON, "content.-1", block)
|
responseJSON, _ = sjson.SetRawBytes(responseJSON, "content.-1", block)
|
||||||
thinkingBuilder.Reset()
|
thinkingBuilder.Reset()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package claude
|
package claude
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -244,3 +245,105 @@ func TestConvertAntigravityResponseToClaude_MultipleThinkingBlocks(t *testing.T)
|
|||||||
t.Error("Second thinking block signature should be cached")
|
t.Error("Second thinking block signature should be cached")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConvertAntigravityResponseToClaude_TextAndSignatureInSameChunk(t *testing.T) {
|
||||||
|
cache.ClearSignatureCache("")
|
||||||
|
|
||||||
|
requestJSON := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
|
"messages": [{"role": "user", "content": [{"type": "text", "text": "Test"}]}]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
validSignature := "RtestSig1234567890123456789012345678901234567890123456789"
|
||||||
|
|
||||||
|
// Chunk 1: thinking text only (no signature)
|
||||||
|
chunk1 := []byte(`{
|
||||||
|
"response": {
|
||||||
|
"candidates": [{
|
||||||
|
"content": {
|
||||||
|
"parts": [{"text": "First part.", "thought": true}]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
// Chunk 2: thinking text AND signature in the same part
|
||||||
|
chunk2 := []byte(`{
|
||||||
|
"response": {
|
||||||
|
"candidates": [{
|
||||||
|
"content": {
|
||||||
|
"parts": [{"text": " Second part.", "thought": true, "thoughtSignature": "` + validSignature + `"}]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
var param any
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
result1 := ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, chunk1, ¶m)
|
||||||
|
result2 := ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, chunk2, ¶m)
|
||||||
|
|
||||||
|
allOutput := string(bytes.Join(result1, nil)) + string(bytes.Join(result2, nil))
|
||||||
|
|
||||||
|
// The text " Second part." must appear as a thinking_delta, not be silently dropped
|
||||||
|
if !strings.Contains(allOutput, "Second part.") {
|
||||||
|
t.Error("Text co-located with signature must be emitted as thinking_delta before the signature")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The signature must also be emitted
|
||||||
|
if !strings.Contains(allOutput, "signature_delta") {
|
||||||
|
t.Error("Signature delta must still be emitted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the cached signature covers the FULL text (both parts)
|
||||||
|
fullText := "First part. Second part."
|
||||||
|
cachedSig := cache.GetCachedSignature("claude-sonnet-4-5-thinking", fullText)
|
||||||
|
if cachedSig != validSignature {
|
||||||
|
t.Errorf("Cached signature should cover full text %q, got sig=%q", fullText, cachedSig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertAntigravityResponseToClaude_SignatureOnlyChunk(t *testing.T) {
|
||||||
|
cache.ClearSignatureCache("")
|
||||||
|
|
||||||
|
requestJSON := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
|
"messages": [{"role": "user", "content": [{"type": "text", "text": "Test"}]}]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
validSignature := "RtestSig1234567890123456789012345678901234567890123456789"
|
||||||
|
|
||||||
|
// Chunk 1: thinking text
|
||||||
|
chunk1 := []byte(`{
|
||||||
|
"response": {
|
||||||
|
"candidates": [{
|
||||||
|
"content": {
|
||||||
|
"parts": [{"text": "Full thinking text.", "thought": true}]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
// Chunk 2: signature only (empty text) — the normal case
|
||||||
|
chunk2 := []byte(`{
|
||||||
|
"response": {
|
||||||
|
"candidates": [{
|
||||||
|
"content": {
|
||||||
|
"parts": [{"text": "", "thought": true, "thoughtSignature": "` + validSignature + `"}]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
var param any
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, chunk1, ¶m)
|
||||||
|
ConvertAntigravityResponseToClaude(ctx, "claude-sonnet-4-5-thinking", requestJSON, requestJSON, chunk2, ¶m)
|
||||||
|
|
||||||
|
cachedSig := cache.GetCachedSignature("claude-sonnet-4-5-thinking", "Full thinking text.")
|
||||||
|
if cachedSig != validSignature {
|
||||||
|
t.Errorf("Signature-only chunk should still cache correctly, got %q", cachedSig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
351
internal/translator/antigravity/claude/signature_validation.go
Normal file
351
internal/translator/antigravity/claude/signature_validation.go
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
package claude
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
"google.golang.org/protobuf/encoding/protowire"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxBypassSignatureLen caps the signature string length (after prefix stripping)
|
||||||
|
// to prevent base64 decode from allocating excessive memory on malicious input.
|
||||||
|
const maxBypassSignatureLen = 8192
|
||||||
|
|
||||||
|
type claudeSignatureTree struct {
|
||||||
|
EncodingLayers int
|
||||||
|
ChannelID uint64
|
||||||
|
Field2 *uint64
|
||||||
|
RoutingClass string
|
||||||
|
InfrastructureClass string
|
||||||
|
SchemaFeatures string
|
||||||
|
ModelText string
|
||||||
|
LegacyRouteHint string
|
||||||
|
HasField7 bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateClaudeBypassSignatures validates Claude thinking signatures in bypass mode.
|
||||||
|
func ValidateClaudeBypassSignatures(inputRawJSON []byte) error {
|
||||||
|
messages := gjson.GetBytes(inputRawJSON, "messages")
|
||||||
|
if !messages.IsArray() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
messageResults := messages.Array()
|
||||||
|
for i := 0; i < len(messageResults); i++ {
|
||||||
|
contentResults := messageResults[i].Get("content")
|
||||||
|
if !contentResults.IsArray() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := contentResults.Array()
|
||||||
|
for j := 0; j < len(parts); j++ {
|
||||||
|
part := parts[j]
|
||||||
|
if part.Get("type").String() != "thinking" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
rawSignature := strings.TrimSpace(part.Get("signature").String())
|
||||||
|
if rawSignature == "" {
|
||||||
|
return fmt.Errorf("messages[%d].content[%d]: missing thinking signature", i, j)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := normalizeClaudeBypassSignature(rawSignature); err != nil {
|
||||||
|
return fmt.Errorf("messages[%d].content[%d]: %w", i, j, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeClaudeBypassSignature validates a raw Claude signature and returns
|
||||||
|
// it in the double-layer (R-starting) form expected by upstream.
|
||||||
|
func normalizeClaudeBypassSignature(rawSignature string) (string, error) {
|
||||||
|
sig := strings.TrimSpace(rawSignature)
|
||||||
|
if sig == "" {
|
||||||
|
return "", fmt.Errorf("empty signature")
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx := strings.IndexByte(sig, '#'); idx >= 0 {
|
||||||
|
sig = strings.TrimSpace(sig[idx+1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
if sig == "" {
|
||||||
|
return "", fmt.Errorf("empty signature after stripping prefix")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sig) > maxBypassSignatureLen {
|
||||||
|
return "", fmt.Errorf("signature exceeds maximum length (%d bytes)", maxBypassSignatureLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch sig[0] {
|
||||||
|
case 'R':
|
||||||
|
if err := validateDoubleLayerSignature(sig); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return sig, nil
|
||||||
|
case 'E':
|
||||||
|
if err := validateSingleLayerSignature(sig); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString([]byte(sig)), nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("invalid signature: expected 'E' or 'R' prefix, got %q", string(sig[0]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDoubleLayerSignature(sig string) error {
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(sig)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid double-layer signature: base64 decode failed: %w", err)
|
||||||
|
}
|
||||||
|
if len(decoded) == 0 {
|
||||||
|
return fmt.Errorf("invalid double-layer signature: empty after decode")
|
||||||
|
}
|
||||||
|
if decoded[0] != 'E' {
|
||||||
|
return fmt.Errorf("invalid double-layer signature: inner does not start with 'E', got 0x%02x", decoded[0])
|
||||||
|
}
|
||||||
|
return validateSingleLayerSignatureContent(string(decoded), 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSingleLayerSignature(sig string) error {
|
||||||
|
return validateSingleLayerSignatureContent(sig, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSingleLayerSignatureContent(sig string, encodingLayers int) error {
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(sig)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid single-layer signature: base64 decode failed: %w", err)
|
||||||
|
}
|
||||||
|
if len(decoded) == 0 {
|
||||||
|
return fmt.Errorf("invalid single-layer signature: empty after decode")
|
||||||
|
}
|
||||||
|
if decoded[0] != 0x12 {
|
||||||
|
return fmt.Errorf("invalid Claude signature: expected first byte 0x12, got 0x%02x", decoded[0])
|
||||||
|
}
|
||||||
|
if !cache.SignatureBypassStrictMode() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err = inspectClaudeSignaturePayload(decoded, encodingLayers)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspectDoubleLayerSignature(sig string) (*claudeSignatureTree, error) {
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(sig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid double-layer signature: base64 decode failed: %w", err)
|
||||||
|
}
|
||||||
|
if len(decoded) == 0 {
|
||||||
|
return nil, fmt.Errorf("invalid double-layer signature: empty after decode")
|
||||||
|
}
|
||||||
|
if decoded[0] != 'E' {
|
||||||
|
return nil, fmt.Errorf("invalid double-layer signature: inner does not start with 'E', got 0x%02x", decoded[0])
|
||||||
|
}
|
||||||
|
return inspectSingleLayerSignatureWithLayers(string(decoded), 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspectSingleLayerSignature(sig string) (*claudeSignatureTree, error) {
|
||||||
|
return inspectSingleLayerSignatureWithLayers(sig, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspectSingleLayerSignatureWithLayers(sig string, encodingLayers int) (*claudeSignatureTree, error) {
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(sig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid single-layer signature: base64 decode failed: %w", err)
|
||||||
|
}
|
||||||
|
if len(decoded) == 0 {
|
||||||
|
return nil, fmt.Errorf("invalid single-layer signature: empty after decode")
|
||||||
|
}
|
||||||
|
return inspectClaudeSignaturePayload(decoded, encodingLayers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspectClaudeSignaturePayload(payload []byte, encodingLayers int) (*claudeSignatureTree, error) {
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return nil, fmt.Errorf("invalid Claude signature: empty payload")
|
||||||
|
}
|
||||||
|
if payload[0] != 0x12 {
|
||||||
|
return nil, fmt.Errorf("invalid Claude signature: expected first byte 0x12, got 0x%02x", payload[0])
|
||||||
|
}
|
||||||
|
container, err := extractBytesField(payload, 2, "top-level protobuf")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
channelBlock, err := extractBytesField(container, 1, "Claude Field 2 container")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return inspectClaudeChannelBlock(channelBlock, encodingLayers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspectClaudeChannelBlock(channelBlock []byte, encodingLayers int) (*claudeSignatureTree, error) {
|
||||||
|
tree := &claudeSignatureTree{
|
||||||
|
EncodingLayers: encodingLayers,
|
||||||
|
RoutingClass: "unknown",
|
||||||
|
InfrastructureClass: "infra_unknown",
|
||||||
|
SchemaFeatures: "unknown_schema_features",
|
||||||
|
}
|
||||||
|
haveChannelID := false
|
||||||
|
hasField6 := false
|
||||||
|
hasField7 := false
|
||||||
|
|
||||||
|
err := walkProtobufFields(channelBlock, func(num protowire.Number, typ protowire.Type, raw []byte) error {
|
||||||
|
switch num {
|
||||||
|
case 1:
|
||||||
|
if typ != protowire.VarintType {
|
||||||
|
return fmt.Errorf("invalid Claude signature: Field 2.1.1 channel_id must be varint")
|
||||||
|
}
|
||||||
|
channelID, err := decodeVarintField(raw, "Field 2.1.1 channel_id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tree.ChannelID = channelID
|
||||||
|
haveChannelID = true
|
||||||
|
case 2:
|
||||||
|
if typ != protowire.VarintType {
|
||||||
|
return fmt.Errorf("invalid Claude signature: Field 2.1.2 field2 must be varint")
|
||||||
|
}
|
||||||
|
field2, err := decodeVarintField(raw, "Field 2.1.2 field2")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tree.Field2 = &field2
|
||||||
|
case 6:
|
||||||
|
if typ != protowire.BytesType {
|
||||||
|
return fmt.Errorf("invalid Claude signature: Field 2.1.6 model_text must be bytes")
|
||||||
|
}
|
||||||
|
modelBytes, err := decodeBytesField(raw, "Field 2.1.6 model_text")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !utf8.Valid(modelBytes) {
|
||||||
|
return fmt.Errorf("invalid Claude signature: Field 2.1.6 model_text is not valid UTF-8")
|
||||||
|
}
|
||||||
|
tree.ModelText = string(modelBytes)
|
||||||
|
hasField6 = true
|
||||||
|
case 7:
|
||||||
|
if typ != protowire.VarintType {
|
||||||
|
return fmt.Errorf("invalid Claude signature: Field 2.1.7 must be varint")
|
||||||
|
}
|
||||||
|
if _, err := decodeVarintField(raw, "Field 2.1.7"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hasField7 = true
|
||||||
|
tree.HasField7 = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !haveChannelID {
|
||||||
|
return nil, fmt.Errorf("invalid Claude signature: missing Field 2.1.1 channel_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch tree.ChannelID {
|
||||||
|
case 11:
|
||||||
|
tree.RoutingClass = "routing_class_11"
|
||||||
|
case 12:
|
||||||
|
tree.RoutingClass = "routing_class_12"
|
||||||
|
}
|
||||||
|
|
||||||
|
if tree.Field2 == nil {
|
||||||
|
tree.InfrastructureClass = "infra_default"
|
||||||
|
} else {
|
||||||
|
switch *tree.Field2 {
|
||||||
|
case 1:
|
||||||
|
tree.InfrastructureClass = "infra_aws"
|
||||||
|
case 2:
|
||||||
|
tree.InfrastructureClass = "infra_google"
|
||||||
|
default:
|
||||||
|
tree.InfrastructureClass = "infra_unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case hasField6:
|
||||||
|
tree.SchemaFeatures = "extended_model_tagged_schema"
|
||||||
|
case !hasField6 && !hasField7 && len(channelBlock) >= 70 && len(channelBlock) <= 72:
|
||||||
|
tree.SchemaFeatures = "compact_schema"
|
||||||
|
}
|
||||||
|
|
||||||
|
if tree.ChannelID == 11 {
|
||||||
|
switch {
|
||||||
|
case tree.Field2 == nil:
|
||||||
|
tree.LegacyRouteHint = "legacy_default_group"
|
||||||
|
case *tree.Field2 == 1:
|
||||||
|
tree.LegacyRouteHint = "legacy_aws_group"
|
||||||
|
case *tree.Field2 == 2 && tree.EncodingLayers == 2:
|
||||||
|
tree.LegacyRouteHint = "legacy_vertex_direct"
|
||||||
|
case *tree.Field2 == 2 && tree.EncodingLayers == 1:
|
||||||
|
tree.LegacyRouteHint = "legacy_vertex_proxy"
|
||||||
|
case *tree.Field2 == 2:
|
||||||
|
tree.LegacyRouteHint = "legacy_vertex_group"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tree, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractBytesField(msg []byte, fieldNum protowire.Number, scope string) ([]byte, error) {
|
||||||
|
var value []byte
|
||||||
|
err := walkProtobufFields(msg, func(num protowire.Number, typ protowire.Type, raw []byte) error {
|
||||||
|
if num != fieldNum {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if typ != protowire.BytesType {
|
||||||
|
return fmt.Errorf("invalid Claude signature: %s field %d must be bytes", scope, fieldNum)
|
||||||
|
}
|
||||||
|
bytesValue, err := decodeBytesField(raw, fmt.Sprintf("%s field %d", scope, fieldNum))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
value = bytesValue
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if value == nil {
|
||||||
|
return nil, fmt.Errorf("invalid Claude signature: missing %s field %d", scope, fieldNum)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func walkProtobufFields(msg []byte, visit func(num protowire.Number, typ protowire.Type, raw []byte) error) error {
|
||||||
|
for offset := 0; offset < len(msg); {
|
||||||
|
num, typ, n := protowire.ConsumeTag(msg[offset:])
|
||||||
|
if n < 0 {
|
||||||
|
return fmt.Errorf("invalid Claude signature: malformed protobuf tag: %w", protowire.ParseError(n))
|
||||||
|
}
|
||||||
|
offset += n
|
||||||
|
valueLen := protowire.ConsumeFieldValue(num, typ, msg[offset:])
|
||||||
|
if valueLen < 0 {
|
||||||
|
return fmt.Errorf("invalid Claude signature: malformed protobuf field %d: %w", num, protowire.ParseError(valueLen))
|
||||||
|
}
|
||||||
|
fieldRaw := msg[offset : offset+valueLen]
|
||||||
|
if err := visit(num, typ, fieldRaw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
offset += valueLen
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeVarintField(raw []byte, label string) (uint64, error) {
|
||||||
|
value, n := protowire.ConsumeVarint(raw)
|
||||||
|
if n < 0 {
|
||||||
|
return 0, fmt.Errorf("invalid Claude signature: failed to decode %s: %w", label, protowire.ParseError(n))
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeBytesField(raw []byte, label string) ([]byte, error) {
|
||||||
|
value, n := protowire.ConsumeBytes(raw)
|
||||||
|
if n < 0 {
|
||||||
|
return nil, fmt.Errorf("invalid Claude signature: failed to decode %s: %w", label, protowire.ParseError(n))
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user