Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/crossusage-cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"

Expand Down
1 change: 1 addition & 0 deletions crates/crossusage-cli/src/batch_probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
Expand Down
2 changes: 2 additions & 0 deletions crates/crossusage-cli/src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
Expand Down Expand Up @@ -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(),
}
Expand Down
2 changes: 1 addition & 1 deletion crates/crossusage-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"

Expand Down
61 changes: 58 additions & 3 deletions crates/crossusage-core/data/pricing_supplement.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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": {
Expand All @@ -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" },
Expand All @@ -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" },
Expand All @@ -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" },
Expand Down Expand Up @@ -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" },
Expand Down
22 changes: 17 additions & 5 deletions crates/crossusage-core/src/claude_usage_scanner.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -216,6 +217,8 @@ fn usage_files(roots: &[PathBuf]) -> Vec<DiscoveredFile> {
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
}
Expand Down Expand Up @@ -251,8 +254,12 @@ fn file_mtime_before(mtime: &SystemTime, since: OffsetDateTime) -> bool {
}

fn parse_file(path: &Path) -> Vec<ClaudeEntry> {
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();
Expand Down Expand Up @@ -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();

Expand Down
14 changes: 10 additions & 4 deletions crates/crossusage-core/src/codex_usage_scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -158,6 +159,7 @@ fn session_files(homes: &[PathBuf]) -> Vec<DiscoveredFile> {
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
}
Expand Down Expand Up @@ -265,8 +267,12 @@ impl RawUsage {
}

fn parse_file(path: &Path) -> Vec<CodexEvent> {
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 {
Expand Down
73 changes: 72 additions & 1 deletion crates/crossusage-core/src/log_usage_types.rs
Original file line number Diff line number Diff line change
@@ -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<Option<HashSet<String>>> = Mutex::new(None);

/// Keep the newest files by mtime, then apply a soft byte budget.
pub fn cap_log_files_by_mtime<T>(
files: &mut Vec<T>,
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")]
Expand Down
Loading
Loading