diff --git a/option/ccm.go b/option/ccm.go index 96200248a..481068e61 100644 --- a/option/ccm.go +++ b/option/ccm.go @@ -76,14 +76,14 @@ 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"` - ReserveWeekly uint8 `json:"reserve_weekly"` - Limit5h uint8 `json:"limit_5h,omitempty"` - LimitWeekly uint8 `json:"limit_weekly,omitempty"` + CredentialPath string `json:"credential_path,omitempty"` + ClaudeDirectory string `json:"claude_directory,omitempty"` + UsagesPath string `json:"usages_path,omitempty"` + Detour string `json:"detour,omitempty"` + Reserve5h uint8 `json:"reserve_5h"` + ReserveWeekly uint8 `json:"reserve_weekly"` + Limit5h uint8 `json:"limit_5h,omitempty"` + LimitWeekly uint8 `json:"limit_weekly,omitempty"` } type CCMBalancerCredentialOptions struct { diff --git a/service/ccm/credential_config_file.go b/service/ccm/credential_config_file.go new file mode 100644 index 000000000..d1b66d2e3 --- /dev/null +++ b/service/ccm/credential_config_file.go @@ -0,0 +1,59 @@ +package ccm + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// claudeCodeConfig represents the persisted config written by Claude Code. +// +// ref (@anthropic-ai/claude-code @2.1.81): +// +// ref: cli.js P8() (line 174997) — reads config +// ref: cli.js c8() (line 174919) — writes config +// ref: cli.js _D() (line 39158-39163) — config file path resolution +type claudeCodeConfig struct { + UserID string `json:"userID"` // ref: cli.js XL() (line 175325) — random 32-byte hex, generated once + OAuthAccount *claudeOAuthAccount `json:"oauthAccount"` // ref: cli.js fP6() / storeOAuthAccountInfo — from /api/oauth/profile +} + +type claudeOAuthAccount struct { + AccountUUID string `json:"accountUuid"` +} + +// resolveClaudeConfigFile finds the Claude Code config file within the given directory. +// +// Config file path resolution mirrors cli.js _D() (line 39158-39163): +// 1. claudeDirectory/.config.json — newer format, checked first +// 2. claudeDirectory/.claude.json — used when CLAUDE_CONFIG_DIR is set +// 3. filepath.Dir(claudeDirectory)/.claude.json — default ~/.claude case → ~/.claude.json +// +// Returns the first path that exists, or "" if none found. +func resolveClaudeConfigFile(claudeDirectory string) string { + candidates := []string{ + filepath.Join(claudeDirectory, ".config.json"), + filepath.Join(claudeDirectory, ".claude.json"), + filepath.Join(filepath.Dir(claudeDirectory), ".claude.json"), + } + for _, candidate := range candidates { + _, err := os.Stat(candidate) + if err == nil { + return candidate + } + } + return "" +} + +func readClaudeCodeConfig(path string) (*claudeCodeConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var config claudeCodeConfig + err = json.Unmarshal(data, &config) + if err != nil { + return nil, err + } + return &config, nil +} diff --git a/service/ccm/credential_default.go b/service/ccm/credential_default.go index 4c571a38e..2172f06c5 100644 --- a/service/ccm/credential_default.go +++ b/service/ccm/credential_default.go @@ -9,6 +9,7 @@ import ( "math" "net" "net/http" + "path/filepath" "slices" "strconv" "sync" @@ -29,9 +30,11 @@ type defaultCredential struct { tag string serviceContext context.Context credentialPath string + claudeDirectory string credentialFilePath string configDir string - statePath string + deviceID string + configLoaded bool credentials *oauthCredentials access sync.RWMutex state credentialState @@ -109,7 +112,7 @@ func newDefaultCredential(ctx context.Context, tag string, options option.CCMDef tag: tag, serviceContext: ctx, credentialPath: options.CredentialPath, - statePath: options.StatePath, + claudeDirectory: options.ClaudeDirectory, cap5h: cap5h, capWeekly: capWeekly, forwardHTTPClient: httpClient, @@ -129,13 +132,18 @@ func newDefaultCredential(ctx context.Context, tag string, options option.CCMDef } func (c *defaultCredential) start() error { + if c.claudeDirectory != "" { + c.loadClaudeCodeConfig() + if c.credentialPath == "" { + c.credentialPath = filepath.Join(c.claudeDirectory, ".credentials.json") + } + } credentialFilePath, err := resolveCredentialFilePath(c.credentialPath) if err != nil { return E.Cause(err, "resolve credential path for ", c.tag) } c.credentialFilePath = credentialFilePath c.configDir = resolveConfigDir(c.credentialPath, credentialFilePath) - c.loadPersistedState() err = c.ensureCredentialWatcher() if err != nil { c.logger.Debug("start credential watcher for ", c.tag, ": ", err) @@ -154,6 +162,28 @@ func (c *defaultCredential) start() error { return nil } +func (c *defaultCredential) loadClaudeCodeConfig() { + configFilePath := resolveClaudeConfigFile(c.claudeDirectory) + if configFilePath == "" { + return + } + config, err := readClaudeCodeConfig(configFilePath) + if err != nil { + c.logger.Warn("read claude code config for ", c.tag, ": ", err) + return + } + c.stateAccess.Lock() + if config.OAuthAccount != nil && config.OAuthAccount.AccountUUID != "" { + c.state.accountUUID = config.OAuthAccount.AccountUUID + } + c.stateAccess.Unlock() + if config.UserID != "" { + c.deviceID = config.UserID + } + c.configLoaded = true + c.logger.Debug("loaded claude code config for ", c.tag, ": account=", c.state.accountUUID, ", device=", c.deviceID) +} + func (c *defaultCredential) setStatusSubscriber(subscriber *observable.Subscriber[struct{}]) { c.statusSubscriber = subscriber } @@ -697,7 +727,7 @@ func (c *defaultCredential) pollUsage() { } c.logger.Debug("poll usage for ", c.tag, ": 5h=", c.state.fiveHourUtilization, "%, weekly=", c.state.weeklyUtilization, "%", resetSuffix) } - needsProfileFetch := c.state.rateLimitTier == "" + needsProfileFetch := !c.configLoaded && c.state.rateLimitTier == "" shouldInterrupt := c.checkTransitionLocked() c.stateAccess.Unlock() if shouldInterrupt { @@ -782,7 +812,6 @@ 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)) } @@ -841,7 +870,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) + bodyBytes = c.injectMetadataFields(bodyBytes) body = bytes.NewReader(bodyBytes) } else { body = original.Body @@ -878,24 +907,20 @@ 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). +// injectMetadataFields fills in account_uuid and device_id in metadata.user_id +// when the client sends them 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 { +func (c *defaultCredential) injectMetadataFields(bodyBytes []byte) []byte { c.stateAccess.RLock() accountUUID := c.state.accountUUID c.stateAccess.RUnlock() - if accountUUID == "" { + deviceID := c.deviceID + if accountUUID == "" && deviceID == "" { return bodyBytes } @@ -931,19 +956,43 @@ func (c *defaultCredential) injectAccountUUID(bodyBytes []byte) []byte { return bodyBytes } - existingRaw, hasExisting := userIDObject["account_uuid"] - if hasExisting { - var existing string - if json.Unmarshal(existingRaw, &existing) == nil && existing != "" { - return bodyBytes + modified := false + + if accountUUID != "" { + existingRaw, hasExisting := userIDObject["account_uuid"] + needsInject := !hasExisting + if hasExisting { + var existing string + needsInject = json.Unmarshal(existingRaw, &existing) != nil || existing == "" + } + if needsInject { + accountUUIDJSON, marshalErr := json.Marshal(accountUUID) + if marshalErr == nil { + userIDObject["account_uuid"] = json.RawMessage(accountUUIDJSON) + modified = true + } } } - accountUUIDJSON, err := json.Marshal(accountUUID) - if err != nil { + if deviceID != "" { + existingRaw, hasExisting := userIDObject["device_id"] + needsInject := !hasExisting + if hasExisting { + var existing string + needsInject = json.Unmarshal(existingRaw, &existing) != nil || existing == "" + } + if needsInject { + deviceIDJSON, marshalErr := json.Marshal(deviceID) + if marshalErr == nil { + userIDObject["device_id"] = json.RawMessage(deviceIDJSON) + modified = true + } + } + } + + if !modified { return bodyBytes } - userIDObject["account_uuid"] = json.RawMessage(accountUUIDJSON) newUserIDBytes, err := json.Marshal(userIDObject) if err != nil { diff --git a/service/ccm/credential_state_file.go b/service/ccm/credential_state_file.go deleted file mode 100644 index 630e26188..000000000 --- a/service/ccm/credential_state_file.go +++ /dev/null @@ -1,64 +0,0 @@ -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) -}