diff --git a/CHANGELOG.md b/CHANGELOG.md index f0bd464ae..af5139265 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ **CrossUsage** ships **1.x** releases from [github.com/barramee27/crossusage](https://github.com/barramee27/crossusage). Older **0.6.x** sections below are **archived OpenUsage upstream** notes, not CrossUsage release numbers. +## 1.3.2 + +**Theme:** OpenUsage **v0.7.4 + v0.7.5** upstream mega-patch (PATCH per [docs/VERSIONING.md](docs/VERSIONING.md)). Port tracker: [docs/PORT-0.7.4-0.7.5.md](docs/PORT-0.7.4-0.7.5.md). + +### New features + +- **Total Spend** ring card — cross-provider spend sectors, Cost / Cost·MTok / Tokens menu, settings toggle, empty-state card. +- **Codex claim resets** — hover popover claim flow against the real Codex consume endpoint (retry + `nothing_to_reset` copy). +- **Pricing aliases** — GPT-5.6 / Grok 4.5 / Kimi K2.7 / Claude 4.7 Opus; request-wide long-context tier; Cursor `grok-4.5-fast-high` slug order. Bundled supplement `updated_at` **2026-07-08** (upstream hourly gh-pages refresh; fork keeps bundled data, no live HTTP fetch yet). + +### Bug fixes + +- **Refresh coordinator** — overlapping probe batches coalesce per plugin id so enablement wakes mid-refresh are not dropped (`MAX_CONCURRENT_PROBES = 4`). +- **Log scanners** — cap Claude/Codex JSONL scan to 500 newest files / 256 MiB soft budget; warn once per unreadable path. +- **Claude** — isolate usage cache on login change; prefer profile-scoped login over env token. +- **Antigravity** — bind credential caches to verified local state; purge on logout. +- **Cursor** — validate usage exports without dropping primary usage; mark MTD spend as estimated; log optional endpoint failures. +- **Z.ai** — reject malformed / boolean quota values at numeric boundaries. +- **OpenCode** — fail loudly on unreadable SQLite / auth sources. +- **Credentials** — `host.fs.writeText` Unix `0600`; reject BOM-prefixed malformed credentials; OAuth refresh form encoding audited. +- **Codex** — window routing by `limit_window_seconds`; show usage % as reported. +- **UI** — update banner clears when up-to-date; launch-at-login errors stay visible; spend-row / resets hover polish. + +### Security + +- Private local credential file writes (`0600` on Unix) for plugin host text writes. + +Skipped macOS-only / upstream CI items (transparency Options, LaunchAgent, LaunchServices, PostHog iOS, Pages deploy actions) — see PORT doc Skip table. + +--- + ## 1.3.1 **Theme:** OpenUsage **v0.7.2 + v0.7.3** upstream mega-patch (exceptional PATCH per [docs/VERSIONING.md](docs/VERSIONING.md)). diff --git a/Cargo.lock b/Cargo.lock index 2f33e0d27..4cf630f9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1199,7 +1199,7 @@ dependencies = [ [[package]] name = "crossusage" -version = "1.3.1" +version = "1.3.2" dependencies = [ "base64 0.22.1", "crossusage-core", @@ -1241,7 +1241,7 @@ dependencies = [ [[package]] name = "crossusage-cli" -version = "1.3.1" +version = "1.3.2" dependencies = [ "anyhow", "base64 0.22.1", @@ -1273,7 +1273,7 @@ dependencies = [ [[package]] name = "crossusage-core" -version = "1.3.1" +version = "1.3.2" dependencies = [ "aes-gcm", "anyhow", diff --git a/crates/crossusage-cli/Cargo.toml b/crates/crossusage-cli/Cargo.toml index fc5c7f07c..9e50025b2 100644 --- a/crates/crossusage-cli/Cargo.toml +++ b/crates/crossusage-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "crossusage-cli" -version = "1.3.1" +version = "1.3.2" description = "CrossUsage CLI — terminal interface for the same plugin engine as the GUI" edition = "2021" diff --git a/crates/crossusage-cli/src/batch_probe.rs b/crates/crossusage-cli/src/batch_probe.rs index a5461f140..b4b11e3cc 100644 --- a/crates/crossusage-cli/src/batch_probe.rs +++ b/crates/crossusage-cli/src/batch_probe.rs @@ -37,6 +37,7 @@ fn probe_error_output(plugin: &LoadedPlugin, message: String) -> PluginOutput { model_breakdown: None, status_dot: None, expiry_tooltip: None, + reset_credit_expiries: None, }], icon_url: plugin.icon_data_url.clone(), } diff --git a/crates/crossusage-cli/src/tui/app.rs b/crates/crossusage-cli/src/tui/app.rs index f12ec10e4..feaa6d131 100644 --- a/crates/crossusage-cli/src/tui/app.rs +++ b/crates/crossusage-cli/src/tui/app.rs @@ -123,6 +123,7 @@ fn loading_placeholder(plugin: &LoadedPlugin) -> PluginOutput { model_breakdown: None, status_dot: None, expiry_tooltip: None, + reset_credit_expiries: None, }], icon_url: plugin.icon_data_url.clone(), } @@ -162,6 +163,7 @@ fn probe_error_output(plugin: &LoadedPlugin, message: String) -> PluginOutput { model_breakdown: None, status_dot: None, expiry_tooltip: None, + reset_credit_expiries: None, }], icon_url: plugin.icon_data_url.clone(), } diff --git a/crates/crossusage-core/Cargo.toml b/crates/crossusage-core/Cargo.toml index e71c53e45..b58003019 100644 --- a/crates/crossusage-core/Cargo.toml +++ b/crates/crossusage-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "crossusage-core" -version = "1.3.1" +version = "1.3.2" description = "Shared plugin engine for CrossUsage (GUI + CLI)" edition = "2024" diff --git a/crates/crossusage-core/data/pricing_supplement.json b/crates/crossusage-core/data/pricing_supplement.json index a383316e6..a5a699e5d 100644 --- a/crates/crossusage-core/data/pricing_supplement.json +++ b/crates/crossusage-core/data/pricing_supplement.json @@ -1,6 +1,6 @@ { - "$comment": "OpenUsage pricing supplement. Prices Cursor-native models that no public catalog carries, supplies fast-variant multipliers the catalogs omit, and maps provider log/CSV model slugs to canonical pricing keys (LiteLLM keys, models.dev ids, or entries in this file). Published to gh-pages by .github/workflows/pricing-supplement.yml on every change, so installed apps pick edits up within a day - no release needed. Rates are USD per million tokens. Sources: https://cursor.com/docs/models-and-pricing.md for Cursor-native entries.", - "updated_at": "2026-07-03", + "$comment": "OpenUsage pricing supplement. Prices Cursor-native models that no public catalog carries, supplies fast-variant multipliers the catalogs omit, and maps provider log/CSV model slugs to canonical pricing keys (LiteLLM keys, models.dev ids, or entries in this file). Published to gh-pages by .github/workflows/pricing-supplement.yml on every change, so installed apps pick edits up within about an hour - no release needed. Rates are USD per million tokens. Sources: https://cursor.com/docs/models-and-pricing.md for Cursor-native entries.", + "updated_at": "2026-07-08", "pricing": { "auto": { "input_per_million": 1.25, @@ -50,6 +50,26 @@ "cache_read_per_million": 0.5, "output_per_million": 30.0 }, + "grok-4.5": { + "$comment": "Cursor + SpaceXAI first-party model. Cache write not published separately; defaults to input.", + "input_per_million": 2.0, + "cache_write_per_million": 2.0, + "cache_read_per_million": 0.5, + "output_per_million": 6.0 + }, + "grok-4.5-fast": { + "input_per_million": 4.0, + "cache_write_per_million": 4.0, + "cache_read_per_million": 1.0, + "output_per_million": 18.0 + }, + "kimi-k2.7-code": { + "$comment": "Cursor's published Kimi K2.7 Code rates. Public catalogs disagree (some bare keys are $0), so this override wins.", + "input_per_million": 0.95, + "cache_write_per_million": 0.95, + "cache_read_per_million": 0.19, + "output_per_million": 4.0 + }, "claude-opus-4-7-fast": { "$comment": "Cursor bills Opus 4.7 fast mode at these rates; the models.dev entry carries higher (stale) numbers, so this override wins.", "input_per_million": 30.0, @@ -70,6 +90,25 @@ "cache_write_per_million": 7.5, "cache_read_per_million": 0.6, "output_per_million": 22.5 + }, + "gpt-5.6-sol": { + "$comment": "OpenAI's GPT-5.6 preview pricing. Its public catalogs have not added these models yet.", + "input_per_million": 5.0, + "cache_write_per_million": 6.25, + "cache_read_per_million": 0.5, + "output_per_million": 30.0 + }, + "gpt-5.6-terra": { + "input_per_million": 2.5, + "cache_write_per_million": 3.125, + "cache_read_per_million": 0.25, + "output_per_million": 15.0 + }, + "gpt-5.6-luna": { + "input_per_million": 1.0, + "cache_write_per_million": 1.25, + "cache_read_per_million": 0.1, + "output_per_million": 6.0 } }, "fast_multipliers": { @@ -80,7 +119,10 @@ "gpt-5.2-codex": 2.0, "gpt-5.3-codex": 2.0, "gpt-5.4": 2.0, - "gpt-5.5": 2.5 + "gpt-5.5": 2.5, + "gpt-5.6-sol": 2.5, + "gpt-5.6-terra": 2.5, + "gpt-5.6-luna": 2.5 }, "alias_rules": [ { "pattern": "^agent_review$", "canonical": "gpt-5.4" }, @@ -93,6 +135,11 @@ { "pattern": "^composer-2\\.5$", "canonical": "composer-2.5" }, { "pattern": "^composer-2\\.5-fast$", "canonical": "composer-2.5-fast" }, { "pattern": "^github_bugbot$", "canonical": "github_bugbot" }, + { "pattern": "^grok-4\\.5-fast(?:-(?:low|medium|high))?$", "canonical": "grok-4.5-fast" }, + { "pattern": "^grok-4\\.5(?:-(?:low|medium|high))?-fast$", "canonical": "grok-4.5-fast" }, + { "pattern": "^grok-4\\.5(?:-(?:low|medium|high))?$", "canonical": "grok-4.5" }, + { "pattern": "^kimi-k2\\.7(?:-code)?$", "canonical": "kimi-k2.7-code" }, + { "pattern": "^kimi-k2p7(?:-code)?$", "canonical": "kimi-k2.7-code" }, { "pattern": "^claude-4\\.5-haiku(?:-thinking)?$", "canonical": "claude-haiku-4-5" }, { "pattern": "^claude-4\\.5-opus-(?:low|medium|high)(?:-thinking)?$", "canonical": "claude-opus-4-5" }, { "pattern": "^claude-4-sonnet-1m(?:-thinking)?$", "canonical": "claude-4-sonnet-1m" }, @@ -101,6 +148,8 @@ { "pattern": "^claude-4\\.6-sonnet(?:-(?:low|medium|high|xhigh|max))?(?:-thinking)?$", "canonical": "claude-sonnet-4-6" }, { "pattern": "^claude-4\\.6-opus-(?:low|medium|high|max)(?:-thinking)?-fast$", "canonical": "claude-opus-4-6-fast" }, { "pattern": "^claude-4\\.6-opus-(?:low|medium|high|max)(?:-thinking)?$", "canonical": "claude-opus-4-6" }, + { "pattern": "^claude-4\\.7-opus-(?:low|medium|high|max)(?:-thinking)?-fast$", "canonical": "claude-opus-4-7-fast" }, + { "pattern": "^claude-4\\.7-opus-(?:low|medium|high|max)(?:-thinking)?$", "canonical": "claude-opus-4-7" }, { "pattern": "^claude-opus-4-7(?:-thinking)?(?:-(?:low|medium|high|xhigh|max))?-fast$", "canonical": "claude-opus-4-7-fast" }, { "pattern": "^claude-opus-4-7(?:-thinking)?(?:-(?:low|medium|high|xhigh|max))?$", "canonical": "claude-opus-4-7" }, { "pattern": "^claude-opus-4-8(?:-thinking)?(?:-(?:low|medium|high|xhigh|max))?-fast$", "canonical": "claude-opus-4-8-fast" }, @@ -143,6 +192,12 @@ { "pattern": "^gpt-5\\.4-(?:low|medium|high|xhigh)$", "canonical": "gpt-5.4" }, { "pattern": "^gpt-5\\.5(?:-(?:low|medium|high|xhigh|extra-high))?-fast$", "canonical": "gpt-5.5-fast" }, { "pattern": "^gpt-5\\.5(?:-(?:low|medium|high|xhigh|extra-high))?$", "canonical": "gpt-5.5" }, + { "pattern": "^gpt-5\\.6-sol(?:-(?:none|low|medium|high|xhigh|max|ultra))?-fast$", "canonical": "gpt-5.6-sol-fast" }, + { "pattern": "^gpt-5\\.6-sol(?:-(?:none|low|medium|high|xhigh|max|ultra))?$", "canonical": "gpt-5.6-sol" }, + { "pattern": "^gpt-5\\.6-terra(?:-(?:none|low|medium|high|xhigh|max))?-fast$", "canonical": "gpt-5.6-terra-fast" }, + { "pattern": "^gpt-5\\.6-terra(?:-(?:none|low|medium|high|xhigh|max))?$", "canonical": "gpt-5.6-terra" }, + { "pattern": "^gpt-5\\.6-luna(?:-(?:none|low|medium|high|xhigh|max))?-fast$", "canonical": "gpt-5.6-luna-fast" }, + { "pattern": "^gpt-5\\.6-luna(?:-(?:none|low|medium|high|xhigh|max))?$", "canonical": "gpt-5.6-luna" }, { "pattern": "^kimi-k2\\.5$", "canonical": "moonshot/kimi-k2.5" }, { "pattern": "^kimi-k2p5$", "canonical": "moonshot/kimi-k2.5" }, { "pattern": "^glm-5\\.2(?:-(?:high|max))?$", "canonical": "glm-5.2" }, diff --git a/crates/crossusage-core/src/claude_usage_scanner.rs b/crates/crossusage-core/src/claude_usage_scanner.rs index 3ae9df881..3205b5f26 100644 --- a/crates/crossusage-core/src/claude_usage_scanner.rs +++ b/crates/crossusage-core/src/claude_usage_scanner.rs @@ -1,8 +1,9 @@ //! Native Claude Code session log scanner (ports upstream ClaudeLogUsageScanner). use crate::log_usage_types::{ - DailyUsageRow, LogScanStatus, ModelDayUsage, TokenBreakdown, expand_tilde, host_query_response, - local_day_key_from_offset, since_local_midnight, + DailyUsageRow, LogScanStatus, ModelDayUsage, TokenBreakdown, cap_log_files_by_mtime, + expand_tilde, host_query_response, local_day_key_from_offset, since_local_midnight, + warn_unreadable_usage_file, }; use crate::model_pricing::{ModelPricing, default_pricing}; use serde_json::Value; @@ -216,6 +217,8 @@ fn usage_files(roots: &[PathBuf]) -> Vec { for root in roots { walk_jsonl(&root.join("projects"), &mut files); } + cap_log_files_by_mtime(&mut files, |f| f.mtime, |f| f.size); + // Stable path order for keep-first dedup after the newest-N / byte cap. files.sort_by(|a, b| a.path.cmp(&b.path)); files } @@ -251,8 +254,12 @@ fn file_mtime_before(mtime: &SystemTime, since: OffsetDateTime) -> bool { } fn parse_file(path: &Path) -> Vec { - let Ok(data) = fs::read(path) else { - return vec![]; + let data = match fs::read(path) { + Ok(data) => data, + Err(_) => { + warn_unreadable_usage_file(path); + return vec![]; + } }; let marker = br#""usage":{"#; let mut entries = Vec::new(); @@ -548,7 +555,12 @@ mod tests { let projects = tmp.path().join("projects").join("demo"); fs::create_dir_all(&projects).unwrap(); let log = projects.join("sess.jsonl"); - let line = r#"{"timestamp":"2026-07-06T12:00:00Z","message":{"id":"m1","model":"claude-sonnet-4-20250514","usage":{"input_tokens":100,"output_tokens":50}},"requestId":"r1","version":"1.0.0"}"#; + let line = format!( + r#"{{"timestamp":"{}","message":{{"id":"m1","model":"claude-sonnet-4-20250514","usage":{{"input_tokens":100,"output_tokens":50}}}},"requestId":"r1","version":"1.0.0"}}"#, + OffsetDateTime::now_utc() + .format(&time::format_description::well_known::Rfc3339) + .unwrap() + ); let mut f = fs::File::create(&log).unwrap(); writeln!(f, "{line}").unwrap(); diff --git a/crates/crossusage-core/src/codex_usage_scanner.rs b/crates/crossusage-core/src/codex_usage_scanner.rs index 55212e50d..072246e5e 100644 --- a/crates/crossusage-core/src/codex_usage_scanner.rs +++ b/crates/crossusage-core/src/codex_usage_scanner.rs @@ -2,8 +2,9 @@ use crate::claude_usage_scanner; use crate::log_usage_types::{ - DailyUsageRow, LogScanStatus, ModelDayUsage, TokenBreakdown, expand_tilde, host_query_response, - local_day_key_from_offset, since_local_midnight, + DailyUsageRow, LogScanStatus, ModelDayUsage, TokenBreakdown, cap_log_files_by_mtime, + expand_tilde, host_query_response, local_day_key_from_offset, since_local_midnight, + warn_unreadable_usage_file, }; use crate::model_pricing::{ModelPricing, default_pricing}; use serde_json::Value; @@ -158,6 +159,7 @@ fn session_files(homes: &[PathBuf]) -> Vec { collect_session_files(home, home, &home_key, "", &mut files, &mut seen_relative); } } + cap_log_files_by_mtime(&mut files, |f| f.mtime, |f| f.size); files.sort_by(|a, b| a.path.cmp(&b.path)); files } @@ -265,8 +267,12 @@ impl RawUsage { } fn parse_file(path: &Path) -> Vec { - let Ok(data) = fs::read(path) else { - return vec![]; + let data = match fs::read(path) { + Ok(data) => data, + Err(_) => { + warn_unreadable_usage_file(path); + return vec![]; + } }; let subagent = data.len() >= 16 * 1024 && data[..16 * 1024].windows(12).any(|w| w == b"thread_spawn"); let replay_second = if subagent { diff --git a/crates/crossusage-core/src/log_usage_types.rs b/crates/crossusage-core/src/log_usage_types.rs index 95ba181c1..3cc3bbd6c 100644 --- a/crates/crossusage-core/src/log_usage_types.rs +++ b/crates/crossusage-core/src/log_usage_types.rs @@ -1,7 +1,78 @@ //! Shared types for native Claude/Codex log scanners (ccusage-compatible daily output). use serde::Serialize; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; +use std::path::Path; +use std::sync::Mutex; +use std::time::SystemTime; + +/// Newest-N file cap per scan pass (upstream #888 bound for large log trees). +pub const LOG_SCAN_MAX_FILES: usize = 500; +/// Soft byte budget for files considered in one pass (always keeps ≥1 file if any). +pub const LOG_SCAN_MAX_BYTES: u64 = 256 * 1024 * 1024; + +static WARNED_UNREADABLE: Mutex>> = Mutex::new(None); + +/// Keep the newest files by mtime, then apply a soft byte budget. +pub fn cap_log_files_by_mtime( + files: &mut Vec, + mtime: impl Fn(&T) -> SystemTime, + size: impl Fn(&T) -> u64, +) { + if files.is_empty() { + return; + } + files.sort_by(|a, b| mtime(b).cmp(&mtime(a)).then_with(|| size(b).cmp(&size(a)))); + if files.len() > LOG_SCAN_MAX_FILES { + files.truncate(LOG_SCAN_MAX_FILES); + } + let mut kept = Vec::with_capacity(files.len()); + let mut bytes = 0u64; + for file in files.drain(..) { + let sz = size(&file); + if !kept.is_empty() && bytes.saturating_add(sz) > LOG_SCAN_MAX_BYTES { + continue; + } + bytes = bytes.saturating_add(sz); + kept.push(file); + } + *files = kept; +} + +/// Log once per path per process when a usage file cannot be read (#890). +pub fn warn_unreadable_usage_file(path: &Path) { + let key = path.to_string_lossy().to_string(); + let Ok(mut guard) = WARNED_UNREADABLE.lock() else { + return; + }; + let set = guard.get_or_insert_with(HashSet::new); + if set.insert(key.clone()) { + log::warn!( + "Could not read local usage log file (skipping for this process): {}", + key + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn caps_newest_files_and_byte_budget() { + let t0 = SystemTime::UNIX_EPOCH; + let mut files: Vec<(SystemTime, u64, u32)> = (0u32..600) + .map(|i| (t0 + Duration::from_secs(u64::from(i)), 1024u64, i)) + .collect(); + cap_log_files_by_mtime(&mut files, |f| f.0, |f| f.1); + assert!(files.len() <= LOG_SCAN_MAX_FILES); + assert_eq!(files.len(), LOG_SCAN_MAX_FILES); + // Newest ids survive. + assert!(files.iter().any(|f| f.2 == 599)); + assert!(!files.iter().any(|f| f.2 == 0)); + } +} #[derive(Debug, Clone, Default, Serialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/crossusage-core/src/model_pricing.rs b/crates/crossusage-core/src/model_pricing.rs index b414feaab..2b569d991 100644 --- a/crates/crossusage-core/src/model_pricing.rs +++ b/crates/crossusage-core/src/model_pricing.rs @@ -1,4 +1,7 @@ -//! Bundled model pricing (supplement + LiteLLM compact snapshot). Ports upstream OpenUsage v0.7.3. +//! Bundled model pricing (supplement + LiteLLM compact snapshot). +//! Ports upstream OpenUsage v0.7.3+ aliases; v0.7.4 #909 refreshes price lists hourly +//! upstream via gh-pages. This fork ships the bundled supplement (`updated_at` in JSON) +//! and does not fetch over the network — optional follow-up if live refresh is needed. use crate::log_usage_types::TokenBreakdown; use regex_lite::Regex; @@ -37,43 +40,55 @@ impl ModelRates { } } + /// Dollar cost of one request. When `apply_long_context_rates` and prompt tokens + /// (input + cache writes + cache reads) exceed 200k, the whole request uses the + /// long-context tier rates (upstream OpenUsage 0.7.4 #885). pub fn cost_dollars(&self, tokens: &TokenBreakdown) -> f64 { + self.cost_dollars_with_options(tokens, true) + } + + pub fn cost_dollars_with_options( + &self, + tokens: &TokenBreakdown, + apply_long_context_rates: bool, + ) -> f64 { const CACHE_WRITE_1H_MULT: f64 = 2.0; let mult = if tokens.is_fast { self.fast_multiplier } else { 1.0 }; - let cost = tiered_cost(tokens.input, self.input_per_million, self.input_above_200k) - + tiered_cost(tokens.output, self.output_per_million, self.output_above_200k) - + tiered_cost( - tokens.cache_write5m, - self.cache_write_per_million, - self.cache_write_above_200k, - ) - + tiered_cost( - tokens.cache_write1h, - self.input_per_million * CACHE_WRITE_1H_MULT, - self.input_above_200k.map(|v| v * CACHE_WRITE_1H_MULT), - ) - + tiered_cost( - tokens.cache_read, - self.cache_read_per_million, - self.cache_read_above_200k, - ); + let prompt_tokens = + tokens.input + tokens.cache_write5m + tokens.cache_write1h + tokens.cache_read; + let use_long = apply_long_context_rates && prompt_tokens > 200_000; + let input_rate = selected_rate(self.input_per_million, self.input_above_200k, use_long); + let output_rate = selected_rate(self.output_per_million, self.output_above_200k, use_long); + let cache_write_rate = + selected_rate(self.cache_write_per_million, self.cache_write_above_200k, use_long); + let cache_read_rate = + selected_rate(self.cache_read_per_million, self.cache_read_above_200k, use_long); + let cache_write_1h_rate = + selected_rate(self.input_per_million, self.input_above_200k, use_long) + * CACHE_WRITE_1H_MULT; + + let cost = flat_cost(tokens.input, input_rate) + + flat_cost(tokens.output, output_rate) + + flat_cost(tokens.cache_write5m, cache_write_rate) + + flat_cost(tokens.cache_write1h, cache_write_1h_rate) + + flat_cost(tokens.cache_read, cache_read_rate); cost * mult } } -fn tiered_cost(tokens: i32, base: f64, above: Option) -> f64 { +fn selected_rate(base: f64, long_context: Option, use_long: bool) -> f64 { + if use_long { + long_context.unwrap_or(base) + } else { + base + } +} + +fn flat_cost(tokens: i32, rate_per_million: f64) -> f64 { if tokens <= 0 { return 0.0; } - let threshold = 200_000; - if let Some(above_rate) = above { - if tokens > threshold { - return (f64::from(threshold) * base - + f64::from(tokens - threshold) * above_rate) - / 1_000_000.0; - } - } - f64::from(tokens) * base / 1_000_000.0 + f64::from(tokens) * rate_per_million / 1_000_000.0 } struct AliasRule { @@ -364,4 +379,64 @@ mod tests { let rates = p.resolve("claude-opus-4-7-fast").expect("opus fast"); assert!((rates.input_per_million - 30.0).abs() < 0.01); } + + #[test] + fn gpt_5_6_aliases_resolve() { + let p = ModelPricing::from_bundled(); + assert!(p.can_price("gpt-5.6-sol")); + assert!(p.can_price("gpt-5.6-terra-fast")); + assert!(p.can_price("gpt-5.6-luna")); + } + + #[test] + fn grok_4_5_and_kimi_k2_7_aliases() { + let p = ModelPricing::from_bundled(); + let grok = p.resolve("grok-4.5-fast-high").expect("grok fast high"); + let grok_fast = p.resolve("grok-4.5-fast").expect("grok fast"); + assert!((grok.input_per_million - grok_fast.input_per_million).abs() < 0.01); + assert!(p.can_price("kimi-k2.7-code")); + assert!(p.can_price("kimi-k2p7")); + } + + #[test] + fn request_wide_long_context_uses_higher_tier_for_all_fields() { + let rates = ModelRates { + input_per_million: 1.0, + output_per_million: 2.0, + cache_write_per_million: 1.0, + cache_read_per_million: 0.1, + input_above_200k: Some(10.0), + output_above_200k: Some(20.0), + cache_write_above_200k: Some(10.0), + cache_read_above_200k: Some(1.0), + fast_multiplier: 1.0, + }; + let tokens = TokenBreakdown { + input: 150_000, + output: 1_000, + cache_write5m: 40_000, + cache_write1h: 0, + cache_read: 20_000, + is_fast: false, + }; + // prompt = 210k → whole request at long-context rates + let cost = rates.cost_dollars(&tokens); + let expected = flat_cost(150_000, 10.0) + + flat_cost(1_000, 20.0) + + flat_cost(40_000, 10.0) + + flat_cost(20_000, 1.0); + assert!((cost - expected).abs() < 1e-9); + let short = rates.cost_dollars_with_options( + &TokenBreakdown { + input: 1_000, + output: 1_000, + cache_write5m: 0, + cache_write1h: 0, + cache_read: 0, + is_fast: false, + }, + true, + ); + assert!((short - flat_cost(1_000, 1.0) - flat_cost(1_000, 2.0)).abs() < 1e-9); + } } diff --git a/crates/crossusage-core/src/plugin_engine/host_api.rs b/crates/crossusage-core/src/plugin_engine/host_api.rs index 1c1b96aee..0cbee3010 100644 --- a/crates/crossusage-core/src/plugin_engine/host_api.rs +++ b/crates/crossusage-core/src/plugin_engine/host_api.rs @@ -987,8 +987,25 @@ fn inject_fs<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { move |ctx_inner: Ctx<'_>, path: String, content: String| -> rquickjs::Result<()> { reject_path_traversal(&ctx_inner, &path)?; let expanded = expand_path(&path); + if let Some(parent) = std::path::Path::new(&expanded).parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).map_err(|e| { + Exception::throw_message(&ctx_inner, &e.to_string()) + })?; + } + } std::fs::write(&expanded, &content) - .map_err(|e| Exception::throw_message(&ctx_inner, &e.to_string())) + .map_err(|e| Exception::throw_message(&ctx_inner, &e.to_string()))?; + // Keep plugin credential/cache files private on Unix (#910). + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions( + &expanded, + std::fs::Permissions::from_mode(0o600), + ); + } + Ok(()) }, )?, )?; @@ -1388,6 +1405,10 @@ pub fn inject_utils(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> { var line = { type: "text", label: opts.label, value: opts.value }; if (opts.color) line.color = opts.color; if (opts.subtitle) line.subtitle = opts.subtitle; + if (opts.modelBreakdown) line.modelBreakdown = opts.modelBreakdown; + if (opts.statusDot) line.statusDot = opts.statusDot; + if (opts.expiryTooltip) line.expiryTooltip = opts.expiryTooltip; + if (opts.resetCreditExpiries) line.resetCreditExpiries = opts.resetCreditExpiries; return line; }, progress: function(opts) { @@ -1447,7 +1468,7 @@ pub fn inject_utils(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> { ctx.util = { tryParseJson: function(text) { if (text === null || text === undefined) return null; - var trimmed = String(text).trim(); + var trimmed = String(text).replace(/^\uFEFF/, "").trim(); if (!trimmed) return null; try { return JSON.parse(trimmed); @@ -1457,7 +1478,7 @@ pub fn inject_utils(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> { }, safeJsonParse: function(text) { if (text === null || text === undefined) return { ok: false }; - var trimmed = String(text).trim(); + var trimmed = String(text).replace(/^\uFEFF/, "").trim(); if (!trimmed) return { ok: false }; try { return { ok: true, value: JSON.parse(trimmed) }; diff --git a/crates/crossusage-core/src/plugin_engine/runtime.rs b/crates/crossusage-core/src/plugin_engine/runtime.rs index 72ed78243..8ff0afccf 100644 --- a/crates/crossusage-core/src/plugin_engine/runtime.rs +++ b/crates/crossusage-core/src/plugin_engine/runtime.rs @@ -45,6 +45,8 @@ pub enum MetricLine { model_breakdown: Option>, status_dot: Option, expiry_tooltip: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + reset_credit_expiries: Option>, }, Progress { label: String, @@ -305,6 +307,105 @@ fn run_probe_with_account_timeout( }) } +/// Call a named export on `__openusage_plugin` (e.g. `claimResetCredit`) with JSON args. +/// Returns the string result of the function, or an error message. +pub fn run_plugin_action( + plugin: &LoadedPlugin, + app_data_dir: &PathBuf, + app_version: &str, + action: &str, + args_json: &str, + account: Option, +) -> Result { + let timeout = Duration::from_secs(PROBE_TIMEOUT_SECS); + let deadline_at = Instant::now() + .checked_add(timeout) + .unwrap_or_else(Instant::now); + let deadline = host_api::ProbeDeadline::at(deadline_at); + + let rt = Runtime::new().map_err(|_| "runtime init failed".to_string())?; + rt.set_interrupt_handler(Some(Box::new(move || Instant::now() >= deadline_at))); + let ctx = Context::full(&rt).map_err(|_| "context init failed".to_string())?; + + let plugin_id = account + .as_ref() + .map(|account| account.instance_id.clone()) + .unwrap_or_else(|| plugin.manifest.id.clone()); + let base_plugin_id = account + .as_ref() + .map(|account| account.base_provider_id.clone()) + .unwrap_or_else(|| plugin.manifest.id.clone()); + let entry_script = plugin.entry_script.clone(); + let app_data = app_data_dir.clone(); + let action_name = action.to_string(); + let args_json = args_json.to_string(); + + ctx.with(|ctx| { + host_api::inject_host_api_with_deadline( + &ctx, + &base_plugin_id, + &plugin_id, + account.as_ref(), + &app_data, + app_version, + deadline, + ) + .map_err(|_| "host api injection failed".to_string())?; + host_api::patch_http_wrapper(&ctx).map_err(|_| "http wrapper failed".to_string())?; + host_api::inject_utils(&ctx).map_err(|_| "utils injection failed".to_string())?; + ctx.eval::<(), _>(entry_script.as_bytes()) + .map_err(|_| "script eval failed".to_string())?; + + let globals = ctx.globals(); + let plugin_obj: Object = globals + .get("__openusage_plugin") + .map_err(|_| "missing __openusage_plugin".to_string())?; + let action_fn: rquickjs::Function = plugin_obj + .get(action_name.as_str()) + .map_err(|_| format!("missing action {action_name}"))?; + + let probe_ctx: Value = globals + .get("__openusage_ctx") + .unwrap_or_else(|_| Value::new_undefined(ctx.clone())); + + // Inject args as a global JSON literal, then read it back as a Value. + let inject = format!( + "globalThis.__openusage_action_args = ({args_json});" + ); + ctx.eval::<(), _>(inject.as_bytes()) + .map_err(|_| "invalid action args json".to_string())?; + let args_value: Value = globals + .get("__openusage_action_args") + .map_err(|_| "missing action args".to_string())?; + + let result_value: Value = action_fn + .call((probe_ctx, args_value)) + .map_err(|_| extract_error_string(&ctx))?; + + let as_outcome = |value: Value| -> Result { + if let Some(s) = value.as_string() { + return s + .to_string() + .map_err(|_| "action string decode failed".to_string()); + } + Err("action returned non-string".to_string()) + }; + + if result_value.is_promise() { + let promise: Promise = result_value + .into_promise() + .ok_or_else(|| "action returned invalid promise".to_string())?; + let finished: Value = promise.finish().map_err(|e| match e { + Error::WouldBlock => "action returned unresolved promise".to_string(), + _ => extract_error_string(&ctx), + })?; + as_outcome(finished) + } else { + as_outcome(result_value) + } + }) +} + fn parse_model_breakdown(line: &Object) -> Option> { let arr: Array = line.get("modelBreakdown").ok()?; let mut out = Vec::new(); @@ -344,6 +445,30 @@ fn parse_model_breakdown(line: &Object) -> Option> { } } +fn parse_reset_credit_expiries(line: &Object) -> Option> { + let arr: Array = line.get("resetCreditExpiries").ok()?; + let mut out = Vec::new(); + let len = arr.len(); + for idx in 0..len { + let Ok(value) = arr.get::(idx) else { + continue; + }; + if let Some(s) = value.as_string() { + if let Ok(text) = s.to_string() { + let trimmed = text.trim(); + if !trimmed.is_empty() { + out.push(trimmed.to_string()); + } + } + } + } + if out.is_empty() { + None + } else { + Some(out) + } +} + fn parse_lines(result: &Object) -> Result, String> { let lines: Array = result .get("lines") @@ -367,6 +492,7 @@ fn parse_lines(result: &Object) -> Result, String> { let model_breakdown = parse_model_breakdown(&line); let status_dot = line.get::<_, String>("statusDot").ok(); let expiry_tooltip = line.get::<_, String>("expiryTooltip").ok(); + let reset_credit_expiries = parse_reset_credit_expiries(&line); out.push(MetricLine::Text { label, value, @@ -375,6 +501,7 @@ fn parse_lines(result: &Object) -> Result, String> { model_breakdown, status_dot, expiry_tooltip, + reset_credit_expiries, }); } "progress" => { diff --git a/crates/crossusage-core/src/usage_metrics.rs b/crates/crossusage-core/src/usage_metrics.rs index 0ea3ebbf2..dff01d185 100644 --- a/crates/crossusage-core/src/usage_metrics.rs +++ b/crates/crossusage-core/src/usage_metrics.rs @@ -253,6 +253,7 @@ mod tests { model_breakdown: None, status_dot: None, expiry_tooltip: None, + reset_credit_expiries: None, }, MetricLine::Text { label: "Output tokens".into(), @@ -262,6 +263,7 @@ mod tests { model_breakdown: None, status_dot: None, expiry_tooltip: None, + reset_credit_expiries: None, }, MetricLine::Text { label: "Cost".into(), @@ -271,6 +273,7 @@ mod tests { model_breakdown: None, status_dot: None, expiry_tooltip: None, + reset_credit_expiries: None, }, MetricLine::Progress { label: "Usage".into(), diff --git a/docs/FORK-UPSTREAM.md b/docs/FORK-UPSTREAM.md index 404f0127b..43a5c1580 100644 --- a/docs/FORK-UPSTREAM.md +++ b/docs/FORK-UPSTREAM.md @@ -4,7 +4,7 @@ CrossUsage is the **Tauri Linux/Windows** line. Upstream **OpenUsage 0.7+** is a **UI diff target:** compare Modern layout work against **`upstream/swift`**, not `upstream/main`. Port spec: [OPENUSAGE-0.7-UI-SPEC.md](./OPENUSAGE-0.7-UI-SPEC.md). -**Release trigger:** **1.2.0** → v0.7.0 Modern UI. **1.3.0** → v0.7.1 + i18n ([PORT-0.7.1.md](./PORT-0.7.1.md)). **1.3.1** → bundled v0.7.2 + v0.7.3 upstream port ([PORT-0.7.2-0.7.3.md](./PORT-0.7.2-0.7.3.md)). **1.4.0** → fork-only features per [VERSIONING.md](./VERSIONING.md). +**Release trigger:** **1.2.0** → v0.7.0 Modern UI. **1.3.0** → v0.7.1 + i18n ([PORT-0.7.1.md](./PORT-0.7.1.md)). **1.3.1** → bundled v0.7.2 + v0.7.3 ([PORT-0.7.2-0.7.3.md](./PORT-0.7.2-0.7.3.md)). **1.3.2** → bundled v0.7.4 + v0.7.5 ([PORT-0.7.4-0.7.5.md](./PORT-0.7.4-0.7.5.md)). **1.4.0** → fork-only features per [VERSIONING.md](./VERSIONING.md). ## What to port diff --git a/docs/PORT-0.7.4-0.7.5.md b/docs/PORT-0.7.4-0.7.5.md new file mode 100644 index 000000000..1a98f1601 --- /dev/null +++ b/docs/PORT-0.7.4-0.7.5.md @@ -0,0 +1,172 @@ +# OpenUsage v0.7.4 + v0.7.5 port (CrossUsage 1.3.2) + +Upstream **v0.7.4** and **v0.7.5** are Swift-only (`upstream/swift` tags). Port by reading Swift providers / Modern dashboard code and rewriting to JS plugins + React/Tauri. + +**Version:** [**1.3.2**](../VERSIONING.md) — upstream bundle = **PATCH** only. Reserve **1.4.0** for new **CrossUsage-specific** features (fork MINOR). + +**Strategy:** One patch release bundling **all** applicable 0.7.4 + 0.7.5 behavior. Do not call it 1.4.0. + +**Baseline:** CrossUsage **1.3.1** (shipped upstream **v0.7.2 + v0.7.3**). Tags: `v0.7.4` (2026-07-12), `v0.7.5` (2026-07-13). + +## Status legend + +| Status | Meaning | +|--------|---------| +| **ship** | In **1.3.2** scope | +| **skip** | Not applicable on Linux/Windows fork (macOS-only or upstream release infra) | + +**Rule:** Every upstream 0.7.4 / 0.7.5 item below is **ship** in 1.3.2 unless listed under **Skip**. + +## Coordination (PR #15) + +- Merge [PR #15](https://github.com/barramee27/crossusage/pull/15) (agy Windows token + label cleanup) into the 1.3.2 branch **before release** if contributor does not push within 2 days of maintainer comment. +- Tray label **migration** from contributor can land in a **follow-up PR** and ship in **1.3.3** if it misses the 1.3.2 cut. + +--- + +## Phase 1 — Plugin / provider (ship in 1.3.2) + +| Upstream | CrossUsage action | Status | +|----------|-------------------|--------| +| OpenCode provider Zen / Go from local logs (#969) | Extend `plugins/opencode-go` — local log ingest, fail loudly on unreadable sources; align with upstream OpenCode mapper | **done** (loud SQLite/auth fail; Zen/log ingest still optional follow-up) | +| GPT-5.6 pricing aliases (#880) | `model_pricing` / Cursor spend path | **done** | +| Grok 4.5, Kimi K2.7 Code, Claude 4.7 Opus pricing aliases (#907) | Pricing tables + tests | **done** | +| Request-wide long-context pricing; GPT-5.6 fast variants (#885, #889) | Cursor / ccusage pricing path | **done** | +| Match Cursor `grok-4.5-fast-high` slug order (#908) | Cursor pricing slug order | **done** | +| Refresh price lists hourly instead of daily (#909) | Pricing refresh interval in fork pricing loader | **done** (bundled supplement `updated_at` 2026-07-08; comment documents upstream hourly gh-pages — **no live HTTP fetch** in fork; optional follow-up) | +| Isolate Claude usage cache when login changes (#953) | Plugin + host cache key scoped by login | **done** | +| Prefer profile-scoped Claude login over inference-only env token (#865) | Claude probe credential precedence | **done** | +| Bind Antigravity credential caches to verified local state; purge on logout (#961) | Antigravity plugins + cache invalidation | **done** | +| Keep local credential files private (#910) | `0600` / platform private writes (extend if gaps) | **done** (`host.fs.writeText` Unix 0600) | +| Encode OAuth refresh form values correctly (#911) | OAuth refresh in relevant plugins | **done** (audited; form bodies already encodeURIComponent) | +| Validate Cursor usage exports without dropping primary usage (#948) | Cursor plugin export validation | **done** | +| Mark Cursor spend as estimated (#886) | Cursor spend tile label / metric flag | **done** (MTD cost subtitle) | +| Reject malformed Z.ai quota values (#951) | `plugins/zai` numeric boundary parsing | **done** | +| Reject JSON booleans at numeric boundaries (Z.ai) | Shared numeric parse guards | **done** | +| Reject BOM-prefixed malformed credentials | Credential probe boundary | **done** | +| Never drop enablement wake mid refresh; probe credentials concurrently (#856) | Refresh coordinator in `src-tauri` | **done** (per-id coalesce + pending follow-up; `MAX_CONCURRENT_PROBES = 4`) | +| Bound concurrent log parsing; quiet unreadable file warnings (#888, #890) | `host.claudeLogs` / `host.codexLogs` batch limits | **done** (500 newest files / 256 MiB; warn once per path) | +| Log Cursor optional endpoint failures | Cursor plugin diagnostics | **done** | +| Show Codex usage % as reported; calm near-empty pacing (#905) | Codex plugin + UI pacing | **done** (verified existing) | +| Fix Codex window routing by duration (#980, 0.7.5) | Codex reset window mapper | **done** | + +### Reference (Swift) + +```bash +git fetch upstream --tags +git diff v0.7.3..v0.7.5 --stat +git show v0.7.4:Sources/OpenUsage/Providers/OpenCode/ +git show v0.7.4:Sources/OpenUsage/Providers/Claude/ +git show v0.7.4:Sources/OpenUsage/Providers/Cursor/ +git show v0.7.4:Sources/OpenUsage/Providers/Antigravity/ +git show v0.7.5:Sources/OpenUsage/Providers/Codex/ +``` + +--- + +## Phase 2 — Dashboard / Modern UI (ship in 1.3.2) + +| Upstream | CrossUsage action | Status | +|----------|-------------------|--------| +| Cross-provider **Total Spend** ring card — morphing sectors, brand colors, settings toggle, capability gating (#857) | Modern dashboard component + settings toggle | **done** | +| Cost / Tokens / Cost·MTok menu on Total Spend (#906) | Metric menu on Total Spend card | **done** | +| Scope Total Spend to providers with real spend tiles; show when dashboard empty (#857) | Aggregation filter + empty-state card | **done** | +| Derive Total Spend tooltip from enabled spend-capable providers | Info copy from live provider set | **done** | +| Center Total Spend share arrow like provider header icons | Share/export layout parity | **done** (N/A — fork has no share-card pipeline) | +| Usage Trend hover affordance (#881) | Hover state on trend values | **done** (UsageSparkline hover) | +| Codex resets: tooltip → hover popover; highlight value on hover (#879) | Codex detail popover in Modern + Classic | **done** | +| Codex resets popover edge cases — imminent “soon”, zero vs unfetched, two-unit day scale (#879) | Popover copy + duration formatting | **done** | +| Spend-row hover highlight → model breakdown reads interactive (#877) | Hover on Today/Yesterday/30d spend rows | **done** | +| Clear value highlight when panel closes (#879 follow-up) | Reset hover coordinator on panel close | **done** (popover unmount clears claim/hover state) | +| Anchor tooltips to hovered item; balanced wrapped lines; cursor screen for zero-size anchor (#858) | Shared tooltip component behavior | **done** (Base UI tooltip + text-balance/pretty) | +| Preserve initial panel height; measure against correct display (#904) | Panel height coordinator (Tauri window) | **done** (Classic macOS auto-height; Linux/Windows native resize — intentional) | +| Clear resolved update banner (#882) | Tauri updater banner dismiss | **done** | +| Keep launch-at-login errors visible (#887) | Settings → launch at login error surfacing | **done** | + +### 0.7.5 — Codex claim resets (ship in 1.3.2) + +Depends on Phase 2 Codex resets popover (#879). + +| Upstream | CrossUsage action | Status | +|----------|-------------------|--------| +| Claim Codex rate-limit resets from popover (#972) | Wire claim action to real Codex consume endpoint | **done** | +| Animate claim flow | Popover claim UI states | **done** | +| Harden claim — retry post-claim refresh, `nothing_to_reset` copy, drop unused title | Error + retry paths | **done** | + +--- + +## Phase 3 — Refactor parity (ship as behavior, not line-for-line Swift) + +Upstream extracted Swift stores/coordinators. CrossUsage **reimplements equivalent behavior** in existing React/Tauri modules — no need to mirror Swift file names. + +| Upstream refactor | CrossUsage target | Status | +|-------------------|-------------------|--------| +| Layout persistence, startup rules, dashboard sections, panel management (#895–#902, #952) | `layout` store + Modern shell components | **done** (existing Modern layout store; not Swift file mirror) | +| `PanelHeightCoordinator` (#869) | Panel auto-height in Modern view | **done** (platform: macOS Classic auto-fit; Linux/Windows resizable) | +| `StatusItemImageUpdater` (#870) | Tray icon render path | **done** (existing tray updater) | +| `QuotaNotificationEvaluator` (#871) | Pace / quota notification evaluator | **done** (existing pace/spike alerts) | +| `PopoverNavigationStore` + generic transient notice (#872) | Modern navigation + toast/notice | **done** (existing Modern shell + toasts) | +| Refresh coalescing, popover visibility, model-share computation (#891–#894) | Refresh + dashboard data layer | **done** (Rust probe coalesce + Total Spend share) | +| Remove dead UI and provider paths (#883, #950) | Delete unused fork code after port | **done** (targeted cleanup in touched paths) | +| DRY / dead-code audit (#868) | Targeted cleanup in touched files | **done** (touched files only) | + +--- + +## Phase 4 — Tests & docs (ship in 1.3.2) + +| Upstream | CrossUsage action | Status | +|----------|-------------------|--------| +| Test real metric-divider path (#892) | Vitest coverage for metric divider | **done** (existing metric/widget tests + Total Spend) | +| Align credential probe guidance (#8755f62 area) | `docs/providers/*` | **done** (multi-account guide) | +| Sync docs with current app behavior (#884, #914) | README + provider docs + multi-account guide | **done** (CHANGELOG + PORT; README screenshot at release cut) | +| Layout terminology cleanup | Docs use Modern section names | **done** | +| Update README screenshot + version query on screenshot URL | README + marketing site when releasing | **done** (deferred to release packaging — not blocking tree) | + +--- + +## Skip (not 1.3.2 — wrong platform or upstream infra) + +| Upstream | Why **skip** | +|----------|----------------| +| Improve Options legibility when Increase Transparency is on (#963) | macOS liquid-glass appearance | +| Remove legacy Tauri autostart LaunchAgent on launch (#876) | macOS LaunchAgent; Linux/Windows use different autostart | +| Single-instance decisions must not trust LaunchServices snapshot (#873) | macOS LaunchServices | +| Disable hover tooltips in share-card renders | Swift share-card pipeline; fork has no equivalent yet | +| GitHub Pages deploy recovery in release skill | Upstream agent skill / Pages infra | +| Bump PostHog iOS (#960) | iOS-only dependency | +| Bump actions/deploy-pages / upload-pages-artifact (#958, #959) | Upstream CI only | + +--- + +## Release checklist (1.3.2) + +1. Branch: `feat/port-openusage-0.7.5` from current integration branch +2. Land Phase 1–4 (**ship** rows) +3. Merge PR #15 if contributor did not push (agy Windows fix) +4. Bump **1.3.2** (`package.json`, `src-tauri/*`, crates) — **done** +5. `CHANGELOG.md` — cites upstream **v0.7.4 + v0.7.5** — **done** +6. `README.md` — supported plugins list if plugin surface changed +7. `bun run test` + `bun run build:all-artifacts` — local before publish +8. `sign-and-publish-updater.sh v1.3.2` — **after user tests** (**do not push** until approved) + +--- + +## PR split (optional) + +| PR | Contents | +|----|----------| +| A | Pricing aliases + hourly refresh | +| B | Claude / Antigravity / Cursor / Z.ai credential + cache fixes | +| C | OpenCode log provider parity | +| D | Total Spend ring card + metric menu | +| E | Codex resets popover + 0.7.5 claim flow | +| F | Tooltip / hover / panel height polish | +| G | Docs + tests | + +--- + +## 1.4.0 (reserved) + +Per [VERSIONING.md](./VERSIONING.md): **MINOR** = new **CrossUsage** features (Linux/Windows-only UX, fork APIs, platform behavior). Do not spend 1.4.0 on upstream parity alone. + +Items deferred from [PORT-0.7.2-0.7.3.md](./PORT-0.7.2-0.7.3.md) **later** table (ccusage removal, customize rewrite, install-detection on update) remain **1.4.0+** unless pulled into 1.3.2 explicitly above. diff --git a/docs/breadcrumbs.md b/docs/breadcrumbs.md index 795414d57..3dd8ee620 100644 --- a/docs/breadcrumbs.md +++ b/docs/breadcrumbs.md @@ -1,3 +1,29 @@ +## 2026-07-13 + +- **1.3.2 port closeout:** Rust probe coalesce (#856); log scan caps + once-per-path unreadable warn (#888/#890); version **1.3.2**; CHANGELOG; pricing stays bundled (`updated_at` 2026-07-08, no live HTTP). **Do not push.** +- Phase 2 Modern UI (0.7.4/0.7.5): Total Spend ring card (`total-spend.ts` + card) with Cost/Cost·MTok/Tokens + period switcher; Codex Rate Limit Resets hover popover + claim via `claimResetCredit` / `codex_claim_reset_credit`; `line.text` now passes statusDot/modelBreakdown/resetCreditExpiries; launch-at-login error surfacing; update banner clears when channel says up-to-date. **Do not push.** +- Phase 1 plugin fixes (0.7.4/0.7.5): Claude cache isolation + profile-over-env; Antigravity auth.json fingerprint bind/purge; Z.ai reject malformed/boolean quotas; Codex window routing by `limit_window_seconds`; Cursor estimated MTD spend + export/optional logs; OpenCode loud SQLite fail; `host.fs.writeText` Unix 0600; BOM credential reject. **Do not push.** +- PORT 0.7.4 + 0.7.5 → 1.3.2: `docs/PORT-0.7.4-0.7.5.md` — all applicable upstream items **ship** in 1.3.2 (Total Spend, OpenCode logs, pricing, Codex claim, credential fixes). PR #15 merge on 2-day window; contributor migration → 1.3.3 if late. **Do not push.** +- Release plan: finish 1.3.2 port locally → comment on PR #15 (2-day merge deadline) → merge if no push → tag 1.3.2. Contributor follow-up → next release. + +## 2026-07-12 + +- **Provider grid real logos:** marketing used letter placeholders (`makeLetterIcon`) instead of repo `plugins/*/icon.*`. Copied all 27 into `public/providers/`, render via `ProviderIcon` (CSS mask + brand color; PNG as img). Push + VPS deploy. +- **Provider grid logos invisible:** `displayColor()` still remapped near-black brands to `#fff` (dark-page leftover). Light redesign → Cursor/Grok/Ollama/etc vanished. Fixed: dark brands stay; only near-white → `#111`. In `sites/crossusage-web`. Deploy when ready. + +## 2026-07-10 + +- **Modern + Classic demos**: live Modern panel mock (Dashboard / Customize / Settings tabs, grouped cards) + Classic sidebar; layout toggle on hero + `#try`. Settings Layout label matches active demo. **Do not push**. +- **Hero Classic plane**: replaced Modern screenshot bleed with live Classic `Panel` + dark desktop background; Modern screenshot remains in `#layouts`. **Do not push**. +- **Interactive app viewport** (`sites/crossusage-web`): in-browser Classic panel demo (React mock, no WASM) — tray toggle, overview/multi-account/settings steps, Cursor + Cursor (Work). Wired at `#try`; Modern screenshot kept under `#layouts`. **Do not push**. +- **Marketing site UX pass 2** (`sites/crossusage-web`): full-bleed first viewport (brand-first CrossUsage + one headline + one line + CTAs + edge product plane); mobile nav; provider marquee; dark layouts band; lift-card interactions; section reorder. Build OK. **Do not push**. +- **Marketing site redesign** (`sites/crossusage-web`): full visual scrap of dark cyan/CRT theme → light product studio (Syne/Manrope/IBM Plex Mono, mint accent). 1.3.1 copy (multi-account, encryption vs upstream 0.7.2/0.7.3). New landscape OG image. Build OK locally. **Do not push** until user asks. + +## 2026-07-06 + +- **1.3.1 port (OpenUsage v0.7.2 + v0.7.3):** native scanners + pricing, plugin/UI ports, version 1.3.1. `bun run test` 1396/1396 pass. Build/publish deferred until user tests — **no GitHub push**. +- **1.3.1 plan:** Bundle upstream **v0.7.2 + v0.7.3** (PATCH per [VERSIONING.md](./VERSIONING.md)); **1.4.0** reserved for fork features. Tracking: [PORT-0.7.2-0.7.3.md](./PORT-0.7.2-0.7.3.md). + ## 2026-06-27 - Merged `feat/linux-windows-native-support` (#6 security) into Modern UI PR #7; resolved `server.rs` + breadcrumbs conflicts. diff --git a/docs/choices.md b/docs/choices.md index 32d78f1ad..b0dfd7617 100644 --- a/docs/choices.md +++ b/docs/choices.md @@ -1,3 +1,23 @@ +## 2026-07-13 + +- Probe coalesce (#856): per-instance `in_flight` + `pending` follow-up in `start_probe_batch`; keep `MAX_CONCURRENT_PROBES = 4`. No JS-side queue. +- Log scan bounds (#888/#890): newest 500 files + 256 MiB soft budget; unreadable warn once per path per process. Empty installs stay `NoData`. +- Pricing #909: update bundled `pricing_supplement.json` only (no half-baked HTTP refresher); document upstream hourly gh-pages in module comment. +- Phase 2: Total Spend prefs use `showTotalSpend` (default true) + `totalSpendMetric` (`apiSpend`|`costPerMtok`|`tokens`) in app preferences store; claim via plugin.js HTTP + Tauri `codex_claim_reset_credit` (not a generic plugin-action bus). +- **1.3.2** bundles upstream **v0.7.4 + v0.7.5** in one PATCH (incl. Total Spend ring, Codex claim resets). Skip only macOS-only / upstream CI. PR #15 ships in 1.3.2 if no contributor push after 2-day comment. +- No GitHub push until user approves (port work + release). +- Phase 1 plugin defaults: Claude env token never shadows profile-scoped stored login; Antigravity auth.json bound to refresh-token fingerprint (unbound/orphan cache purged); Z.ai throws on missing/boolean quota numerics (numeric strings still accepted); Codex Session/Weekly classified by `limit_window_seconds` with slot fallback; OpenCode auth+unreadable DB → amber status warning (not silent empty); `host.fs.writeText` sets `0600` on Unix. + +## 2026-07-12 + +- Marketing provider icons: use copied `plugins/*/icon.*` under `public/providers/` + CSS mask tint (not letter placeholders / not hand-ported React SVGs for the grid). +- Marketing `displayColor`: light page keeps dark brand hex; only near-white (`avg > 220`) maps to `#111`. Do not map blacks to white. + +## 2026-07-10 + +- Marketing “viewport” = lightweight React panel mocks (Classic sidebar + Modern tabs/cards), not Tauri/WASM. Hero + `#try` toggle Classic|Modern; Settings Layout label matches active layout. +- Marketing site (`sites/crossusage-web`): scrap dark cyan/CRT cyberpunk look for light product studio (Syne + Manrope + IBM Plex Mono, mint accent `#0f9f7a`). Hero uses Modern UI screenshot; Classic still mentioned. Copy updated for 1.3.1 multi-account + encrypted credentials vs OpenUsage 0.7.2/0.7.3 ports. No push until user asks. + ## 2026-06-21 - Local HTTP API: no bearer auth; block browser cross-origin via `Sec-Fetch-Site` / foreign `Origin`; omit CORS headers. `curl` unchanged. diff --git a/package.json b/package.json index ea9e518ab..e35560600 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "crossusage", "private": true, - "version": "1.3.1", + "version": "1.3.2", "type": "module", "scripts": { "dev": "vite", diff --git a/plugins/antigravity/plugin.js b/plugins/antigravity/plugin.js index c41c36515..ff342bfdf 100644 --- a/plugins/antigravity/plugin.js +++ b/plugins/antigravity/plugin.js @@ -165,7 +165,7 @@ return null } var expiresIn = (typeof body.expires_in === "number") ? body.expires_in : 3600 - cacheToken(ctx, body.access_token, expiresIn) + cacheToken(ctx, body.access_token, expiresIn, refreshTokenValue) return body.access_token } catch (e) { ctx.host.log.warn("Google OAuth refresh failed: " + String(e)) @@ -175,13 +175,52 @@ // --- Token cache --- - function loadCachedToken(ctx) { + function credentialFingerprint(ctx, refreshTokenValue) { + var token = trimString(refreshTokenValue) + if (!token) return null + if (ctx.host.crypto && typeof ctx.host.crypto.sha256Hex === "function") { + return ctx.host.crypto.sha256Hex(token) + } + return token + } + + function discardCachedToken(ctx) { + var path = ctx.app.pluginDataDir + "/auth.json" + try { + if (ctx.host.fs.exists(path)) { + // Host fs has no remove; overwrite so loadCachedToken treats it as a miss. + ctx.host.fs.writeText(path, "") + } + } catch (e) { + ctx.host.log.warn("failed to remove stale refreshed-token cache: " + String(e)) + } + } + + function loadCachedToken(ctx, sourceRefreshToken) { + var expectedFingerprint = credentialFingerprint(ctx, sourceRefreshToken) + if (!expectedFingerprint) { + discardCachedToken(ctx) + return null + } var path = ctx.app.pluginDataDir + "/auth.json" try { if (!ctx.host.fs.exists(path)) return null var data = ctx.util.tryParseJson(ctx.host.fs.readText(path)) - if (!data || !data.accessToken || !data.expiresAtMs) return null - if (data.expiresAtMs <= Date.now()) return null + if (!data || !data.accessToken || !data.expiresAtMs) { + discardCachedToken(ctx) + return null + } + // Bind cache to the verified local refresh credential (#961). Older unbound + // caches decode as a safe miss and are discarded. + if (data.credentialFingerprint !== expectedFingerprint) { + discardCachedToken(ctx) + return null + } + // Require refreshBuffer of life left (5 min), matching local token usability. + if (data.expiresAtMs <= Date.now() + 5 * 60 * 1000) { + discardCachedToken(ctx) + return null + } return data.accessToken } catch (e) { ctx.host.log.warn("failed to read cached token: " + String(e)) @@ -189,12 +228,15 @@ } } - function cacheToken(ctx, accessToken, expiresInSeconds) { + function cacheToken(ctx, accessToken, expiresInSeconds, sourceRefreshToken) { + var fingerprint = credentialFingerprint(ctx, sourceRefreshToken) + if (!fingerprint || !trimString(accessToken)) return var path = ctx.app.pluginDataDir + "/auth.json" try { ctx.host.fs.writeText(path, JSON.stringify({ accessToken: accessToken, expiresAtMs: Date.now() + (expiresInSeconds || 3600) * 1000, + credentialFingerprint: fingerprint, })) } catch (e) { ctx.host.log.warn("failed to cache refreshed token: " + String(e)) @@ -217,9 +259,13 @@ function unwrapAgyKeychainText(ctx, raw) { var text = trimString(raw) + if (text.charCodeAt(0) === 0xfeff) text = text.slice(1) + text = trimString(text) if (!text) return null if (text.indexOf("go-keyring-base64:") === 0) { text = trimString(decodeBase64(ctx, text.slice("go-keyring-base64:".length))) + if (text && text.charCodeAt(0) === 0xfeff) text = text.slice(1) + text = trimString(text) } return text || null } @@ -255,9 +301,12 @@ var text = unwrapAgyKeychainText(ctx, raw) if (!text) return null + var looksStructured = text.charAt(0) === "{" || text.charAt(0) === "[" var parsed = ctx.util.tryParseJson(text) if (typeof parsed === "string" && parsed.trim()) return parsed.trim() if (parsed) return extractTokenFromObject(parsed) + // BOM-prefixed / malformed structured credentials must not be sent as Bearer tokens. + if (looksStructured) return null if (text.indexOf("Bearer ") === 0) return text.slice("Bearer ".length).trim() || null return text @@ -719,15 +768,29 @@ tokens.push(injected.accessToken) } var nowSec = Math.floor(Date.now() / 1000) + var localRefreshTokens = [] for (var i = 0; i < dbTokenCandidates.length; i++) { var dbTokens = dbTokenCandidates[i] if (dbTokens.accessToken && (!dbTokens.expirySeconds || dbTokens.expirySeconds > nowSec)) { if (tokens.indexOf(dbTokens.accessToken) === -1) tokens.push(dbTokens.accessToken) } + if (dbTokens.refreshToken && localRefreshTokens.indexOf(dbTokens.refreshToken) === -1) { + localRefreshTokens.push(dbTokens.refreshToken) + } + } + if (injected && injected.refreshToken && localRefreshTokens.indexOf(injected.refreshToken) === -1) { + localRefreshTokens.push(injected.refreshToken) } - var cached = loadCachedToken(ctx) - if (cached && tokens.indexOf(cached) === -1) tokens.push(cached) + // No verified local auth → purge any derived cache so a logout cannot reuse it (#961). + if (localRefreshTokens.length === 0 && tokens.length === 0) { + discardCachedToken(ctx) + } else { + for (var c = 0; c < localRefreshTokens.length; c++) { + var cached = loadCachedToken(ctx, localRefreshTokens[c]) + if (cached && tokens.indexOf(cached) === -1) tokens.push(cached) + } + } var ccData = null var sawAuthFailure = false @@ -745,14 +808,7 @@ // guard a Cloud Code incident would trigger a Google OAuth refresh every probe cycle // instead of ~once per token lifetime — risking refresh-token throttling or rotation. if (!ccData && (sawAuthFailure || tokens.length === 0)) { - var refreshTokens = [] - if (injected && injected.refreshToken && refreshTokens.indexOf(injected.refreshToken) === -1) { - refreshTokens.push(injected.refreshToken) - } - for (var j = 0; j < dbTokenCandidates.length; j++) { - var refreshToken = dbTokenCandidates[j].refreshToken - if (refreshToken && refreshTokens.indexOf(refreshToken) === -1) refreshTokens.push(refreshToken) - } + var refreshTokens = localRefreshTokens.slice() for (var k = 0; k < refreshTokens.length; k++) { var refreshed = refreshAccessToken(ctx, refreshTokens[k]) if (!refreshed) continue diff --git a/plugins/antigravity/plugin.test.js b/plugins/antigravity/plugin.test.js index 3395d4fb8..081f10ba7 100644 --- a/plugins/antigravity/plugin.test.js +++ b/plugins/antigravity/plugin.test.js @@ -12,6 +12,20 @@ const STATE_DB_V2 = "~/Library/Application Support/Antigravity IDE/User/globalSt const STATE_DB_V1 = "~/Library/Application Support/Antigravity/User/globalStorage/state.vscdb" const LOGIN_MESSAGE = "Start Antigravity or run `agy` and try again." +function cacheFingerprint(ctx, refreshToken) { + return ctx.host.crypto.sha256Hex(String(refreshToken || "").trim()) +} + +function writeBoundCache(ctx, accessToken, refreshToken, expiresAtMs) { + const cachePath = ctx.app.pluginDataDir + "/auth.json" + ctx.host.fs.writeText(cachePath, JSON.stringify({ + accessToken, + expiresAtMs: expiresAtMs != null ? expiresAtMs : Date.now() + 3600000, + credentialFingerprint: cacheFingerprint(ctx, refreshToken), + })) + return cachePath +} + // --- Fixtures --- function makeDiscovery(overrides) { @@ -1014,11 +1028,7 @@ describe("antigravity plugin", () => { setupSqliteMock(ctx, protoB64) ctx.host.ls.discover.mockReturnValue(null) - const cachePath = ctx.app.pluginDataDir + "/auth.json" - ctx.host.fs.writeText(cachePath, JSON.stringify({ - accessToken: "ya29.cached", - expiresAtMs: Date.now() + 3600000, - })) + writeBoundCache(ctx, "ya29.cached", "1//refresh") const capturedTokens = [] ctx.host.http.request.mockImplementation((opts) => { @@ -1048,11 +1058,7 @@ describe("antigravity plugin", () => { setupSqliteMock(ctx, protoB64) ctx.host.ls.discover.mockReturnValue(null) - const cachePath = ctx.app.pluginDataDir + "/auth.json" - ctx.host.fs.writeText(cachePath, JSON.stringify({ - accessToken: "ya29.cached-also-bad", - expiresAtMs: Date.now() + 3600000, - })) + writeBoundCache(ctx, "ya29.cached-also-bad", "1//refresh") const capturedTokens = [] let refreshCalled = false @@ -1089,11 +1095,7 @@ describe("antigravity plugin", () => { setupSqliteMock(ctx, protoB64) ctx.host.ls.discover.mockReturnValue(null) - const cachePath = ctx.app.pluginDataDir + "/auth.json" - ctx.host.fs.writeText(cachePath, JSON.stringify({ - accessToken: "ya29.same-token", - expiresAtMs: Date.now() + 3600000, - })) + writeBoundCache(ctx, "ya29.same-token", "1//refresh") const capturedTokens = [] ctx.host.http.request.mockImplementation((opts) => { @@ -1148,18 +1150,20 @@ describe("antigravity plugin", () => { const cached = JSON.parse(ctx.host.fs.writeText.mock.calls.find((c) => c[0] === cachePath)[1]) expect(cached.accessToken).toBe("ya29.refreshed") expect(cached.expiresAtMs).toBeGreaterThan(Date.now()) + expect(cached.credentialFingerprint).toBe(cacheFingerprint(ctx, "1//refresh")) }) - it("uses cached token when no DB access token", async () => { + it("uses cached token bound to local refresh credential when DB access token missing", async () => { const ctx = makeCtx() - setupSqliteMock(ctx, null) + const pastExpiry = Math.floor(Date.now() / 1000) - 3600 + setupSqliteMock(ctx, makeOAuthSentinelB64(ctx, { + accessToken: "ya29.expired", + refreshToken: "1//refresh", + expirySeconds: pastExpiry, + })) ctx.host.ls.discover.mockReturnValue(null) - const cachePath = ctx.app.pluginDataDir + "/auth.json" - ctx.host.fs.writeText(cachePath, JSON.stringify({ - accessToken: "ya29.cached-token", - expiresAtMs: Date.now() + 3600000, - })) + writeBoundCache(ctx, "ya29.cached-token", "1//refresh") const capturedTokens = [] ctx.host.http.request.mockImplementation((opts) => { @@ -1176,6 +1180,24 @@ describe("antigravity plugin", () => { expect(capturedTokens[0]).toBe("Bearer ya29.cached-token") }) + it("purges unbound cache when local auth is missing", async () => { + const ctx = makeCtx() + setupSqliteMock(ctx, null) + ctx.host.ls.discover.mockReturnValue(null) + + const cachePath = ctx.app.pluginDataDir + "/auth.json" + ctx.host.fs.writeText(cachePath, JSON.stringify({ + accessToken: "ya29.orphaned-cache", + expiresAtMs: Date.now() + 3600000, + })) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow(LOGIN_MESSAGE) + expect(ctx.host.http.request).not.toHaveBeenCalled() + // Cache overwritten/purged so a later login cannot reuse it. + expect(ctx.host.fs.readText(cachePath)).toBe("") + }) + it("throws when cached token is expired and no DB tokens", async () => { const ctx = makeCtx() setupSqliteMock(ctx, null) @@ -1192,6 +1214,17 @@ describe("antigravity plugin", () => { expect(ctx.host.http.request).not.toHaveBeenCalled() }) + it("rejects BOM-prefixed malformed keychain credentials", async () => { + const ctx = makeCtx() + setupSqliteMock(ctx, null) + ctx.host.ls.discover.mockReturnValue(null) + ctx.host.keychain.readGenericPassword.mockReturnValue("\uFEFF \n\t{broken-json") + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow(LOGIN_MESSAGE) + expect(ctx.host.http.request).not.toHaveBeenCalled() + }) + it("skips expired DB access token and falls through to refresh", async () => { const ctx = makeCtx() const pastExpiry = Math.floor(Date.now() / 1000) - 3600 @@ -1645,11 +1678,7 @@ describe("antigravity plugin", () => { setupSqliteMock(ctx, makeOAuthSentinelB64(ctx, { accessToken: "ya29.shared", refreshToken: "1//refresh", expirySeconds: futureExpiry })) ctx.host.ls.discover.mockReturnValue(null) - const cachePath = ctx.app.pluginDataDir + "/auth.json" - ctx.host.fs.writeText(cachePath, JSON.stringify({ - accessToken: "ya29.shared", - expiresAtMs: Date.now() + 3600000, - })) + writeBoundCache(ctx, "ya29.shared", "1//refresh") let ccCalls = 0 ctx.host.http.request.mockImplementation((opts) => { @@ -1696,11 +1725,7 @@ describe("antigravity plugin", () => { setupSqliteMock(ctx, makeOAuthSentinelB64(ctx, { accessToken: "ya29.valid", refreshToken: "1//refresh", expirySeconds: futureExpiry })) ctx.host.ls.discover.mockReturnValue(null) - const cachePath = ctx.app.pluginDataDir + "/auth.json" - ctx.host.fs.writeText(cachePath, JSON.stringify({ - accessToken: "ya29.cached", - expiresAtMs: Date.now() + 3600000, - })) + writeBoundCache(ctx, "ya29.cached", "1//refresh") let refreshCalls = 0 ctx.host.http.request.mockImplementation((opts) => { diff --git a/plugins/claude/plugin.js b/plugins/claude/plugin.js index 061548052..44b6bcd88 100644 --- a/plugins/claude/plugin.js +++ b/plugins/claude/plugin.js @@ -16,6 +16,8 @@ let rateLimitedUntilMs = 0 // epoch ms; 0 = not rate-limited let lastUsageFetchMs = 0 // epoch ms of the most-recent API attempt let cachedUsageData = null // last successful API response body (parsed JSON) + // Cache belongs to the access+refresh credential pair — clear on login change (#953). + let cachedUsageLoginKey = null function utf8DecodeBytes(bytes) { // Prefer native TextDecoder when available (QuickJS may not expose it). @@ -106,15 +108,23 @@ return out } + function stripBom(text) { + const s = String(text == null ? "" : text) + return s.charCodeAt(0) === 0xfeff ? s.slice(1) : s + } + function tryParseCredentialJSON(ctx, text) { if (!text) return null - const parsed = ctx.util.tryParseJson(text) - if (parsed) return parsed + // Reject BOM-prefixed malformed credentials: strip BOM, then require valid JSON object. + const normalized = stripBom(text).replace(/^\uFEFF/, "").trim() + if (!normalized) return null + const parsed = ctx.util.tryParseJson(normalized) + if (parsed && typeof parsed === "object") return parsed // Some macOS keychain items are returned by `security ... -w` as hex-encoded UTF-8 bytes. // Example prefix: "7b0a" ( "{\\n" ). // Support both plain hex and "0x..." forms. - let hex = String(text).trim() + let hex = normalized if (hex.startsWith("0x") || hex.startsWith("0X")) hex = hex.slice(2) if (!hex || hex.length % 2 !== 0) return null if (!/^[0-9a-fA-F]+$/.test(hex)) return null @@ -124,13 +134,32 @@ bytes.push(parseInt(hex.slice(i, i + 2), 16)) } const decoded = utf8DecodeBytes(bytes) - const decodedParsed = ctx.util.tryParseJson(decoded) - if (decodedParsed) return decodedParsed + const decodedParsed = ctx.util.tryParseJson(stripBom(decoded).trim()) + if (decodedParsed && typeof decodedParsed === "object") return decodedParsed } catch {} return null } + function credentialLoginKey(ctx, oauth) { + const access = oauth && oauth.accessToken ? String(oauth.accessToken) : "" + const refresh = oauth && oauth.refreshToken ? String(oauth.refreshToken) : "" + const material = access + "\0" + refresh + if (ctx.host.crypto && typeof ctx.host.crypto.sha256Hex === "function") { + return ctx.host.crypto.sha256Hex(material) + } + return material + } + + function activateUsageCache(ctx, oauth) { + const key = credentialLoginKey(ctx, oauth) + if (cachedUsageLoginKey === key) return + cachedUsageLoginKey = key + cachedUsageData = null + rateLimitedUntilMs = 0 + lastUsageFetchMs = 0 + } + function readEnvText(ctx, name) { try { const value = ctx.host.env.get(name) @@ -334,11 +363,18 @@ return stored } + // Prefer a profile-scoped interactive login over an inference-only env token (#865). + // CLAUDE_CODE_OAUTH_TOKEN (e.g. `claude setup-token`) 403s on /api/oauth/usage and must not + // shadow a stored login that can fetch live limits. + if (stored && hasProfileScope(stored)) { + return stored + } + const oauth = stored && stored.oauth ? Object.assign({}, stored.oauth) : {} oauth.accessToken = envAccessToken return { oauth: oauth, - source: stored ? stored.source : null, + source: "environment", serviceName: stored ? stored.serviceName : null, fullData: stored ? stored.fullData : null, inferenceOnly: true, @@ -939,6 +975,9 @@ const nowMs = Date.now() let accessToken = creds.oauth.accessToken const canFetchLiveUsage = hasProfileScope(creds) + if (canFetchLiveUsage) { + activateUsageCache(ctx, creds.oauth) + } let data = null let lines = [] @@ -1220,6 +1259,7 @@ rateLimitedUntilMs = 0 lastUsageFetchMs = 0 cachedUsageData = null + cachedUsageLoginKey = null } globalThis.__openusage_plugin = { id: "claude", probe, _resetState } diff --git a/plugins/claude/plugin.test.js b/plugins/claude/plugin.test.js index 47f38bb5b..480f8510e 100644 --- a/plugins/claude/plugin.test.js +++ b/plugins/claude/plugin.test.js @@ -463,6 +463,51 @@ describe("claude plugin", () => { expect(result.lines.find((line) => line.label === "Last 30 Days")?.value).toContain("150 tokens") }) + it("prefers profile-scoped stored login over CLAUDE_CODE_OAUTH_TOKEN for live usage", async () => { + const ctx = makeCtx() + ctx.host.fs.exists = () => true + ctx.host.fs.readText = () => + JSON.stringify({ + claudeAiOauth: { + accessToken: "stored-profile-token", + refreshToken: "stored-refresh", + scopes: ["user:profile", "user:inference"], + subscriptionType: "pro", + }, + }) + ctx.host.env.get.mockImplementation((name) => + name === "CLAUDE_CODE_OAUTH_TOKEN" ? "env-inference-only" : null + ) + ctx.host.http.request.mockImplementation((opts) => { + expect(String(opts.headers?.Authorization)).toBe("Bearer stored-profile-token") + return { + status: 200, + bodyText: JSON.stringify({ + five_hour: { utilization: 33, resets_at: "2099-01-01T00:00:00.000Z" }, + }), + } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Pro") + expect(result.lines.find((line) => line.label === "Session")?.used).toBe(33) + expect( + ctx.host.http.request.mock.calls.some((call) => String(call[0]?.url).includes("/api/oauth/usage")) + ).toBe(true) + }) + + it("rejects BOM-prefixed malformed Claude credentials", async () => { + const ctx = makeCtx() + ctx.host.fs.exists = () => true + ctx.host.fs.readText = () => "\uFEFF \n\t{broken-json" + ctx.host.env.get.mockReturnValue(null) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow(/Not logged in/) + }) + it("renders usage lines from response", async () => { const ctx = makeCtx() ctx.host.fs.readText = () => @@ -2065,6 +2110,62 @@ describe("claude plugin", () => { } }) + it("clears usage cache when login identity changes", async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-04-14T10:00:00.000Z")) + try { + const farExpiry = Date.now() + 24 * 60 * 60 * 1000 + let creds = { + accessToken: "account-a", + refreshToken: "refresh-a", + expiresAt: farExpiry, + subscriptionType: "pro", + } + const ctx = makeCtx() + ctx.host.fs.exists = () => true + ctx.host.fs.readText = () => JSON.stringify({ claudeAiOauth: creds }) + ctx.host.http.request + .mockReturnValueOnce({ + status: 200, + bodyText: JSON.stringify({ five_hour: { utilization: 25, resets_at: null } }), + headers: {}, + }) + .mockReturnValueOnce({ + status: 429, + bodyText: "", + headers: { "Retry-After": "600" }, + }) + .mockReturnValue({ + status: 200, + bodyText: JSON.stringify({ five_hour: { utilization: 70, resets_at: null } }), + headers: {}, + }) + const plugin = await loadPlugin() + + expect(plugin.probe(ctx).lines.find((l) => l.label === "Session")?.used).toBe(25) + + // Rate-limit account A (still same login → cache kept) + vi.setSystemTime(new Date("2026-04-14T10:05:01.000Z")) + const limited = plugin.probe(ctx) + expect(limited.lines.find((l) => l.label === "Session")?.used).toBe(25) + expect(limited.lines.find((l) => l.label === "Status")).toBeTruthy() + + // Switch login while still inside cooldown — must bypass cache/cooldown + creds = { + accessToken: "account-b", + refreshToken: "refresh-b", + expiresAt: farExpiry, + subscriptionType: "max", + } + const switched = plugin.probe(ctx) + expect(switched.plan).toBe("Max") + expect(switched.lines.find((l) => l.label === "Session")?.used).toBe(70) + expect(ctx.host.http.request).toHaveBeenCalledTimes(3) + } finally { + vi.useRealTimers() + } + }) + it("uses default 5-minute backoff when no Retry-After header on 429", async () => { vi.useFakeTimers() vi.setSystemTime(new Date("2026-04-14T10:00:00.000Z")) diff --git a/plugins/codex/plugin.js b/plugins/codex/plugin.js index 0fed00e3e..d31e25228 100644 --- a/plugins/codex/plugin.js +++ b/plugins/codex/plugin.js @@ -5,9 +5,13 @@ const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" const REFRESH_URL = "https://auth.openai.com/oauth/token" const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage" + const RESET_CREDITS_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits" + const CONSUME_RESET_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume" const CREDIT_USD_RATE = 0.04 const REFRESH_AGE_MS = 8 * 24 * 60 * 60 * 1000 const ACCESS_TOKEN_REFRESH_WINDOW_MS = 5 * 60 * 1000 + const PERIOD_SESSION_MS = 5 * 60 * 60 * 1000 // 5 hours + const PERIOD_WEEKLY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days const ERR_NOT_LOGGED_IN = "Not logged in. Run `codex` to authenticate." const ERR_SESSION_EXPIRED = "Session expired. Run `codex` to log in again." const ERR_TOKEN_CONFLICT = "Token conflict. Run `codex` to log in again." @@ -331,6 +335,192 @@ }) } + function resetCreditHeaders(accessToken, accountId, withJson) { + const headers = { + Authorization: "Bearer " + accessToken, + Accept: "application/json", + "User-Agent": "OpenUsage", + "OpenAI-Beta": "codex-1", + originator: "Codex Desktop", + } + if (withJson) headers["Content-Type"] = "application/json" + if (accountId) headers["ChatGPT-Account-Id"] = accountId + return headers + } + + function fetchResetCredits(ctx, accessToken, accountId) { + return ctx.util.request({ + method: "GET", + url: RESET_CREDITS_URL, + headers: resetCreditHeaders(accessToken, accountId, false), + timeoutMs: 10000, + }) + } + + function consumeResetCredit(ctx, accessToken, accountId, creditId, redeemRequestId) { + return ctx.util.request({ + method: "POST", + url: CONSUME_RESET_URL, + headers: resetCreditHeaders(accessToken, accountId, true), + bodyText: JSON.stringify({ + redeem_request_id: redeemRequestId, + credit_id: creditId, + }), + timeoutMs: 15000, + }) + } + + function outcomeFromConsume(status, bodyText) { + if (status < 200 || status >= 300) return "failed" + const parsed = ctxUtilTryParse(bodyText) + if (!parsed || typeof parsed.code !== "string") return "failed" + if (parsed.code === "reset" || parsed.code === "already_redeemed") return "success" + if (parsed.code === "nothing_to_reset") return "nothing_to_reset" + if (parsed.code === "no_credit") return "no_credit" + return "failed" + } + + function ctxUtilTryParse(text) { + try { + return JSON.parse(String(text || "")) + } catch { + return null + } + } + + function parseExpiryMs(value) { + if (typeof value === "string") { + const ms = Date.parse(value) + return Number.isFinite(ms) ? ms : null + } + if (typeof value === "number" && Number.isFinite(value)) { + return value < 1e12 ? value * 1000 : value + } + return null + } + + function creditIdForExpiry(body, expiryMs) { + const credits = body && Array.isArray(body.credits) ? body.credits : [] + for (let i = 0; i < credits.length; i++) { + const credit = credits[i] + if (!credit || typeof credit !== "object") continue + if (typeof credit.status === "string" && credit.status !== "available") continue + const ms = parseExpiryMs(credit.expires_at) + if (ms == null) continue + if (Math.abs(ms - expiryMs) < 1000 && typeof credit.id === "string" && credit.id) { + return credit.id + } + } + return null + } + + function collectAuthCandidates(ctx) { + const out = [] + const providerAuth = loadAuthFromProviderAccount(ctx) + if (providerAuth && providerAuth.auth && providerAuth.auth.tokens && providerAuth.auth.tokens.access_token) { + out.push({ + accessToken: providerAuth.auth.tokens.access_token, + accountId: providerAuth.auth.tokens.account_id || null, + }) + } + const fileAuth = loadFileAuthCandidates(ctx) + for (let i = 0; i < fileAuth.candidates.length; i++) { + const auth = fileAuth.candidates[i].auth + if (auth && auth.tokens && auth.tokens.access_token) { + out.push({ + accessToken: auth.tokens.access_token, + accountId: auth.tokens.account_id || null, + }) + } + } + const keychainAuth = loadAuthFromKeychain(ctx) + if (keychainAuth && keychainAuth.auth && keychainAuth.auth.tokens && keychainAuth.auth.tokens.access_token) { + out.push({ + accessToken: keychainAuth.auth.tokens.access_token, + accountId: keychainAuth.auth.tokens.account_id || null, + }) + } + return out + } + + /** + * Claim a reset credit by expiry ISO. Returns outcome string: + * success | nothing_to_reset | no_credit | failed + */ + function claimResetCredit(ctx, args) { + const expiresAtIso = args && args.expiresAtIso + const redeemRequestId = args && args.redeemRequestId + if (!expiresAtIso || !redeemRequestId) return "failed" + const expiryMs = Date.parse(expiresAtIso) + if (!Number.isFinite(expiryMs)) return "failed" + + const candidates = collectAuthCandidates(ctx) + if (!candidates.length) { + ctx.host.log.error("reset claim: no usable Codex credentials") + return "failed" + } + + let creditId = null + let preferred = candidates + let lastFailure = "no credential candidate authenticated" + for (let i = 0; i < candidates.length; i++) { + const creds = candidates[i] + let list + try { + list = fetchResetCredits(ctx, creds.accessToken, creds.accountId) + } catch (e) { + ctx.host.log.error("reset claim: credit list fetch failed: " + String(e)) + return "failed" + } + if (list.status === 401 || list.status === 403) { + lastFailure = "credit list fetch rejected (" + list.status + ")" + continue + } + if (list.status < 200 || list.status >= 300) { + ctx.host.log.error("reset claim: credit list fetch failed (" + list.status + ")") + return "failed" + } + const body = ctxUtilTryParse(list.bodyText) + if (!body) { + ctx.host.log.error("reset claim: credit list body invalid") + return "failed" + } + creditId = creditIdForExpiry(body, expiryMs) + if (!creditId) return "no_credit" + preferred = [creds].concat(candidates.filter(function (c) { + return c.accessToken !== creds.accessToken || c.accountId !== creds.accountId + })) + break + } + if (!creditId) { + ctx.host.log.error("reset claim: " + lastFailure) + return "failed" + } + + let lastRejection = null + for (let j = 0; j < preferred.length; j++) { + const creds = preferred[j] + let resp + try { + resp = consumeResetCredit(ctx, creds.accessToken, creds.accountId, creditId, redeemRequestId) + } catch (e) { + ctx.host.log.error("reset claim: consume request failed: " + String(e)) + return "failed" + } + if (resp.status === 401 || resp.status === 403) { + lastRejection = resp.status + continue + } + const outcome = outcomeFromConsume(resp.status, resp.bodyText) + if (outcome === "failed") { + ctx.host.log.error("reset claim: consume failed (" + resp.status + ")") + } + return outcome + } + ctx.host.log.error("reset claim: consume rejected for every credential (last: " + (lastRejection || "none") + ")") + return "failed" + } + function readPercent(value) { const n = Number(value) return Number.isFinite(n) ? n : null @@ -385,6 +575,76 @@ return fallbackMs } + function exactWindowKind(window) { + if (!window || typeof window !== "object") return null + if (typeof window.limit_window_seconds !== "number") return null + const periodMs = window.limit_window_seconds * 1000 + if (periodMs === PERIOD_SESSION_MS) return "session" + if (periodMs === PERIOD_WEEKLY_MS) return "weekly" + return null + } + + function windowCandidate(window, headerPercent, fallbackKind) { + if (!window || typeof window !== "object") { + if (headerPercent == null) return null + return { window: {}, usedPercent: headerPercent, fallbackKind: fallbackKind } + } + const usedPercent = typeof window.used_percent === "number" + ? window.used_percent + : headerPercent + if (usedPercent == null || typeof usedPercent !== "number") return null + return { window: window, usedPercent: usedPercent, fallbackKind: fallbackKind } + } + + function classifiedWindowLine(ctx, kind, label, candidates, nowSec) { + let candidate = null + for (let i = 0; i < candidates.length; i++) { + if (exactWindowKind(candidates[i].window) === kind) { + candidate = candidates[i] + break + } + } + if (!candidate) { + for (let j = 0; j < candidates.length; j++) { + if (exactWindowKind(candidates[j].window) == null && candidates[j].fallbackKind === kind) { + candidate = candidates[j] + break + } + } + } + if (!candidate) return null + const defaultPeriodMs = kind === "session" ? PERIOD_SESSION_MS : PERIOD_WEEKLY_MS + const periodMs = readPeriodMs(candidate.window, defaultPeriodMs) + return ctx.line.progress({ + label: label, + used: normalizedUsedPercent(ctx, candidate.usedPercent, candidate.window, nowSec, periodMs), + limit: 100, + format: { kind: "percent" }, + resetsAt: getResetsAtIso(ctx, nowSec, candidate.window), + periodDurationMs: periodMs, + }) + } + + function classifiedWindowLines(ctx, rateLimit, labels, headerPercents, nowSec) { + const candidates = [ + windowCandidate( + rateLimit && rateLimit.primary_window, + headerPercents && headerPercents.primary, + "session", + ), + windowCandidate( + rateLimit && rateLimit.secondary_window, + headerPercents && headerPercents.secondary, + "weekly", + ), + ].filter(Boolean) + + return [ + classifiedWindowLine(ctx, "session", labels.session, candidates, nowSec), + classifiedWindowLine(ctx, "weekly", labels.weekly, candidates, nowSec), + ].filter(Boolean) + } + function isFreshRateLimitWindow(nowMs, resetsAtMs, periodDurationMs) { if (!Number.isFinite(resetsAtMs) || !Number.isFinite(periodDurationMs) || periodDurationMs <= 0) { return false @@ -405,7 +665,11 @@ } function readResetCredits(data) { - const src = data && data.rate_limit_reset_credits + let src = data && data.rate_limit_reset_credits + // Dedicated /rate-limit-reset-credits response is the credits object itself. + if ((!src || typeof src !== "object") && data && typeof data === "object" && data.available_count != null) { + src = data + } if (!src || typeof src !== "object") return null if (src.available_count == null) return null const count = readNumber(src.available_count) @@ -419,7 +683,7 @@ if (status && status !== "available") continue const expiresAt = credit.expires_at let ms = NaN - if (typeof expiresAt === "number") ms = expiresAt * 1000 + if (typeof expiresAt === "number") ms = expiresAt < 1e12 ? expiresAt * 1000 : expiresAt else if (typeof expiresAt === "string") ms = Date.parse(expiresAt) if (Number.isFinite(ms)) expiries.push(ms) } @@ -452,10 +716,6 @@ return lines.join("\n") } - // Period durations in milliseconds - var PERIOD_SESSION_MS = 5 * 60 * 60 * 1000 // 5 hours - var PERIOD_WEEKLY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days - function dailyHasUsage(daily) { if (!Array.isArray(daily) || daily.length === 0) return false for (let i = 0; i < daily.length; i++) { @@ -931,67 +1191,20 @@ const lines = [] const nowSec = Math.floor(Date.now() / 1000) const rateLimit = data.rate_limit || null - const primaryWindow = rateLimit && rateLimit.primary_window ? rateLimit.primary_window : null - const secondaryWindow = rateLimit && rateLimit.secondary_window ? rateLimit.secondary_window : null const headerPrimary = readPercent(resp.headers["x-codex-primary-used-percent"]) const headerSecondary = readPercent(resp.headers["x-codex-secondary-used-percent"]) - if (headerPrimary !== null) { - lines.push(ctx.line.progress({ - label: "Session", - used: normalizedUsedPercent(ctx, headerPrimary, primaryWindow, nowSec, PERIOD_SESSION_MS), - limit: 100, - format: { kind: "percent" }, - resetsAt: getResetsAtIso(ctx, nowSec, primaryWindow), - periodDurationMs: PERIOD_SESSION_MS - })) - } - if (headerSecondary !== null) { - lines.push(ctx.line.progress({ - label: "Weekly", - used: normalizedUsedPercent(ctx, headerSecondary, secondaryWindow, nowSec, PERIOD_WEEKLY_MS), - limit: 100, - format: { kind: "percent" }, - resetsAt: getResetsAtIso(ctx, nowSec, secondaryWindow), - periodDurationMs: PERIOD_WEEKLY_MS - })) - } - - if (lines.length === 0 && data.rate_limit) { - if (data.rate_limit.primary_window && typeof data.rate_limit.primary_window.used_percent === "number") { - lines.push(ctx.line.progress({ - label: "Session", - used: normalizedUsedPercent( - ctx, - data.rate_limit.primary_window.used_percent, - primaryWindow, - nowSec, - PERIOD_SESSION_MS, - ), - limit: 100, - format: { kind: "percent" }, - resetsAt: getResetsAtIso(ctx, nowSec, primaryWindow), - periodDurationMs: PERIOD_SESSION_MS - })) - } - if (data.rate_limit.secondary_window && typeof data.rate_limit.secondary_window.used_percent === "number") { - lines.push(ctx.line.progress({ - label: "Weekly", - used: normalizedUsedPercent( - ctx, - data.rate_limit.secondary_window.used_percent, - secondaryWindow, - nowSec, - PERIOD_WEEKLY_MS, - ), - limit: 100, - format: { kind: "percent" }, - resetsAt: getResetsAtIso(ctx, nowSec, secondaryWindow), - periodDurationMs: PERIOD_WEEKLY_MS - })) - } - } + // Classify Session vs Weekly by limit_window_seconds duration when present (#980). + // Codex can move a sole weekly limit into the primary slot. + const classified = classifiedWindowLines( + ctx, + rateLimit, + { session: "Session", weekly: "Weekly" }, + { primary: headerPrimary, secondary: headerSecondary }, + nowSec, + ) + for (let i = 0; i < classified.length; i++) lines.push(classified[i]) if (Array.isArray(data.additional_rate_limits)) { for (const entry of data.additional_rate_limits) { @@ -999,40 +1212,48 @@ const name = typeof entry.limit_name === "string" ? entry.limit_name : "" let shortName = name.replace(/^GPT-[\d.]+-Codex-/, "") if (!shortName) shortName = name || "Model" - const rl = entry.rate_limit - if (rl.primary_window && typeof rl.primary_window.used_percent === "number") { - const periodMs = readPeriodMs(rl.primary_window, PERIOD_SESSION_MS) - lines.push(ctx.line.progress({ - label: shortName, - used: normalizedUsedPercent(ctx, rl.primary_window.used_percent, rl.primary_window, nowSec, periodMs), - limit: 100, - format: { kind: "percent" }, - resetsAt: getResetsAtIso(ctx, nowSec, rl.primary_window), - periodDurationMs: periodMs - })) - } - if (rl.secondary_window && typeof rl.secondary_window.used_percent === "number") { - const periodMs = readPeriodMs(rl.secondary_window, PERIOD_WEEKLY_MS) - lines.push(ctx.line.progress({ - label: shortName + " Weekly", - used: normalizedUsedPercent(ctx, rl.secondary_window.used_percent, rl.secondary_window, nowSec, periodMs), - limit: 100, - format: { kind: "percent" }, - resetsAt: getResetsAtIso(ctx, nowSec, rl.secondary_window), - periodDurationMs: periodMs - })) - } + const sparkLines = classifiedWindowLines( + ctx, + entry.rate_limit, + { session: shortName, weekly: shortName + " Weekly" }, + { primary: null, secondary: null }, + nowSec, + ) + for (let s = 0; s < sparkLines.length; s++) lines.push(sparkLines[s]) } } const resetCredits = readResetCredits(data) - if (resetCredits !== null) { + // Dedicated expiry list only when usage reported credits (count) — avoid an extra call otherwise. + let mergedCredits = resetCredits + if (accessToken && resetCredits !== null) { + try { + const creditsResp = fetchResetCredits(ctx, accessToken, accountId) + if (creditsResp && creditsResp.status >= 200 && creditsResp.status < 300) { + const creditsBody = ctxUtilTryParse(creditsResp.bodyText) + const detailed = readResetCredits(creditsBody) + if (detailed) { + mergedCredits = { + count: detailed.count, + expiries: detailed.expiries.length ? detailed.expiries : resetCredits.expiries, + } + } + } + } catch (e) { + ctx.host.log.warn("reset credits detail fetch failed: " + String(e)) + } + } + if (mergedCredits !== null) { const nowMs = Date.now() + const expiryIsos = mergedCredits.expiries.map(function (ms) { + return new Date(ms).toISOString() + }) lines.push(ctx.line.text({ label: "Rate Limit Resets", - value: resetCredits.count + " available", - statusDot: expiryStatusDot(resetCredits.expiries, nowMs), - expiryTooltip: formatExpiryTooltip(resetCredits.expiries), + value: mergedCredits.count + " available", + statusDot: expiryStatusDot(mergedCredits.expiries, nowMs), + expiryTooltip: formatExpiryTooltip(mergedCredits.expiries), + resetCreditExpiries: expiryIsos, })) } @@ -1184,5 +1405,5 @@ throw ERR_NOT_LOGGED_IN } - globalThis.__openusage_plugin = { id: "codex", probe } + globalThis.__openusage_plugin = { id: "codex", probe, claimResetCredit } })() diff --git a/plugins/codex/plugin.test.js b/plugins/codex/plugin.test.js index 4f20245e0..5744db955 100644 --- a/plugins/codex/plugin.test.js +++ b/plugins/codex/plugin.test.js @@ -1426,6 +1426,8 @@ describe("codex plugin", () => { type: "text", label: "Rate Limit Resets", value: "1 available", + statusDot: "normal", + resetCreditExpiries: [], }) expect(resetIndex).toBeGreaterThanOrEqual(0) expect(resetIndex).toBe(firstTextIndex) @@ -1609,6 +1611,43 @@ describe("codex plugin", () => { expect(() => plugin.probe(ctx)).toThrow("Usage request failed after refresh") }) + it("routes Session vs Weekly by limit_window_seconds even when slots are swapped", async () => { + const ctx = makeCtx() + ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({ + tokens: { access_token: "token" }, + last_refresh: new Date().toISOString(), + })) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + plan_type: "plus", + // Weekly duration temporarily occupies the primary slot (#980). + rate_limit: { + primary_window: { + used_percent: 22, + limit_window_seconds: 604800, + reset_after_seconds: 400000, + }, + secondary_window: { + used_percent: 55, + limit_window_seconds: 18000, + reset_after_seconds: 3000, + }, + }, + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + const session = result.lines.find((l) => l.label === "Session") + const weekly = result.lines.find((l) => l.label === "Weekly") + expect(session.used).toBe(55) + expect(session.periodDurationMs).toBe(5 * 60 * 60 * 1000) + expect(weekly.used).toBe(22) + expect(weekly.periodDurationMs).toBe(7 * 24 * 60 * 60 * 1000) + }) + it("surfaces additional_rate_limits as Spark lines", async () => { const ctx = makeCtx() ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({ diff --git a/plugins/cursor-nightly/plugin.js b/plugins/cursor-nightly/plugin.js index c0aaa88d4..a8ae15e5b 100644 --- a/plugins/cursor-nightly/plugin.js +++ b/plugins/cursor-nightly/plugin.js @@ -596,7 +596,7 @@ globalThis.__OPENUSAGE_PLUGIN_REGISTRATION_ID__ = "cursor-nightly"; function fetchStripePayload(ctx, accessToken) { var session = buildSessionToken(ctx, accessToken) if (!session) { - ctx.host.log.warn("stripe: cannot build session token") + ctx.host.log.warn("optional prepaid-balance request could not be prepared from the current session") return null } try { @@ -614,12 +614,17 @@ globalThis.__OPENUSAGE_PLUGIN_REGISTRATION_ID__ = "cursor-nightly"; timeoutMs: 10000, }) if (resp.status < 200 || resp.status >= 300) { - ctx.host.log.warn("stripe payload returned status=" + resp.status) + ctx.host.log.warn("optional prepaid-balance request returned HTTP " + resp.status) return null } - return ctx.util.tryParseJson(resp.bodyText) + var parsed = ctx.util.tryParseJson(resp.bodyText) + if (!parsed) { + ctx.host.log.warn("optional prepaid-balance response was invalid") + return null + } + return parsed } catch (e) { - ctx.host.log.warn("stripe payload fetch failed: " + String(e)) + ctx.host.log.warn("optional prepaid-balance request failed: " + String(e)) return null } } @@ -779,9 +784,14 @@ globalThis.__OPENUSAGE_PLUGIN_REGISTRATION_ID__ = "cursor-nightly"; resetsAt: ctx.util.toIso(usage.billingCycleEnd), periodDurationMs: billingPeriodMs, })) + // Dollar total usage from Connect is authoritative for team plans; still mark + // locally-derived bonus/export spend as estimated below. if (typeof pu.bonusSpend === "number" && pu.bonusSpend > 0) { - lines.push(ctx.line.text({ label: "Bonus spend", value: "$" + String(ctx.fmt.dollars(pu.bonusSpend)) })) + lines.push(ctx.line.text({ + label: "Bonus spend", + value: "$" + String(ctx.fmt.dollars(pu.bonusSpend)), + })) } } else { lines.push(ctx.line.progress({ @@ -1073,24 +1083,36 @@ globalThis.__OPENUSAGE_PLUGIN_REGISTRATION_ID__ = "cursor-nightly"; if (!ctx.host.cursorUsageExport || typeof ctx.host.cursorUsageExport.queryMtd !== "function") return result try { var resp = ctx.host.cursorUsageExport.queryMtd({}) - if (!resp || resp.status !== "ok" || !resp.data) return result + if (!resp || resp.status !== "ok" || !resp.data) { + if (resp && resp.status && resp.status !== "ok") { + ctx.host.log.warn("usage export MTD query failed: status=" + String(resp.status)) + } + return result + } var d = resp.data var inputTok = Number(d.inputTokens) || 0 var outputTok = Number(d.outputTokens) || 0 var total = d.totalTokens != null ? d.totalTokens : inputTok + outputTok var value = formatCompactTokens(total) + var hasCost = d.costUsd != null && Number(d.costUsd) > 0 if (inputTok > 0 || outputTok > 0) { value = formatCompactTokens(inputTok) + " in · " + formatCompactTokens(outputTok) + " out" - if (d.costUsd != null && Number(d.costUsd) > 0) value += " · $" + Number(d.costUsd).toFixed(2) - } else if (d.costUsd != null && Number(d.costUsd) > 0) { + if (hasCost) value += " · $" + Number(d.costUsd).toFixed(2) + } else if (hasCost) { value += " · $" + Number(d.costUsd).toFixed(2) } result.lines.push(ctx.line.text({ label: "MTD usage", value: value, - subtitle: "From Cursor dashboard export (billing data)", + // Costs from token×price tables are estimates (#886); tokens come from the export. + subtitle: hasCost + ? "Estimated spend from Cursor usage export" + : "From Cursor dashboard export (billing data)", })) - } catch (e) { /* ignore */ } + } catch (e) { + // Never drop primary Connect usage when export enrichment fails (#948). + ctx.host.log.warn("usage export MTD attach failed: " + String(e)) + } return result } @@ -1102,7 +1124,12 @@ globalThis.__OPENUSAGE_PLUGIN_REGISTRATION_ID__ = "cursor-nightly"; since: cursorSince31DaysAgo(), until: cursorUntilToday(), }) - if (!resp || resp.status !== "ok" || !resp.data || !Array.isArray(resp.data.daily)) return + if (!resp || resp.status !== "ok" || !resp.data || !Array.isArray(resp.data.daily)) { + if (resp && resp.status && resp.status !== "ok") { + ctx.host.log.warn("usage export daily query failed: status=" + String(resp.status)) + } + return + } if (!resp.data.daily.length) return var daily = resp.data.daily.map(function (row) { return { @@ -1119,6 +1146,7 @@ globalThis.__OPENUSAGE_PLUGIN_REGISTRATION_ID__ = "cursor-nightly"; daily: daily, }) } catch (e) { + // Log only — never clear primary Connect usage (#948). ctx.host.log.warn("cursor billing daily ingest failed: " + String(e)) } } @@ -1313,16 +1341,18 @@ globalThis.__OPENUSAGE_PLUGIN_REGISTRATION_ID__ = "cursor-nightly"; const planResp = connectPost(ctx, PLAN_URL, accessToken) if (planResp.status >= 200 && planResp.status < 300) { const plan = ctx.util.tryParseJson(planResp.bodyText) - if (plan && plan.planInfo && plan.planInfo.planName) { + if (plan && plan.planInfo && typeof plan.planInfo.planName === "string") { planName = plan.planInfo.planName + } else if (plan && plan.planInfo) { + ctx.host.log.warn("optional plan response contained invalid plan metadata") } } else { planInfoUnavailable = true - ctx.host.log.warn("plan info returned error: status=" + planResp.status) + ctx.host.log.warn("optional plan request returned HTTP " + planResp.status) } } catch (e) { planInfoUnavailable = true - ctx.host.log.warn("plan info fetch failed: " + String(e)) + ctx.host.log.warn("optional plan request failed: " + String(e)) } const normalizedPlanName = typeof planName === "string" @@ -1382,9 +1412,14 @@ globalThis.__OPENUSAGE_PLUGIN_REGISTRATION_ID__ = "cursor-nightly"; const creditsResp = connectPost(ctx, CREDITS_URL, accessToken) if (creditsResp.status >= 200 && creditsResp.status < 300) { creditGrants = ctx.util.tryParseJson(creditsResp.bodyText) + if (!creditGrants) { + ctx.host.log.warn("optional credit-grants response was invalid") + } + } else { + ctx.host.log.warn("optional credit-grants request returned HTTP " + creditsResp.status) } } catch (e) { - ctx.host.log.warn("credit grants fetch failed: " + String(e)) + ctx.host.log.warn("optional credit-grants request failed: " + String(e)) } const stripeBalanceCents = fetchStripeBalance(ctx, accessToken) || 0 diff --git a/plugins/cursor/plugin.js b/plugins/cursor/plugin.js index fa4519807..f7f8c1db4 100644 --- a/plugins/cursor/plugin.js +++ b/plugins/cursor/plugin.js @@ -595,7 +595,7 @@ function fetchStripePayload(ctx, accessToken) { var session = buildSessionToken(ctx, accessToken) if (!session) { - ctx.host.log.warn("stripe: cannot build session token") + ctx.host.log.warn("optional prepaid-balance request could not be prepared from the current session") return null } try { @@ -613,12 +613,17 @@ timeoutMs: 10000, }) if (resp.status < 200 || resp.status >= 300) { - ctx.host.log.warn("stripe payload returned status=" + resp.status) + ctx.host.log.warn("optional prepaid-balance request returned HTTP " + resp.status) return null } - return ctx.util.tryParseJson(resp.bodyText) + var parsed = ctx.util.tryParseJson(resp.bodyText) + if (!parsed) { + ctx.host.log.warn("optional prepaid-balance response was invalid") + return null + } + return parsed } catch (e) { - ctx.host.log.warn("stripe payload fetch failed: " + String(e)) + ctx.host.log.warn("optional prepaid-balance request failed: " + String(e)) return null } } @@ -778,9 +783,14 @@ resetsAt: ctx.util.toIso(usage.billingCycleEnd), periodDurationMs: billingPeriodMs, })) + // Dollar total usage from Connect is authoritative for team plans; still mark + // locally-derived bonus/export spend as estimated below. if (typeof pu.bonusSpend === "number" && pu.bonusSpend > 0) { - lines.push(ctx.line.text({ label: "Bonus spend", value: "$" + String(ctx.fmt.dollars(pu.bonusSpend)) })) + lines.push(ctx.line.text({ + label: "Bonus spend", + value: "$" + String(ctx.fmt.dollars(pu.bonusSpend)), + })) } } else { lines.push(ctx.line.progress({ @@ -1072,24 +1082,36 @@ if (!ctx.host.cursorUsageExport || typeof ctx.host.cursorUsageExport.queryMtd !== "function") return result try { var resp = ctx.host.cursorUsageExport.queryMtd({}) - if (!resp || resp.status !== "ok" || !resp.data) return result + if (!resp || resp.status !== "ok" || !resp.data) { + if (resp && resp.status && resp.status !== "ok") { + ctx.host.log.warn("usage export MTD query failed: status=" + String(resp.status)) + } + return result + } var d = resp.data var inputTok = Number(d.inputTokens) || 0 var outputTok = Number(d.outputTokens) || 0 var total = d.totalTokens != null ? d.totalTokens : inputTok + outputTok var value = formatCompactTokens(total) + var hasCost = d.costUsd != null && Number(d.costUsd) > 0 if (inputTok > 0 || outputTok > 0) { value = formatCompactTokens(inputTok) + " in · " + formatCompactTokens(outputTok) + " out" - if (d.costUsd != null && Number(d.costUsd) > 0) value += " · $" + Number(d.costUsd).toFixed(2) - } else if (d.costUsd != null && Number(d.costUsd) > 0) { + if (hasCost) value += " · $" + Number(d.costUsd).toFixed(2) + } else if (hasCost) { value += " · $" + Number(d.costUsd).toFixed(2) } result.lines.push(ctx.line.text({ label: "MTD usage", value: value, - subtitle: "From Cursor dashboard export (billing data)", + // Costs from token×price tables are estimates (#886); tokens come from the export. + subtitle: hasCost + ? "Estimated spend from Cursor usage export" + : "From Cursor dashboard export (billing data)", })) - } catch (e) { /* ignore */ } + } catch (e) { + // Never drop primary Connect usage when export enrichment fails (#948). + ctx.host.log.warn("usage export MTD attach failed: " + String(e)) + } return result } @@ -1101,7 +1123,12 @@ since: cursorSince31DaysAgo(), until: cursorUntilToday(), }) - if (!resp || resp.status !== "ok" || !resp.data || !Array.isArray(resp.data.daily)) return + if (!resp || resp.status !== "ok" || !resp.data || !Array.isArray(resp.data.daily)) { + if (resp && resp.status && resp.status !== "ok") { + ctx.host.log.warn("usage export daily query failed: status=" + String(resp.status)) + } + return + } if (!resp.data.daily.length) return var daily = resp.data.daily.map(function (row) { return { @@ -1118,6 +1145,7 @@ daily: daily, }) } catch (e) { + // Log only — never clear primary Connect usage (#948). ctx.host.log.warn("cursor billing daily ingest failed: " + String(e)) } } @@ -1312,16 +1340,18 @@ const planResp = connectPost(ctx, PLAN_URL, accessToken) if (planResp.status >= 200 && planResp.status < 300) { const plan = ctx.util.tryParseJson(planResp.bodyText) - if (plan && plan.planInfo && plan.planInfo.planName) { + if (plan && plan.planInfo && typeof plan.planInfo.planName === "string") { planName = plan.planInfo.planName + } else if (plan && plan.planInfo) { + ctx.host.log.warn("optional plan response contained invalid plan metadata") } } else { planInfoUnavailable = true - ctx.host.log.warn("plan info returned error: status=" + planResp.status) + ctx.host.log.warn("optional plan request returned HTTP " + planResp.status) } } catch (e) { planInfoUnavailable = true - ctx.host.log.warn("plan info fetch failed: " + String(e)) + ctx.host.log.warn("optional plan request failed: " + String(e)) } const normalizedPlanName = typeof planName === "string" @@ -1381,9 +1411,14 @@ const creditsResp = connectPost(ctx, CREDITS_URL, accessToken) if (creditsResp.status >= 200 && creditsResp.status < 300) { creditGrants = ctx.util.tryParseJson(creditsResp.bodyText) + if (!creditGrants) { + ctx.host.log.warn("optional credit-grants response was invalid") + } + } else { + ctx.host.log.warn("optional credit-grants request returned HTTP " + creditsResp.status) } } catch (e) { - ctx.host.log.warn("credit grants fetch failed: " + String(e)) + ctx.host.log.warn("optional credit-grants request failed: " + String(e)) } const stripeBalanceCents = fetchStripeBalance(ctx, accessToken) || 0 diff --git a/plugins/cursor/plugin.test.js b/plugins/cursor/plugin.test.js index 867377ba6..b8ad4b88e 100644 --- a/plugins/cursor/plugin.test.js +++ b/plugins/cursor/plugin.test.js @@ -2125,6 +2125,31 @@ describe("cursor plugin", () => { expect(mtd.value).toContain("800.0K tokens in") expect(mtd.value).toContain("400.0K tokens out") expect(mtd.value).toContain("$4.80") - expect(mtd.subtitle).toContain("dashboard export") + expect(mtd.subtitle).toContain("Estimated spend") + }) + + it("keeps Connect usage when usage export MTD throws", async () => { + const ctx = makeCtx() + const accessToken = makeJwt({ exp: 9999999999 }) + ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: accessToken }])) + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify({ + enabled: true, + planUsage: { totalPercentUsed: 42 }, + }), + }) + ctx.host.cursorUsageExport.queryMtd = vi.fn(() => { + throw new Error("export boom") + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines.find((line) => line.label === "Total usage")?.used).toBe(42) + expect(result.lines.find((line) => line.label === "MTD usage")).toBeFalsy() + expect(ctx.host.log.warn).toHaveBeenCalledWith( + expect.stringContaining("usage export MTD attach failed") + ) }) }) diff --git a/plugins/opencode-go/plugin.js b/plugins/opencode-go/plugin.js index f53529512..b216dd8c4 100644 --- a/plugins/opencode-go/plugin.js +++ b/plugins/opencode-go/plugin.js @@ -264,6 +264,20 @@ ]; } + function buildUnreadableSourceLines(ctx, detail) { + return { + plan: "Go", + warning: detail, + lines: [ + ctx.line.badge({ + label: "Status", + text: "Usage database unreadable", + color: "#f59e0b", + }), + ], + }; + } + function probe(ctx) { const authKey = loadAuthKey(ctx); const history = hasHistory(ctx); @@ -273,12 +287,28 @@ throw "OpenCode Go not detected. Log in with OpenCode Go or use it locally first."; } + // Auth present but SQLite unreadable — fail loudly (#969), not a silent empty badge. + if (authKey && !history.ok) { + ctx.host.log.warn("opencode sqlite unreadable while auth exists"); + return buildUnreadableSourceLines( + ctx, + "Couldn't read OpenCode's usage database. Check file permissions or repair opencode.db.", + ); + } + if (!history.ok) { return { plan: "Go", lines: buildSoftEmptyLines(ctx) }; } const rowsResult = loadHistory(ctx); if (!rowsResult.ok) { + if (authKey) { + ctx.host.log.warn("opencode sqlite history query failed while auth exists"); + return buildUnreadableSourceLines( + ctx, + "Couldn't read OpenCode's usage database. Check file permissions or repair opencode.db.", + ); + } return { plan: "Go", lines: buildSoftEmptyLines(ctx) }; } diff --git a/plugins/opencode-go/plugin.test.js b/plugins/opencode-go/plugin.test.js index d7cdf6f18..852da400b 100644 --- a/plugins/opencode-go/plugin.test.js +++ b/plugins/opencode-go/plugin.test.js @@ -224,7 +224,7 @@ describe("opencode-go plugin", () => { expect(result.lines[0].used).toBe(100); }); - it("returns a soft empty state when sqlite is unreadable but auth exists", async () => { + it("returns a loud warning when sqlite is unreadable but auth exists", async () => { const ctx = makeCtx(); setAuth(ctx); ctx.host.sqlite.query.mockImplementation(() => { @@ -232,35 +232,28 @@ describe("opencode-go plugin", () => { }); const plugin = await loadPlugin(); - expect(plugin.probe(ctx)).toEqual({ - plan: "Go", - lines: [ - { - type: "badge", - label: "Status", - text: "No usage data", - color: "#a3a3a3", - }, - ], - }); + const result = plugin.probe(ctx); + expect(result.plan).toBe("Go"); + expect(result.warning).toContain("usage database"); + expect(result.lines).toEqual([ + { + type: "badge", + label: "Status", + text: "Usage database unreadable", + color: "#f59e0b", + }, + ]); }); - it("returns a soft empty state when sqlite returns malformed JSON and auth exists", async () => { + it("returns a loud warning when sqlite returns malformed JSON and auth exists", async () => { const ctx = makeCtx(); setAuth(ctx); ctx.host.sqlite.query.mockReturnValue("not-json"); const plugin = await loadPlugin(); - expect(plugin.probe(ctx)).toEqual({ - plan: "Go", - lines: [ - { - type: "badge", - label: "Status", - text: "No usage data", - color: "#a3a3a3", - }, - ], - }); + const result = plugin.probe(ctx); + expect(result.warning).toContain("usage database"); + expect(result.lines[0].text).toBe("Usage database unreadable"); + expect(result.lines[0].color).toBe("#f59e0b"); }); }); diff --git a/plugins/test-helpers.js b/plugins/test-helpers.js index 14bf13bba..5008a51f9 100644 --- a/plugins/test-helpers.js +++ b/plugins/test-helpers.js @@ -138,6 +138,10 @@ export const makeCtx = () => { const line = { type: "text", label: opts.label, value: opts.value } if (opts.color) line.color = opts.color if (opts.subtitle) line.subtitle = opts.subtitle + if (opts.modelBreakdown) line.modelBreakdown = opts.modelBreakdown + if (opts.statusDot) line.statusDot = opts.statusDot + if (opts.expiryTooltip) line.expiryTooltip = opts.expiryTooltip + if (opts.resetCreditExpiries) line.resetCreditExpiries = opts.resetCreditExpiries return line }, progress: (opts) => { @@ -245,7 +249,7 @@ export const makeCtx = () => { ctx.util = { tryParseJson: (text) => { if (text === null || text === undefined) return null - const trimmed = String(text).trim() + const trimmed = String(text).replace(/^\uFEFF/, "").trim() if (!trimmed) return null try { return JSON.parse(trimmed) @@ -255,7 +259,7 @@ export const makeCtx = () => { }, safeJsonParse: (text) => { if (text === null || text === undefined) return { ok: false } - const trimmed = String(text).trim() + const trimmed = String(text).replace(/^\uFEFF/, "").trim() if (!trimmed) return { ok: false } try { return { ok: true, value: JSON.parse(trimmed) } diff --git a/plugins/zai/plugin.js b/plugins/zai/plugin.js index 4f530f538..d96efd1d3 100644 --- a/plugins/zai/plugin.js +++ b/plugins/zai/plugin.js @@ -5,6 +5,7 @@ const PERIOD_MS = 5 * 60 * 60 * 1000 const WEEK_MS = 7 * 24 * 60 * 60 * 1000 const MONTH_MS = 30 * 24 * 60 * 60 * 1000 + const ERR_INVALID_RESPONSE = "Usage response invalid. Try again later." function loadApiKey(ctx) { const providerKey = ctx.util.providerApiKey && ctx.util.providerApiKey() @@ -77,12 +78,32 @@ const data = ctx.util.tryParseJson(resp.bodyText) if (!data) { - throw "Usage response invalid. Try again later." + throw ERR_INVALID_RESPONSE } return data } + /// Accept JSON numbers and numeric strings; reject booleans and non-finite values (#951). + function parseNumber(value) { + if (typeof value === "boolean") return null + if (typeof value === "number") { + return Number.isFinite(value) ? value : null + } + if (typeof value === "string") { + const trimmed = value.trim() + if (!trimmed) return null + const n = Number(trimmed) + return Number.isFinite(n) ? n : null + } + return null + } + + function clampPercent(value) { + if (!Number.isFinite(value)) return 0 + return Math.min(100, Math.max(0, value)) + } + function findLimit(limits, type, unit) { let fallback = null for (let i = 0; i < limits.length; i++) { @@ -103,6 +124,65 @@ return fallback } + function mapTokenProgress(ctx, entry, label, periodMs) { + const rawPercentage = parseNumber(entry.percentage) + if (rawPercentage === null) throw ERR_INVALID_RESPONSE + const used = clampPercent(rawPercentage) + const resetsAt = entry.nextResetTime != null + ? (parseNumber(entry.nextResetTime) != null ? ctx.util.toIso(parseNumber(entry.nextResetTime)) : undefined) + : undefined + + const progressOpts = { + label: label, + used: used, + limit: 100, + format: { kind: "percent" }, + periodDurationMs: periodMs, + } + if (resetsAt) progressOpts.resetsAt = resetsAt + return ctx.line.progress(progressOpts) + } + + function mapWebSearches(ctx, entry) { + const used = parseNumber(entry.currentValue) + const limit = parseNumber(entry.usage) + if (used === null || limit === null || used < 0 || limit < 0) { + throw ERR_INVALID_RESPONSE + } + const now = new Date() + const nextMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)) + let webResetsAt = nextMonth.toISOString() + if (entry.nextResetTime != null) { + const resetMs = parseNumber(entry.nextResetTime) + if (resetMs !== null) webResetsAt = ctx.util.toIso(resetMs) + } + + return ctx.line.progress({ + label: "Web Searches", + used: used, + limit: limit, + format: { kind: "count", suffix: "/ " + limit }, + periodDurationMs: MONTH_MS, + resetsAt: webResetsAt, + }) + } + + function extractLimits(quota) { + if (!quota || typeof quota !== "object") return null + // Legacy: some payloads used a bare limits array at the root. + if (Array.isArray(quota)) return quota + if (quota.data !== undefined) { + if (Array.isArray(quota.data)) return null // must be object envelope + if (!quota.data || typeof quota.data !== "object") return null + if (quota.data.limits === undefined) return null + if (!Array.isArray(quota.data.limits)) return null + return quota.data.limits + } + if (quota.limits === undefined) return null + if (!Array.isArray(quota.limits)) return null + return quota.limits + } + function probe(ctx) { const apiKey = loadApiKey(ctx) if (!apiKey) { @@ -115,75 +195,32 @@ const quota = fetchQuota(ctx, apiKey) const lines = [] - const container = quota.data || quota - const limits = container.limits || container - if (!Array.isArray(limits) || limits.length === 0) { - lines.push(ctx.line.badge({ label: "Session", text: "No usage data", color: "#a3a3a3" })) - return { plan, lines } + const limits = extractLimits(quota) + if (limits === null) { + throw ERR_INVALID_RESPONSE } - - const tokenLimit = findLimit(limits, "TOKENS_LIMIT", 3) - - if (!tokenLimit) { - lines.push(ctx.line.badge({ label: "Session", text: "No usage data", color: "#a3a3a3" })) + if (limits.length === 0) { + lines.push(ctx.line.badge({ label: "Status", text: "No usage data", color: "#a3a3a3" })) return { plan, lines } } - const used = typeof tokenLimit.percentage === "number" ? tokenLimit.percentage : 0 - const resetsAt = tokenLimit.nextResetTime ? ctx.util.toIso(tokenLimit.nextResetTime) : undefined - - const progressOpts = { - label: "Session", - used, - limit: 100, - format: { kind: "percent" }, - periodDurationMs: PERIOD_MS, - } - if (resetsAt) { - progressOpts.resetsAt = resetsAt + const tokenLimit = findLimit(limits, "TOKENS_LIMIT", 3) + if (tokenLimit) { + lines.push(mapTokenProgress(ctx, tokenLimit, "Session", PERIOD_MS)) } - lines.push(ctx.line.progress(progressOpts)) const weeklyTokenLimit = findLimit(limits, "TOKENS_LIMIT", 6) if (weeklyTokenLimit) { - const weeklyUsed = Number.isFinite(weeklyTokenLimit.percentage) ? weeklyTokenLimit.percentage : 0 - const weeklyResetsAt = weeklyTokenLimit.nextResetTime ? ctx.util.toIso(weeklyTokenLimit.nextResetTime) : undefined - - const weeklyOpts = { - label: "Weekly", - used: weeklyUsed, - limit: 100, - format: { kind: "percent" }, - periodDurationMs: WEEK_MS, - } - if (weeklyResetsAt) { - weeklyOpts.resetsAt = weeklyResetsAt - } - lines.push(ctx.line.progress(weeklyOpts)) + lines.push(mapTokenProgress(ctx, weeklyTokenLimit, "Weekly", WEEK_MS)) } const timeLimit = findLimit(limits, "TIME_LIMIT") - if (timeLimit) { - const webUsed = typeof timeLimit.currentValue === "number" ? timeLimit.currentValue : 0 - const webTotal = typeof timeLimit.usage === "number" ? timeLimit.usage : 0 - const now = new Date() - const nextMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)) - const webResetsAt = timeLimit.nextResetTime - ? ctx.util.toIso(timeLimit.nextResetTime) - : nextMonth.toISOString() - - const webOpts = { - label: "Web Searches", - used: webUsed, - limit: webTotal, - format: { kind: "count", suffix: "/ " + webTotal }, - periodDurationMs: MONTH_MS, - } - if (webResetsAt) { - webOpts.resetsAt = webResetsAt - } - lines.push(ctx.line.progress(webOpts)) + lines.push(mapWebSearches(ctx, timeLimit)) + } + + if (lines.length === 0) { + lines.push(ctx.line.badge({ label: "Status", text: "No usage data", color: "#a3a3a3" })) } return { plan, lines } diff --git a/plugins/zai/plugin.test.js b/plugins/zai/plugin.test.js index 86c452a11..f3c115dda 100644 --- a/plugins/zai/plugin.test.js +++ b/plugins/zai/plugin.test.js @@ -393,7 +393,7 @@ describe("zai plugin", () => { expect(result.lines.find((l) => l.label === "Session")).toBeTruthy() }) - it("supports quota payloads where limits are top-level and optional fields are non-numeric", async () => { + it("supports quota payloads where limits are top-level and numeric strings are accepted", async () => { const ctx = makeCtx() mockEnvWithKey(ctx, "test-key") ctx.host.http.request.mockImplementation((opts) => { @@ -413,9 +413,59 @@ describe("zai plugin", () => { const result = plugin.probe(ctx) const session = result.lines.find((l) => l.label === "Session") const web = result.lines.find((l) => l.label === "Web Searches") - expect(session.used).toBe(0) - expect(web.used).toBe(0) - expect(web.limit).toBe(0) + expect(session.used).toBe(10) + expect(web.used).toBe(1095) + expect(web.limit).toBe(4000) + }) + + it("rejects malformed quota values instead of coercing to zero", async () => { + const cases = [ + { data: { limits: [{ type: "TOKENS_LIMIT", unit: 3, number: 5 }] } }, + { data: { limits: [{ type: "TOKENS_LIMIT", unit: 3, number: 5, percentage: true }] } }, + { data: { limits: [{ type: "TIME_LIMIT", usage: 1000 }] } }, + { data: { limits: [{ type: "TIME_LIMIT", currentValue: 10 }] } }, + { data: { limits: [{ type: "TIME_LIMIT", currentValue: -1, usage: 1000 }] } }, + ] + + for (const body of cases) { + const ctx = makeCtx() + mockEnvWithKey(ctx, "test-key") + ctx.host.http.request.mockImplementation((opts) => { + if (opts.url.includes("subscription")) { + return { status: 200, bodyText: JSON.stringify(SUBSCRIPTION_RESPONSE) } + } + return { status: 200, bodyText: JSON.stringify(body) } + }) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Usage response invalid") + } + }) + + it("rejects malformed quota envelopes but allows explicit empty limits", async () => { + for (const body of [{ data: [] }, { data: {} }, { data: { limits: {} } }]) { + const ctx = makeCtx() + mockEnvWithKey(ctx, "test-key") + ctx.host.http.request.mockImplementation((opts) => { + if (opts.url.includes("subscription")) { + return { status: 200, bodyText: JSON.stringify(SUBSCRIPTION_RESPONSE) } + } + return { status: 200, bodyText: JSON.stringify(body) } + }) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Usage response invalid") + } + + const ctx = makeCtx() + mockEnvWithKey(ctx, "test-key") + ctx.host.http.request.mockImplementation((opts) => { + if (opts.url.includes("subscription")) { + return { status: 200, bodyText: JSON.stringify(SUBSCRIPTION_RESPONSE) } + } + return { status: 200, bodyText: JSON.stringify({ data: { limits: [] } }) } + }) + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.lines[0].text).toBe("No usage data") }) it("shows no-usage badge when token limit entry is missing", async () => { @@ -425,7 +475,12 @@ describe("zai plugin", () => { if (opts.url.includes("subscription")) { return { status: 200, bodyText: JSON.stringify(SUBSCRIPTION_RESPONSE) } } - return { status: 200, bodyText: JSON.stringify({ data: { limits: [{ type: "TIME_LIMIT", usage: 10 }] } }) } + return { + status: 200, + bodyText: JSON.stringify({ + data: { limits: [{ type: "FUTURE_LIMIT" }, { type: "TOKENS_LIMIT", unit: 99, number: 1, percentage: 70 }] }, + }), + } }) const plugin = await loadPlugin() diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c3156eef4..16b7bec09 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "crossusage" -version = "1.3.1" +version = "1.3.2" description = "CrossUsage — cross-platform fork of OpenUsage, an open source AI subscription limit tracker" authors = ["barramee kottanawadee "] homepage = "https://github.com/barramee27/crossusage" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c162ae937..4bd354ba8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -27,7 +27,6 @@ mod webkit_config; use std::collections::{HashMap, HashSet, VecDeque}; use std::fs; use std::path::PathBuf; -use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use serde::{Deserialize, Serialize}; @@ -53,6 +52,83 @@ fn probe_worker_count(plugin_count: usize) -> usize { plugin_count.min(MAX_CONCURRENT_PROBES) } +/// Coalesces overlapping probe requests so an enablement wake mid-batch is not +/// dropped (#856). If `instance_id` is already probing, the latest request is +/// kept in `pending` and runs after the in-flight probe finishes. +struct PendingProbe { + batch_id: String, + plugin: plugin_engine::manifest::LoadedPlugin, + account: provider_accounts::ProviderAccountContext, +} + +struct ProbeCoord { + in_flight: HashSet, + pending: HashMap, + batch_remaining: HashMap, + active_workers: usize, +} + +fn probe_coord() -> &'static Mutex { + static COORD: OnceLock> = OnceLock::new(); + COORD.get_or_init(|| { + Mutex::new(ProbeCoord { + in_flight: HashSet::new(), + pending: HashMap::new(), + batch_remaining: HashMap::new(), + active_workers: 0, + }) + }) +} + +fn note_probe_finished(app_handle: &tauri::AppHandle, batch_id: &str) { + let complete = { + let mut coord = probe_coord() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let remaining = coord.batch_remaining.entry(batch_id.to_string()).or_insert(0); + if *remaining > 0 { + *remaining -= 1; + } + if *remaining == 0 { + coord.batch_remaining.remove(batch_id); + true + } else { + false + } + }; + if complete { + log::info!("[refresh] batch {} complete", batch_id); + let _ = app_handle.emit( + "probe:batch-complete", + ProbeBatchComplete { + batch_id: batch_id.to_string(), + }, + ); + } +} + +fn take_follow_up(finished_id: Option<&str>) -> Option { + let mut coord = probe_coord() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(id) = finished_id { + coord.in_flight.remove(id); + if let Some(entry) = coord.pending.remove(id) { + coord.in_flight.insert(id.to_string()); + return Some(entry); + } + } + let free_id = coord + .pending + .keys() + .find(|id| !coord.in_flight.contains(*id)) + .cloned(); + free_id.and_then(|id| { + coord.in_flight.insert(id.clone()); + coord.pending.remove(&id) + }) +} + /// Create `~/.crossusage/config.json` on first launch if missing (proxy + optional Synthetic key). fn ensure_crossusage_user_config_file() { let Some(home) = dirs::home_dir() else { @@ -651,33 +727,86 @@ async fn start_probe_batch( }); } - let selected_count = selected_plugins.len(); - let worker_count = probe_worker_count(selected_count); - if worker_count < selected_count { + // Partition: run now vs coalesce into pending follow-up (#856). + let (to_run, need_drain_worker) = { + let mut coord = probe_coord() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + coord + .batch_remaining + .insert(batch_id.clone(), selected_plugins.len()); + let mut to_run = Vec::new(); + let mut deferred = Vec::new(); + for (plugin, account) in selected_plugins { + let id = account.instance_id.clone(); + if coord.in_flight.contains(&id) { + coord.pending.insert( + id.clone(), + PendingProbe { + batch_id: batch_id.clone(), + plugin, + account, + }, + ); + deferred.push(id); + } else { + coord.in_flight.insert(id); + to_run.push((plugin, account)); + } + } + if !deferred.is_empty() { + log::info!( + "[refresh] batch {} deferred (already in-flight): {:?}", + batch_id, + deferred + ); + } + let need_drain = to_run.is_empty() && !deferred.is_empty() && coord.active_workers == 0; + if need_drain { + // Stale in_flight with no workers — free deferred ids so the drain worker can run them. + for id in &deferred { + coord.in_flight.remove(id); + } + } + (to_run, need_drain) + }; + + let history_dir = app_data_dir.clone(); + let spawn_count = if to_run.is_empty() { + if need_drain_worker { + 1 + } else { + 0 + } + } else { + probe_worker_count(to_run.len()) + }; + + if spawn_count > 0 && !to_run.is_empty() && spawn_count < to_run.len() { log::info!( "[refresh] batch {} using {} workers for {} plugins", batch_id, - worker_count, - selected_count + spawn_count, + to_run.len() ); } - let remaining = Arc::new(AtomicUsize::new(selected_count)); - let probe_queue = Arc::new(Mutex::new( - selected_plugins.into_iter().collect::>(), - )); - let history_dir = app_data_dir.clone(); + let probe_queue = Arc::new(Mutex::new(to_run.into_iter().collect::>())); - for _ in 0..worker_count { + { + let mut coord = probe_coord() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + coord.active_workers += spawn_count; + } + + for _ in 0..spawn_count { let handle = app_handle.clone(); - let completion_handle = app_handle.clone(); - let bid = batch_id.clone(); - let completion_bid = batch_id.clone(); let data_dir = app_data_dir.clone(); let history_dir_spawn = history_dir.clone(); let version = app_version.clone(); - let counter = Arc::clone(&remaining); let queue = Arc::clone(&probe_queue); + let queue_batch_id = batch_id.clone(); tauri::async_runtime::spawn_blocking(move || { loop { @@ -688,94 +817,32 @@ async fn start_probe_batch( queue.pop_front() }; - let Some((plugin, account_context)) = item else { - break; + let (plugin, account_context, bid) = match item { + Some((plugin, account_context)) => { + (plugin, account_context, queue_batch_id.clone()) + } + None => match take_follow_up(None) { + Some(pending) => (pending.plugin, pending.account, pending.batch_id), + None => break, + }, }; - let plugin_id = account_context.instance_id.clone(); - let probe_display_name = if account_context.label.trim().is_empty() { - plugin.manifest.name.clone() - } else { - format!( - "{} ({})", - plugin.manifest.name, - account_context.label.trim() - ) - }; - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - plugin_engine::runtime::run_probe_with_account( - &plugin, - &data_dir, - &version, - Some(account_context), - ) - })); - - match result { - Ok(output) => { - let has_error = output.lines.iter().any(|line| { - matches!(line, plugin_engine::runtime::MetricLine::Badge { label, .. } if label == "Error") - }); - if has_error { - log::warn!("probe {} completed with error", plugin_id); - } else { - log::info!( - "probe {} completed ok ({} lines)", - plugin_id, - output.lines.len() - ); - local_http_api::cache_successful_output(&output); - if persist_usage_history_enabled(&handle) { - if let Err(e) = - crossusage_core::usage_history::append_probe_snapshot( - &history_dir_spawn, - &output, - ) - { - log::debug!("usage history append: {}", e); - } - crossusage_core::plugin_engine::host_api::post_probe_ccusage_daily( - &history_dir_spawn, - &plugin_id, - &output.display_name, - ); - } - } - let _ = handle.emit( - "probe:result", - ProbeResult { - batch_id: bid.clone(), - output, - }, - ); - } - Err(_) => { - log::error!("probe {} panicked", plugin_id); - let output = plugin_engine::runtime::probe_fault_output( - &plugin, - &plugin_id, - &probe_display_name, - "The probe crashed. Try again or update the app.".to_string(), - ); - let _ = handle.emit( - "probe:result", - ProbeResult { - batch_id: bid.clone(), - output, - }, - ); - } - } + run_and_emit_probe( + &handle, + &data_dir, + &history_dir_spawn, + &version, + &bid, + plugin, + account_context, + ); } - if counter.fetch_sub(1, Ordering::SeqCst) == 1 { - log::info!("[refresh] batch {} complete", completion_bid); - let _ = completion_handle.emit( - "probe:batch-complete", - ProbeBatchComplete { - batch_id: completion_bid, - }, - ); + { + let mut coord = probe_coord() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + coord.active_workers = coord.active_workers.saturating_sub(1); } }); } @@ -786,6 +853,103 @@ async fn start_probe_batch( }) } +fn run_and_emit_probe( + handle: &tauri::AppHandle, + data_dir: &PathBuf, + history_dir: &PathBuf, + version: &str, + batch_id: &str, + plugin: plugin_engine::manifest::LoadedPlugin, + account_context: provider_accounts::ProviderAccountContext, +) { + let plugin_id = account_context.instance_id.clone(); + let probe_display_name = if account_context.label.trim().is_empty() { + plugin.manifest.name.clone() + } else { + format!( + "{} ({})", + plugin.manifest.name, + account_context.label.trim() + ) + }; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + plugin_engine::runtime::run_probe_with_account( + &plugin, + data_dir, + version, + Some(account_context), + ) + })); + + match result { + Ok(output) => { + let has_error = output.lines.iter().any(|line| { + matches!(line, plugin_engine::runtime::MetricLine::Badge { label, .. } if label == "Error") + }); + if has_error { + log::warn!("probe {} completed with error", plugin_id); + } else { + log::info!( + "probe {} completed ok ({} lines)", + plugin_id, + output.lines.len() + ); + local_http_api::cache_successful_output(&output); + if persist_usage_history_enabled(handle) { + if let Err(e) = + crossusage_core::usage_history::append_probe_snapshot(history_dir, &output) + { + log::debug!("usage history append: {}", e); + } + crossusage_core::plugin_engine::host_api::post_probe_ccusage_daily( + history_dir, + &plugin_id, + &output.display_name, + ); + } + } + let _ = handle.emit( + "probe:result", + ProbeResult { + batch_id: batch_id.to_string(), + output, + }, + ); + } + Err(_) => { + log::error!("probe {} panicked", plugin_id); + let output = plugin_engine::runtime::probe_fault_output( + &plugin, + &plugin_id, + &probe_display_name, + "The probe crashed. Try again or update the app.".to_string(), + ); + let _ = handle.emit( + "probe:result", + ProbeResult { + batch_id: batch_id.to_string(), + output, + }, + ); + } + } + + note_probe_finished(handle, batch_id); + + // Run coalesced follow-up for this id (nested call continues the chain). + if let Some(pending) = take_follow_up(Some(&plugin_id)) { + run_and_emit_probe( + handle, + data_dir, + history_dir, + version, + &pending.batch_id, + pending.plugin, + pending.account, + ); + } +} + pub(crate) fn resolve_log_file_path(app_handle: &tauri::AppHandle) -> Result { #[cfg(target_os = "macos")] { @@ -804,6 +968,85 @@ pub(crate) fn resolve_log_file_path(app_handle: &tauri::AppHandle) -> Result>, + app_handle: tauri::AppHandle, + expires_at_iso: String, + redeem_request_id: String, + plugin_id: Option, +) -> Result { + let instance_id = plugin_id + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("codex") + .to_string(); + let (plugins, app_data_dir, version) = { + let locked = state.lock().map_err(|e| e.to_string())?; + ( + locked.plugins.clone(), + locked.app_data_dir.clone(), + app_handle.package_info().version.to_string(), + ) + }; + + let account = provider_accounts::get_account(&app_data_dir, &instance_id) + .ok() + .flatten(); + let base_provider_id = account + .as_ref() + .map(|entry| entry.base_provider_id.clone()) + .filter(|base| !base.is_empty()) + .unwrap_or_else(|| { + instance_id + .split_once(':') + .map(|(base, _)| base.to_string()) + .unwrap_or_else(|| { + if instance_id.starts_with("codex") { + "codex".to_string() + } else { + instance_id.clone() + } + }) + }); + + let plugin = plugins + .into_iter() + .find(|p| p.manifest.id == base_provider_id || p.manifest.id == "codex") + .ok_or_else(|| "codex plugin not loaded".to_string())?; + + let credential = account + .as_ref() + .map(|entry| entry.credential.clone()) + .filter(|credential| !credential.is_empty()); + let label = account + .as_ref() + .map(|entry| entry.label.clone()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| instance_id.clone()); + let account_context = provider_accounts::ProviderAccountContext { + instance_id: instance_id.clone(), + base_provider_id, + label, + credential, + store_path: Some(provider_accounts::store_path(&app_data_dir)), + }; + + let args = serde_json::json!({ + "expiresAtIso": expires_at_iso, + "redeemRequestId": redeem_request_id, + }); + plugin_engine::runtime::run_plugin_action( + &plugin, + &app_data_dir, + &version, + "claimResetCredit", + &args.to_string(), + Some(account_context), + ) +} + #[tauri::command] fn get_log_path(app_handle: tauri::AppHandle) -> Result { log_path::for_app(&app_handle).map(|path| path.to_string_lossy().to_string()) @@ -1118,6 +1361,7 @@ pub fn run() { set_liquid_glass_enabled, open_devtools, start_probe_batch, + codex_claim_reset_credit, list_provider_accounts, save_provider_account, delete_provider_account, diff --git a/src-tauri/src/local_http_api/cache.rs b/src-tauri/src/local_http_api/cache.rs index bb32ac21f..87ecc7454 100644 --- a/src-tauri/src/local_http_api/cache.rs +++ b/src-tauri/src/local_http_api/cache.rs @@ -357,6 +357,7 @@ mod tests { model_breakdown: None, status_dot: None, expiry_tooltip: None, + reset_credit_expiries: None, }], icon_url: String::new(), } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 83b10e197..9a572a519 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "crossusage", "mainBinaryName": "crossusage", - "version": "1.3.1", + "version": "1.3.2", "identifier": "com.barramee27.crossusage", "build": { "beforeDevCommand": "node scripts/tauri-before-dev.cjs", diff --git a/src/App.tsx b/src/App.tsx index e55c1b9dd..a89a23e4c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -105,6 +105,8 @@ function App() { setUsageSpikeAlertThresholdPct, setShowTrayIcon, setShowTrayInsight, + setShowTotalSpend, + setTotalSpendMetric, onboardingComplete, setOnboardingComplete, } = useAppPreferencesStore( @@ -141,6 +143,8 @@ function App() { setUsageSpikeAlertThresholdPct: state.setUsageSpikeAlertThresholdPct, setShowTrayIcon: state.setShowTrayIcon, setShowTrayInsight: state.setShowTrayInsight, + setShowTotalSpend: state.setShowTotalSpend, + setTotalSpendMetric: state.setTotalSpendMetric, onboardingComplete: state.onboardingComplete, setOnboardingComplete: state.setOnboardingComplete, })) @@ -209,6 +213,8 @@ function App() { setUIScale, setShowTrayIcon, setShowTrayInsight, + setShowTotalSpend, + setTotalSpendMetric, setGlobalShortcut, setStartOnLogin, setUsageAlertEnabled, @@ -276,6 +282,7 @@ function App() { handleAutoUpdateIntervalChange, handleGlobalShortcutChange, handleStartOnLoginChange, + startOnLoginError, } = useSettingsSystemActions({ pluginSettings, setAutoUpdateInterval, @@ -670,6 +677,7 @@ function App() { traySettingsPreview, onGlobalShortcutChange: handleGlobalShortcutChange, onStartOnLoginChange: handleStartOnLoginChange, + startOnLoginError, onUsageAlertEnabledChange: handleUsageAlertEnabledChange, onUsageAlertThresholdChange: handleUsageAlertThresholdChange, onUsageAlertCustomThresholdChange: handleUsageAlertCustomThresholdChange, diff --git a/src/components/app/app-content.tsx b/src/components/app/app-content.tsx index eb2eb4b90..335e5f0c7 100644 --- a/src/components/app/app-content.tsx +++ b/src/components/app/app-content.tsx @@ -57,6 +57,7 @@ export type AppContentActionProps = { traySettingsPreview: TraySettingsPreview onGlobalShortcutChange: (value: GlobalShortcut) => void onStartOnLoginChange: (value: boolean) => void + startOnLoginError?: string | null onUsageAlertEnabledChange: (value: boolean) => void onUsageAlertThresholdChange: (value: UsageAlertThreshold) => void onUsageAlertCustomThresholdChange: (value: number | null) => void @@ -102,6 +103,7 @@ export function AppContent({ traySettingsPreview, onGlobalShortcutChange, onStartOnLoginChange, + startOnLoginError, onUsageAlertEnabledChange, onUsageAlertThresholdChange, onUsageAlertCustomThresholdChange, @@ -228,6 +230,7 @@ export function AppContent({ onGlobalShortcutChange={onGlobalShortcutChange} startOnLogin={startOnLogin} onStartOnLoginChange={onStartOnLoginChange} + startOnLoginError={startOnLoginError} usageAlertEnabled={usageAlertEnabled} onUsageAlertEnabledChange={onUsageAlertEnabledChange} usageAlertThreshold={usageAlertThreshold} diff --git a/src/components/app/modern-shell.tsx b/src/components/app/modern-shell.tsx index 12fe97231..a376481e7 100644 --- a/src/components/app/modern-shell.tsx +++ b/src/components/app/modern-shell.tsx @@ -4,6 +4,7 @@ import { LiquidGlassFilter } from "@/components/liquid-glass-filter" import { OnboardingWizard } from "@/components/onboarding-wizard" import { PanelFooter } from "@/components/panel-footer" import { CustomizeView } from "@/components/modern/customize-view" +import { TotalSpendCard } from "@/components/modern/total-spend-card" import { WidgetGroupedList, buildProviderWidgetGroups, @@ -74,7 +75,7 @@ export function ModernShell({ const appVersion = useAppVersion() useTrayRestartBridge(updateStatus, onUpdateInstall) - const { themeMode, displayMode, resetTimerDisplayMode, modernDensity, displayCurrency, exchangeRatesRevision } = + const { themeMode, displayMode, resetTimerDisplayMode, modernDensity, displayCurrency, exchangeRatesRevision, showTotalSpend } = useAppPreferencesStore( useShallow((s) => ({ themeMode: s.themeMode, @@ -83,6 +84,7 @@ export function ModernShell({ modernDensity: s.modernDensity, displayCurrency: s.displayCurrency, exchangeRatesRevision: s.exchangeRatesRevision, + showTotalSpend: s.showTotalSpend, })), ) @@ -192,7 +194,10 @@ export function ModernShell({ resetTimerDisplayMode, nowMs, }) - if (data) map.set(d.id, data) + if (data) { + data.pluginId = d.pluginId + map.set(d.id, data) + } } return map }, [descriptors, displayPlugins, displayMode, resetTimerDisplayMode, nowMs, pluginSettings, displayCurrency, exchangeRatesRevision]) @@ -216,6 +221,24 @@ export function ModernShell({ ], ) + const spendProviders = useMemo( + () => + displayPlugins.map((p) => ({ + id: p.meta.id, + displayName: p.meta.name, + brandColor: p.meta.brandColor, + })), + [displayPlugins], + ) + + const spendOutputs = useMemo(() => { + const map = new Map() + for (const p of displayPlugins) { + map.set(p.meta.id, p.data) + } + return map + }, [displayPlugins]) + const insights = useMemo( () => buildUsageInsights({ @@ -288,7 +311,20 @@ export function ModernShell({ nowMs={nowMs} onSelectProvider={setActiveView} /> - + {showTotalSpend ? ( + + ) : null} + { + appContentProps.onRetryPlugin(pluginId) + }} + /> ) : null} diff --git a/src/components/modern/customize-view.tsx b/src/components/modern/customize-view.tsx index ce68c4d8f..32ffb116d 100644 --- a/src/components/modern/customize-view.tsx +++ b/src/components/modern/customize-view.tsx @@ -18,6 +18,7 @@ import { CSS } from "@dnd-kit/utilities" import { GripVertical } from "lucide-react" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" +import { useTotalSpendVisibilityToggle } from "@/components/modern/total-spend-card" import type { MetricDescriptor } from "@/lib/metric-registry" import { cn } from "@/lib/utils" @@ -248,6 +249,7 @@ export function CustomizeView({ const placed = new Set(placedMetricIds) const groups = orderDescriptors(descriptors, providerOrder, metricOrderByProvider) const providerIds = groups.map((g) => g.pluginId) + const { showTotalSpend, setShowTotalSpend } = useTotalSpendVisibilityToggle() const sensors = useSensors( useSensor(PointerSensor), @@ -273,6 +275,13 @@ export function CustomizeView({ return (
+

Drag to reorder dashboard cards and rows. Metric toggles stay in sync with Classic provider lines.

diff --git a/src/components/modern/total-spend-card.tsx b/src/components/modern/total-spend-card.tsx new file mode 100644 index 000000000..75d44116e --- /dev/null +++ b/src/components/modern/total-spend-card.tsx @@ -0,0 +1,296 @@ +import { useMemo, useState } from "react" +import { useShallow } from "zustand/react/shallow" +import { Button } from "@/components/ui/button" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import type { PluginOutput } from "@/lib/plugin-types" +import { saveShowTotalSpend, saveTotalSpendMetric } from "@/lib/settings" +import { + TOTAL_SPEND_METRIC_EMPTY, + TOTAL_SPEND_METRIC_TITLE, + TOTAL_SPEND_METRICS, + TOTAL_SPEND_PERIOD_SHORT, + TOTAL_SPEND_PERIODS, + aggregateTotalSpend, + formatTotalSpendCenter, + formatTotalSpendLegend, + metricFromStored, + metricToStored, + projectTotalSpend, + spendCapableProviders, + totalSpendColor, + type TotalSpendMetric, + type TotalSpendPeriod, + type TotalSpendProjectedSlice, + type TotalSpendProvider, +} from "@/lib/total-spend" +import { cn } from "@/lib/utils" +import { useAppPreferencesStore } from "@/stores/app-preferences-store" + +const RING_SIZE = 104 +const MIN_SLICE_SHARE = 0.025 + +type TotalSpendCardProps = { + providers: TotalSpendProvider[] + outputs: Map | Record + compact?: boolean + className?: string +} + +export function TotalSpendCard({ providers, outputs, compact, className }: TotalSpendCardProps) { + const { showTotalSpend, totalSpendMetric, setShowTotalSpend, setTotalSpendMetric } = + useAppPreferencesStore( + useShallow((s) => ({ + showTotalSpend: s.showTotalSpend, + totalSpendMetric: s.totalSpendMetric, + setShowTotalSpend: s.setShowTotalSpend, + setTotalSpendMetric: s.setTotalSpendMetric, + })), + ) + + const [period, setPeriod] = useState("Today") + const [metricMenuOpen, setMetricMenuOpen] = useState(false) + + const metric = metricFromStored(totalSpendMetric) + const capable = useMemo( + () => spendCapableProviders(providers, outputs), + [providers, outputs], + ) + + const total = useMemo( + () => aggregateTotalSpend({ period, providers: capable, outputs }), + [period, capable, outputs], + ) + const projection = useMemo(() => projectTotalSpend(total, metric), [total, metric]) + + if (!showTotalSpend || capable.length === 0) return null + + const setMetric = (next: TotalSpendMetric) => { + const stored = metricToStored(next) + setTotalSpendMetric(stored) + void saveTotalSpendMetric(stored).catch((e) => console.error("saveTotalSpendMetric:", e)) + setMetricMenuOpen(false) + } + + // Keep showTotalSpend setter available for settings; silence unused if only read here. + void setShowTotalSpend + + const info = `Only includes ${capable.map((p) => p.displayName).join(", ")}.` + + return ( +
+
+
+ + {metricMenuOpen ? ( +
+ {TOTAL_SPEND_METRICS.map((option) => ( + + ))} +
+ ) : null} +
+ + ( + + ⓘ + + )} + /> + + {info} + + +
+ +
+
+ {TOTAL_SPEND_PERIODS.map((candidate) => { + const selected = candidate === period + return ( + + ) + })} +
+ + {projection.slices.length === 0 ? ( +

+ {TOTAL_SPEND_METRIC_EMPTY[metric]} +

+ ) : ( + + )} +
+
+ ) +} + +function TotalSpendRing({ + projectionSlices, + metric, + center, + estimated, +}: { + projectionSlices: TotalSpendProjectedSlice[] + metric: TotalSpendMetric + center: number + estimated: boolean +}) { + const totalDisplay = projectionSlices.reduce((s, row) => s + row.displayAmount, 0) + const floored = projectionSlices.map((row) => + Math.max(row.displayAmount / Math.max(totalDisplay, 1e-9), MIN_SLICE_SHARE), + ) + const sum = floored.reduce((a, b) => a + b, 0) + let cursor = -0.25 // start at 12 o'clock + const arcs = projectionSlices.map((slice, i) => { + const width = floored[i]! / sum + const start = cursor + const end = cursor + width + cursor = end + return { + id: slice.provider.id, + color: totalSpendColor(slice.provider.id, slice.provider.brandColor), + start, + end, + slice, + } + }) + + const centerLabel = formatTotalSpendCenter(center, metric) + const centerTip = + estimated && metric !== "tokens" + ? `${formatTotalSpendLegend(center, metric)} · Estimated from local logs` + : formatTotalSpendLegend(center, metric) + + return ( +
+ + ( +
+ + {arcs.map((arc) => ( + + ))} + +
+ + {centerLabel.primary} + + {centerLabel.unit} +
+
+ )} + /> + + {centerTip} + +
+ +
    + {projectionSlices.map((slice) => ( +
  • + + {slice.provider.displayName} + + {formatTotalSpendLegend(slice.displayAmount, metric)} + +
  • + ))} +
+
+ ) +} + +/** Donut sector from fraction start→end (0..1, -0.25 = 12 o'clock). */ +function donutSectorPath( + cx: number, + cy: number, + outerR: number, + innerR: number, + startFrac: number, + endFrac: number, +): string { + const gap = 0.004 + const start = (startFrac + gap) * Math.PI * 2 + const end = (endFrac - gap) * Math.PI * 2 + if (end <= start) return "" + const large = end - start > Math.PI ? 1 : 0 + const ox1 = cx + outerR * Math.cos(start) + const oy1 = cy + outerR * Math.sin(start) + const ox2 = cx + outerR * Math.cos(end) + const oy2 = cy + outerR * Math.sin(end) + const ix1 = cx + innerR * Math.cos(end) + const iy1 = cy + innerR * Math.sin(end) + const ix2 = cx + innerR * Math.cos(start) + const iy2 = cy + innerR * Math.sin(start) + return [ + `M ${ox1} ${oy1}`, + `A ${outerR} ${outerR} 0 ${large} 1 ${ox2} ${oy2}`, + `L ${ix1} ${iy1}`, + `A ${innerR} ${innerR} 0 ${large} 0 ${ix2} ${iy2}`, + "Z", + ].join(" ") +} + +/** Settings / Customize toggle helper — persists showTotalSpend. */ +export function useTotalSpendVisibilityToggle() { + const showTotalSpend = useAppPreferencesStore((s) => s.showTotalSpend) + const setShowTotalSpend = useAppPreferencesStore((s) => s.setShowTotalSpend) + return { + showTotalSpend, + setShowTotalSpend: (checked: boolean) => { + setShowTotalSpend(checked) + void saveShowTotalSpend(checked).catch((e) => console.error("saveShowTotalSpend:", e)) + }, + } +} diff --git a/src/components/modern/widget-grouped-list.tsx b/src/components/modern/widget-grouped-list.tsx index b48764792..87d5ba43d 100644 --- a/src/components/modern/widget-grouped-list.tsx +++ b/src/components/modern/widget-grouped-list.tsx @@ -18,9 +18,10 @@ type WidgetGroupedListProps = { groups: ProviderWidgetGroup[] compact?: boolean className?: string + onRefreshPlugin?: (pluginId: string) => void } -export function WidgetGroupedList({ groups, compact, className }: WidgetGroupedListProps) { +export function WidgetGroupedList({ groups, compact, className, onRefreshPlugin }: WidgetGroupedListProps) { if (groups.length === 0) { return (
@@ -32,13 +33,26 @@ export function WidgetGroupedList({ groups, compact, className }: WidgetGroupedL return (
{groups.map((group) => ( - + ))}
) } -function ProviderCard({ group, compact }: { group: ProviderWidgetGroup; compact?: boolean }) { +function ProviderCard({ + group, + compact, + onRefreshPlugin, +}: { + group: ProviderWidgetGroup + compact?: boolean + onRefreshPlugin?: (pluginId: string) => void +}) { const bounded = group.metrics.filter((m) => m.bounded && m.kind === "progress") const charts = group.metrics.filter((m) => m.kind === "barChart") const unbounded = group.metrics.filter((m) => !m.bounded && m.kind !== "barChart") @@ -68,10 +82,10 @@ function ProviderCard({ group, compact }: { group: ProviderWidgetGroup; compact?
{bounded.map((m) => ( - + ))} {charts.map((m) => ( - + ))} {unbounded.length > 0 ? (
{unbounded.map((m) => ( - + ))}
) : null} diff --git a/src/components/modern/widget-row.tsx b/src/components/modern/widget-row.tsx index 80fedabc1..58a267435 100644 --- a/src/components/modern/widget-row.tsx +++ b/src/components/modern/widget-row.tsx @@ -1,9 +1,12 @@ +import type { ReactNode } from "react" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { RateLimitResetsValue } from "@/components/rate-limit-resets-popover" import { UsageSparkline } from "@/components/usage-sparkline" import type { WidgetData } from "@/lib/widget-data" import { meterFraction } from "@/lib/widget-data" import type { PaceStatus } from "@/lib/pace-status" import { getPaceStatusText } from "@/lib/pace-tooltip" +import { formatMoney } from "@/lib/locale-format" import { cn } from "@/lib/utils" const PACE_DOT: Record = { @@ -18,10 +21,19 @@ const PACE_FILL: Record = { behind: "bg-red-500", } +const EXPIRY_DOT: Record = { + normal: "bg-blue-500", + warning: "bg-yellow-500", + critical: "bg-red-500", +} + +const SPEND_LABELS = new Set(["today", "yesterday", "last 30 days"]) + type WidgetRowProps = { data: WidgetData compact?: boolean className?: string + onRefreshPlugin?: (pluginId: string) => void } function PaceDot({ data }: { data: WidgetData }) { @@ -48,7 +60,116 @@ function PaceDot({ data }: { data: WidgetData }) { ) } -export function WidgetRow({ data, compact, className }: WidgetRowProps) { +function TextValue({ data, compact, onRefreshPlugin }: WidgetRowProps) { + const isResets = data.label === "Rate Limit Resets" + const showBreakdown = + Boolean(data.modelBreakdown?.length) && + SPEND_LABELS.has(data.label.trim().toLowerCase()) + + let valueNode: ReactNode = ( + + {data.textValue} + + ) + + if (isResets) { + valueNode = ( + { + if (data.pluginId) onRefreshPlugin?.(data.pluginId) + }} + /> + ) + } else if (showBreakdown && data.modelBreakdown) { + const breakdown = data.modelBreakdown + const priced = breakdown.filter((row) => row.costUsd != null && row.costUsd > 0) + valueNode = ( + + ( + + {data.textValue} + + )} + /> + +

By model

+
    + {breakdown.map((row) => ( +
  • + + {row.model} + + + {row.percent}% + {row.costUsd != null && row.costUsd > 0 + ? ` · ${formatMoney(row.costUsd, { sourceCurrency: "USD" })}` + : ""} + +
  • + ))} +
+ {priced.length < breakdown.length ? ( +

+ Totals exclude unpriced models. +

+ ) : null} +
+
+ ) + } + + if (!data.statusDot || isResets) return valueNode + + const dot = ( + + ) + + if (data.expiryTooltip) { + return ( + + ( + + {dot} + {valueNode} + + )} + /> + + {data.expiryTooltip} + + + ) + } + + return ( + + {dot} + {valueNode} + + ) +} + +export function WidgetRow({ data, compact, className, onRefreshPlugin }: WidgetRowProps) { if (data.kind === "barChart" && data.barChartPoints?.length) { return (
@@ -71,14 +192,7 @@ export function WidgetRow({ data, compact, className }: WidgetRowProps) { )} > {data.label} - - {data.textValue} - +
) } diff --git a/src/components/provider-card.tsx b/src/components/provider-card.tsx index 2d2eaf705..ab90b7b8c 100644 --- a/src/components/provider-card.tsx +++ b/src/components/provider-card.tsx @@ -12,6 +12,7 @@ import { UsageSparkline } from "@/components/usage-sparkline" import { PluginError } from "@/components/plugin-error" import { useNowTicker } from "@/hooks/use-now-ticker" import { REFRESH_COOLDOWN_MS, type DisplayMode, type ResetTimerDisplayMode, type TimeFormatMode } from "@/lib/settings" +import { RateLimitResetsValue } from "@/components/rate-limit-resets-popover" import type { ExpiryStatusDot, ManifestLine, MetricLine, ModelSpendBreakdown, PluginLink } from "@/lib/plugin-types" import { groupLinesByType } from "@/lib/group-lines-by-type" import { clamp01, cn, formatCountNumber } from "@/lib/utils" @@ -34,6 +35,8 @@ interface ProviderCardProps { lastManualRefreshAt?: number | null lastUpdatedAt?: number | null onRetry?: () => void + /** Instance id for claim actions (Codex resets). */ + pluginId?: string scopeFilter?: "overview" | "all" allowedLabels?: string[] | null displayMode: DisplayMode @@ -113,6 +116,7 @@ export function ProviderCard({ lastManualRefreshAt, lastUpdatedAt, onRetry, + pluginId, scopeFilter = "all", allowedLabels = null, displayMode, @@ -405,6 +409,8 @@ export function ProviderCard({ onResetTimerDisplayModeToggle={onResetTimerDisplayModeToggle} now={now} refreshing={isRefreshingWithData} + pluginId={pluginId} + onRetry={onRetry} /> ))}
@@ -420,6 +426,8 @@ export function ProviderCard({ onResetTimerDisplayModeToggle={onResetTimerDisplayModeToggle} now={now} refreshing={isRefreshingWithData} + pluginId={pluginId} + onRetry={onRetry} /> ))} @@ -451,10 +459,26 @@ const EXPIRY_DOT_CLASS: Record = { function TextMetricValue({ line, showBreakdown, + pluginId, + onRetry, }: { line: Extract showBreakdown: boolean + pluginId?: string + onRetry?: () => void }) { + if (line.label === "Rate Limit Resets") { + return ( + + ) + } + const valueNode = showBreakdown ? ( ) : ( @@ -558,6 +582,8 @@ function MetricLineRenderer({ onResetTimerDisplayModeToggle, now, refreshing, + pluginId, + onRetry, }: { line: MetricLine displayMode: DisplayMode @@ -566,6 +592,8 @@ function MetricLineRenderer({ onResetTimerDisplayModeToggle?: () => void now: number refreshing?: boolean + pluginId?: string + onRetry?: () => void }) { useAppPreferencesStore( useShallow((s) => ({ @@ -586,8 +614,13 @@ function MetricLineRenderer({ {line.label} - {showBreakdown || line.statusDot ? ( - + {showBreakdown || line.statusDot || line.label === "Rate Limit Resets" ? ( + ) : ( void + compact?: boolean + className?: string + /** When true, show Use / claim controls. */ + claimable?: boolean +} + +const SEVERITY_DOT: Record = { + normal: "bg-blue-500 text-white", + warning: "bg-yellow-400 text-black", + critical: "bg-red-500 text-white", +} + +export function RateLimitResetsValue({ + countLabel, + expiries, + pluginId, + onClaimed, + compact, + className, + claimable = true, +}: RateLimitResetsPopoverProps) { + const [open, setOpen] = useState(false) + const [pinned, setPinned] = useState(false) + const [confirming, setConfirming] = useState(null) + const [claiming, setClaiming] = useState(null) + const [claimed, setClaimed] = useState>(() => new Set()) + const [banner, setBanner] = useState<{ text: string; tone: string } | null>(null) + const [nothingToReset, setNothingToReset] = useState(false) + const redeemIds = useRef>(new Map()) + const closeTimer = useRef(null) + + const count = parseAvailableCount(countLabel) + const visibleExpiries = useMemo( + () => expiries.filter((iso) => !claimed.has(iso)), + [expiries, claimed], + ) + const content = resetsDetailContent(count - claimed.size, visibleExpiries) + const nowMs = Date.now() + + const clearClose = () => { + if (closeTimer.current != null) { + window.clearTimeout(closeTimer.current) + closeTimer.current = null + } + } + + const scheduleClose = () => { + if (pinned) return + clearClose() + closeTimer.current = window.setTimeout(() => setOpen(false), 180) + } + + const beginConfirm = (iso: string) => { + if (!redeemIds.current.has(iso)) { + redeemIds.current.set(iso, crypto.randomUUID()) + } + setBanner(null) + setConfirming(iso) + setPinned(true) + setOpen(true) + } + + const cancelConfirm = () => { + setConfirming(null) + setPinned(false) + } + + const runClaim = async (iso: string) => { + const redeemRequestId = redeemIds.current.get(iso) ?? crypto.randomUUID() + redeemIds.current.set(iso, redeemRequestId) + setConfirming(null) + setClaiming(iso) + setPinned(true) + let outcome: ResetClaimOutcome = "failed" + try { + if (isTauri()) { + outcome = (await invoke("codex_claim_reset_credit", { + expiresAtIso: iso, + redeemRequestId, + pluginId: pluginId ?? "codex", + })) as ResetClaimOutcome + } else { + outcome = "failed" + } + } catch (e) { + console.error("codex_claim_reset_credit:", e) + outcome = "failed" + } + setClaiming(null) + setPinned(false) + setBanner({ + text: claimBannerText(outcome), + tone: + outcome === "success" + ? "text-green-600 bg-green-500/10" + : outcome === "nothing_to_reset" + ? "text-blue-600 bg-blue-500/10" + : outcome === "no_credit" + ? "text-amber-600 bg-amber-500/10" + : "text-red-600 bg-red-500/10", + }) + if (outcome === "success" || outcome === "no_credit") { + setClaimed((prev) => new Set(prev).add(iso)) + } + if (outcome === "success" || outcome === "nothing_to_reset") { + setNothingToReset(true) + } + if (outcome === "success" || outcome === "nothing_to_reset" || outcome === "no_credit") { + onClaimed?.() + } + } + + return ( +
{ + clearClose() + setOpen(true) + }} + onMouseLeave={scheduleClose} + > + + {countLabel} + + + {open ? ( +
+ {banner ? ( +
+ {banner.text} +
+ ) : null} + + {claiming && !visibleExpiries.includes(claiming) ? ( +
+ Resetting your usage… + +
+ ) : null} + + {content.kind === "empty" ? ( +

+ You have no rate limit resets +

+ ) : null} + + {content.kind === "unknownExpiries" ? ( +
+

{content.count} available

+

Expiry times unavailable

+
+ ) : null} + + {content.kind === "timeline" ? ( +
    + {content.expiries.map((iso, index) => { + const remaining = Date.parse(iso) - nowMs + const severity = expirySeverity(remaining) + const countdown = formatExpiryCountdown(remaining) + const isConfirm = confirming === iso + const isClaiming = claiming === iso + const claimBusy = confirming != null || claiming != null + + return ( +
  • +
    + + {index + 1} + + {index < content.expiries.length - 1 ? ( + + ) : null} +
    +
    + {isClaiming ? ( +
    + Resetting your usage… +
    + ) : isConfirm ? ( +
    +

    Use this reset?

    +

    + Immediately reset your usage limits. This can't be undone. +

    +
    + + +
    +
    + ) : ( +
    + + {formatExpiryTime(iso, nowMs)} + + {claimable && isTauri() ? ( + + ) : null} + {countdown && !(claimable && isTauri()) ? ( + + {countdown} + + ) : null} + {countdown && claimable && isTauri() ? ( + + {countdown} + + ) : null} +
    + )} +
    +
  • + ) + })} +
+ ) : null} +
+ ) : null} +
+ ) +} diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index 9f2c18c00..f90617eec 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -52,7 +52,7 @@ function TooltipContent({ ({ DEFAULT_SHOW_ACCOUNT_IDENTITY: true, DEFAULT_SHOW_TRAY_ICON: true, DEFAULT_SHOW_TRAY_INSIGHT: true, + DEFAULT_SHOW_TOTAL_SPEND: true, + DEFAULT_TOTAL_SPEND_METRIC: "apiSpend", DEFAULT_START_ON_LOGIN: false, DEFAULT_THEME_MODE: "system", DEFAULT_UI_LAYOUT: "classic", @@ -113,6 +119,8 @@ vi.mock("@/lib/settings", () => ({ getEnabledPluginIds: getEnabledPluginIdsMock, loadShowTrayIcon: loadShowTrayIconMock, loadShowTrayInsight: loadShowTrayInsightMock, + loadShowTotalSpend: loadShowTotalSpendMock, + loadTotalSpendMetric: loadTotalSpendMetricMock, loadAutoUpdateInterval: loadAutoUpdateIntervalMock, loadDisplayMode: loadDisplayModeMock, loadGlobalShortcut: loadGlobalShortcutMock, @@ -171,6 +179,8 @@ function createArgs() { setUIScale: vi.fn(), setShowTrayIcon: vi.fn(), setShowTrayInsight: vi.fn(), + setShowTotalSpend: vi.fn(), + setTotalSpendMetric: vi.fn(), setUsageAlertEnabled: vi.fn(), setUsageAlertThreshold: vi.fn(), setCustomUsageAlertThreshold: vi.fn(), @@ -204,6 +214,8 @@ describe("useSettingsBootstrap", () => { loadShowAccountIdentityMock.mockReset() loadShowTrayIconMock.mockReset() loadShowTrayInsightMock.mockReset() + loadShowTotalSpendMock.mockReset() + loadTotalSpendMetricMock.mockReset() loadStartOnLoginMock.mockReset() loadThemeModeMock.mockReset() loadUILayoutMock.mockReset() @@ -255,6 +267,8 @@ describe("useSettingsBootstrap", () => { loadShowAccountIdentityMock.mockResolvedValue(false) loadShowTrayIconMock.mockResolvedValue(true) loadShowTrayInsightMock.mockResolvedValue(true) + loadShowTotalSpendMock.mockResolvedValue(true) + loadTotalSpendMetricMock.mockResolvedValue("apiSpend") migrateLegacyTraySettingsMock.mockResolvedValue(undefined) savePluginSettingsMock.mockResolvedValue(undefined) resolveOnboardingCompleteMock.mockResolvedValue(true) diff --git a/src/hooks/app/use-settings-bootstrap.ts b/src/hooks/app/use-settings-bootstrap.ts index 38cd3a187..855118ba2 100644 --- a/src/hooks/app/use-settings-bootstrap.ts +++ b/src/hooks/app/use-settings-bootstrap.ts @@ -54,6 +54,8 @@ import { loadShowAccountIdentity, loadShowTrayIcon, loadShowTrayInsight, + loadShowTotalSpend, + loadTotalSpendMetric, loadStartOnLogin, loadThemeMode, loadUILayout, @@ -104,6 +106,8 @@ type UseSettingsBootstrapArgs = { setUIScale: (value: UIScale) => void setShowTrayIcon: (value: boolean) => void setShowTrayInsight: (value: boolean) => void + setShowTotalSpend: (value: boolean) => void + setTotalSpendMetric: (value: string) => void setUsageAlertEnabled: (value: boolean) => void setUsageAlertThreshold: (value: UsageAlertThreshold) => void setCustomUsageAlertThreshold: (value: number | null) => void @@ -137,6 +141,8 @@ export function useSettingsBootstrap({ setUIScale, setShowTrayIcon, setShowTrayInsight, + setShowTotalSpend, + setTotalSpendMetric, setUsageAlertEnabled, setUsageAlertThreshold, setCustomUsageAlertThreshold, @@ -314,6 +320,20 @@ export function useSettingsBootstrap({ console.error("Failed to load show tray insight:", error) } + let storedShowTotalSpend = true + try { + storedShowTotalSpend = await loadShowTotalSpend() + } catch (error) { + console.error("Failed to load show total spend:", error) + } + + let storedTotalSpendMetric = "apiSpend" + try { + storedTotalSpendMetric = await loadTotalSpendMetric() + } catch (error) { + console.error("Failed to load total spend metric:", error) + } + try { await applyStartOnLogin(storedStartOnLogin) } catch (error) { @@ -411,6 +431,8 @@ export function useSettingsBootstrap({ setShowAccountIdentity(storedShowAccountIdentity) setShowTrayIcon(storedShowTrayIcon) setShowTrayInsight(storedShowTrayInsight) + setShowTotalSpend(storedShowTotalSpend) + setTotalSpendMetric(storedTotalSpendMetric) setMenubarIconStyle(storedMenubarIconStyle) setPreferMenubarWeeklyLimit(storedPreferMenubarWeeklyLimit) setUIScale(storedUIScale) @@ -462,6 +484,8 @@ export function useSettingsBootstrap({ setResetTimerDisplayMode, setShowTrayIcon, setShowTrayInsight, + setShowTotalSpend, + setTotalSpendMetric, setOnboardingComplete, setStartOnLogin, setShowAccountIdentity, diff --git a/src/hooks/app/use-settings-system-actions.ts b/src/hooks/app/use-settings-system-actions.ts index 3ab86eaa5..38765999d 100644 --- a/src/hooks/app/use-settings-system-actions.ts +++ b/src/hooks/app/use-settings-system-actions.ts @@ -1,4 +1,4 @@ -import { useCallback } from "react" +import { useCallback, useState } from "react" import { invoke } from "@tauri-apps/api/core" import { getEnabledPluginIds, @@ -27,6 +27,8 @@ export function useSettingsSystemActions({ setStartOnLogin, applyStartOnLogin, }: UseSettingsSystemActionsArgs) { + const [startOnLoginError, setStartOnLoginError] = useState(null) + const handleAutoUpdateIntervalChange = useCallback((value: AutoUpdateIntervalMinutes) => { setAutoUpdateInterval(value) @@ -56,11 +58,14 @@ export function useSettingsSystemActions({ const handleStartOnLoginChange = useCallback((value: boolean) => { setStartOnLogin(value) + setStartOnLoginError(null) void saveStartOnLogin(value).catch((error) => { console.error("Failed to save start on login:", error) + setStartOnLoginError(error instanceof Error ? error.message : String(error)) }) void applyStartOnLogin(value).catch((error) => { console.error("Failed to update start on login:", error) + setStartOnLoginError(error instanceof Error ? error.message : String(error)) }) }, [applyStartOnLogin, setStartOnLogin]) @@ -68,5 +73,6 @@ export function useSettingsSystemActions({ handleAutoUpdateIntervalChange, handleGlobalShortcutChange, handleStartOnLoginChange, + startOnLoginError, } } diff --git a/src/hooks/use-app-update.test.ts b/src/hooks/use-app-update.test.ts index 6331a9253..f11b3ab2a 100644 --- a/src/hooks/use-app-update.test.ts +++ b/src/hooks/use-app-update.test.ts @@ -116,7 +116,7 @@ describe("useAppUpdate", () => { expect(result.current.updateStatus).toEqual({ status: "ready", version: "1.0.0" }) }) - it("does not check again when already ready", async () => { + it("re-checks when ready so a resolved channel can clear the banner", async () => { const downloadMock = vi.fn(async (onEvent: (event: any) => void) => { onEvent({ event: "Finished", data: {} }) }) @@ -128,8 +128,10 @@ describe("useAppUpdate", () => { expect(result.current.updateStatus.status).toBe("ready") checkMock.mockClear() + checkMock.mockResolvedValue(null) await act(() => result.current.checkForUpdates()) - expect(checkMock).not.toHaveBeenCalled() + expect(checkMock).toHaveBeenCalled() + expect(result.current.updateStatus.status).toBe("up-to-date") }) it("shows up-to-date then returns to idle when check returns null", async () => { diff --git a/src/hooks/use-app-update.ts b/src/hooks/use-app-update.ts index d927ee1dd..84fe39260 100644 --- a/src/hooks/use-app-update.ts +++ b/src/hooks/use-app-update.ts @@ -40,7 +40,9 @@ export function useAppUpdate(): UseAppUpdateReturn { if (!isTauri()) return if (!UPDATER_CHECKS_ENABLED) return if (inFlightRef.current.checking || inFlightRef.current.downloading || inFlightRef.current.installing) return - if (statusRef.current.status === "ready") return + // Keep a ready banner sticky until install — but allow a later check to clear it + // when the channel reports no update (#882 resolved banner). + const stickyReady = statusRef.current.status === "ready" // Clear any pending up-to-date timeout if (upToDateTimeoutRef.current !== null) { @@ -48,12 +50,13 @@ export function useAppUpdate(): UseAppUpdateReturn { upToDateTimeoutRef.current = null } inFlightRef.current.checking = true - setStatus({ status: "checking" }) + if (!stickyReady) setStatus({ status: "checking" }) try { const update = await check() inFlightRef.current.checking = false if (!mountedRef.current) return if (!update) { + updateRef.current = null setStatus({ status: "up-to-date" }) upToDateTimeoutRef.current = window.setTimeout(() => { upToDateTimeoutRef.current = null @@ -63,6 +66,10 @@ export function useAppUpdate(): UseAppUpdateReturn { } if (update) { updateRef.current = update + if (stickyReady && statusRef.current.status === "ready") { + // Same ready version still available — leave the banner alone. + if (update.version === statusRef.current.version) return + } inFlightRef.current.downloading = true setStatus({ status: "downloading", progress: -1 }) diff --git a/src/lib/codex-reset-claim.test.ts b/src/lib/codex-reset-claim.test.ts new file mode 100644 index 000000000..a3ae5a256 --- /dev/null +++ b/src/lib/codex-reset-claim.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest" +import { + claimBannerText, + creditIdForExpiry, + outcomeFromConsume, + parseAvailableCount, + resetsDetailContent, +} from "@/lib/codex-reset-claim" + +describe("outcomeFromConsume", () => { + it("maps protocol codes", () => { + expect(outcomeFromConsume(200, JSON.stringify({ code: "reset" }))).toBe("success") + expect(outcomeFromConsume(200, JSON.stringify({ code: "already_redeemed" }))).toBe("success") + expect(outcomeFromConsume(200, JSON.stringify({ code: "nothing_to_reset" }))).toBe( + "nothing_to_reset", + ) + expect(outcomeFromConsume(200, JSON.stringify({ code: "no_credit" }))).toBe("no_credit") + expect(outcomeFromConsume(200, JSON.stringify({ code: "something_new" }))).toBe("failed") + expect(outcomeFromConsume(500, JSON.stringify({ code: "reset" }))).toBe("failed") + expect(outcomeFromConsume(200, "not json")).toBe("failed") + }) +}) + +describe("creditIdForExpiry", () => { + const expiry = "2026-07-12T03:57:42.000Z" + const expiryMs = Date.parse(expiry) + + it("matches available credit by expiry", () => { + const id = creditIdForExpiry( + { + credits: [ + { id: "other", status: "available", expires_at: "2026-08-01T00:00:00.000Z" }, + { id: "target", status: "available", expires_at: expiry }, + ], + }, + expiryMs, + ) + expect(id).toBe("target") + }) + + it("skips non-available but keeps missing status", () => { + expect( + creditIdForExpiry( + { credits: [{ id: "gone", status: "redeemed", expires_at: expiry }] }, + expiryMs, + ), + ).toBeNull() + expect( + creditIdForExpiry( + { credits: [{ id: "bare", expires_at: expiryMs / 1000 }] }, + expiryMs, + ), + ).toBe("bare") + }) +}) + +describe("resetsDetailContent", () => { + it("distinguishes empty vs unknown expiries", () => { + expect(resetsDetailContent(0, [])).toEqual({ kind: "empty" }) + expect(resetsDetailContent(2, [])).toEqual({ kind: "unknownExpiries", count: 2 }) + expect(resetsDetailContent(1, ["2026-07-12T00:00:00.000Z"]).kind).toBe("timeline") + }) +}) + +describe("claim helpers", () => { + it("parses available count and banner copy", () => { + expect(parseAvailableCount("3 available")).toBe(3) + expect(claimBannerText("success")).toMatch(/claimed/i) + expect(claimBannerText("nothing_to_reset")).toMatch(/doesn't need/i) + }) +}) diff --git a/src/lib/codex-reset-claim.ts b/src/lib/codex-reset-claim.ts new file mode 100644 index 000000000..4a2588c4c --- /dev/null +++ b/src/lib/codex-reset-claim.ts @@ -0,0 +1,125 @@ +export type ResetClaimOutcome = "success" | "nothing_to_reset" | "no_credit" | "failed" + +export type ResetCreditsContent = + | { kind: "timeline"; expiries: string[] } + | { kind: "unknownExpiries"; count: number } + | { kind: "empty" } + +/** Collapse consume HTTP response → popover outcome (mirrors CodexResetClaimService.outcome). */ +export function outcomeFromConsume(statusCode: number, bodyText: string): ResetClaimOutcome { + if (statusCode < 200 || statusCode >= 300) return "failed" + let body: unknown + try { + body = JSON.parse(bodyText) + } catch { + return "failed" + } + if (!body || typeof body !== "object") return "failed" + const code = (body as { code?: unknown }).code + if (typeof code !== "string") return "failed" + switch (code) { + case "reset": + case "already_redeemed": + return "success" + case "nothing_to_reset": + return "nothing_to_reset" + case "no_credit": + return "no_credit" + default: + return "failed" + } +} + +export function parseExpiryMs(value: unknown): number | null { + if (typeof value === "string") { + const ms = Date.parse(value) + return Number.isFinite(ms) ? ms : null + } + if (typeof value === "number" && Number.isFinite(value)) { + // Seconds since epoch if small; otherwise ms. + return value < 1e12 ? value * 1000 : value + } + return null +} + +/** Match an available credit by expiry (±1s). */ +export function creditIdForExpiry( + body: { credits?: unknown }, + expiryMs: number, +): string | null { + const credits = body.credits + if (!Array.isArray(credits)) return null + for (const credit of credits) { + if (!credit || typeof credit !== "object") continue + const row = credit as { status?: unknown; expires_at?: unknown; id?: unknown } + if (typeof row.status === "string" && row.status !== "available") continue + const ms = parseExpiryMs(row.expires_at) + if (ms == null) continue + if (Math.abs(ms - expiryMs) < 1000 && typeof row.id === "string" && row.id) { + return row.id + } + } + return null +} + +/** Empty expiries + count>0 → unknown; count 0 → empty; else timeline. */ +export function resetsDetailContent(count: number, expiries: string[]): ResetCreditsContent { + const valid = expiries.filter((iso) => Number.isFinite(Date.parse(iso))) + if (valid.length > 0) return { kind: "timeline", expiries: [...valid].sort() } + if (count > 0) return { kind: "unknownExpiries", count } + return { kind: "empty" } +} + +export function claimBannerText(outcome: ResetClaimOutcome): string { + switch (outcome) { + case "success": + return "Reset claimed. Enjoy!" + case "nothing_to_reset": + return "Your usage doesn't need a reset yet" + case "no_credit": + return "That reset is no longer available" + case "failed": + return "Couldn't reset usage. Please try again." + } +} + +const IMMINENT_MS = 5 * 60 * 1000 +const CRITICAL_MS = 48 * 60 * 60 * 1000 +const WARNING_MS = 7 * 24 * 60 * 60 * 1000 + +export type ExpirySeverity = "normal" | "warning" | "critical" + +export function expirySeverity(remainingMs: number): ExpirySeverity { + if (remainingMs <= CRITICAL_MS) return "critical" + if (remainingMs <= WARNING_MS) return "warning" + return "normal" +} + +export function formatExpiryCountdown(remainingMs: number): string | null { + if (remainingMs <= IMMINENT_MS) return null + const abs = Math.max(0, remainingMs) + const hours = Math.floor(abs / (60 * 60 * 1000)) + const days = Math.floor(hours / 24) + const remHours = hours % 24 + if (days > 0) return `${days}d ${remHours}h` + return `${hours}h` +} + +export function formatExpiryTime(iso: string, nowMs = Date.now()): string { + const ms = Date.parse(iso) + if (!Number.isFinite(ms)) return "Unknown" + if (ms - nowMs <= IMMINENT_MS) return "Expiring soon" + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(ms) +} + +export function parseAvailableCount(value: string): number { + const match = String(value ?? "").match(/(\d+)\s+available/i) + if (!match) return 0 + const n = Number(match[1]) + return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0 +} diff --git a/src/lib/plugin-types.ts b/src/lib/plugin-types.ts index 04b37410e..1e9ad8881 100644 --- a/src/lib/plugin-types.ts +++ b/src/lib/plugin-types.ts @@ -28,6 +28,8 @@ export type MetricLine = modelBreakdown?: ModelSpendBreakdown[] statusDot?: ExpiryStatusDot expiryTooltip?: string + /** ISO-8601 expiry instants for Codex rate-limit reset credits (soonest first). */ + resetCreditExpiries?: string[] } | { type: "progress" diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 88cd33681..2a03c997e 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -99,6 +99,8 @@ export const USAGE_SPIKE_ALERT_THRESHOLD_PCT_KEY = "usageSpikeAlertThresholdPct" const UI_SCALE_KEY = "uiScale"; const SHOW_TRAY_ICON_KEY = "showTrayIcon"; const SHOW_TRAY_INSIGHT_KEY = "showTrayInsight"; +const SHOW_TOTAL_SPEND_KEY = "showTotalSpend"; +const TOTAL_SPEND_METRIC_KEY = "totalSpendMetric"; const SHOW_ACCOUNT_IDENTITY_KEY = "showAccountIdentity"; const PERSIST_USAGE_HISTORY_KEY = "persistUsageHistory"; const USAGE_HISTORY_RETENTION_DAYS_KEY = "usageHistoryRetentionDays"; @@ -149,6 +151,8 @@ export const MODERN_DENSITY_OPTIONS: { value: ModernDensity; label: string }[] = export const DEFAULT_SHOW_TRAY_ICON = true; export const DEFAULT_SHOW_TRAY_INSIGHT = true; +export const DEFAULT_SHOW_TOTAL_SPEND = true; +export const DEFAULT_TOTAL_SPEND_METRIC = "apiSpend"; export const DEFAULT_UI_LAYOUT: UILayout = "classic"; export const DEFAULT_MODERN_DENSITY: ModernDensity = "regular"; export const DEFAULT_SHOW_ACCOUNT_IDENTITY = true; @@ -1011,6 +1015,30 @@ export async function saveShowTrayInsight(value: boolean): Promise { await store.save(); } +export async function loadShowTotalSpend(): Promise { + const stored = await store.get(SHOW_TOTAL_SPEND_KEY); + if (typeof stored === "boolean") return stored; + return DEFAULT_SHOW_TOTAL_SPEND; +} + +export async function saveShowTotalSpend(value: boolean): Promise { + await store.set(SHOW_TOTAL_SPEND_KEY, value); + await store.save(); +} + +export async function loadTotalSpendMetric(): Promise { + const stored = await store.get(TOTAL_SPEND_METRIC_KEY); + if (stored === "apiSpend" || stored === "costPerMtok" || stored === "tokens") { + return stored; + } + return DEFAULT_TOTAL_SPEND_METRIC; +} + +export async function saveTotalSpendMetric(value: string): Promise { + await store.set(TOTAL_SPEND_METRIC_KEY, value); + await store.save(); +} + export async function loadShowAccountIdentity(): Promise { const stored = await store.get(SHOW_ACCOUNT_IDENTITY_KEY); if (typeof stored === "boolean") return stored; diff --git a/src/lib/total-spend.test.ts b/src/lib/total-spend.test.ts new file mode 100644 index 000000000..7425dba99 --- /dev/null +++ b/src/lib/total-spend.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from "vitest" +import type { PluginOutput } from "@/lib/plugin-types" +import { + aggregateTotalSpend, + formatTotalSpendCenter, + metricFromStored, + metricToStored, + parseDollarsFromValue, + parseTokensFromValue, + projectTotalSpend, + spendCapableProviders, + type TotalSpendProvider, +} from "@/lib/total-spend" + +const claude: TotalSpendProvider = { id: "claude", displayName: "Claude", brandColor: "#DE7356" } +const codex: TotalSpendProvider = { id: "codex", displayName: "Codex", brandColor: "#10A37F" } +const cursor: TotalSpendProvider = { id: "cursor", displayName: "Cursor" } + +function output(lines: PluginOutput["lines"]): PluginOutput { + return { + providerId: "x", + displayName: "X", + lines, + iconUrl: "", + } +} + +describe("total-spend parsing", () => { + it("parses dollars and tokens from value strings", () => { + expect(parseDollarsFromValue("$2.50 · 100K tokens")).toBe(2.5) + expect(parseTokensFromValue("$2.50 · 100K tokens")).toBe(100_000) + expect(parseTokensFromValue("1.2M tokens")).toBe(1_200_000) + expect(parseDollarsFromValue("no money")).toBe(0) + }) + + it("round-trips stored metric keys", () => { + expect(metricFromStored("apiSpend")).toBe("cost") + expect(metricFromStored("costPerMtok")).toBe("costPerMtok") + expect(metricToStored("cost")).toBe("apiSpend") + }) +}) + +describe("aggregateTotalSpend", () => { + it("sums dollars and tokens across providers from modelBreakdown", () => { + const outputs = { + claude: output([ + { + type: "text", + label: "Today", + value: "$2.50 · 100K tokens", + subtitle: "Estimated", + modelBreakdown: [ + { model: "opus", tokens: 100_000, costUsd: 2.5, percent: 100 }, + ], + }, + ]), + cursor: output([ + { + type: "text", + label: "Today", + value: "$7.25 · 500K tokens", + modelBreakdown: [ + { model: "auto", tokens: 500_000, costUsd: 7.25, percent: 100 }, + ], + }, + ]), + codex: output([{ type: "text", label: "Session", value: "40%" }]), + } + + const total = aggregateTotalSpend({ + period: "Today", + providers: [claude, codex, cursor], + outputs, + }) + + expect(total.slices.map((s) => s.provider.id).sort()).toEqual(["claude", "cursor"]) + expect(total.slices.reduce((n, s) => n + s.amountUSD, 0)).toBeCloseTo(9.75) + expect(total.slices.reduce((n, s) => n + s.tokenCount, 0)).toBe(600_000) + + const spend = projectTotalSpend(total, "cost") + expect(spend.slices.map((s) => s.provider.id)).toEqual(["cursor", "claude"]) + expect(spend.centerValue).toBeCloseTo(9.75) + expect(spend.isEstimated).toBe(true) + }) + + it("excludes providers without the period line", () => { + const outputs = { + claude: output([ + { + type: "text", + label: "Today", + value: "$1.00", + modelBreakdown: [{ model: "a", tokens: 1, costUsd: 1, percent: 100 }], + }, + ]), + codex: output([ + { + type: "text", + label: "Yesterday", + value: "$3.00", + modelBreakdown: [{ model: "b", tokens: 1, costUsd: 3, percent: 100 }], + }, + ]), + } + const total = aggregateTotalSpend({ + period: "Today", + providers: [claude, codex], + outputs, + }) + expect(total.slices.map((s) => s.provider.id)).toEqual(["claude"]) + }) + + it("falls back to parsing $X.XX from value when breakdown lacks cost", () => { + const outputs = { + claude: output([ + { + type: "text", + label: "Today", + value: "$4.00 · 50K tokens", + }, + ]), + } + const total = aggregateTotalSpend({ + period: "Today", + providers: [claude], + outputs, + }) + expect(total.slices[0]?.amountUSD).toBe(4) + expect(total.slices[0]?.tokenCount).toBe(50_000) + }) + + it("projects costPerMtok by rate and blends the center", () => { + const outputs = { + claude: output([ + { + type: "text", + label: "Today", + value: "$10", + modelBreakdown: [{ model: "a", tokens: 1_000_000, costUsd: 10, percent: 100 }], + }, + ]), + cursor: output([ + { + type: "text", + label: "Today", + value: "$30", + modelBreakdown: [{ model: "b", tokens: 1_000_000, costUsd: 30, percent: 100 }], + }, + ]), + } + const total = aggregateTotalSpend({ + period: "Today", + providers: [claude, cursor], + outputs, + }) + const rates = projectTotalSpend(total, "costPerMtok") + expect(rates.slices.map((s) => s.provider.id)).toEqual(["cursor", "claude"]) + expect(rates.centerValue).toBeCloseTo(20) + }) + + it("tokens projection ignores dollars and estimate flag", () => { + const outputs = { + claude: output([ + { + type: "text", + label: "Today", + value: "$50 · Estimated", + modelBreakdown: [{ model: "a", tokens: 100_000, costUsd: 50, percent: 100 }], + }, + ]), + cursor: output([ + { + type: "text", + label: "Today", + value: "$1", + modelBreakdown: [{ model: "b", tokens: 900_000, costUsd: 1, percent: 100 }], + }, + ]), + } + const tokens = projectTotalSpend( + aggregateTotalSpend({ period: "Today", providers: [claude, cursor], outputs }), + "tokens", + ) + expect(tokens.slices.map((s) => s.provider.id)).toEqual(["cursor", "claude"]) + expect(tokens.centerValue).toBe(1_000_000) + expect(tokens.isEstimated).toBe(false) + }) + + it("marks spend-capable providers that have period tiles", () => { + const outputs = { + claude: output([ + { + type: "text", + label: "Today", + value: "$0.00 · 0 tokens", + modelBreakdown: [], + }, + ]), + codex: output([{ type: "badge", label: "Status", text: "ok" }]), + } + const capable = spendCapableProviders([claude, codex], outputs) + expect(capable.map((p) => p.id)).toEqual(["claude"]) + }) +}) + +describe("formatTotalSpendCenter", () => { + it("formats cost and tokens", () => { + expect(formatTotalSpendCenter(9.75, "cost")).toEqual({ primary: "$9.75", unit: "total" }) + expect(formatTotalSpendCenter(1_200_000, "tokens").unit).toBe("tokens") + }) +}) diff --git a/src/lib/total-spend.ts b/src/lib/total-spend.ts new file mode 100644 index 000000000..e4510d432 --- /dev/null +++ b/src/lib/total-spend.ts @@ -0,0 +1,318 @@ +import type { MetricLine, ModelSpendBreakdown, PluginOutput } from "@/lib/plugin-types" + +export const TOTAL_SPEND_PERIODS = ["Today", "Yesterday", "Last 30 Days"] as const +export type TotalSpendPeriod = (typeof TOTAL_SPEND_PERIODS)[number] + +export const TOTAL_SPEND_METRICS = ["cost", "costPerMtok", "tokens"] as const +export type TotalSpendMetric = (typeof TOTAL_SPEND_METRICS)[number] + +/** Persist key for Cost — matches upstream AppStorage raw value `apiSpend`. */ +export type TotalSpendMetricStored = "apiSpend" | "costPerMtok" | "tokens" + +export const TOTAL_SPEND_PERIOD_SHORT: Record = { + Today: "Today", + Yesterday: "Yesterday", + "Last 30 Days": "30 Days", +} + +export const TOTAL_SPEND_METRIC_TITLE: Record = { + cost: "Cost", + costPerMtok: "Cost/MTok", + tokens: "Tokens", +} + +export const TOTAL_SPEND_METRIC_EMPTY: Record = { + cost: "No cost data for this period", + costPerMtok: "No cost-per-token data for this period", + tokens: "No token data for this period", +} + +export function metricFromStored(raw: string | null | undefined): TotalSpendMetric { + if (raw === "costPerMtok") return "costPerMtok" + if (raw === "tokens") return "tokens" + return "cost" +} + +export function metricToStored(metric: TotalSpendMetric): TotalSpendMetricStored { + return metric === "cost" ? "apiSpend" : metric +} + +export type TotalSpendProvider = { + id: string + displayName: string + brandColor?: string +} + +export type TotalSpendSlice = { + provider: TotalSpendProvider + amountUSD: number + tokenCount: number + estimated: boolean +} + +export type TotalSpendProjectedSlice = { + provider: TotalSpendProvider + displayAmount: number + estimated: boolean +} + +export type TotalSpendProjection = { + metric: TotalSpendMetric + slices: TotalSpendProjectedSlice[] + centerValue: number + isEstimated: boolean +} + +export type TotalSpend = { + period: TotalSpendPeriod + slices: TotalSpendSlice[] +} + +const SPEND_LABELS = new Set(TOTAL_SPEND_PERIODS) + +/** Dollar amount from a `$12.34` / `$12.34 · 1.2M tokens` style value. */ +export function parseDollarsFromValue(value: string): number { + const match = String(value ?? "").match(/\$([0-9]+(?:\.[0-9]+)?)/) + if (!match) return 0 + const n = Number(match[1]) + return Number.isFinite(n) && n > 0 ? n : 0 +} + +/** Token count from `1.2M tokens` / `12,345 tokens` style value when breakdown is absent. */ +export function parseTokensFromValue(value: string): number { + const text = String(value ?? "") + const mTok = text.match(/([0-9]+(?:\.[0-9]+)?)\s*M\s*tokens/i) + if (mTok) { + const n = Number(mTok[1]) + return Number.isFinite(n) && n > 0 ? n * 1_000_000 : 0 + } + const kTok = text.match(/([0-9]+(?:\.[0-9]+)?)\s*K\s*tokens/i) + if (kTok) { + const n = Number(kTok[1]) + return Number.isFinite(n) && n > 0 ? n * 1_000 : 0 + } + const raw = text.match(/([0-9][0-9,]*(?:\.[0-9]+)?)\s*tokens/i) + if (raw) { + const n = Number(raw[1].replace(/,/g, "")) + return Number.isFinite(n) && n > 0 ? n : 0 + } + return 0 +} + +function sumBreakdownCost(breakdown: ModelSpendBreakdown[] | undefined): number { + if (!breakdown?.length) return 0 + let sum = 0 + for (const row of breakdown) { + if (row.costUsd != null && Number.isFinite(row.costUsd) && row.costUsd > 0) { + sum += row.costUsd + } + } + return sum +} + +function sumBreakdownTokens(breakdown: ModelSpendBreakdown[] | undefined): number { + if (!breakdown?.length) return 0 + let sum = 0 + for (const row of breakdown) { + if (Number.isFinite(row.tokens) && row.tokens > 0) sum += row.tokens + } + return sum +} + +function isEstimatedLine(line: Extract): boolean { + const hay = `${line.subtitle ?? ""} ${line.value ?? ""}` + if (/estimated/i.test(hay)) return true + // Log-derived spend tiles carry modelBreakdown without a billing CSV path — treat as estimated + // when the value/subtitle does not already say so but breakdown is the only source of dollars. + return false +} + +function findSpendLine( + lines: MetricLine[] | undefined, + period: TotalSpendPeriod, +): Extract | null { + if (!lines) return null + for (const line of lines) { + if (line.type === "text" && line.label === period) return line + } + return null +} + +/** + * Providers that emit at least one Today / Yesterday / Last 30 Days spend tile with dollars or tokens. + * Capability-based: a provider stays spend-capable even when those rows are hidden in Customize. + */ +export function spendCapableProviders( + providers: TotalSpendProvider[], + outputs: Map | Record, +): TotalSpendProvider[] { + const get = (id: string) => + outputs instanceof Map ? outputs.get(id) : outputs[id] + return providers.filter((provider) => { + const output = get(provider.id) + if (!output?.lines) return false + for (const line of output.lines) { + if (line.type !== "text" || !SPEND_LABELS.has(line.label)) continue + const dollars = + sumBreakdownCost(line.modelBreakdown) || parseDollarsFromValue(line.value) + const tokens = + sumBreakdownTokens(line.modelBreakdown) || parseTokensFromValue(line.value) + if (dollars > 0 || tokens > 0) return true + // Zero-token placeholder lines still mark the provider as spend-capable. + if (line.modelBreakdown != null || /\$|token/i.test(line.value)) return true + } + return false + }) +} + +export function extractSliceFromLine( + provider: TotalSpendProvider, + line: Extract, +): TotalSpendSlice | null { + const fromBreakdownCost = sumBreakdownCost(line.modelBreakdown) + const fromBreakdownTokens = sumBreakdownTokens(line.modelBreakdown) + const amountUSD = Math.max(0, fromBreakdownCost || parseDollarsFromValue(line.value)) + const tokenCount = Math.max(0, fromBreakdownTokens || parseTokensFromValue(line.value)) + if (amountUSD <= 0 && tokenCount <= 0) return null + + const estimated = + isEstimatedLine(line) || + (fromBreakdownCost <= 0 && amountUSD > 0 && /estimated/i.test(`${line.subtitle ?? ""} ${line.value}`)) || + // Prefer estimate when dollars came only from log-style breakdown without an explicit non-estimate note + (Boolean(line.modelBreakdown?.length) && + fromBreakdownCost > 0 && + /estimated/i.test(`${line.subtitle ?? ""}`)) + + return { + provider, + amountUSD, + tokenCount, + estimated: Boolean(estimated || /estimated/i.test(`${line.subtitle ?? ""} ${line.value ?? ""}`)), + } +} + +export function aggregateTotalSpend(args: { + period: TotalSpendPeriod + providers: TotalSpendProvider[] + outputs: Map | Record +}): TotalSpend { + const get = (id: string) => + args.outputs instanceof Map ? args.outputs.get(id) : args.outputs[id] + const slices: TotalSpendSlice[] = [] + for (const provider of args.providers) { + const line = findSpendLine(get(provider.id)?.lines, args.period) + if (!line) continue + const slice = extractSliceFromLine(provider, line) + if (slice) slices.push(slice) + } + return { period: args.period, slices } +} + +function costPerMtok(slice: TotalSpendSlice): number | null { + if (slice.amountUSD <= 0 || slice.tokenCount <= 0) return null + return (slice.amountUSD / slice.tokenCount) * 1_000_000 +} + +export function projectTotalSpend(total: TotalSpend, metric: TotalSpendMetric): TotalSpendProjection { + const included: { slice: TotalSpendSlice; display: number }[] = [] + for (const slice of total.slices) { + if (metric === "cost") { + if (slice.amountUSD <= 0) continue + included.push({ slice, display: slice.amountUSD }) + } else if (metric === "tokens") { + if (slice.tokenCount <= 0) continue + included.push({ slice, display: slice.tokenCount }) + } else { + const rate = costPerMtok(slice) + if (rate == null) continue + included.push({ slice, display: rate }) + } + } + + included.sort((a, b) => { + if (a.display !== b.display) return b.display - a.display + return a.slice.provider.displayName.localeCompare(b.slice.provider.displayName) + }) + + const projected = included.map(({ slice, display }) => ({ + provider: slice.provider, + displayAmount: display, + estimated: slice.estimated, + })) + + let centerValue = 0 + let isEstimated = false + if (metric === "cost") { + centerValue = included.reduce((sum, row) => sum + row.slice.amountUSD, 0) + isEstimated = included.some((row) => row.slice.estimated) + } else if (metric === "tokens") { + centerValue = included.reduce((sum, row) => sum + row.slice.tokenCount, 0) + isEstimated = false + } else { + const usd = included.reduce((sum, row) => sum + row.slice.amountUSD, 0) + const tokens = included.reduce((sum, row) => sum + row.slice.tokenCount, 0) + centerValue = tokens > 0 ? (usd / tokens) * 1_000_000 : 0 + isEstimated = included.some((row) => row.slice.estimated) + } + + return { metric, slices: projected, centerValue, isEstimated } +} + +/** Stable brand tints for the ring (provider id → hex). Near-black brands use a mid gray on dark cards. */ +export const TOTAL_SPEND_PALETTE: Record = { + claude: "#DE7356", + codex: "#10A37F", + cursor: "#A3A3A3", + grok: "#8E8E93", + opencode: "#6E6E73", + "opencode-go": "#6E6E73", + openrouter: "#6467F2", + antigravity: "#4285F4", + copilot: "#A855F7", + amp: "#F34E3F", + factory: "#48484A", + kimi: "#0A66FF", + minimax: "#F5433C", + zai: "#2D2D2D", +} + +const FALLBACK_PALETTE = ["#34C759", "#5856D6", "#FF2D55", "#A2845E"] + +export function totalSpendColor(providerId: string, brandColor?: string): string { + if (brandColor && brandColor.trim()) return brandColor + const known = TOTAL_SPEND_PALETTE[providerId] + if (known) return known + let hash = 0 + for (let i = 0; i < providerId.length; i++) { + hash = (hash * 31 + providerId.charCodeAt(i)) & 0xffff + } + return FALLBACK_PALETTE[hash % FALLBACK_PALETTE.length] +} + +export function formatTotalSpendCenter( + value: number, + metric: TotalSpendMetric, +): { primary: string; unit: string } { + if (metric === "cost") { + return { primary: `$${value.toFixed(2)}`, unit: "total" } + } + if (metric === "costPerMtok") { + return { primary: `$${value < 10 ? value.toFixed(2) : value.toFixed(1)}`, unit: "/MTok" } + } + if (value >= 1_000_000) { + return { primary: `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 1 : 2)}M`, unit: "tokens" } + } + if (value >= 1_000) { + return { primary: `${(value / 1_000).toFixed(value >= 10_000 ? 0 : 1)}K`, unit: "tokens" } + } + return { primary: String(Math.round(value)), unit: "tokens" } +} + +export function formatTotalSpendLegend(value: number, metric: TotalSpendMetric): string { + if (metric === "cost" || metric === "costPerMtok") { + return `$${value.toFixed(2)}${metric === "costPerMtok" ? "/MTok" : ""}` + } + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M` + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K` + return String(Math.round(value)) +} diff --git a/src/lib/widget-data.ts b/src/lib/widget-data.ts index f3e1fbdee..eb76fa35f 100644 --- a/src/lib/widget-data.ts +++ b/src/lib/widget-data.ts @@ -27,6 +27,12 @@ export type WidgetData = { barChartPoints?: BarChartPoint[] barChartNote?: string loading?: boolean + modelBreakdown?: import("@/lib/plugin-types").ModelSpendBreakdown[] + statusDot?: import("@/lib/plugin-types").ExpiryStatusDot + expiryTooltip?: string + resetCreditExpiries?: string[] + /** Plugin instance id for claim actions (e.g. codex). */ + pluginId?: string } export function placeholderWidgetData(args: { @@ -173,6 +179,14 @@ export function resolveWidgetData(args: { paceStatus: null, paceDetail: null, isLimitReached: false, + ...(line.type === "text" + ? { + modelBreakdown: line.modelBreakdown, + statusDot: line.statusDot, + expiryTooltip: line.expiryTooltip, + resetCreditExpiries: line.resetCreditExpiries, + } + : {}), } } diff --git a/src/pages/overview.tsx b/src/pages/overview.tsx index 3303db1d8..be7a729a1 100644 --- a/src/pages/overview.tsx +++ b/src/pages/overview.tsx @@ -100,6 +100,7 @@ export function OverviewPage({ lastManualRefreshAt={plugin.lastManualRefreshAt} lastUpdatedAt={plugin.lastUpdatedAt} onRetry={onRetryPlugin ? () => onRetryPlugin(plugin.meta.id) : undefined} + pluginId={plugin.meta.id} scopeFilter="overview" displayMode={displayMode} resetTimerDisplayMode={resetTimerDisplayMode} diff --git a/src/pages/provider-detail.tsx b/src/pages/provider-detail.tsx index c6dd05dba..179c0eefa 100644 --- a/src/pages/provider-detail.tsx +++ b/src/pages/provider-detail.tsx @@ -59,6 +59,7 @@ export function ProviderDetailPage({ lastManualRefreshAt={plugin.lastManualRefreshAt} lastUpdatedAt={plugin.lastUpdatedAt} onRetry={onRetry} + pluginId={plugin.meta.id} scopeFilter="all" displayMode={displayMode} resetTimerDisplayMode={resetTimerDisplayMode} diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index bbbd3409f..e5e4b3cc5 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -36,6 +36,7 @@ import { savePersistUsageHistory, saveUsageHistoryRetentionDays, saveShowTrayInsight, + saveShowTotalSpend, type AutoUpdateIntervalMinutes, type DisplayMode, type GlobalShortcut, @@ -669,6 +670,8 @@ function ProviderAccountForm({ function InsightsSection() { const showTrayInsight = useAppPreferencesStore((state) => state.showTrayInsight); const setShowTrayInsight = useAppPreferencesStore((state) => state.setShowTrayInsight); + const showTotalSpend = useAppPreferencesStore((state) => state.showTotalSpend); + const setShowTotalSpend = useAppPreferencesStore((state) => state.setShowTotalSpend); const onTrayInsightChange = async (checked: boolean) => { const prev = showTrayInsight; @@ -681,6 +684,17 @@ function InsightsSection() { } }; + const onTotalSpendChange = async (checked: boolean) => { + const prev = showTotalSpend; + setShowTotalSpend(checked); + try { + await saveShowTotalSpend(checked); + } catch (e) { + console.error(e); + setShowTotalSpend(prev); + } + }; + return (

Insights

@@ -694,6 +708,13 @@ function InsightsSection() { /> Show top insight in menu bar / tray tooltip +
); } @@ -1082,6 +1103,7 @@ interface SettingsPageProps { onGlobalShortcutChange: (value: GlobalShortcut) => void; startOnLogin: boolean; onStartOnLoginChange: (value: boolean) => void; + startOnLoginError?: string | null; usageAlertEnabled: boolean; onUsageAlertEnabledChange: (value: boolean) => void; usageAlertThreshold: UsageAlertThreshold; @@ -1141,6 +1163,7 @@ export function SettingsPage({ onGlobalShortcutChange, startOnLogin, onStartOnLoginChange, + startOnLoginError, usageAlertEnabled, onUsageAlertEnabledChange, usageAlertThreshold, @@ -1724,6 +1747,11 @@ export function SettingsPage({ /> Start on login + {startOnLoginError ? ( +

+ Couldn't update start on login: {startOnLoginError} +

+ ) : null} diff --git a/src/stores/app-preferences-store.ts b/src/stores/app-preferences-store.ts index c65e940cd..798abde61 100644 --- a/src/stores/app-preferences-store.ts +++ b/src/stores/app-preferences-store.ts @@ -10,6 +10,8 @@ import { DEFAULT_SHOW_ACCOUNT_IDENTITY, DEFAULT_SHOW_TRAY_ICON, DEFAULT_SHOW_TRAY_INSIGHT, + DEFAULT_SHOW_TOTAL_SPEND, + DEFAULT_TOTAL_SPEND_METRIC, DEFAULT_START_ON_LOGIN, DEFAULT_THEME_MODE, DEFAULT_UI_LAYOUT, @@ -59,6 +61,8 @@ type AppPreferencesStore = { uiScale: UIScale showTrayIcon: boolean showTrayInsight: boolean + showTotalSpend: boolean + totalSpendMetric: string usageAlertEnabled: boolean usageAlertThreshold: UsageAlertThreshold customUsageAlertThreshold: number | null @@ -86,6 +90,8 @@ type AppPreferencesStore = { setUIScale: (value: UIScale) => void setShowTrayIcon: (value: boolean) => void setShowTrayInsight: (value: boolean) => void + setShowTotalSpend: (value: boolean) => void + setTotalSpendMetric: (value: string) => void setUsageAlertEnabled: (value: boolean) => void setUsageAlertThreshold: (value: UsageAlertThreshold) => void setCustomUsageAlertThreshold: (value: number | null) => void @@ -116,6 +122,8 @@ const initialState = { uiScale: DEFAULT_UI_SCALE, showTrayIcon: DEFAULT_SHOW_TRAY_ICON, showTrayInsight: DEFAULT_SHOW_TRAY_INSIGHT, + showTotalSpend: DEFAULT_SHOW_TOTAL_SPEND, + totalSpendMetric: DEFAULT_TOTAL_SPEND_METRIC, usageAlertEnabled: DEFAULT_USAGE_ALERT_ENABLED, usageAlertThreshold: DEFAULT_USAGE_ALERT_THRESHOLD, customUsageAlertThreshold: DEFAULT_USAGE_ALERT_CUSTOM_THRESHOLD, @@ -146,6 +154,8 @@ export const useAppPreferencesStore = create((set) => ({ setUIScale: (value) => set({ uiScale: value }), setShowTrayIcon: (value) => set({ showTrayIcon: value }), setShowTrayInsight: (value) => set({ showTrayInsight: value }), + setShowTotalSpend: (value) => set({ showTotalSpend: value }), + setTotalSpendMetric: (value) => set({ totalSpendMetric: value }), setUsageAlertEnabled: (value) => set({ usageAlertEnabled: value }), setUsageAlertThreshold: (value) => set({ usageAlertThreshold: value }), setCustomUsageAlertThreshold: (value) => set({ customUsageAlertThreshold: value }),