Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/cmd/server/wire_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

114 changes: 110 additions & 4 deletions backend/internal/pkg/claude/constants.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
// Package claude provides constants and helpers for Claude API integration.
package claude

import (
"crypto/sha256"
"encoding/binary"
"fmt"
)

// Claude Code 客户端相关常量

// Beta header 常量
Expand Down Expand Up @@ -44,23 +50,123 @@ const APIKeyBetaHeader = BetaClaudeCode + "," + BetaInterleavedThinking + "," +
// APIKeyHaikuBetaHeader Haiku 模型在 API-key 账号下使用的 anthropic-beta header(不包含 oauth / claude-code)
const APIKeyHaikuBetaHeader = BetaInterleavedThinking

// DefaultCLIVersion 与 header_util.go 抓包来源(claude-cli/2.1.81)保持一致
const DefaultCLIVersion = "2.1.81"

// DefaultHeaders 是 Claude Code 客户端默认请求头。
// 注意:当启用了指纹系统时,这些值会被 ApplyFingerprint 覆盖。
// 此处作为最后的 fallback,使用最新版本。
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.
"User-Agent": "claude-cli/2.1.22 (external, cli)",
"User-Agent": "claude-cli/" + DefaultCLIVersion + " (external, cli)",
"X-Stainless-Lang": "js",
"X-Stainless-Package-Version": "0.70.0",
"X-Stainless-OS": "Linux",
"X-Stainless-Package-Version": "0.72.1",
"X-Stainless-OS": "MacOS",
"X-Stainless-Arch": "arm64",
"X-Stainless-Runtime": "node",
"X-Stainless-Runtime-Version": "v24.13.0",
"X-Stainless-Runtime-Version": "v22.16.0",
"X-Stainless-Retry-Count": "0",
"X-Stainless-Timeout": "600",
"X-App": "cli",
"Anthropic-Dangerous-Direct-Browser-Access": "true",
}

// FingerprintProfile 真实 Claude Code 客户端的指纹模板。
// 基于对真实 Claude CLI 流量的观察,覆盖常见的 OS/Arch/Runtime 组合。
type FingerprintProfile struct {
OS string // X-Stainless-OS: "MacOS", "Linux", "Windows_NT"
Arch string // X-Stainless-Arch: "arm64", "x64"
Runtime string // X-Stainless-Runtime: "node"
RuntimeVersions []string // 该平台常见的 node 版本
PackageVersions []string // @anthropic-ai/sdk 版本
CLIVersions []string // claude-cli 版本
}

// RealisticProfiles 基于真实 Claude Code 用户环境的指纹模板池。
// 权重按真实用户分布:macOS arm64 最多,其次 Linux x64,再次 macOS x64 等。
var RealisticProfiles = []FingerprintProfile{
{
// macOS Apple Silicon — 最常见的 Claude Code 用户环境
OS: "MacOS", Arch: "arm64", Runtime: "node",
RuntimeVersions: []string{"v22.11.0", "v22.16.0", "v24.13.0"},
PackageVersions: []string{"0.70.0", "0.72.1", "0.68.2"},
CLIVersions: []string{"2.1.81", "2.1.78", "2.2.0"},
},
{
// macOS Intel — 老款 Mac 用户
OS: "MacOS", Arch: "x64", Runtime: "node",
RuntimeVersions: []string{"v22.11.0", "v22.16.0"},
PackageVersions: []string{"0.70.0", "0.72.1"},
CLIVersions: []string{"2.1.81", "2.1.78"},
},
{
// Linux x64 — 服务器/WSL/开发者
OS: "Linux", Arch: "x64", Runtime: "node",
RuntimeVersions: []string{"v22.11.0", "v22.16.0", "v24.13.0"},
PackageVersions: []string{"0.70.0", "0.72.1", "0.68.2"},
CLIVersions: []string{"2.1.81", "2.1.78", "2.2.0"},
},
{
// Linux arm64 — ARM 服务器/Raspberry Pi/Codespaces
OS: "Linux", Arch: "arm64", Runtime: "node",
RuntimeVersions: []string{"v22.16.0", "v24.13.0"},
PackageVersions: []string{"0.70.0", "0.72.1"},
CLIVersions: []string{"2.1.81", "2.1.78"},
},
{
// Windows x64 — Windows 用户
OS: "Windows_NT", Arch: "x64", Runtime: "node",
RuntimeVersions: []string{"v22.11.0", "v22.16.0"},
PackageVersions: []string{"0.70.0", "0.72.1"},
CLIVersions: []string{"2.1.81", "2.1.78"},
},
}

// SelectedFingerprint 从 SelectProfileForAccount 返回的完整指纹选择结果。
type SelectedFingerprint struct {
ProfileIndex int // 在 RealisticProfiles 中的索引,用于持久化
Profile FingerprintProfile
CLIVersion string
PackageVersion string
RuntimeVersion string
UserAgent string // 完整的 User-Agent 字符串
}

// SelectProfileForAccount 基于 accountID 确定性地选择一个完整的指纹组合。
// 同一个 accountID 永远返回相同的结果。如果提供了 lockedIndex >= 0,
// 则使用该索引选择 profile(用于已持久化的账号)。
func SelectProfileForAccount(accountID int64, lockedIndex int) SelectedFingerprint {
h := sha256.Sum256([]byte(fmt.Sprintf("fingerprint:%d", accountID)))
seed := binary.BigEndian.Uint64(h[:8])

// 选择 profile
profileIdx := int(seed % uint64(len(RealisticProfiles)))
if lockedIndex >= 0 && lockedIndex < len(RealisticProfiles) {
profileIdx = lockedIndex
}
profile := RealisticProfiles[profileIdx]

// 确定性选择版本组合(使用不同的 hash 位段避免关联)
seed2 := binary.BigEndian.Uint64(h[8:16])
cliVersion := profile.CLIVersions[int(seed2%uint64(len(profile.CLIVersions)))]

seed3 := binary.BigEndian.Uint64(h[16:24])
pkgVersion := profile.PackageVersions[int(seed3%uint64(len(profile.PackageVersions)))]

seed4 := binary.BigEndian.Uint64(h[24:32])
rtVersion := profile.RuntimeVersions[int(seed4%uint64(len(profile.RuntimeVersions)))]

return SelectedFingerprint{
ProfileIndex: profileIdx,
Profile: profile,
CLIVersion: cliVersion,
PackageVersion: pkgVersion,
RuntimeVersion: rtVersion,
UserAgent: fmt.Sprintf("claude-cli/%s (external, cli)", cliVersion),
}
}

// Model 表示一个 Claude 模型
type Model struct {
ID string `json:"id"`
Expand Down
78 changes: 78 additions & 0 deletions backend/internal/pkg/claude/constants_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package claude

import (
"testing"
)

func TestSelectProfileForAccount_Deterministic(t *testing.T) {
// Same accountID always produces same result
r1 := SelectProfileForAccount(42, -1)
r2 := SelectProfileForAccount(42, -1)

if r1.UserAgent != r2.UserAgent {
t.Errorf("UserAgent not deterministic: %q != %q", r1.UserAgent, r2.UserAgent)
}
if r1.Profile.OS != r2.Profile.OS {
t.Errorf("OS not deterministic: %q != %q", r1.Profile.OS, r2.Profile.OS)
}
if r1.CLIVersion != r2.CLIVersion {
t.Errorf("CLIVersion not deterministic: %q != %q", r1.CLIVersion, r2.CLIVersion)
}
if r1.PackageVersion != r2.PackageVersion {
t.Errorf("PackageVersion not deterministic: %q != %q", r1.PackageVersion, r2.PackageVersion)
}
if r1.RuntimeVersion != r2.RuntimeVersion {
t.Errorf("RuntimeVersion not deterministic: %q != %q", r1.RuntimeVersion, r2.RuntimeVersion)
}
}

func TestSelectProfileForAccount_Diverse(t *testing.T) {
// Different accountIDs should produce at least 2 different OS values across 100 accounts
osSet := make(map[string]struct{})
for i := int64(1); i <= 100; i++ {
sel := SelectProfileForAccount(i, -1)
osSet[sel.Profile.OS] = struct{}{}
}

if len(osSet) < 2 {
t.Errorf("expected at least 2 different OS values across 100 accounts, got %d: %v", len(osSet), osSet)
}
}

func TestSelectProfileForAccount_LockedIndex(t *testing.T) {
// When lockedIndex is provided, profile selection should use that index
sel0 := SelectProfileForAccount(999, 0)
sel4 := SelectProfileForAccount(999, 4)

if sel0.Profile.OS != RealisticProfiles[0].OS {
t.Errorf("lockedIndex=0 should use profile[0], got OS=%q, want %q", sel0.Profile.OS, RealisticProfiles[0].OS)
}
if sel4.Profile.OS != RealisticProfiles[4].OS {
t.Errorf("lockedIndex=4 should use profile[4], got OS=%q, want %q", sel4.Profile.OS, RealisticProfiles[4].OS)
}
}

func TestSelectProfileForAccount_ValidOutput(t *testing.T) {
for i := int64(0); i < 50; i++ {
sel := SelectProfileForAccount(i, -1)

if sel.UserAgent == "" {
t.Errorf("account %d: empty UserAgent", i)
}
if sel.CLIVersion == "" {
t.Errorf("account %d: empty CLIVersion", i)
}
if sel.PackageVersion == "" {
t.Errorf("account %d: empty PackageVersion", i)
}
if sel.RuntimeVersion == "" {
t.Errorf("account %d: empty RuntimeVersion", i)
}
if sel.Profile.OS == "" {
t.Errorf("account %d: empty OS", i)
}
if sel.Profile.Arch == "" {
t.Errorf("account %d: empty Arch", i)
}
}
}
77 changes: 73 additions & 4 deletions backend/internal/service/gateway_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -4166,7 +4166,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A

normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: true}
if s.identityService != nil {
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header)
fp, _, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header, account)
if err == nil && fp != nil {
// metadata 透传开启时跳过 metadata 注入
_, mimicMPT := s.settingService.GetGatewayForwardingSettings(ctx)
Expand Down Expand Up @@ -5720,8 +5720,8 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
enableFP, enableMPT = s.settingService.GetGatewayForwardingSettings(ctx)
}
if account.IsOAuth() && s.identityService != nil {
// 1. 获取或创建指纹(包含随机生成的ClientID
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders)
// 1. 获取或创建指纹(包含持久化的 device_id 和多样化 profile
fp, isNewDevice, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders, account)
if err != nil {
logger.LegacyPrintf("service.gateway", "Warning: failed to get fingerprint for account %d: %v", account.ID, err)
// 失败时降级为透传原始headers
Expand All @@ -5741,6 +5741,11 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
}
}
}

// 3. 首次 device_id 生成时,异步发送 Claude Code 启动探测
if isNewDevice {
go s.simulateStartupProbe(context.Background(), account, fp)
}
}
}

Expand Down Expand Up @@ -6244,6 +6249,70 @@ func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) {
}
}

// simulateStartupProbe 模拟 Claude Code 启动时的连通性探测请求。
// 真实 Claude Code 在启动时发送 max_tokens=1 + haiku 的非流式请求来验证 API 连接。
// 此方法在新 device_id 首次生成后异步调用,让 Anthropic 服务端关联 device_id 与 OAuth token。
func (s *GatewayService) simulateStartupProbe(ctx context.Context, account *Account, fp *Fingerprint) {
if account == nil || fp == nil {
return
}

token, tokenType, err := s.GetAccessToken(ctx, account)
if err != nil {
logger.LegacyPrintf("service.gateway", "Startup probe: failed to get token for account %d: %v", account.ID, err)
return
}

accountUUID := account.GetExtraString("account_uuid")
sessionID := generateRandomUUID()
version := ExtractCLIVersion(fp.UserAgent)
metadataUserID := FormatMetadataUserID(fp.ClientID, accountUUID, sessionID, version)

probeBody := fmt.Sprintf(`{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":[{"role":"user","content":"hi"}],"metadata":{"user_id":%q}}`, metadataUserID)

probeURL := "https://api.anthropic.com/v1/messages?beta=true"
req, err := http.NewRequestWithContext(ctx, "POST", probeURL, strings.NewReader(probeBody))
if err != nil {
logger.LegacyPrintf("service.gateway", "Startup probe: failed to create request for account %d: %v", account.ID, err)
return
}

// 认证头
if tokenType == "oauth" {
setHeaderRaw(req.Header, "authorization", "Bearer "+token)
} else {
setHeaderRaw(req.Header, "x-api-key", token)
}

// 基础头
setHeaderRaw(req.Header, "content-type", "application/json")
setHeaderRaw(req.Header, "anthropic-version", "2023-06-01")
setHeaderRaw(req.Header, "anthropic-beta", claude.HaikuBetaHeader)
setHeaderRaw(req.Header, "Accept", "application/json")
setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", sessionID)

// 应用指纹头(User-Agent, X-Stainless-*)
s.identityService.ApplyFingerprint(req, fp)

// 补充 DefaultHeaders 中 ApplyFingerprint 未覆盖的静态头
for key, value := range claude.DefaultHeaders {
if getHeaderRaw(req.Header, key) == "" && value != "" {
setHeaderRaw(req.Header, resolveWireCasing(key), value)
}
}

resp, err := s.httpUpstream.Do(req, "", account.ID, account.Concurrency)
if err != nil {
logger.LegacyPrintf("service.gateway", "Startup probe: request failed for account %d: %v", account.ID, err)
return
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()

logger.LegacyPrintf("service.gateway", "Startup probe: completed for account %d, status=%d, device_id=%s",
account.ID, resp.StatusCode, fp.ClientID[:min(16, len(fp.ClientID))]+"...")
}

func truncateForLog(b []byte, maxBytes int) string {
if maxBytes <= 0 {
maxBytes = 2048
Expand Down Expand Up @@ -8459,7 +8528,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
}
var ctFingerprint *Fingerprint
if account.IsOAuth() && s.identityService != nil {
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders)
fp, _, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders, account)
if err == nil {
ctFingerprint = fp
if !ctEnableMPT {
Expand Down
Loading
Loading