@@ -26,11 +26,19 @@ use std::os::windows::process::CommandExt;
2626
2727#[ derive( Debug , Deserialize ) ]
2828struct ProfileResponse {
29+ account : Option < ProfileAccount > ,
2930 organization : Option < ProfileOrg > ,
3031}
3132
33+ #[ derive( Debug , Deserialize ) ]
34+ struct ProfileAccount {
35+ uuid : Option < String > ,
36+ email : Option < String > ,
37+ }
38+
3239#[ derive( Debug , Deserialize ) ]
3340struct ProfileOrg {
41+ uuid : Option < String > ,
3442 rate_limit_tier : Option < String > ,
3543}
3644
@@ -74,6 +82,13 @@ struct RefreshResponse {
7482 expires_in : Option < i64 > ,
7583}
7684
85+ #[ derive( Debug , Clone , Deserialize , Serialize ) ]
86+ struct CachedProfileIdentity {
87+ token_key : String ,
88+ account : AccountIdentity ,
89+ ts : i64 ,
90+ }
91+
7792/// Returns the Claude config root, honoring the `CLAUDE_HOME` env var the same
7893/// way Codex honors `CODEX_HOME`. Defaults to `~/.claude/`. Lets users with
7994/// non-standard installs (relocated home, sandboxed CI runners, etc.) point
@@ -94,6 +109,14 @@ fn credentials_path() -> Result<PathBuf> {
94109 Ok ( claude_home_dir ( ) ?. join ( ".credentials.json" ) )
95110}
96111
112+ fn profile_identity_cache_path ( ) -> Option < PathBuf > {
113+ let mut p = dirs:: cache_dir ( ) ?;
114+ p. push ( "tally" ) ;
115+ let _ = std:: fs:: create_dir_all ( & p) ;
116+ p. push ( "claude-account.json" ) ;
117+ Some ( p)
118+ }
119+
97120fn read_credentials ( ) -> Result < ( PathBuf , CredentialsFile ) > {
98121 let path = credentials_path ( ) ?;
99122 let file = File :: open ( & path) . map_err ( |e| anyhow ! ( "read {}: {e}" , path. display( ) ) ) ?;
@@ -183,9 +206,18 @@ pub(crate) fn read_oauth_token() -> Result<String> {
183206}
184207
185208pub ( crate ) fn active_account_identity ( ) -> Option < AccountIdentity > {
186- if let Some ( identity) = active_auth_status_identity ( ) {
187- return Some ( identity) ;
209+ let token_identity = credential_token_identity ( ) ;
210+ if let Some ( token_id) = token_identity. as_ref ( ) {
211+ if let Some ( profile_id) = read_cached_profile_identity ( token_id) {
212+ return Some ( profile_id) ;
213+ }
214+ return token_identity;
188215 }
216+
217+ active_auth_status_identity ( )
218+ }
219+
220+ fn credential_token_identity ( ) -> Option < AccountIdentity > {
189221 let ( _, creds) = read_credentials ( ) . ok ( ) ?;
190222 crate :: account:: token_identity (
191223 "claude" ,
@@ -194,7 +226,83 @@ pub(crate) fn active_account_identity() -> Option<AccountIdentity> {
194226 )
195227}
196228
197- fn active_auth_status_identity ( ) -> Option < AccountIdentity > {
229+ fn token_account_identity ( token : & str ) -> Option < AccountIdentity > {
230+ crate :: account:: token_identity ( "claude" , token, "claude-oauth-token" )
231+ }
232+
233+ fn read_cached_profile_identity ( token_identity : & AccountIdentity ) -> Option < AccountIdentity > {
234+ let path = profile_identity_cache_path ( ) ?;
235+ let raw = std:: fs:: read_to_string ( path) . ok ( ) ?;
236+ let cached: CachedProfileIdentity = serde_json:: from_str ( & raw ) . ok ( ) ?;
237+ if cached. token_key == token_identity. key {
238+ Some ( cached. account )
239+ } else {
240+ None
241+ }
242+ }
243+
244+ fn write_cached_profile_identity ( token_identity : & AccountIdentity , account : & AccountIdentity ) {
245+ let Some ( path) = profile_identity_cache_path ( ) else {
246+ return ;
247+ } ;
248+ let payload = CachedProfileIdentity {
249+ token_key : token_identity. key . clone ( ) ,
250+ account : account. clone ( ) ,
251+ ts : Utc :: now ( ) . timestamp ( ) ,
252+ } ;
253+ if let Ok ( body) = serde_json:: to_string ( & payload) {
254+ let _ = std:: fs:: write ( path, body) ;
255+ }
256+ }
257+
258+ fn profile_account_identity ( profile : & ProfileResponse ) -> Option < AccountIdentity > {
259+ let mut parts = Vec :: new ( ) ;
260+ if let Some ( org_id) = profile
261+ . organization
262+ . as_ref ( )
263+ . and_then ( |org| org. uuid . as_deref ( ) )
264+ . map ( str:: trim)
265+ . filter ( |s| !s. is_empty ( ) )
266+ {
267+ parts. push ( format ! ( "org:{org_id}" ) ) ;
268+ }
269+ if let Some ( account_id) = profile
270+ . account
271+ . as_ref ( )
272+ . and_then ( |acct| acct. uuid . as_deref ( ) )
273+ . map ( str:: trim)
274+ . filter ( |s| !s. is_empty ( ) )
275+ {
276+ parts. push ( format ! ( "account:{account_id}" ) ) ;
277+ }
278+ if let Some ( email) = profile
279+ . account
280+ . as_ref ( )
281+ . and_then ( |acct| acct. email . as_deref ( ) )
282+ . map ( str:: trim)
283+ . filter ( |s| !s. is_empty ( ) )
284+ {
285+ parts. push ( format ! ( "email:{}" , email. to_ascii_lowercase( ) ) ) ;
286+ }
287+ if parts. is_empty ( ) {
288+ return None ;
289+ }
290+ crate :: account:: explicit_identity ( "claude" , & parts. join ( "|" ) , "claude-oauth-profile" )
291+ }
292+
293+ fn fetch_oauth_profile ( token : & str ) -> Result < ProfileResponse > {
294+ let resp = ureq:: get ( "https://api.anthropic.com/api/oauth/profile" )
295+ . set ( "Authorization" , & format ! ( "Bearer {token}" ) )
296+ . set ( "anthropic-version" , "2023-06-01" )
297+ . set ( "anthropic-beta" , "oauth-2025-04-20" )
298+ . timeout ( std:: time:: Duration :: from_secs ( 8 ) )
299+ . call ( )
300+ . map_err ( |e| anyhow ! ( "call /api/oauth/profile: {e}" ) ) ?;
301+ resp. into_json ( )
302+ . map_err ( |e| anyhow ! ( "decode profile response: {e}" ) )
303+ }
304+
305+ pub ( super ) fn active_auth_status_identity ( ) -> Option < AccountIdentity > {
198306 #[ cfg( windows) ]
199307 let mut cmd = {
200308 let mut cmd = Command :: new ( "cmd.exe" ) ;
@@ -295,16 +403,13 @@ pub fn fetch_plan_tier() -> Result<String> {
295403 }
296404 }
297405 let token = read_oauth_token ( ) ?;
298- let resp = ureq:: get ( "https://api.anthropic.com/api/oauth/profile" )
299- . set ( "Authorization" , & format ! ( "Bearer {token}" ) )
300- . set ( "anthropic-version" , "2023-06-01" )
301- . set ( "anthropic-beta" , "oauth-2025-04-20" )
302- . timeout ( std:: time:: Duration :: from_secs ( 8 ) )
303- . call ( )
304- . map_err ( |e| anyhow ! ( "call /api/oauth/profile: {e}" ) ) ?;
305- let body: ProfileResponse = resp
306- . into_json ( )
307- . map_err ( |e| anyhow ! ( "decode profile response: {e}" ) ) ?;
406+ let token_identity = token_account_identity ( & token) ;
407+ let body = fetch_oauth_profile ( & token) ?;
408+ let profile_identity = profile_account_identity ( & body) ;
409+ if let ( Some ( token_id) , Some ( profile_id) ) = ( token_identity. as_ref ( ) , profile_identity. as_ref ( ) )
410+ {
411+ write_cached_profile_identity ( token_id, profile_id) ;
412+ }
308413 let tier = body
309414 . organization
310415 . and_then ( |o| o. rate_limit_tier )
@@ -325,6 +430,21 @@ pub(crate) fn http_fetch_live_limits() -> FetchOutcome {
325430 Ok ( t) => t,
326431 Err ( e) => return FetchOutcome :: Other ( e) ,
327432 } ;
433+ let token_identity = token_account_identity ( & token) ;
434+ let profile_identity = match fetch_oauth_profile ( & token) {
435+ Ok ( profile) => {
436+ let identity = profile_account_identity ( & profile) ;
437+ if let ( Some ( token_id) , Some ( profile_id) ) = ( token_identity. as_ref ( ) , identity. as_ref ( ) )
438+ {
439+ write_cached_profile_identity ( token_id, profile_id) ;
440+ }
441+ identity
442+ }
443+ Err ( e) => {
444+ eprintln ! ( "[tally] claude OAuth profile identity failed ({e}); using token identity" ) ;
445+ None
446+ }
447+ } ;
328448 let user_agent = format ! ( "claude-code/{}" , claude_code_version( ) ) ;
329449 let result = ureq:: get ( "https://api.anthropic.com/api/oauth/usage" )
330450 . set ( "Authorization" , & format ! ( "Bearer {token}" ) )
@@ -351,7 +471,9 @@ pub(crate) fn http_fetch_live_limits() -> FetchOutcome {
351471 } ;
352472
353473 let mut live = live_limits_from_usage_response ( body, ClaudeLimitSource :: Oauth ) ;
354- live. account = active_account_identity ( ) ;
474+ live. account = profile_identity
475+ . or ( token_identity)
476+ . or_else ( active_auth_status_identity) ;
355477 FetchOutcome :: Ok ( live)
356478}
357479
0 commit comments