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:
@@ -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"`
|
||||
|
||||
@@ -59,6 +59,7 @@ type credentialState struct {
|
||||
weeklyReset time.Time
|
||||
hardRateLimited bool
|
||||
rateLimitResetAt time.Time
|
||||
accountUUID string
|
||||
accountType string
|
||||
rateLimitTier string
|
||||
remotePlanWeight float64
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
64
service/ccm/credential_state_file.go
Normal file
64
service/ccm/credential_state_file.go
Normal 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)
|
||||
}
|
||||
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user