From 53f832330dded92fc1a552970f9436c09ad80e9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 21 Mar 2026 10:45:24 +0800 Subject: [PATCH] fix(ccm): adapt to Claude Code v2.1.78 metadata format, separate state from credentials Claude Code v2.1.78 changed metadata.user_id from a template literal (`user_${id}_account_${uuid}_session_${sid}`) to a JSON-encoded object (`JSON.stringify({device_id, account_uuid, session_id})`), breaking session ID extraction via `_session_` substring match. - Fix extractCCMSessionID to try JSON parse first, fallback to legacy - Remove subscriptionType/rateLimitTier/isMax from oauthCredentials (profile state does not belong in auth credentials) - Add state_path option for persisting profile state across restarts - Parse account.uuid from /api/oauth/profile response - Inject account_uuid into forwarded requests when client sends it empty (happens when using ANTHROPIC_AUTH_TOKEN instead of Claude AI OAuth) --- option/ccm.go | 1 + service/ccm/credential.go | 1 + service/ccm/credential_default.go | 77 ++++++++++++++++++++++++++-- service/ccm/credential_file.go | 2 - service/ccm/credential_oauth.go | 24 +++++---- service/ccm/credential_state_file.go | 64 +++++++++++++++++++++++ service/ccm/service_handler.go | 23 +++++++++ 7 files changed, 175 insertions(+), 17 deletions(-) create mode 100644 service/ccm/credential_state_file.go diff --git a/option/ccm.go b/option/ccm.go index 7a4f0709f..96200248a 100644 --- a/option/ccm.go +++ b/option/ccm.go @@ -77,6 +77,7 @@ func (c *CCMCredential) UnmarshalJSON(bytes []byte) error { type CCMDefaultCredentialOptions struct { CredentialPath string `json:"credential_path,omitempty"` + StatePath string `json:"state_path,omitempty"` UsagesPath string `json:"usages_path,omitempty"` Detour string `json:"detour,omitempty"` Reserve5h uint8 `json:"reserve_5h"` diff --git a/service/ccm/credential.go b/service/ccm/credential.go index ed2c713d3..f2d4003f1 100644 --- a/service/ccm/credential.go +++ b/service/ccm/credential.go @@ -59,6 +59,7 @@ type credentialState struct { weeklyReset time.Time hardRateLimited bool rateLimitResetAt time.Time + accountUUID string accountType string rateLimitTier string remotePlanWeight float64 diff --git a/service/ccm/credential_default.go b/service/ccm/credential_default.go index 68a37f122..9c19a2cc9 100644 --- a/service/ccm/credential_default.go +++ b/service/ccm/credential_default.go @@ -29,6 +29,7 @@ type defaultCredential struct { serviceContext context.Context credentialPath string credentialFilePath string + statePath string credentials *oauthCredentials access sync.RWMutex state credentialState @@ -106,6 +107,7 @@ func newDefaultCredential(ctx context.Context, tag string, options option.CCMDef tag: tag, serviceContext: ctx, credentialPath: options.CredentialPath, + statePath: options.StatePath, cap5h: cap5h, capWeekly: capWeekly, forwardHTTPClient: httpClient, @@ -130,6 +132,7 @@ func (c *defaultCredential) start() error { return E.Cause(err, "resolve credential path for ", c.tag) } c.credentialFilePath = credentialFilePath + c.loadPersistedState() err = c.ensureCredentialWatcher() if err != nil { c.logger.Debug("start credential watcher for ", c.tag, ": ", err) @@ -238,8 +241,6 @@ func (c *defaultCredential) getAccessToken() (string, error) { c.state.unavailable = false c.state.lastCredentialLoadAttempt = time.Now() c.state.lastCredentialLoadError = "" - c.state.accountType = latestCredentials.SubscriptionType - c.state.rateLimitTier = latestCredentials.RateLimitTier c.checkTransitionLocked() shouldEmit := before != c.statusSnapshotLocked() c.stateAccess.Unlock() @@ -258,8 +259,6 @@ func (c *defaultCredential) getAccessToken() (string, error) { c.state.unavailable = false c.state.lastCredentialLoadAttempt = time.Now() c.state.lastCredentialLoadError = "" - c.state.accountType = newCredentials.SubscriptionType - c.state.rateLimitTier = newCredentials.RateLimitTier c.checkTransitionLocked() shouldEmit := before != c.statusSnapshotLocked() c.stateAccess.Unlock() @@ -663,6 +662,12 @@ func (c *defaultCredential) pollUsage() { } } +// fetchProfile calls GET /api/oauth/profile to retrieve account and organization info. +// Same endpoint used by Claude Code (@anthropic-ai/claude-code @2.1.81): +// +// ref: cli.js GB() — fetches profile +// ref: cli.js AH8() / fetchProfileInfo — parses organization_type, rate_limit_tier +// ref: cli.js EX1() / populateOAuthAccountInfoIfNeeded — stores account.uuid func (c *defaultCredential) fetchProfile(httpClient *http.Client, accessToken string) { ctx := c.serviceContext response, err := doHTTPWithRetry(ctx, httpClient, func() (*http.Request, error) { @@ -686,6 +691,9 @@ func (c *defaultCredential) fetchProfile(httpClient *http.Client, accessToken st } var profileResponse struct { + Account *struct { + UUID string `json:"uuid"` + } `json:"account"` Organization *struct { OrganizationType string `json:"organization_type"` RateLimitTier string `json:"rate_limit_tier"` @@ -711,6 +719,9 @@ func (c *defaultCredential) fetchProfile(httpClient *http.Client, accessToken st c.stateAccess.Lock() before := c.statusSnapshotLocked() + if profileResponse.Account != nil && profileResponse.Account.UUID != "" { + c.state.accountUUID = profileResponse.Account.UUID + } if accountType != "" && c.state.accountType == "" { c.state.accountType = accountType } @@ -723,6 +734,7 @@ func (c *defaultCredential) fetchProfile(httpClient *http.Client, accessToken st if shouldEmit { c.emitStatusUpdate() } + c.savePersistedState() c.logger.Info("fetched profile for ", c.tag, ": type=", resolvedAccountType, ", tier=", rateLimitTier, ", weight=", ccmPlanWeight(resolvedAccountType, rateLimitTier)) } @@ -781,6 +793,7 @@ func (c *defaultCredential) buildProxyRequest(ctx context.Context, original *htt proxyURL := claudeAPIBaseURL + original.URL.RequestURI() var body io.Reader if bodyBytes != nil { + bodyBytes = c.injectAccountUUID(bodyBytes) body = bytes.NewReader(bodyBytes) } else { body = original.Body @@ -816,3 +829,59 @@ func (c *defaultCredential) buildProxyRequest(ctx context.Context, original *htt return proxyRequest, nil } + +// injectAccountUUID fills in the account_uuid field in metadata.user_id +// when the client sends it empty (e.g. using ANTHROPIC_AUTH_TOKEN). +// +// Claude Code >= 2.1.78 (@anthropic-ai/claude-code) sets metadata as: +// +// {user_id: JSON.stringify({device_id, account_uuid, session_id})} +// +// ref: cli.js L66() — metadata constructor +// +// account_uuid is populated from oauthAccount.accountUuid which comes from +// the /api/oauth/profile endpoint (ref: cli.js EX1() → fP6()). +// When the client uses ANTHROPIC_AUTH_TOKEN instead of Claude AI OAuth, +// account_uuid is empty. We inject it from the fetchProfile result. +func (c *defaultCredential) injectAccountUUID(bodyBytes []byte) []byte { + c.stateAccess.RLock() + accountUUID := c.state.accountUUID + c.stateAccess.RUnlock() + if accountUUID == "" { + return bodyBytes + } + + var body struct { + Metadata struct { + UserID string `json:"user_id"` + } `json:"metadata"` + } + if json.Unmarshal(bodyBytes, &body) != nil || body.Metadata.UserID == "" { + return bodyBytes + } + + var userIDObject map[string]any + if json.Unmarshal([]byte(body.Metadata.UserID), &userIDObject) != nil { + return bodyBytes + } + existing, _ := userIDObject["account_uuid"].(string) + if existing != "" { + return bodyBytes + } + userIDObject["account_uuid"] = accountUUID + newUserID, err := json.Marshal(userIDObject) + if err != nil { + return bodyBytes + } + + newUserIDStr := string(newUserID) + oldUserIDJSON, err := json.Marshal(body.Metadata.UserID) + if err != nil { + return bodyBytes + } + newUserIDJSON, err := json.Marshal(newUserIDStr) + if err != nil { + return bodyBytes + } + return bytes.Replace(bodyBytes, oldUserIDJSON, newUserIDJSON, 1) +} diff --git a/service/ccm/credential_file.go b/service/ccm/credential_file.go index 3f67eaf13..23e71d544 100644 --- a/service/ccm/credential_file.go +++ b/service/ccm/credential_file.go @@ -117,8 +117,6 @@ func (c *defaultCredential) reloadCredentials(force bool) error { before := c.statusSnapshotLocked() c.state.unavailable = false c.state.lastCredentialLoadError = "" - c.state.accountType = credentials.SubscriptionType - c.state.rateLimitTier = credentials.RateLimitTier c.checkTransitionLocked() shouldEmit := before != c.statusSnapshotLocked() c.stateAccess.Unlock() diff --git a/service/ccm/credential_oauth.go b/service/ccm/credential_oauth.go index 114f87d3e..9d0b5146e 100644 --- a/service/ccm/credential_oauth.go +++ b/service/ccm/credential_oauth.go @@ -128,14 +128,19 @@ func writeCredentialsToFile(oauthCredentials *oauthCredentials, path string) err return os.WriteFile(path, data, 0o600) } +// oauthCredentials mirrors the claudeAiOauth object in Claude Code's +// credential file ($CLAUDE_CONFIG_DIR/.credentials.json). +// +// ref (@anthropic-ai/claude-code @2.1.81): cli.js mB6() / refreshOAuthToken +// +// Note: subscriptionType, rateLimitTier, and isMax were removed from this +// struct — they are profile state, not auth credentials. Claude Code also +// stores them here, but we persist them separately via state_path instead. type oauthCredentials struct { - AccessToken string `json:"accessToken"` - RefreshToken string `json:"refreshToken"` - ExpiresAt int64 `json:"expiresAt"` - Scopes []string `json:"scopes,omitempty"` - SubscriptionType string `json:"subscriptionType,omitempty"` - RateLimitTier string `json:"rateLimitTier,omitempty"` - IsMax bool `json:"isMax,omitempty"` + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresAt int64 `json:"expiresAt"` + Scopes []string `json:"scopes,omitempty"` } func (c *oauthCredentials) needsRefresh() bool { @@ -225,8 +230,5 @@ func credentialsEqual(left *oauthCredentials, right *oauthCredentials) bool { return left.AccessToken == right.AccessToken && left.RefreshToken == right.RefreshToken && left.ExpiresAt == right.ExpiresAt && - slices.Equal(left.Scopes, right.Scopes) && - left.SubscriptionType == right.SubscriptionType && - left.RateLimitTier == right.RateLimitTier && - left.IsMax == right.IsMax + slices.Equal(left.Scopes, right.Scopes) } diff --git a/service/ccm/credential_state_file.go b/service/ccm/credential_state_file.go new file mode 100644 index 000000000..630e26188 --- /dev/null +++ b/service/ccm/credential_state_file.go @@ -0,0 +1,64 @@ +package ccm + +import ( + "encoding/json" + "os" +) + +// persistedState holds profile data fetched from /api/oauth/profile, +// persisted to state_path so it survives restarts without re-fetching. +// +// Claude Code (@anthropic-ai/claude-code @2.1.81) stores equivalent data in +// its config file (~/.claude/.config.json) under the oauthAccount key: +// +// ref: cli.js fP6() / storeOAuthAccountInfo — writes accountUuid, billingType, etc. +// ref: cli.js P8() — reads config from $CLAUDE_CONFIG_DIR/.config.json +type persistedState struct { + AccountUUID string `json:"account_uuid,omitempty"` + AccountType string `json:"account_type,omitempty"` + RateLimitTier string `json:"rate_limit_tier,omitempty"` +} + +func (c *defaultCredential) loadPersistedState() { + if c.statePath == "" { + return + } + data, err := os.ReadFile(c.statePath) + if err != nil { + return + } + var state persistedState + err = json.Unmarshal(data, &state) + if err != nil { + return + } + c.stateAccess.Lock() + if state.AccountUUID != "" { + c.state.accountUUID = state.AccountUUID + } + if state.AccountType != "" { + c.state.accountType = state.AccountType + } + if state.RateLimitTier != "" { + c.state.rateLimitTier = state.RateLimitTier + } + c.stateAccess.Unlock() +} + +func (c *defaultCredential) savePersistedState() { + if c.statePath == "" { + return + } + c.stateAccess.RLock() + state := persistedState{ + AccountUUID: c.state.accountUUID, + AccountType: c.state.accountType, + RateLimitTier: c.state.rateLimitTier, + } + c.stateAccess.RUnlock() + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return + } + os.WriteFile(c.statePath, data, 0o600) +} diff --git a/service/ccm/service_handler.go b/service/ccm/service_handler.go index 8e6d65e24..07b0556d4 100644 --- a/service/ccm/service_handler.go +++ b/service/ccm/service_handler.go @@ -69,6 +69,19 @@ func extractWeeklyCycleHint(headers http.Header) *WeeklyCycleHint { } } +// extractCCMSessionID extracts the session ID from the request body's metadata.user_id field. +// +// Claude Code >= 2.1.78 (@anthropic-ai/claude-code) encodes user_id as: +// +// JSON.stringify({device_id, account_uuid, session_id, ...extras}) +// +// ref: cli.js L66() — metadata constructor +// +// Claude Code < 2.1.78 used a template literal: +// +// `user_${deviceId}_account_${accountUuid}_session_${sessionId}` +// +// ref: cli.js qs() — old metadata constructor func extractCCMSessionID(bodyBytes []byte) string { var body struct { Metadata struct { @@ -80,6 +93,16 @@ func extractCCMSessionID(bodyBytes []byte) string { return "" } userID := body.Metadata.UserID + + // v2.1.78+ JSON object format + var userIDObject struct { + SessionID string `json:"session_id"` + } + if json.Unmarshal([]byte(userID), &userIDObject) == nil && userIDObject.SessionID != "" { + return userIDObject.SessionID + } + + // legacy template literal format sessionIndex := strings.LastIndex(userID, "_session_") if sessionIndex < 0 { return ""