Commit 496469ac authored by shaw's avatar shaw
Browse files

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
parent c1b52615
...@@ -9,6 +9,11 @@ import ( ...@@ -9,6 +9,11 @@ import (
) )
func TestIsClaudeCodeClient(t *testing.T) { 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 { tests := []struct {
name string name string
userAgent string userAgent string
...@@ -16,15 +21,21 @@ func TestIsClaudeCodeClient(t *testing.T) { ...@@ -16,15 +21,21 @@ func TestIsClaudeCodeClient(t *testing.T) {
want bool want bool
}{ }{
{ {
name: "Claude Code client", name: "Claude Code client with legacy user_id",
userAgent: "claude-cli/1.0.62 (darwin; arm64)", userAgent: "claude-cli/1.0.62 (darwin; arm64)",
metadataUserID: "session_123e4567-e89b-12d3-a456-426614174000", metadataUserID: legacyUserID,
want: true, want: true,
}, },
{ {
name: "Claude Code without version suffix", name: "Claude Code client with JSON user_id",
userAgent: "claude-cli/2.0.0", userAgent: "claude-cli/2.1.92 (external, cli)",
metadataUserID: "session_abc", metadataUserID: jsonUserID,
want: true,
},
{
name: "Claude Code case insensitive UA",
userAgent: "Claude-CLI/2.0.0",
metadataUserID: legacyUserID,
want: true, want: true,
}, },
{ {
...@@ -34,21 +45,33 @@ func TestIsClaudeCodeClient(t *testing.T) { ...@@ -34,21 +45,33 @@ func TestIsClaudeCodeClient(t *testing.T) {
want: false, 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", userAgent: "curl/7.68.0",
metadataUserID: "user123", metadataUserID: legacyUserID,
want: false, want: false,
}, },
{ {
name: "Empty user agent", name: "Empty user agent",
userAgent: "", userAgent: "",
metadataUserID: "user123", metadataUserID: legacyUserID,
want: false, want: false,
}, },
{ {
name: "Similar but not Claude CLI", name: "Similar but not Claude CLI",
userAgent: "claude-api/1.0.0", 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, want: false,
}, },
} }
......
...@@ -329,7 +329,7 @@ func isClaudeCodeCredentialScopeError(msg string) bool { ...@@ -329,7 +329,7 @@ func isClaudeCodeCredentialScopeError(msg string) bool {
// Some upstream APIs return non-standard "data:" without space (should be "data: "). // Some upstream APIs return non-standard "data:" without space (should be "data: ").
var ( var (
sseDataRe = regexp.MustCompile(`^data:\s*`) 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 系统提示词的前缀列表 // claudeCodePromptPrefixes 用于检测 Claude Code 系统提示词的前缀列表
// 支持多种变体:标准版、Agent SDK 版、Explore Agent 版、Compact 版等 // 支持多种变体:标准版、Agent SDK 版、Explore Agent 版、Compact 版等
...@@ -3709,13 +3709,19 @@ func sleepWithContext(ctx context.Context, d time.Duration) error { ...@@ -3709,13 +3709,19 @@ func sleepWithContext(ctx context.Context, d time.Duration) error {
} }
} }
// isClaudeCodeClient 判断请求是否来自 Claude Code 客户端 // isClaudeCodeClient 判断请求是否来自真正的 Claude Code 客户端。
// 简化判断:User-Agent 匹配 + metadata.user_id 存在 // 判定条件:
// 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 { func isClaudeCodeClient(userAgent string, metadataUserID string) bool {
if metadataUserID == "" { if !claudeCliUserAgentRe.MatchString(userAgent) {
return false return false
} }
return claudeCliUserAgentRe.MatchString(userAgent) return ParseMetadataUserID(metadataUserID) != nil
} }
// normalizeSystemParam 将 json.RawMessage 类型的 system 参数转为标准 Go 类型(string / []any / 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 ...@@ -4144,12 +4150,15 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
}) })
} }
// OAuth 账号无条件走完整 mimicry,与 Parrot 对齐。 // Claude Code 客户端判定:UA 匹配 claude-cli/* 且携带 metadata.user_id。
// 不再检查 isClaudeCodeRequest —— 即使客户端自称 Claude Code(opencode 等 // 真正的 Claude Code 客户端自带完整的 system prompt、cache_control 断点和 header,
// 第三方工具会伪装 UA / X-App / system prompt),它的伪装往往不完整(缺 billing // 不需要代理做任何 body 级别的 mimicry;强行替换反而会破坏客户端的缓存策略
// block / 工具名混淆 / cache 策略等),被 Anthropic 判为 third-party。 // (长 system prompt 被替换为 ~45 tokens 的短 prompt,低于 Anthropic 1024 token
// 无条件覆盖不会对真正的 Claude Code 造成问题,因为我们的伪装更完整。 // 最低缓存门槛,导致系统级缓存失效)。
shouldMimicClaudeCode := account.IsOAuth() //
// 对于非 Claude Code 的第三方客户端(opencode 等),仍然走完整 mimicry。
isClaudeCode := IsClaudeCodeClient(ctx) || isClaudeCodeClient(c.GetHeader("User-Agent"), parsed.MetadataUserID)
shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode
if shouldMimicClaudeCode { if shouldMimicClaudeCode {
// 与 Parrot 对齐:OAuth 账号无条件重写 system(即使客户端已发了 Claude Code // 与 Parrot 对齐:OAuth 账号无条件重写 system(即使客户端已发了 Claude Code
...@@ -8387,7 +8396,8 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, ...@@ -8387,7 +8396,8 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
// Pre-filter: strip empty text blocks to prevent upstream 400. // Pre-filter: strip empty text blocks to prevent upstream 400.
body = StripEmptyTextBlocks(body) body = StripEmptyTextBlocks(body)
shouldMimicClaudeCode := account.IsOAuth() isClaudeCodeCT := IsClaudeCodeClient(ctx) || isClaudeCodeClient(c.GetHeader("User-Agent"), parsed.MetadataUserID)
shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCodeCT
if shouldMimicClaudeCode { if shouldMimicClaudeCode {
normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: true} normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: true}
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment