diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index 594d91ad..aa59ba64 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -62,6 +62,11 @@ const APIKeyHaikuBetaHeader = BetaInterleavedThinking // 客户端缺省时统一使用 5m",这样既不浪费 1h 缓存额度,也保留客户端自定义能力。 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" + // FullClaudeCodeMimicryBetas 返回最"像"真实 Claude Code CLI 的完整 beta 列表, // 用于 OAuth 账号伪装成 Claude Code 时使用。 // 顺序与真实 CLI 抓包一致。 diff --git a/backend/internal/service/gateway_billing_block.go b/backend/internal/service/gateway_billing_block.go new file mode 100644 index 00000000..45c307fd --- /dev/null +++ b/backend/internal/service/gateway_billing_block.go @@ -0,0 +1,98 @@ +package service + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + + "github.com/tidwall/gjson" +) + +// fingerprintSalt 是计算 cc_version 后缀指纹的盐值。 +// +// 来源:与 Parrot src/transform/cc_mimicry.py 的 FINGERPRINT_SALT 完全一致; +// 这是真实 Claude Code CLI 抓包推导出的常量,改动会导致 fp 与 CLI 不一致, +// 进一步触发 Anthropic 的第三方检测。 +const fingerprintSalt = "59cf53e54c78" + +// computeClaudeCodeFingerprint 复刻真实 Claude Code CLI 的 cc_version 指纹算法: +// +// 1. 取 messages 中第一条 role=user 的纯文本(首块 text) +// 2. 取该文本的第 4、7、20 字符(不足以 '0' 补齐) +// 3. SHA256(SALT + chars + cc_version) 取 hex 前 3 字符 +// +// 算法来自 Parrot src/transform/cc_mimicry.py:compute_fingerprint,与官方 CLI 字节对齐。 +// 任何偏差都会导致 cc_version=X.Y.Z.{fp} 在上游侧与真实 CLI 不一致。 +func computeClaudeCodeFingerprint(body []byte, version string) string { + firstText := extractFirstUserText(body) + indices := []int{4, 7, 20} + chars := make([]byte, 0, 3) + for _, i := range indices { + if i < len(firstText) { + chars = append(chars, firstText[i]) + } else { + chars = append(chars, '0') + } + } + sum := sha256.Sum256([]byte(fingerprintSalt + string(chars) + version)) + return hex.EncodeToString(sum[:])[:3] +} + +// extractFirstUserText 提取 messages 中第一条 user 消息的首段 text 内容。 +// 兼容 string 和 []block 两种 content 格式。 +func extractFirstUserText(body []byte) string { + messages := gjson.GetBytes(body, "messages") + if !messages.IsArray() { + return "" + } + first := "" + messages.ForEach(func(_, msg gjson.Result) bool { + if msg.Get("role").String() != "user" { + return true + } + content := msg.Get("content") + if content.Type == gjson.String { + first = content.String() + return false + } + if content.IsArray() { + content.ForEach(func(_, block gjson.Result) bool { + if block.Get("type").String() == "text" { + first = block.Get("text").String() + return false + } + return true + }) + return false + } + return false + }) + return first +} + +// buildBillingAttributionBlockJSON 构造 system 数组的 billing attribution block。 +// +// 形态严格对齐真实 Claude Code CLI: +// +// {"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.92.{fp}; cc_entrypoint=cli; cch=00000;"} +// +// cch=00000 是签名占位符,由 signBillingHeaderCCH 在 buildUpstreamRequest 阶段 +// 替换为基于完整 body 的 xxhash64 5 位十六进制摘要。 +// +// 此 block 不带 cache_control(与真实 CLI 一致;cache breakpoint 由后续的 +// Claude Code prompt block 承担)。 +func buildBillingAttributionBlockJSON(body []byte, cliVersion string) ([]byte, error) { + if cliVersion == "" { + return nil, fmt.Errorf("cliVersion required") + } + fp := computeClaudeCodeFingerprint(body, cliVersion) + text := fmt.Sprintf( + "x-anthropic-billing-header: cc_version=%s.%s; cc_entrypoint=cli; cch=00000;", + cliVersion, fp, + ) + return json.Marshal(map[string]string{ + "type": "text", + "text": text, + }) +} diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 5ca5de0c..ce9967de 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -3853,17 +3853,20 @@ func rewriteSystemForNonClaudeCode(body []byte, system any) []byte { originalSystemText = strings.Join(parts, "\n\n") } - // 2. 将 system 替换为 Claude Code 标准提示词(array 格式,与真实 Claude Code 一致) - // 真实 Claude Code 始终以 [{type: "text", text: "...", cache_control: {type: "ephemeral"}}] 发送 system。 - // 使用 string 格式会被 Anthropic 检测为第三方应用。 - claudeCodeSystemBlock := []map[string]any{ - { - "type": "text", - "text": claudeCodeSystemPrompt, - "cache_control": map[string]string{"type": "ephemeral", "ttl": claude.DefaultCacheControlTTL}, - }, + // 2. 构造 system 数组,对齐真实 Claude Code CLI 的 2-block 形态: + // [0] billing attribution block(cc_version={cliVer}.{fp}; cc_entrypoint=cli; cch=00000;) + // [1] "You are Claude Code..." prompt block(带 cache_control 作为稳定缓存断点) + // + // 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) + return body } - out, ok := setJSONValueBytes(body, "system", claudeCodeSystemBlock) + out, ok := setJSONRawBytes(body, "system", buildJSONArrayRaw([][]byte{billingBlock, ccPromptBlock})) if !ok { logger.LegacyPrintf("service.gateway", "Warning: failed to set Claude Code system prompt") return body