fix(gateway): skip body mimicry for real Claude Code clients to restore prompt caching
PR #1914 unconditionally applied the full mimicry pipeline to all OAuth accounts, including real Claude Code CLI clients. This replaced the client's long system prompt (~10K+ tokens with stable cache_control breakpoints) with a short ~45 token [billing, CC prompt] pair, which falls below Anthropic's 1024-token minimum cacheable prefix threshold. The result: every request created a new cache but never hit an existing one. Fix: restore the Claude Code client detection gate so that real CC clients bypass body-level mimicry (system rewrite, message cache management, tool name obfuscation). Non-CC third-party clients (opencode, etc.) continue to receive full mimicry. Also harden the detection logic: - Make UA regex case-insensitive (align with claude_code_validator.go) - Validate metadata.user_id format via ParseMetadataUserID() instead of just checking non-empty, preventing third-party tools from spoofing a claude-cli/* UA with an arbitrary user_id string to bypass mimicry
This commit is contained in:
@@ -9,6 +9,11 @@ import (
|
||||
)
|
||||
|
||||
func TestIsClaudeCodeClient(t *testing.T) {
|
||||
// 合法的 legacy 格式 metadata.user_id(64位 hex + account uuid + session uuid)
|
||||
legacyUserID := "user_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2_account_550e8400-e29b-41d4-a716-446655440000_session_123e4567-e89b-12d3-a456-426614174000"
|
||||
// 合法的 JSON 格式 metadata.user_id(2.1.78+ 版本)
|
||||
jsonUserID := `{"device_id":"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2","account_uuid":"550e8400-e29b-41d4-a716-446655440000","session_id":"123e4567-e89b-12d3-a456-426614174000"}`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
userAgent string
|
||||
@@ -16,15 +21,21 @@ func TestIsClaudeCodeClient(t *testing.T) {
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "Claude Code client",
|
||||
name: "Claude Code client with legacy user_id",
|
||||
userAgent: "claude-cli/1.0.62 (darwin; arm64)",
|
||||
metadataUserID: "session_123e4567-e89b-12d3-a456-426614174000",
|
||||
metadataUserID: legacyUserID,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Claude Code without version suffix",
|
||||
userAgent: "claude-cli/2.0.0",
|
||||
metadataUserID: "session_abc",
|
||||
name: "Claude Code client with JSON user_id",
|
||||
userAgent: "claude-cli/2.1.92 (external, cli)",
|
||||
metadataUserID: jsonUserID,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Claude Code case insensitive UA",
|
||||
userAgent: "Claude-CLI/2.0.0",
|
||||
metadataUserID: legacyUserID,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
@@ -34,21 +45,33 @@ func TestIsClaudeCodeClient(t *testing.T) {
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Different user agent",
|
||||
name: "Claude CLI UA with invalid user_id format",
|
||||
userAgent: "claude-cli/2.0.0",
|
||||
metadataUserID: "fake-user-id-12345",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Different user agent with valid user_id",
|
||||
userAgent: "curl/7.68.0",
|
||||
metadataUserID: "user123",
|
||||
metadataUserID: legacyUserID,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Empty user agent",
|
||||
userAgent: "",
|
||||
metadataUserID: "user123",
|
||||
metadataUserID: legacyUserID,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Similar but not Claude CLI",
|
||||
userAgent: "claude-api/1.0.0",
|
||||
metadataUserID: "user123",
|
||||
metadataUserID: legacyUserID,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Opencode spoofing UA with arbitrary user_id",
|
||||
userAgent: "claude-cli/2.1.92",
|
||||
metadataUserID: "session_abc",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -329,7 +329,7 @@ func isClaudeCodeCredentialScopeError(msg string) bool {
|
||||
// Some upstream APIs return non-standard "data:" without space (should be "data: ").
|
||||
var (
|
||||
sseDataRe = regexp.MustCompile(`^data:\s*`)
|
||||
claudeCliUserAgentRe = regexp.MustCompile(`^claude-cli/\d+\.\d+\.\d+`)
|
||||
claudeCliUserAgentRe = regexp.MustCompile(`(?i)^claude-cli/\d+\.\d+\.\d+`)
|
||||
|
||||
// claudeCodePromptPrefixes 用于检测 Claude Code 系统提示词的前缀列表
|
||||
// 支持多种变体:标准版、Agent SDK 版、Explore Agent 版、Compact 版等
|
||||
@@ -3709,13 +3709,19 @@ func sleepWithContext(ctx context.Context, d time.Duration) error {
|
||||
}
|
||||
}
|
||||
|
||||
// isClaudeCodeClient 判断请求是否来自 Claude Code 客户端
|
||||
// 简化判断:User-Agent 匹配 + metadata.user_id 存在
|
||||
// isClaudeCodeClient 判断请求是否来自真正的 Claude Code 客户端。
|
||||
// 判定条件:
|
||||
// 1. User-Agent 匹配 claude-cli/X.Y.Z(大小写不敏感)
|
||||
// 2. metadata.user_id 符合 Claude Code 格式(legacy 或 JSON 格式)
|
||||
//
|
||||
// 只检查 metadata.user_id 非空不够严格:第三方工具(opencode 等)可能伪造 UA
|
||||
// 并附带任意 metadata.user_id 字符串,从而绕过 mimicry。必须通过 ParseMetadataUserID
|
||||
// 验证格式才能确认是真正的 Claude Code 客户端。
|
||||
func isClaudeCodeClient(userAgent string, metadataUserID string) bool {
|
||||
if metadataUserID == "" {
|
||||
if !claudeCliUserAgentRe.MatchString(userAgent) {
|
||||
return false
|
||||
}
|
||||
return claudeCliUserAgentRe.MatchString(userAgent)
|
||||
return ParseMetadataUserID(metadataUserID) != nil
|
||||
}
|
||||
|
||||
// normalizeSystemParam 将 json.RawMessage 类型的 system 参数转为标准 Go 类型(string / []any / nil),
|
||||
@@ -4144,12 +4150,15 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
})
|
||||
}
|
||||
|
||||
// OAuth 账号无条件走完整 mimicry,与 Parrot 对齐。
|
||||
// 不再检查 isClaudeCodeRequest —— 即使客户端自称 Claude Code(opencode 等
|
||||
// 第三方工具会伪装 UA / X-App / system prompt),它的伪装往往不完整(缺 billing
|
||||
// block / 工具名混淆 / cache 策略等),被 Anthropic 判为 third-party。
|
||||
// 无条件覆盖不会对真正的 Claude Code 造成问题,因为我们的伪装更完整。
|
||||
shouldMimicClaudeCode := account.IsOAuth()
|
||||
// Claude Code 客户端判定:UA 匹配 claude-cli/* 且携带 metadata.user_id。
|
||||
// 真正的 Claude Code 客户端自带完整的 system prompt、cache_control 断点和 header,
|
||||
// 不需要代理做任何 body 级别的 mimicry;强行替换反而会破坏客户端的缓存策略
|
||||
// (长 system prompt 被替换为 ~45 tokens 的短 prompt,低于 Anthropic 1024 token
|
||||
// 最低缓存门槛,导致系统级缓存失效)。
|
||||
//
|
||||
// 对于非 Claude Code 的第三方客户端(opencode 等),仍然走完整 mimicry。
|
||||
isClaudeCode := IsClaudeCodeClient(ctx) || isClaudeCodeClient(c.GetHeader("User-Agent"), parsed.MetadataUserID)
|
||||
shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode
|
||||
|
||||
if shouldMimicClaudeCode {
|
||||
// 与 Parrot 对齐:OAuth 账号无条件重写 system(即使客户端已发了 Claude Code
|
||||
@@ -8387,7 +8396,8 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
|
||||
// Pre-filter: strip empty text blocks to prevent upstream 400.
|
||||
body = StripEmptyTextBlocks(body)
|
||||
|
||||
shouldMimicClaudeCode := account.IsOAuth()
|
||||
isClaudeCodeCT := IsClaudeCodeClient(ctx) || isClaudeCodeClient(c.GetHeader("User-Agent"), parsed.MetadataUserID)
|
||||
shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCodeCT
|
||||
|
||||
if shouldMimicClaudeCode {
|
||||
normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: true}
|
||||
|
||||
Reference in New Issue
Block a user