Skip to content

Commit a14babd

Browse files
committed
fix: 兼容 Claude Code v2.1.78+ 新 JSON 格式 metadata.user_id
Claude Code v2.1.78 起将 metadata.user_id 从拼接字符串改为 JSON: 旧: user_{hex}_account_{uuid}_session_{uuid} 新: {"device_id":"...","account_uuid":"...","session_id":"..."} 新增集中解析/格式化模块 metadata_userid.go: - ParseMetadataUserID: 自动识别两种格式,提取 DeviceID/AccountUUID/SessionID - FormatMetadataUserID: 根据 UA 版本输出对应格式(>= 2.1.78 输出 JSON) - ExtractCLIVersion: 从 UA 提取版本号,消除与 ClaudeCodeValidator.ExtractVersion 的重复 修改消费者统一使用新模块: - claude_code_validator: 用 ParseMetadataUserID 替代只匹配旧格式的 userIDPattern - identity_service: RewriteUserID/WithMasking 增加 fingerprintUA 参数, 解析用 ParseMetadataUserID,输出用 FormatMetadataUserID(版本感知) - gateway_service: GenerateSessionHash 用 ParseMetadataUserID 提取 session_id, buildOAuthMetadataUserID 用 FormatMetadataUserID 输出版本匹配格式, 两处 RewriteUserIDWithMasking 调用传入 fp.UserAgent - account_test_service: generateSessionString 改用 FormatMetadataUserID, 自动跟随 DefaultHeaders UA 版本 删除三个旧正则: userIDPattern, userIDRegex, sessionIDRegex 统一 hex 匹配为 [a-fA-F0-9],修复旧 userIDRegex 只匹配小写的不一致
1 parent 045cba7 commit a14babd

7 files changed

Lines changed: 343 additions & 52 deletions

File tree

backend/internal/service/account_test_service.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -113,15 +113,18 @@ func (s *AccountTestService) validateUpstreamBaseURL(raw string) (string, error)
113113
return normalized, nil
114114
}
115115

116-
// generateSessionString generates a Claude Code style session string
116+
// generateSessionString generates a Claude Code style session string.
117+
// The output format is determined by the UA version in claude.DefaultHeaders,
118+
// ensuring consistency between the user_id format and the UA sent to upstream.
117119
func generateSessionString() (string, error) {
118-
bytes := make([]byte, 32)
119-
if _, err := rand.Read(bytes); err != nil {
120+
b := make([]byte, 32)
121+
if _, err := rand.Read(b); err != nil {
120122
return "", err
121123
}
122-
hex64 := hex.EncodeToString(bytes)
124+
hex64 := hex.EncodeToString(b)
123125
sessionUUID := uuid.New().String()
124-
return fmt.Sprintf("user_%s_account__session_%s", hex64, sessionUUID), nil
126+
uaVersion := ExtractCLIVersion(claude.DefaultHeaders["User-Agent"])
127+
return FormatMetadataUserID(hex64, "", sessionUUID, uaVersion), nil
125128
}
126129

127130
// createTestPayload creates a Claude Code style test request payload

backend/internal/service/claude_code_validator.go

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,6 @@ var (
2121
// 带捕获组的版本提取正则
2222
claudeCodeUAVersionPattern = regexp.MustCompile(`(?i)^claude-cli/(\d+\.\d+\.\d+)`)
2323

24-
// metadata.user_id 格式: user_{64位hex}_account__session_{uuid}
25-
userIDPattern = regexp.MustCompile(`^user_[a-fA-F0-9]{64}_account__session_[\w-]+$`)
26-
2724
// System prompt 相似度阈值(默认 0.5,和 claude-relay-service 一致)
2825
systemPromptThreshold = 0.5
2926
)
@@ -124,7 +121,7 @@ func (v *ClaudeCodeValidator) Validate(r *http.Request, body map[string]any) boo
124121
return false
125122
}
126123

127-
if !userIDPattern.MatchString(userID) {
124+
if ParseMetadataUserID(userID) == nil {
128125
return false
129126
}
130127

@@ -278,11 +275,7 @@ func SetClaudeCodeClient(ctx context.Context, isClaudeCode bool) context.Context
278275
// ExtractVersion 从 User-Agent 中提取 Claude Code 版本号
279276
// 返回 "2.1.22" 形式的版本号,如果不匹配返回空字符串
280277
func (v *ClaudeCodeValidator) ExtractVersion(ua string) string {
281-
matches := claudeCodeUAVersionPattern.FindStringSubmatch(ua)
282-
if len(matches) >= 2 {
283-
return matches[1]
284-
}
285-
return ""
278+
return ExtractCLIVersion(ua)
286279
}
287280

288281
// SetClaudeCodeVersion 将 Claude Code 版本号设置到 context 中

backend/internal/service/gateway_service.go

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,6 @@ func isClaudeCodeCredentialScopeError(msg string) bool {
326326
// Some upstream APIs return non-standard "data:" without space (should be "data: ").
327327
var (
328328
sseDataRe = regexp.MustCompile(`^data:\s*`)
329-
sessionIDRegex = regexp.MustCompile(`session_([a-f0-9-]{36})`)
330329
claudeCliUserAgentRe = regexp.MustCompile(`^claude-cli/\d+\.\d+\.\d+`)
331330

332331
// claudeCodePromptPrefixes 用于检测 Claude Code 系统提示词的前缀列表
@@ -644,8 +643,8 @@ func (s *GatewayService) GenerateSessionHash(parsed *ParsedRequest) string {
644643

645644
// 1. 最高优先级:从 metadata.user_id 提取 session_xxx
646645
if parsed.MetadataUserID != "" {
647-
if match := sessionIDRegex.FindStringSubmatch(parsed.MetadataUserID); len(match) > 1 {
648-
return match[1]
646+
if uid := ParseMetadataUserID(parsed.MetadataUserID); uid != nil && uid.SessionID != "" {
647+
return uid.SessionID
649648
}
650649
}
651650

@@ -1026,13 +1025,13 @@ func (s *GatewayService) buildOAuthMetadataUserID(parsed *ParsedRequest, account
10261025
sessionID = generateSessionUUID(seed)
10271026
}
10281027

1029-
// Prefer the newer format that includes account_uuid (if present),
1030-
// otherwise fall back to the legacy Claude Code format.
1031-
accountUUID := strings.TrimSpace(account.GetExtraString("account_uuid"))
1032-
if accountUUID != "" {
1033-
return fmt.Sprintf("user_%s_account_%s_session_%s", userID, accountUUID, sessionID)
1028+
// 根据指纹 UA 版本选择输出格式
1029+
var uaVersion string
1030+
if fp != nil {
1031+
uaVersion = ExtractCLIVersion(fp.UserAgent)
10341032
}
1035-
return fmt.Sprintf("user_%s_account__session_%s", userID, sessionID)
1033+
accountUUID := strings.TrimSpace(account.GetExtraString("account_uuid"))
1034+
return FormatMetadataUserID(userID, accountUUID, sessionID, uaVersion)
10361035
}
10371036

10381037
// GenerateSessionUUID creates a deterministic UUID4 from a seed string.
@@ -5533,7 +5532,7 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
55335532
// 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值
55345533
accountUUID := account.GetExtraString("account_uuid")
55355534
if accountUUID != "" && fp.ClientID != "" {
5536-
if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID); err == nil && len(newBody) > 0 {
5535+
if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 {
55375536
body = newBody
55385537
}
55395538
}
@@ -8161,7 +8160,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
81618160
if err == nil {
81628161
accountUUID := account.GetExtraString("account_uuid")
81638162
if accountUUID != "" && fp.ClientID != "" {
8164-
if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID); err == nil && len(newBody) > 0 {
8163+
if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 {
81658164
body = newBody
81668165
}
81678166
}

backend/internal/service/generate_session_hash_test.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ func TestGenerateSessionHash_MetadataHasHighestPriority(t *testing.T) {
2424
svc := &GatewayService{}
2525

2626
parsed := &ParsedRequest{
27-
MetadataUserID: "session_123e4567-e89b-12d3-a456-426614174000",
27+
MetadataUserID: "user_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2_account__session_123e4567-e89b-12d3-a456-426614174000",
2828
System: "You are a helpful assistant.",
2929
HasSystem: true,
3030
Messages: []any{
@@ -196,7 +196,7 @@ func TestGenerateSessionHash_MetadataOverridesSessionContext(t *testing.T) {
196196
svc := &GatewayService{}
197197

198198
parsed := &ParsedRequest{
199-
MetadataUserID: "session_123e4567-e89b-12d3-a456-426614174000",
199+
MetadataUserID: "user_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2_account__session_123e4567-e89b-12d3-a456-426614174000",
200200
Messages: []any{
201201
map[string]any{"role": "user", "content": "hello"},
202202
},
@@ -212,6 +212,22 @@ func TestGenerateSessionHash_MetadataOverridesSessionContext(t *testing.T) {
212212
"metadata session_id should take priority over SessionContext")
213213
}
214214

215+
func TestGenerateSessionHash_MetadataJSON_HasHighestPriority(t *testing.T) {
216+
svc := &GatewayService{}
217+
218+
parsed := &ParsedRequest{
219+
MetadataUserID: `{"device_id":"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2","account_uuid":"","session_id":"c72554f2-1234-5678-abcd-123456789abc"}`,
220+
System: "You are a helpful assistant.",
221+
HasSystem: true,
222+
Messages: []any{
223+
map[string]any{"role": "user", "content": "hello"},
224+
},
225+
}
226+
227+
hash := svc.GenerateSessionHash(parsed)
228+
require.Equal(t, "c72554f2-1234-5678-abcd-123456789abc", hash, "JSON format metadata session_id should have highest priority")
229+
}
230+
215231
func TestGenerateSessionHash_NilSessionContextBackwardCompatible(t *testing.T) {
216232
svc := &GatewayService{}
217233

backend/internal/service/identity_service.go

Lines changed: 18 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,6 @@ import (
1919

2020
// 预编译正则表达式(避免每次调用重新编译)
2121
var (
22-
// 匹配 user_id 格式:
23-
// 旧格式: user_{64位hex}_account__session_{uuid} (account 后无 UUID)
24-
// 新格式: user_{64位hex}_account_{uuid}_session_{uuid} (account 后有 UUID)
25-
userIDRegex = regexp.MustCompile(`^user_[a-f0-9]{64}_account_([a-f0-9-]*)_session_([a-f0-9-]{36})$`)
2622
// 匹配 User-Agent 版本号: xxx/x.y.z
2723
userAgentVersionRegex = regexp.MustCompile(`/(\d+)\.(\d+)\.(\d+)`)
2824
)
@@ -209,12 +205,12 @@ func (s *IdentityService) ApplyFingerprint(req *http.Request, fp *Fingerprint) {
209205
}
210206

211207
// RewriteUserID 重写body中的metadata.user_id
212-
// 输入格式:user_{clientId}_account__session_{sessionUUID}
213-
// 输出格式:user_{cachedClientID}_account_{accountUUID}_session_{newHash}
208+
// 支持旧拼接格式和新 JSON 格式的 user_id 解析,
209+
// 根据 fingerprintUA 版本选择输出格式。
214210
//
215211
// 重要:此函数使用 json.RawMessage 保留其他字段的原始字节,
216212
// 避免重新序列化导致 thinking 块等内容被修改。
217-
func (s *IdentityService) RewriteUserID(body []byte, accountID int64, accountUUID, cachedClientID string) ([]byte, error) {
213+
func (s *IdentityService) RewriteUserID(body []byte, accountID int64, accountUUID, cachedClientID, fingerprintUA string) ([]byte, error) {
218214
if len(body) == 0 || accountUUID == "" || cachedClientID == "" {
219215
return body, nil
220216
}
@@ -241,24 +237,21 @@ func (s *IdentityService) RewriteUserID(body []byte, accountID int64, accountUUI
241237
return body, nil
242238
}
243239

244-
// 匹配格式:
245-
// 旧格式: user_{64位hex}_account__session_{uuid}
246-
// 新格式: user_{64位hex}_account_{uuid}_session_{uuid}
247-
matches := userIDRegex.FindStringSubmatch(userID)
248-
if matches == nil {
240+
// 解析 user_id(兼容旧拼接格式和新 JSON 格式)
241+
parsed := ParseMetadataUserID(userID)
242+
if parsed == nil {
249243
return body, nil
250244
}
251245

252-
// matches[1] = account UUID (可能为空), matches[2] = session UUID
253-
sessionTail := matches[2] // 原始session UUID
246+
sessionTail := parsed.SessionID // 原始session UUID
254247

255248
// 生成新的session hash: SHA256(accountID::sessionTail) -> UUID格式
256249
seed := fmt.Sprintf("%d::%s", accountID, sessionTail)
257250
newSessionHash := generateUUIDFromSeed(seed)
258251

259-
// 构建新的user_id
260-
// 格式: user_{cachedClientID}_account_{account_uuid}_session_{newSessionHash}
261-
newUserID := fmt.Sprintf("user_%s_account_%s_session_%s", cachedClientID, accountUUID, newSessionHash)
252+
// 根据客户端版本选择输出格式
253+
version := ExtractCLIVersion(fingerprintUA)
254+
newUserID := FormatMetadataUserID(cachedClientID, accountUUID, newSessionHash, version)
262255

263256
metadata["user_id"] = newUserID
264257

@@ -278,9 +271,9 @@ func (s *IdentityService) RewriteUserID(body []byte, accountID int64, accountUUI
278271
//
279272
// 重要:此函数使用 json.RawMessage 保留其他字段的原始字节,
280273
// 避免重新序列化导致 thinking 块等内容被修改。
281-
func (s *IdentityService) RewriteUserIDWithMasking(ctx context.Context, body []byte, account *Account, accountUUID, cachedClientID string) ([]byte, error) {
274+
func (s *IdentityService) RewriteUserIDWithMasking(ctx context.Context, body []byte, account *Account, accountUUID, cachedClientID, fingerprintUA string) ([]byte, error) {
282275
// 先执行常规的 RewriteUserID 逻辑
283-
newBody, err := s.RewriteUserID(body, account.ID, accountUUID, cachedClientID)
276+
newBody, err := s.RewriteUserID(body, account.ID, accountUUID, cachedClientID, fingerprintUA)
284277
if err != nil {
285278
return newBody, err
286279
}
@@ -312,10 +305,9 @@ func (s *IdentityService) RewriteUserIDWithMasking(ctx context.Context, body []b
312305
return newBody, nil
313306
}
314307

315-
// 查找 _session_ 的位置,替换其后的内容
316-
const sessionMarker = "_session_"
317-
idx := strings.LastIndex(userID, sessionMarker)
318-
if idx == -1 {
308+
// 解析已重写的 user_id
309+
uidParsed := ParseMetadataUserID(userID)
310+
if uidParsed == nil {
319311
return newBody, nil
320312
}
321313

@@ -337,8 +329,9 @@ func (s *IdentityService) RewriteUserIDWithMasking(ctx context.Context, body []b
337329
logger.LegacyPrintf("service.identity", "Warning: failed to set masked session ID for account %d: %v", account.ID, err)
338330
}
339331

340-
// 替换 session 部分:保留 _session_ 之前的内容,替换之后的内容
341-
newUserID := userID[:idx+len(sessionMarker)] + maskedSessionID
332+
// 用 FormatMetadataUserID 重建(保持与 RewriteUserID 相同的格式)
333+
version := ExtractCLIVersion(fingerprintUA)
334+
newUserID := FormatMetadataUserID(uidParsed.DeviceID, uidParsed.AccountUUID, maskedSessionID, version)
342335

343336
slog.Debug("session_id_masking_applied",
344337
"account_id", account.ID,
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package service
2+
3+
import (
4+
"encoding/json"
5+
"regexp"
6+
"strings"
7+
)
8+
9+
// NewMetadataFormatMinVersion is the minimum Claude Code version that uses
10+
// JSON-formatted metadata.user_id instead of the legacy concatenated string.
11+
const NewMetadataFormatMinVersion = "2.1.78"
12+
13+
// ParsedUserID represents the components extracted from a metadata.user_id value.
14+
type ParsedUserID struct {
15+
DeviceID string // 64-char hex (or arbitrary client id)
16+
AccountUUID string // may be empty
17+
SessionID string // UUID
18+
IsNewFormat bool // true if the original was JSON format
19+
}
20+
21+
// legacyUserIDRegex matches the legacy user_id format:
22+
//
23+
// user_{64hex}_account_{optional_uuid}_session_{uuid}
24+
var legacyUserIDRegex = regexp.MustCompile(`^user_([a-fA-F0-9]{64})_account_([a-fA-F0-9-]*)_session_([a-fA-F0-9-]{36})$`)
25+
26+
// jsonUserID is the JSON structure for the new metadata.user_id format.
27+
type jsonUserID struct {
28+
DeviceID string `json:"device_id"`
29+
AccountUUID string `json:"account_uuid"`
30+
SessionID string `json:"session_id"`
31+
}
32+
33+
// ParseMetadataUserID parses a metadata.user_id string in either format.
34+
// Returns nil if the input cannot be parsed.
35+
func ParseMetadataUserID(raw string) *ParsedUserID {
36+
raw = strings.TrimSpace(raw)
37+
if raw == "" {
38+
return nil
39+
}
40+
41+
// Try JSON format first (starts with '{')
42+
if raw[0] == '{' {
43+
var j jsonUserID
44+
if err := json.Unmarshal([]byte(raw), &j); err != nil {
45+
return nil
46+
}
47+
if j.DeviceID == "" || j.SessionID == "" {
48+
return nil
49+
}
50+
return &ParsedUserID{
51+
DeviceID: j.DeviceID,
52+
AccountUUID: j.AccountUUID,
53+
SessionID: j.SessionID,
54+
IsNewFormat: true,
55+
}
56+
}
57+
58+
// Try legacy format
59+
matches := legacyUserIDRegex.FindStringSubmatch(raw)
60+
if matches == nil {
61+
return nil
62+
}
63+
return &ParsedUserID{
64+
DeviceID: matches[1],
65+
AccountUUID: matches[2],
66+
SessionID: matches[3],
67+
IsNewFormat: false,
68+
}
69+
}
70+
71+
// FormatMetadataUserID builds a metadata.user_id string in the format
72+
// appropriate for the given CLI version. Components are the rewritten values
73+
// (not necessarily the originals).
74+
func FormatMetadataUserID(deviceID, accountUUID, sessionID, uaVersion string) string {
75+
if IsNewMetadataFormatVersion(uaVersion) {
76+
b, _ := json.Marshal(jsonUserID{
77+
DeviceID: deviceID,
78+
AccountUUID: accountUUID,
79+
SessionID: sessionID,
80+
})
81+
return string(b)
82+
}
83+
// Legacy format
84+
return "user_" + deviceID + "_account_" + accountUUID + "_session_" + sessionID
85+
}
86+
87+
// IsNewMetadataFormatVersion returns true if the given CLI version uses the
88+
// new JSON metadata.user_id format (>= 2.1.78).
89+
func IsNewMetadataFormatVersion(version string) bool {
90+
if version == "" {
91+
return false
92+
}
93+
return CompareVersions(version, NewMetadataFormatMinVersion) >= 0
94+
}
95+
96+
// ExtractCLIVersion extracts the Claude Code version from a User-Agent string.
97+
// Returns "" if the UA doesn't match the expected pattern.
98+
func ExtractCLIVersion(ua string) string {
99+
matches := claudeCodeUAVersionPattern.FindStringSubmatch(ua)
100+
if len(matches) >= 2 {
101+
return matches[1]
102+
}
103+
return ""
104+
}

0 commit comments

Comments
 (0)