Skip to content

Commit 70d214f

Browse files
committed
v0.1.66 - key Claude cache by source account
1 parent e260ea6 commit 70d214f

10 files changed

Lines changed: 155 additions & 29 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "tally",
3-
"version": "0.1.65",
3+
"version": "0.1.66",
44
"private": true,
55
"scripts": {
66
"tauri": "tauri",

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "tally"
3-
version = "0.1.65"
3+
version = "0.1.66"
44
edition = "2021"
55
rust-version = "1.77"
66

src-tauri/src/account.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#[derive(Debug, Clone, serde::Serialize)]
1+
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
22
pub struct AccountIdentity {
33
pub key: String,
44
pub label: String,

src-tauri/src/claude/cache.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,16 +260,17 @@ pub fn fetch_live_limits(refresh_ms: u64) -> Result<ClaudeLiveLimits> {
260260
if fresh.account.is_none() {
261261
fresh.account = active_account.clone();
262262
}
263+
let fresh_account_key = fresh.account.as_ref().map(|id| id.key.as_str());
263264
let mut guard = cache().lock().unwrap();
264265
*guard = Some(CacheEntry {
265-
account_key: active_account_key.map(|s| s.to_string()),
266+
account_key: fresh_account_key.map(|s| s.to_string()),
266267
fetched_at: Instant::now(),
267268
value: fresh.clone(),
268269
cooldown_until: cooldown_after_success,
269270
last_error: None,
270271
});
271272
drop(guard);
272-
write_disk_cache(&fresh, active_account_key);
273+
write_disk_cache(&fresh, fresh_account_key);
273274
record_limit_sample(&fresh);
274275
Ok(fresh)
275276
}

src-tauri/src/claude/cli.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use super::oauth::active_auth_status_identity;
12
use super::{ClaudeLimitSource, ClaudeLiveLimits, SubQuota};
23
use anyhow::{anyhow, Result};
34
use chrono::{DateTime, Datelike, Duration, Local, NaiveTime, TimeZone, Utc, Weekday};
@@ -170,11 +171,13 @@ fn fetch_cli_usage_limits_once(timeout: StdDuration) -> Result<ClaudeLiveLimits>
170171
dump_cli_output_tail(&output);
171172
}
172173

173-
parse_cli_usage_limits(&output).inspect_err(|_| {
174+
let mut limits = parse_cli_usage_limits(&output).inspect_err(|_| {
174175
if std::env::var_os("TALLY_CLAUDE_DEBUG_CLI_OUTPUT").is_some() {
175176
dump_cli_output_tail(&output);
176177
}
177-
})
178+
})?;
179+
limits.account = active_auth_status_identity();
180+
Ok(limits)
178181
}
179182

180183
fn dump_cli_output_tail(output: &str) {

src-tauri/src/claude/oauth.rs

Lines changed: 136 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,19 @@ use std::os::windows::process::CommandExt;
2626

2727
#[derive(Debug, Deserialize)]
2828
struct 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)]
3340
struct 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+
97120
fn 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

185208
pub(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

src-tauri/src/claude/web.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,10 @@ pub(crate) fn web_fetch_live_limits() -> FetchOutcome {
4848
Err(e) => return FetchOutcome::Other(anyhow!("decode Claude web usage: {e}")),
4949
};
5050

51-
FetchOutcome::Ok(live_limits_from_usage_response(
52-
body,
53-
ClaudeLimitSource::Web,
54-
))
51+
let mut live = live_limits_from_usage_response(body, ClaudeLimitSource::Web);
52+
live.account =
53+
crate::account::explicit_identity("claude", &format!("org:{org_id}"), "claude-web-org");
54+
FetchOutcome::Ok(live)
5555
}
5656

5757
fn read_web_cookie_header() -> Option<String> {

src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "TALLY - Ai Usage Monitor",
4-
"version": "0.1.65",
4+
"version": "0.1.66",
55
"identifier": "com.cjmedia.tally",
66
"build": {
77
"frontendDist": "../src"

0 commit comments

Comments
 (0)