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)
This commit is contained in:
世界
2026-03-21 10:45:24 +08:00
parent 99d9e06dd0
commit 53f832330d
7 changed files with 175 additions and 17 deletions

View File

@@ -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"`

View File

@@ -59,6 +59,7 @@ type credentialState struct {
weeklyReset time.Time
hardRateLimited bool
rateLimitResetAt time.Time
accountUUID string
accountType string
rateLimitTier string
remotePlanWeight float64

View File

@@ -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)
}

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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 ""