mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-21 14:19:18 +08:00
feat(claude-mimicry): align Claude Code fingerprint with CLI 2.1.161
- bump impersonated CLI version 2.1.92 -> 2.1.161; derive User-Agent from CLICurrentVersion so the two hardcoded copies can no longer drift apart - fix x-stainless headers to real 2.1.161 values: package-version 0.70.0 -> 0.94.0, runtime-version v24.13.0 -> v24.3.0 (verified against the installed Bun-compiled binary) - expand the disguise-path system prompt from a 2-block identity skeleton to a 3-block layout (billing + identity + tool-agnostic prose), matching real CC's multi-block shape; cache breakpoint moved to the last static block. Deliberately excludes # Doing tasks / # Using your tools / # Executing actions to avoid polluting proxied-client behavior - stabilize the synthesized metadata.user_id session_id across conversation turns: derive it from (account + client discriminator + first user message) instead of a per-turn content/body hash. Sticky-routing GenerateSessionHash is intentionally left untouched; remove now-dead hashBodyForSessionSeed Tests: update the 3-block system assertions in gateway_prompt_test and gateway_anthropic_apikey_passthrough_test; add a session_id cross-turn stability test in gateway_oauth_metadata_test.
This commit is contained in:
@@ -65,7 +65,7 @@ const DefaultCacheControlTTL = "5m"
|
||||
// CLICurrentVersion 是 sub2api 当前对外伪装的 Claude Code CLI 版本号(三段 semver)。
|
||||
// 用于 billing attribution block 中的 cc_version=X.Y.Z.{fp} 前缀以及 fingerprint 计算。
|
||||
// 必须与 DefaultHeaders["User-Agent"] 中的版本号严格一致;不一致会被 Anthropic 判第三方。
|
||||
const CLICurrentVersion = "2.1.92"
|
||||
const CLICurrentVersion = "2.1.161"
|
||||
|
||||
// FullClaudeCodeMimicryBetas 返回最"像"真实 Claude Code CLI 的完整 beta 列表,
|
||||
// 用于 OAuth 账号伪装成 Claude Code 时使用。
|
||||
@@ -93,13 +93,13 @@ var DefaultHeaders = map[string]string{
|
||||
// Keep these in sync with recent Claude CLI traffic to reduce the chance
|
||||
// that Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage.
|
||||
// 版本参考:对齐 Parrot (src/transform/cc_mimicry.py:49) 的 CLI_USER_AGENT。
|
||||
"User-Agent": "claude-cli/2.1.92 (external, cli)",
|
||||
"User-Agent": "claude-cli/" + CLICurrentVersion + " (external, cli)",
|
||||
"X-Stainless-Lang": "js",
|
||||
"X-Stainless-Package-Version": "0.70.0",
|
||||
"X-Stainless-Package-Version": "0.94.0",
|
||||
"X-Stainless-OS": "Linux",
|
||||
"X-Stainless-Arch": "arm64",
|
||||
"X-Stainless-Runtime": "node",
|
||||
"X-Stainless-Runtime-Version": "v24.13.0",
|
||||
"X-Stainless-Runtime-Version": "v24.3.0",
|
||||
"X-Stainless-Retry-Count": "0",
|
||||
"X-Stainless-Timeout": "600",
|
||||
"X-App": "cli",
|
||||
|
||||
@@ -818,13 +818,16 @@ func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock(
|
||||
require.True(t, system.Exists())
|
||||
require.True(t, system.IsArray(), "system should be an array")
|
||||
arr := system.Array()
|
||||
require.Len(t, arr, 2, "system array should have billing block + cc prompt block")
|
||||
require.Len(t, arr, 3, "system array should have billing block + cc prompt block + expansion block")
|
||||
|
||||
require.Contains(t, arr[0].Get("text").String(), "x-anthropic-billing-header:")
|
||||
require.Contains(t, arr[0].Get("text").String(), "cc_version=")
|
||||
|
||||
require.Equal(t, claudeCodeSystemPrompt, arr[1].Get("text").String())
|
||||
require.Equal(t, "ephemeral", arr[1].Get("cache_control.type").String())
|
||||
require.False(t, arr[1].Get("cache_control").Exists(), "身份前缀 block 不应带 cache_control")
|
||||
|
||||
require.Equal(t, claudeCodeSystemPromptExpansion, arr[2].Get("text").String())
|
||||
require.Equal(t, "ephemeral", arr[2].Get("cache_control.type").String())
|
||||
|
||||
// 原始 system prompt 应迁移至 messages 中
|
||||
messages := gjson.GetBytes(upstream.lastBody, "messages")
|
||||
|
||||
@@ -75,7 +75,7 @@ func extractFirstUserText(body []byte) string {
|
||||
//
|
||||
// 形态严格对齐真实 Claude Code CLI:
|
||||
//
|
||||
// {"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.92.{fp}; cc_entrypoint=cli; cch=00000;"}
|
||||
// {"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.161.{fp}; cc_entrypoint=cli; cch=00000;"}
|
||||
//
|
||||
// cch=00000 是签名占位符,由 signBillingHeaderCCH 在 buildUpstreamRequest 阶段
|
||||
// 替换为基于完整 body 的 xxhash64 5 位十六进制摘要。
|
||||
|
||||
@@ -58,3 +58,46 @@ func TestBuildOAuthMetadataUserID_UsesAccountUUIDWhenPresent(t *testing.T) {
|
||||
re := regexp.MustCompile(`^user_clientid123_account_acc-uuid_session_[a-f0-9-]{36}$`)
|
||||
require.True(t, re.MatchString(got), "unexpected user_id format: %s", got)
|
||||
}
|
||||
|
||||
// TestBuildOAuthMetadataUserID_SessionIDStableAcrossTurns 验证伪装路径合成的
|
||||
// metadata.user_id 在同一会话多轮请求间保持不变(session_id 稳定),贴近真实 Claude Code
|
||||
// 进程级稳定的 session。账号 / 指纹 / UA 版本均相同,唯一可能变化的就是 session_id,
|
||||
// 因此直接比较完整 user_id 字符串即可判定 session_id 是否稳定。
|
||||
func TestBuildOAuthMetadataUserID_SessionIDStableAcrossTurns(t *testing.T) {
|
||||
svc := &GatewayService{}
|
||||
account := &Account{ID: 777, Type: AccountTypeOAuth, Extra: map[string]any{"account_uuid": "acc-uuid"}}
|
||||
fp := &Fingerprint{ClientID: "clientid777", UserAgent: "claude-cli/2.1.161 (external, cli)"}
|
||||
|
||||
mustParse := func(body string) *ParsedRequest {
|
||||
parsed, err := ParseGatewayRequest(NewRequestBodyRef([]byte(body)), PlatformAnthropic)
|
||||
require.NoError(t, err)
|
||||
return parsed
|
||||
}
|
||||
|
||||
round1 := mustParse(`{"model":"claude-sonnet-4-5","system":"sys","messages":[` +
|
||||
`{"role":"user","content":"first question"}]}`)
|
||||
round2 := mustParse(`{"model":"claude-sonnet-4-5","system":"sys","messages":[` +
|
||||
`{"role":"user","content":"first question"},` +
|
||||
`{"role":"assistant","content":"answer 1"},` +
|
||||
`{"role":"user","content":"second question"}]}`)
|
||||
round3 := mustParse(`{"model":"claude-sonnet-4-5","system":"sys","messages":[` +
|
||||
`{"role":"user","content":"first question"},` +
|
||||
`{"role":"assistant","content":"answer 1"},` +
|
||||
`{"role":"user","content":"second question"},` +
|
||||
`{"role":"assistant","content":"answer 2"},` +
|
||||
`{"role":"user","content":"third question"}]}`)
|
||||
|
||||
id1 := svc.buildOAuthMetadataUserID(round1, account, fp)
|
||||
id2 := svc.buildOAuthMetadataUserID(round2, account, fp)
|
||||
id3 := svc.buildOAuthMetadataUserID(round3, account, fp)
|
||||
|
||||
require.NotEmpty(t, id1)
|
||||
require.Equal(t, id1, id2, "session_id 应随对话增长保持不变")
|
||||
require.Equal(t, id2, id3, "session_id 应跨所有轮次保持不变")
|
||||
|
||||
// 不同的首条 user 消息应派生出不同的 session_id(不同会话)。
|
||||
other := mustParse(`{"model":"claude-sonnet-4-5","system":"sys","messages":[` +
|
||||
`{"role":"user","content":"a completely different opener"}]}`)
|
||||
idOther := svc.buildOAuthMetadataUserID(other, account, fp)
|
||||
require.NotEqual(t, id1, idOther, "不同首条消息应派生不同 session_id")
|
||||
}
|
||||
|
||||
@@ -401,12 +401,13 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) {
|
||||
err := json.Unmarshal(result, &parsed)
|
||||
require.NoError(t, err)
|
||||
|
||||
// system 应为 array 格式,对齐真实 Claude Code CLI 的 2-block 形态:
|
||||
// system 应为 array 格式,对齐真实 Claude Code CLI 的 3-block 形态:
|
||||
// [0] billing attribution block (x-anthropic-billing-header: cc_version=...;)
|
||||
// [1] Claude Code prompt block (带 cache_control)
|
||||
// [1] Claude Code 身份前缀 block (不带 cache_control)
|
||||
// [2] 工具无关的通用提示词扩充 block (带 cache_control,作为缓存断点)
|
||||
systemArr, ok := parsed["system"].([]any)
|
||||
require.True(t, ok, "system should be an array, got %T", parsed["system"])
|
||||
require.Len(t, systemArr, 2, "system array should have exactly 2 blocks (billing + cc prompt)")
|
||||
require.Len(t, systemArr, 3, "system array should have exactly 3 blocks (billing + cc prompt + expansion)")
|
||||
|
||||
billingBlock, ok := systemArr[0].(map[string]any)
|
||||
require.True(t, ok)
|
||||
@@ -420,8 +421,15 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) {
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "text", systemBlock["type"])
|
||||
require.Equal(t, tt.wantSystemText, systemBlock["text"])
|
||||
cc, ok := systemBlock["cache_control"].(map[string]any)
|
||||
require.True(t, ok, "cc prompt block should have cache_control")
|
||||
_, hasCC := systemBlock["cache_control"]
|
||||
require.False(t, hasCC, "身份前缀 block 不应带 cache_control(断点落在扩充块)")
|
||||
|
||||
expansionBlock, ok := systemArr[2].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "text", expansionBlock["type"])
|
||||
require.Equal(t, claudeCodeSystemPromptExpansion, expansionBlock["text"])
|
||||
cc, ok := expansionBlock["cache_control"].(map[string]any)
|
||||
require.True(t, ok, "expansion block should have cache_control")
|
||||
require.Equal(t, "ephemeral", cc["type"])
|
||||
|
||||
// 检查 messages
|
||||
|
||||
@@ -51,7 +51,23 @@ const (
|
||||
// to match real Claude CLI traffic as closely as possible. When we need a visual
|
||||
// separator between system blocks, we add "\n\n" at concatenation time.
|
||||
claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude."
|
||||
maxCacheControlBlocks = 4 // Anthropic API 允许的最大 cache_control 块数量
|
||||
// claudeCodeSystemPromptExpansion 是真实 Claude Code 主系统提示词中"与具体工具无关"
|
||||
// 的通用段落(身份/用途总述 + 安全声明 + URL 告警 + Tone and style),逐字取自真实
|
||||
// CLI(2.1.x 一致)。伪装路径用它把 system 块数从 2 提升到 3、体量贴近真实 CC,同时
|
||||
// 刻意排除 # Doing tasks / # Using your tools / # Executing actions 等会污染被代理
|
||||
// 用户行为的工具专属指令。
|
||||
claudeCodeSystemPromptExpansion = `You are an interactive agent that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
|
||||
|
||||
IMPORTANT: Assist with authorized security testing, defensive security, CTF challenges, and educational contexts. Refuse requests for destructive techniques, DoS attacks, mass targeting, supply chain compromise, or detection evasion for malicious purposes. Dual-use security tools (C2 frameworks, credential testing, exploit development) require clear authorization context: pentesting engagements, CTF competitions, security research, or defensive use cases.
|
||||
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
|
||||
|
||||
# Tone and style
|
||||
- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
|
||||
- Your responses should be short and concise.
|
||||
- When referencing specific functions or pieces of code include the pattern file_path:line_number to allow the user to easily navigate to the source code location.
|
||||
- When referencing GitHub issues or pull requests, use the owner/repo#123 format (e.g. anthropics/claude-code#100) so they render as clickable links.
|
||||
- Do not use a colon before tool calls. Your tool calls may not be shown directly in the output, so text like "Let me read the file:" followed by a read tool call should just be "Let me read the file." with a period.`
|
||||
maxCacheControlBlocks = 4 // Anthropic API 允许的最大 cache_control 块数量
|
||||
|
||||
defaultUserGroupRateCacheTTL = 30 * time.Second
|
||||
defaultModelsListCacheTTL = 15 * time.Second
|
||||
@@ -1270,12 +1286,15 @@ func (s *GatewayService) buildOAuthMetadataUserID(parsed *ParsedRequest, account
|
||||
userID = generateClientID()
|
||||
}
|
||||
|
||||
sessionHash := s.GenerateSessionHash(parsed)
|
||||
sessionID := uuid.NewString()
|
||||
if sessionHash != "" {
|
||||
seed := fmt.Sprintf("%d::%s", account.ID, sessionHash)
|
||||
sessionID = generateSessionUUID(seed)
|
||||
// session_id 用"会话级稳定种子"派生(账号 + 客户端区分因子 + 首条 user 文本):
|
||||
// 随对话在尾部追加 messages 时保持不变,贴近真实 CC 进程级稳定的 session_id。
|
||||
// 不复用 GenerateSessionHash —— 后者是粘性路由键、按设计逐轮变化(见其测试)。
|
||||
var firstUserText string
|
||||
if parsed.Body != nil {
|
||||
firstUserText = extractFirstUserText(parsed.Body.Bytes())
|
||||
}
|
||||
seed := buildStableSessionSeed(account.ID, sessionContextDiscriminator(parsed.SessionContext), firstUserText)
|
||||
sessionID := generateSessionUUID(seed)
|
||||
|
||||
// 根据指纹 UA 版本选择输出格式
|
||||
var uaVersion string
|
||||
@@ -1390,10 +1409,14 @@ func (s *GatewayService) buildOAuthMetadataUserIDFromBody(
|
||||
userID = generateClientID()
|
||||
}
|
||||
|
||||
sessionID := uuid.NewString()
|
||||
if hash := hashBodyForSessionSeed(body); hash != "" {
|
||||
sessionID = generateSessionUUID(fmt.Sprintf("%d::%s", account.ID, hash))
|
||||
// 与 buildOAuthMetadataUserID 一致:用会话级稳定种子,避免整 body 哈希导致
|
||||
// 每轮(甚至每个 token 变化)都重算出不同的 session_id。
|
||||
var clientDiscriminator string
|
||||
if fp != nil {
|
||||
clientDiscriminator = fp.ClientID
|
||||
}
|
||||
seed := buildStableSessionSeed(account.ID, clientDiscriminator, extractFirstUserText(body))
|
||||
sessionID := generateSessionUUID(seed)
|
||||
|
||||
var uaVersion string
|
||||
if fp != nil {
|
||||
@@ -1403,14 +1426,31 @@ func (s *GatewayService) buildOAuthMetadataUserIDFromBody(
|
||||
return FormatMetadataUserID(userID, accountUUID, sessionID, uaVersion)
|
||||
}
|
||||
|
||||
// hashBodyForSessionSeed 为 sessionID 提供一个稳定但仅对本次请求特征化的种子。
|
||||
// 复用 SHA-256 + 截断,与 generateSessionUUID 的输入格式对齐。
|
||||
func hashBodyForSessionSeed(body []byte) string {
|
||||
if len(body) == 0 {
|
||||
// buildStableSessionSeed 为伪装路径合成的 metadata.user_id session_id 生成"会话级稳定"种子。
|
||||
//
|
||||
// 真实 Claude Code 的 session_id 是进程级随机 UUID,在一段会话内跨请求保持不变。无状态代理
|
||||
// 无法恢复该值,这里用"会话内不变的锚点"近似:账号 ID + 客户端区分因子 + 首条 user 消息文本。
|
||||
// 对话在尾部追加 messages 时这三者都不变,因此 generateSessionUUID(seed) 跨轮稳定。
|
||||
//
|
||||
// 注意:粘性路由键 GenerateSessionHash 按设计逐轮变化(见其测试),本函数与之独立、互不影响。
|
||||
// accountID 恒存在,故 seed 永不为空 —— 输出始终是确定性 UUID,而非随机值。
|
||||
func buildStableSessionSeed(accountID int64, clientDiscriminator, firstUserText string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(strconv.FormatInt(accountID, 10))
|
||||
b.WriteString("::")
|
||||
b.WriteString(clientDiscriminator)
|
||||
b.WriteString("::")
|
||||
b.WriteString(firstUserText)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// sessionContextDiscriminator 把请求上下文(客户端 IP / 归一化 UA / API Key ID)拼成
|
||||
// 一个跨客户端的区分因子,避免不同用户的相同首条消息派生出相同 session_id。
|
||||
func sessionContextDiscriminator(sc *SessionContext) string {
|
||||
if sc == nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
return fmt.Sprintf("%x", sum[:16])
|
||||
return sc.ClientIP + ":" + NormalizeSessionUserAgent(sc.UserAgent) + ":" + strconv.FormatInt(sc.APIKeyID, 10)
|
||||
}
|
||||
|
||||
// GenerateSessionUUID creates a deterministic UUID4 from a seed string.
|
||||
@@ -4134,20 +4174,28 @@ func rewriteSystemForNonClaudeCode(body []byte, system any) []byte {
|
||||
originalSystemText = strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
// 2. 构造 system 数组,对齐真实 Claude Code CLI 的 2-block 形态:
|
||||
// 2. 构造 system 数组,对齐真实 Claude Code CLI 的 3-block 形态:
|
||||
// [0] billing attribution block(cc_version={cliVer}.{fp}; cc_entrypoint=cli; cch=00000;)
|
||||
// [1] "You are Claude Code..." prompt block(带 cache_control 作为稳定缓存断点)
|
||||
// [1] "You are Claude Code..." 身份前缀 block(带 cache_control)
|
||||
// [2] 工具无关的通用提示词扩充 block(带 cache_control 作为稳定缓存断点)
|
||||
//
|
||||
// 真实 CC 的 system 在身份前缀之后还有大段提示词,仅有 2 块会在块数/体量上明显
|
||||
// 区别于真实 CLI。这里注入 claudeCodeSystemPromptExpansion(中性段落)把形态做到
|
||||
// 接近真实,同时不注入会污染被代理用户行为的工具专属指令。
|
||||
//
|
||||
// billing block 的 cch=00000 是占位符,会被 buildUpstreamRequest 里的
|
||||
// signBillingHeaderCCH 替换成 xxhash64 签名。缺失 billing block 的系统 payload
|
||||
// 是 Anthropic 判定第三方的关键信号之一(真实 CLI 每个请求都带)。
|
||||
billingBlock, billingErr := buildBillingAttributionBlockJSON(body, claude.CLICurrentVersion)
|
||||
ccPromptBlock, ccErr := marshalAnthropicSystemTextBlock(claudeCodeSystemPrompt, true)
|
||||
if billingErr != nil || ccErr != nil {
|
||||
logger.LegacyPrintf("service.gateway", "Warning: failed to build system blocks (billing=%v, cc=%v)", billingErr, ccErr)
|
||||
// 身份块不带 cache_control;缓存断点统一落在最后一个静态块(扩充块)上,
|
||||
// 使 billing+身份+扩充 整段静态前缀都被同一断点覆盖,且只消耗 1 个断点配额。
|
||||
ccPromptBlock, ccErr := marshalAnthropicSystemTextBlock(claudeCodeSystemPrompt, false)
|
||||
ccExpansionBlock, expErr := marshalAnthropicSystemTextBlock(claudeCodeSystemPromptExpansion, true)
|
||||
if billingErr != nil || ccErr != nil || expErr != nil {
|
||||
logger.LegacyPrintf("service.gateway", "Warning: failed to build system blocks (billing=%v, cc=%v, exp=%v)", billingErr, ccErr, expErr)
|
||||
return body
|
||||
}
|
||||
out, ok := setJSONRawBytes(body, "system", buildJSONArrayRaw([][]byte{billingBlock, ccPromptBlock}))
|
||||
out, ok := setJSONRawBytes(body, "system", buildJSONArrayRaw([][]byte{billingBlock, ccPromptBlock, ccExpansionBlock}))
|
||||
if !ok {
|
||||
logger.LegacyPrintf("service.gateway", "Warning: failed to set Claude Code system prompt")
|
||||
return body
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
@@ -26,13 +27,13 @@ var (
|
||||
|
||||
// 默认指纹值(当客户端未提供时使用)
|
||||
var defaultFingerprint = Fingerprint{
|
||||
UserAgent: "claude-cli/2.1.92 (external, cli)",
|
||||
UserAgent: "claude-cli/" + claude.CLICurrentVersion + " (external, cli)",
|
||||
StainlessLang: "js",
|
||||
StainlessPackageVersion: "0.70.0",
|
||||
StainlessPackageVersion: "0.94.0",
|
||||
StainlessOS: "Linux",
|
||||
StainlessArch: "arm64",
|
||||
StainlessRuntime: "node",
|
||||
StainlessRuntimeVersion: "v24.13.0",
|
||||
StainlessRuntimeVersion: "v24.3.0",
|
||||
}
|
||||
|
||||
// Fingerprint represents account fingerprint data
|
||||
|
||||
Reference in New Issue
Block a user