diff --git a/CHANGELOG.md b/CHANGELOG.md index af5139265..95e9cf72d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ **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.3 + +**Theme:** OpenUsage **v0.7.6** upstream patch (PATCH per [docs/VERSIONING.md](docs/VERSIONING.md)). Port tracker: [docs/PORT-0.7.6.md](docs/PORT-0.7.6.md). + +### Bug fixes + +- **Codex spend** — subagent replay dedup (`session_meta` + `task_started` gate, stale equal-count skip); session dir canonicalize + relative-path dedup across symlinks / archived copies (#1001, #973). +- **Token costs** — Codex fast-tier from rollout `thread_settings_applied`; 272k long-context + cache-discount rules (#995). Claude `advisor_message` iterations counted; session-logged fast speed (#995). +- **Pi agent** — fold pi coding-agent daily rows into Claude and Codex spend tiles (#975). +- **Cursor enterprise** — restore included-request + on-demand meters via `/api/usage` + `/api/usage-summary` (#986); synced to Cursor Nightly. +- **Pricing** — Grok 4.5 aliases accept `cursor-` prefix and `xhigh` effort (#981, #999). Supplement `updated_at` **2026-07-14**. + +### Other + +- **Grok** — updated provider logo (`currentColor`) (#1005). + +Skipped / deferred: macOS menu-bar / iCloud / Sparkle; Claude Desktop Safe Storage fallback (#962 → later); header screenshot copy (#989); limits CLI shape (#982). See PORT doc. + +--- + ## 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). diff --git a/Cargo.lock b/Cargo.lock index 4cf630f9b..e14c62745 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1199,7 +1199,7 @@ dependencies = [ [[package]] name = "crossusage" -version = "1.3.2" +version = "1.3.3" dependencies = [ "base64 0.22.1", "crossusage-core", @@ -1241,7 +1241,7 @@ dependencies = [ [[package]] name = "crossusage-cli" -version = "1.3.2" +version = "1.3.3" dependencies = [ "anyhow", "base64 0.22.1", @@ -1273,7 +1273,7 @@ dependencies = [ [[package]] name = "crossusage-core" -version = "1.3.2" +version = "1.3.3" dependencies = [ "aes-gcm", "anyhow", diff --git a/crates/crossusage-cli/Cargo.toml b/crates/crossusage-cli/Cargo.toml index 9e50025b2..2ac857fff 100644 --- a/crates/crossusage-cli/Cargo.toml +++ b/crates/crossusage-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "crossusage-cli" -version = "1.3.2" +version = "1.3.3" description = "CrossUsage CLI — terminal interface for the same plugin engine as the GUI" edition = "2021" diff --git a/crates/crossusage-core/Cargo.toml b/crates/crossusage-core/Cargo.toml index b58003019..435ee35db 100644 --- a/crates/crossusage-core/Cargo.toml +++ b/crates/crossusage-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "crossusage-core" -version = "1.3.2" +version = "1.3.3" 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 a5a699e5d..b5fbc4ef2 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 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", + "updated_at": "2026-07-14", "pricing": { "auto": { "input_per_million": 1.25, @@ -125,82 +125,313 @@ "gpt-5.6-luna": 2.5 }, "alias_rules": [ - { "pattern": "^agent_review$", "canonical": "gpt-5.4" }, - { "pattern": "^default$", "canonical": "auto" }, - { "pattern": "^auto$", "canonical": "auto" }, - { "pattern": "^composer-1$", "canonical": "composer-1" }, - { "pattern": "^composer-1\\.5$", "canonical": "composer-1.5" }, - { "pattern": "^composer-2$", "canonical": "composer-2" }, - { "pattern": "^composer-2-fast$", "canonical": "composer-2-fast" }, - { "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" }, - { "pattern": "^claude-4-sonnet(?:-thinking)?$", "canonical": "claude-sonnet-4-20250514" }, - { "pattern": "^claude-4\\.5-sonnet(?:-thinking)?$", "canonical": "claude-sonnet-4-5" }, - { "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" }, - { "pattern": "^claude-opus-4-8(?:-thinking)?(?:-(?:low|medium|high|xhigh|max))?$", "canonical": "claude-opus-4-8" }, - { "pattern": "^claude-fable-5(?:-thinking)?(?:-(?:low|medium|high|xhigh|max))?$", "canonical": "claude-fable-5" }, - { "pattern": "^claude-sonnet-5(?:-thinking)?(?:-(?:low|medium|high|xhigh|max))?$", "canonical": "claude-sonnet-5" }, - { "pattern": "^gemini-2\\.5-flash$", "canonical": "gemini-2.5-flash" }, - { "pattern": "^gemini-3-flash(?:-preview)?$", "canonical": "gemini-3-flash-preview" }, - { "pattern": "^gemini-3\\.5-flash(?:-preview)?$", "canonical": "gemini-3.5-flash" }, - { "pattern": "^gemini-3-pro-preview$", "canonical": "gemini-3-pro-preview" }, - { "pattern": "^gemini-3\\.1-pro$", "canonical": "gemini-3.1-pro-preview" }, - { "pattern": "^gemini-3\\.1-pro-preview$", "canonical": "gemini-3.1-pro-preview" }, - { "pattern": "^gpt-5(?:-(?:low|high))?-fast$", "canonical": "gpt-5-fast" }, - { "pattern": "^gpt-5(?:-(?:low|high))?$", "canonical": "gpt-5" }, - { "pattern": "^gpt-5\\.1(?:-(?:low|high))?-fast$", "canonical": "gpt-5-fast" }, - { "pattern": "^gpt-5\\.1(?:-(?:low|high))?$", "canonical": "gpt-5" }, - { "pattern": "^gpt-5-mini$", "canonical": "gpt-5-mini" }, - { "pattern": "^grok-4-20(?:-thinking)?$", "canonical": "xai/grok-4.20-0309-reasoning" }, - { "pattern": "^grok-4\\.3(?:-thinking)?$", "canonical": "xai/grok-4.3" }, - { "pattern": "^grok-build-0\\.1$", "canonical": "grok-build-0.1" }, - { "pattern": "^grok-code-fast-1$", "canonical": "grok-build-0.1" }, - { "pattern": "^grok-build$", "canonical": "grok-build-0.1" }, - { "pattern": "^grok-composer-2\\.5-fast$", "canonical": "composer-2.5-fast" }, - { "pattern": "^gpt-5\\.1-codex-max(?:-(?:low|medium|high|xhigh))?-fast$", "canonical": "gpt-5.1-codex-max-fast" }, - { "pattern": "^gpt-5\\.1-codex-max(?:-(?:low|medium|high|xhigh))?$", "canonical": "gpt-5.1-codex-max" }, - { "pattern": "^gpt-5\\.1-codex-mini(?:-(?:low|medium|high|xhigh))?$", "canonical": "gpt-5.1-codex-mini" }, - { "pattern": "^gpt-5\\.1-codex(?:-(?:low|medium|high|xhigh))?-fast$", "canonical": "gpt-5.1-codex-fast" }, - { "pattern": "^gpt-5\\.1-codex(?:-(?:low|medium|high|xhigh))?$", "canonical": "gpt-5.1-codex" }, - { "pattern": "^gpt-5\\.2-codex(?:-(?:low|high|xhigh))?-fast$", "canonical": "gpt-5.2-codex-fast" }, - { "pattern": "^gpt-5\\.2-codex(?:-(?:low|high|xhigh))?$", "canonical": "gpt-5.2-codex" }, - { "pattern": "^gpt-5\\.2(?:-(?:low|high|xhigh))?-fast$", "canonical": "gpt-5.2-fast" }, - { "pattern": "^gpt-5\\.2(?:-(?:low|high|xhigh))?$", "canonical": "gpt-5.2" }, - { "pattern": "^gpt-5\\.3-codex-spark-preview(?:-(?:low|high|xhigh))?$", "canonical": "gpt-5.3-codex-spark" }, - { "pattern": "^gpt-5\\.3-spark$", "canonical": "gpt-5.3-codex-spark" }, - { "pattern": "^gpt-5\\.3-codex(?:-(?:low|high|xhigh))?-fast$", "canonical": "gpt-5.3-codex-fast" }, - { "pattern": "^gpt-5\\.3-codex(?:-(?:low|high|xhigh))?$", "canonical": "gpt-5.3-codex" }, - { "pattern": "^gpt-5\\.4-mini(?:-(?:none|low|medium|high|xhigh))?$", "canonical": "gpt-5.4-mini" }, - { "pattern": "^gpt-5\\.4-nano(?:-(?:none|low|medium|high|xhigh))?$", "canonical": "gpt-5.4-nano" }, - { "pattern": "^gpt-5\\.4-(?:low|medium|high|xhigh)-fast$", "canonical": "gpt-5.4-fast" }, - { "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" }, - { "pattern": "^[Pp]remium \\((?:[Gg][Pp][Tt]-5\\.3-[Cc]odex|[Cc]odex 5\\.3)\\)$", "canonical": "gpt-5.3-codex" } + { + "pattern": "^agent_review$", + "canonical": "gpt-5.4" + }, + { + "pattern": "^default$", + "canonical": "auto" + }, + { + "pattern": "^auto$", + "canonical": "auto" + }, + { + "pattern": "^composer-1$", + "canonical": "composer-1" + }, + { + "pattern": "^composer-1\\.5$", + "canonical": "composer-1.5" + }, + { + "pattern": "^composer-2$", + "canonical": "composer-2" + }, + { + "pattern": "^composer-2-fast$", + "canonical": "composer-2-fast" + }, + { + "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": "^(?:cursor-)?grok-4\\.5-fast(?:-(?:low|medium|high|xhigh))?$", + "canonical": "grok-4.5-fast" + }, + { + "pattern": "^(?:cursor-)?grok-4\\.5(?:-(?:low|medium|high|xhigh))?-fast$", + "canonical": "grok-4.5-fast" + }, + { + "pattern": "^(?:cursor-)?grok-4\\.5(?:-(?:low|medium|high|xhigh))?$", + "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" + }, + { + "pattern": "^claude-4-sonnet(?:-thinking)?$", + "canonical": "claude-sonnet-4-20250514" + }, + { + "pattern": "^claude-4\\.5-sonnet(?:-thinking)?$", + "canonical": "claude-sonnet-4-5" + }, + { + "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" + }, + { + "pattern": "^claude-opus-4-8(?:-thinking)?(?:-(?:low|medium|high|xhigh|max))?$", + "canonical": "claude-opus-4-8" + }, + { + "pattern": "^claude-fable-5(?:-thinking)?(?:-(?:low|medium|high|xhigh|max))?$", + "canonical": "claude-fable-5" + }, + { + "pattern": "^claude-sonnet-5(?:-thinking)?(?:-(?:low|medium|high|xhigh|max))?$", + "canonical": "claude-sonnet-5" + }, + { + "pattern": "^gemini-2\\.5-flash$", + "canonical": "gemini-2.5-flash" + }, + { + "pattern": "^gemini-3-flash(?:-preview)?$", + "canonical": "gemini-3-flash-preview" + }, + { + "pattern": "^gemini-3\\.5-flash(?:-preview)?$", + "canonical": "gemini-3.5-flash" + }, + { + "pattern": "^gemini-3-pro-preview$", + "canonical": "gemini-3-pro-preview" + }, + { + "pattern": "^gemini-3\\.1-pro$", + "canonical": "gemini-3.1-pro-preview" + }, + { + "pattern": "^gemini-3\\.1-pro-preview$", + "canonical": "gemini-3.1-pro-preview" + }, + { + "pattern": "^gpt-5(?:-(?:low|high))?-fast$", + "canonical": "gpt-5-fast" + }, + { + "pattern": "^gpt-5(?:-(?:low|high))?$", + "canonical": "gpt-5" + }, + { + "pattern": "^gpt-5\\.1(?:-(?:low|high))?-fast$", + "canonical": "gpt-5-fast" + }, + { + "pattern": "^gpt-5\\.1(?:-(?:low|high))?$", + "canonical": "gpt-5" + }, + { + "pattern": "^gpt-5-mini$", + "canonical": "gpt-5-mini" + }, + { + "pattern": "^grok-4-20(?:-thinking)?$", + "canonical": "xai/grok-4.20-0309-reasoning" + }, + { + "pattern": "^grok-4\\.3(?:-thinking)?$", + "canonical": "xai/grok-4.3" + }, + { + "pattern": "^grok-build-0\\.1$", + "canonical": "grok-build-0.1" + }, + { + "pattern": "^grok-code-fast-1$", + "canonical": "grok-build-0.1" + }, + { + "pattern": "^grok-build$", + "canonical": "grok-build-0.1" + }, + { + "pattern": "^grok-composer-2\\.5-fast$", + "canonical": "composer-2.5-fast" + }, + { + "pattern": "^gpt-5\\.1-codex-max(?:-(?:low|medium|high|xhigh))?-fast$", + "canonical": "gpt-5.1-codex-max-fast" + }, + { + "pattern": "^gpt-5\\.1-codex-max(?:-(?:low|medium|high|xhigh))?$", + "canonical": "gpt-5.1-codex-max" + }, + { + "pattern": "^gpt-5\\.1-codex-mini(?:-(?:low|medium|high|xhigh))?$", + "canonical": "gpt-5.1-codex-mini" + }, + { + "pattern": "^gpt-5\\.1-codex(?:-(?:low|medium|high|xhigh))?-fast$", + "canonical": "gpt-5.1-codex-fast" + }, + { + "pattern": "^gpt-5\\.1-codex(?:-(?:low|medium|high|xhigh))?$", + "canonical": "gpt-5.1-codex" + }, + { + "pattern": "^gpt-5\\.2-codex(?:-(?:low|high|xhigh))?-fast$", + "canonical": "gpt-5.2-codex-fast" + }, + { + "pattern": "^gpt-5\\.2-codex(?:-(?:low|high|xhigh))?$", + "canonical": "gpt-5.2-codex" + }, + { + "pattern": "^gpt-5\\.2(?:-(?:low|high|xhigh))?-fast$", + "canonical": "gpt-5.2-fast" + }, + { + "pattern": "^gpt-5\\.2(?:-(?:low|high|xhigh))?$", + "canonical": "gpt-5.2" + }, + { + "pattern": "^gpt-5\\.3-codex-spark-preview(?:-(?:low|high|xhigh))?$", + "canonical": "gpt-5.3-codex-spark" + }, + { + "pattern": "^gpt-5\\.3-spark$", + "canonical": "gpt-5.3-codex-spark" + }, + { + "pattern": "^gpt-5\\.3-codex(?:-(?:low|high|xhigh))?-fast$", + "canonical": "gpt-5.3-codex-fast" + }, + { + "pattern": "^gpt-5\\.3-codex(?:-(?:low|high|xhigh))?$", + "canonical": "gpt-5.3-codex" + }, + { + "pattern": "^gpt-5\\.4-mini(?:-(?:none|low|medium|high|xhigh))?$", + "canonical": "gpt-5.4-mini" + }, + { + "pattern": "^gpt-5\\.4-nano(?:-(?:none|low|medium|high|xhigh))?$", + "canonical": "gpt-5.4-nano" + }, + { + "pattern": "^gpt-5\\.4-(?:low|medium|high|xhigh)-fast$", + "canonical": "gpt-5.4-fast" + }, + { + "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" + }, + { + "pattern": "^[Pp]remium \\((?:[Gg][Pp][Tt]-5\\.3-[Cc]odex|[Cc]odex 5\\.3)\\)$", + "canonical": "gpt-5.3-codex" + } ] } diff --git a/crates/crossusage-core/src/claude_usage_scanner.rs b/crates/crossusage-core/src/claude_usage_scanner.rs index 3205b5f26..e87794ebc 100644 --- a/crates/crossusage-core/src/claude_usage_scanner.rs +++ b/crates/crossusage-core/src/claude_usage_scanner.rs @@ -2,8 +2,8 @@ use crate::log_usage_types::{ 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, + expand_tilde, host_query_response, local_day_key_from_offset, merge_daily_rows, + since_local_midnight, warn_unreadable_usage_file, }; use crate::model_pricing::{ModelPricing, default_pricing}; use serde_json::Value; @@ -43,9 +43,13 @@ struct DiscoveredFile { pub fn query_daily_since(since_compact: &str, home_path: Option<&str>) -> (LogScanStatus, Vec) { let since = parse_since(since_compact); let pricing = default_pricing(); - match scan(days_back_from_since(since), home_path, pricing) { - Some(rows) if !rows.is_empty() => (LogScanStatus::Ok, rows), - _ => (LogScanStatus::NoData, vec![]), + let native = scan(days_back_from_since(since), home_path, pricing).unwrap_or_default(); + let pi = crate::pi_usage_scanner::query_daily_for_card("claude", since_compact); + let rows = merge_daily_rows(native, pi); + if rows.is_empty() { + (LogScanStatus::NoData, vec![]) + } else { + (LogScanStatus::Ok, rows) } } @@ -270,19 +274,102 @@ fn parse_file(path: &Path) -> Vec { if has_unsupported_null_field(line) { continue; } - if let Some(entry) = parse_line(line) { - entries.push(entry); + entries.extend(parse_entries(line)); + } + entries +} + +/// Parent entry from top-level usage; advisor_message iterations become extra entries. +fn parse_entries(line: &[u8]) -> Vec { + let Ok(v) = serde_json::from_slice::(line) else { + return vec![]; + }; + let Some(timestamp_raw) = v.get("timestamp").and_then(|t| t.as_str()) else { + return vec![]; + }; + let Some(timestamp) = parse_iso_timestamp(timestamp_raw) else { + return vec![]; + }; + let Some(message) = v.get("message") else { + return vec![]; + }; + let Some(usage) = message.get("usage") else { + return vec![]; + }; + let Some((tokens, has_speed)) = token_breakdown(usage) else { + return vec![]; + }; + if !is_valid_entry(&v, message) { + return vec![]; + } + let model = message + .get("model") + .and_then(|m| m.as_str()) + .filter(|m| *m != "") + .map(str::to_string); + let message_id = message + .get("id") + .and_then(|id| id.as_str()) + .map(str::to_string); + let request_id = v + .get("requestId") + .and_then(|id| id.as_str()) + .map(str::to_string); + let is_sidechain = v + .get("isSidechain") + .and_then(|b| b.as_bool()) + .unwrap_or(false); + let cost_usd = v.get("costUSD").and_then(|c| c.as_f64()); + + let parent = ClaudeEntry { + timestamp, + tokens, + message_id: message_id.clone(), + request_id: request_id.clone(), + is_sidechain, + has_speed, + cost_usd, + model, + }; + + let Some(iterations) = usage.get("iterations").and_then(|i| i.as_array()) else { + return vec![parent]; + }; + + let mut entries = vec![parent]; + let mut advisor_index = 0usize; + for iteration in iterations { + if iteration.get("type").and_then(|t| t.as_str()) != Some("advisor_message") { + continue; } + let Some(advisor_model) = iteration + .get("model") + .and_then(|m| m.as_str()) + .filter(|m| !m.is_empty()) + else { + continue; + }; + let Some((advisor_tokens, advisor_has_speed)) = token_breakdown(iteration) else { + continue; + }; + entries.push(ClaudeEntry { + timestamp, + tokens: advisor_tokens, + message_id: message_id + .as_ref() + .map(|id| format!("{id}:advisor:{advisor_index}")), + request_id: request_id.clone(), + is_sidechain, + has_speed: advisor_has_speed, + cost_usd: None, + model: Some(advisor_model.to_string()), + }); + advisor_index += 1; } entries } -fn parse_line(line: &[u8]) -> Option { - let v: Value = serde_json::from_slice(line).ok()?; - let timestamp_raw = v.get("timestamp")?.as_str()?; - let timestamp = parse_iso_timestamp(timestamp_raw)?; - let message = v.get("message")?; - let usage = message.get("usage")?; +fn token_breakdown(usage: &Value) -> Option<(TokenBreakdown, bool)> { let input = usage.get("input_tokens")?.as_i64()? as i32; let output = usage.get("output_tokens")?.as_i64()? as i32; let speed = usage.get("speed").and_then(|s| s.as_str()); @@ -291,52 +378,40 @@ fn parse_line(line: &[u8]) -> Option { return None; } } - if !is_valid_entry(&v, message) { - return None; - } - let mut cache_write5m = 0; - let mut cache_write1h = 0; - if let Some(cache_creation) = usage.get("cache_creation") { - cache_write5m = cache_creation - .get("ephemeral_5m_input_tokens") - .and_then(|n| n.as_i64()) - .unwrap_or(0) as i32; - cache_write1h = cache_creation - .get("ephemeral_1h_input_tokens") - .and_then(|n| n.as_i64()) - .unwrap_or(0) as i32; + let (cache_write5m, cache_write1h) = if let Some(cache_creation) = usage.get("cache_creation") { + ( + cache_creation + .get("ephemeral_5m_input_tokens") + .and_then(|n| n.as_i64()) + .unwrap_or(0) as i32, + cache_creation + .get("ephemeral_1h_input_tokens") + .and_then(|n| n.as_i64()) + .unwrap_or(0) as i32, + ) } else { - cache_write5m = usage - .get("cache_creation_input_tokens") - .and_then(|n| n.as_i64()) - .unwrap_or(0) as i32; - } - let tokens = TokenBreakdown { - input, - cache_write5m, - cache_write1h, - cache_read: usage - .get("cache_read_input_tokens") - .and_then(|n| n.as_i64()) - .unwrap_or(0) as i32, - output, - is_fast: speed == Some("fast"), + ( + usage + .get("cache_creation_input_tokens") + .and_then(|n| n.as_i64()) + .unwrap_or(0) as i32, + 0, + ) }; - let model = message - .get("model") - .and_then(|m| m.as_str()) - .filter(|m| *m != "") - .map(str::to_string); - Some(ClaudeEntry { - timestamp, - tokens, - message_id: message.get("id").and_then(|id| id.as_str()).map(str::to_string), - request_id: v.get("requestId").and_then(|id| id.as_str()).map(str::to_string), - is_sidechain: v.get("isSidechain").and_then(|b| b.as_bool()).unwrap_or(false), - has_speed: speed.is_some(), - cost_usd: v.get("costUSD").and_then(|c| c.as_f64()), - model, - }) + Some(( + TokenBreakdown { + input, + cache_write5m, + cache_write1h, + cache_read: usage + .get("cache_read_input_tokens") + .and_then(|n| n.as_i64()) + .unwrap_or(0) as i32, + output, + is_fast: speed == Some("fast"), + }, + speed.is_some(), + )) } fn is_valid_entry(object: &Value, message: &Value) -> bool { @@ -568,4 +643,18 @@ mod tests { assert!(!rows.is_empty()); assert!(rows[0].total_tokens >= 150); } + + #[test] + fn parse_entries_emits_advisor_iterations() { + let line = br#"{"timestamp":"2026-07-12T10:00:00.000Z","message":{"id":"m1","model":"claude-sonnet-4-20250514","usage":{"input_tokens":100,"output_tokens":50,"iterations":[{"type":"advisor_message","model":"claude-opus-4-20250514","input_tokens":10,"output_tokens":5},{"type":"other","model":"x","input_tokens":1,"output_tokens":1}]}},"requestId":"r1","version":"1.0.0"}"#; + let entries = parse_entries(line); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].message_id.as_deref(), Some("m1")); + assert_eq!(entries[0].tokens.input, 100); + assert_eq!(entries[1].message_id.as_deref(), Some("m1:advisor:0")); + assert_eq!(entries[1].model.as_deref(), Some("claude-opus-4-20250514")); + assert_eq!(entries[1].tokens.input, 10); + assert_eq!(entries[1].tokens.output, 5); + assert!(entries[1].cost_usd.is_none()); + } } diff --git a/crates/crossusage-core/src/codex_pricing.rs b/crates/crossusage-core/src/codex_pricing.rs new file mode 100644 index 000000000..eb1afec27 --- /dev/null +++ b/crates/crossusage-core/src/codex_pricing.rs @@ -0,0 +1,119 @@ +//! Codex-specific spend math (upstream OpenUsage 0.7.6 #995). + +use crate::log_usage_types::TokenBreakdown; +use crate::model_pricing::{ModelPricing, ModelRates}; + +pub struct CodexCostInput<'a> { + pub model: &'a str, + pub input: i32, + pub cached: i32, + pub output: i32, + pub reasoning: i32, + pub is_fast: bool, +} + +fn dated_base_model(model: &str) -> String { + let re_ymd = regex_lite::Regex::new(r"-\d{4}-\d{2}-\d{2}$").expect("ymd"); + let re_ymd8 = regex_lite::Regex::new(r"-\d{8}$").expect("ymd8"); + let s = re_ymd.replace(model, ""); + re_ymd8.replace(&s, "").into_owned() +} + +fn codex_priority_multiplier(model: &str, rates: &ModelRates) -> f64 { + match dated_base_model(model).as_str() { + "gpt-5.5" | "gpt-5.5-pro" => 2.5, + "gpt-5.4" | "gpt-5.4-pro" | "gpt-5.6-sol" | "gpt-5.6-terra" | "gpt-5.6-luna" => 2.0, + _ if (rates.fast_multiplier - 1.0).abs() < f64::EPSILON => 2.0, + _ => rates.fast_multiplier, + } +} + +fn codex_model_has_no_cache_discount(model: &str) -> bool { + matches!( + dated_base_model(model).as_str(), + "gpt-5.4-pro" | "gpt-5.5-pro" + ) +} + +fn codex_long_context_rates(model: &str) -> Option<(f64, f64, f64)> { + match dated_base_model(model).as_str() { + "gpt-5.4" => Some((5.0, 22.5, 0.5)), + "gpt-5.4-pro" => Some((60.0, 270.0, 60.0)), + "gpt-5.5" => Some((10.0, 45.0, 1.0)), + "gpt-5.5-pro" => Some((60.0, 270.0, 60.0)), + "gpt-5.6-sol" => Some((10.0, 45.0, 1.0)), + "gpt-5.6-terra" => Some((5.0, 22.5, 0.5)), + "gpt-5.6-luna" => Some((2.0, 9.0, 0.2)), + _ => None, + } +} + +/// Non-cached input + explicit cache-read (or full input), output+reasoning, 272k tier, priority mult. +pub fn estimated_cost_dollars(pricing: &ModelPricing, event: &CodexCostInput<'_>) -> Option { + let trimmed = event.model.trim(); + if trimmed.is_empty() { + return None; + } + let canonical = pricing + .canonical_name(trimmed) + .unwrap_or_else(|| trimmed.to_string()); + let is_fast_alias = canonical.ends_with("-fast"); + let rate_model = if is_fast_alias { + canonical.trim_end_matches("-fast").to_string() + } else { + canonical + }; + let base_rates = pricing.resolve(&rate_model); + let mut rates = base_rates.clone().or_else(|| pricing.resolve(trimmed))?; + let applies_codex_fast = if is_fast_alias { + base_rates.is_some() + } else { + event.is_fast + }; + + if let Some((input, output, cache_read)) = codex_long_context_rates(&rate_model) { + rates.input_above_200k = Some(input); + rates.output_above_200k = Some(output); + rates.cache_read_above_200k = Some(cache_read); + rates.long_context_threshold_tokens = 272_000; + } + if codex_model_has_no_cache_discount(&rate_model) || !rates.cache_read_is_explicit { + rates.cache_read_per_million = rates.input_per_million; + rates.cache_read_above_200k = rates.input_above_200k; + } + rates.fast_multiplier = codex_priority_multiplier(&rate_model, &rates); + + let non_cached = (event.input - event.cached).max(0); + let tokens = TokenBreakdown { + input: non_cached, + cache_write5m: 0, + cache_write1h: 0, + cache_read: event.cached, + output: event.output + event.reasoning, + is_fast: applies_codex_fast, + }; + Some(rates.cost_dollars(&tokens)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model_pricing::ModelPricing; + + #[test] + fn fast_alias_unwraps_base_rates() { + let pricing = ModelPricing::from_bundled(); + let cost = estimated_cost_dollars( + &pricing, + &CodexCostInput { + model: "gpt-5.5-fast", + input: 1_000, + cached: 0, + output: 100, + reasoning: 0, + is_fast: false, + }, + ); + assert!(cost.is_some()); + } +} diff --git a/crates/crossusage-core/src/codex_usage_scanner.rs b/crates/crossusage-core/src/codex_usage_scanner.rs index 072246e5e..8ca79493c 100644 --- a/crates/crossusage-core/src/codex_usage_scanner.rs +++ b/crates/crossusage-core/src/codex_usage_scanner.rs @@ -1,10 +1,11 @@ //! Native Codex CLI session log scanner (ports upstream CodexLogUsageScanner). use crate::claude_usage_scanner; +use crate::codex_pricing::{self, CodexCostInput}; use crate::log_usage_types::{ 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, + expand_tilde, host_query_response, local_day_key_from_offset, merge_daily_rows, + since_local_midnight, warn_unreadable_usage_file, }; use crate::model_pricing::{ModelPricing, default_pricing}; use serde_json::Value; @@ -32,21 +33,25 @@ struct CodexEvent { output: i32, reasoning: i32, total: i32, + is_fast: bool, } struct DiscoveredFile { path: PathBuf, size: u64, mtime: SystemTime, - relative: String, } pub fn query_daily_since(since_compact: &str, home_path: Option<&str>) -> (LogScanStatus, Vec) { let since = parse_since(since_compact); let pricing = default_pricing(); - match scan(days_back_from_since(since), home_path, pricing) { - Some(rows) if !rows.is_empty() => (LogScanStatus::Ok, rows), - _ => (LogScanStatus::NoData, vec![]), + let native = scan(days_back_from_since(since), home_path, pricing).unwrap_or_default(); + let pi = crate::pi_usage_scanner::query_daily_for_card("codex", since_compact); + let rows = merge_daily_rows(native, pi); + if rows.is_empty() { + (LogScanStatus::NoData, vec![]) + } else { + (LogScanStatus::Ok, rows) } } @@ -146,17 +151,26 @@ fn session_files(homes: &[PathBuf]) -> Vec { let mut files = Vec::new(); let mut seen_relative: HashSet<(String, String)> = HashSet::new(); for home in homes { + let home_resolved = fs::canonicalize(home).unwrap_or_else(|_| home.clone()); for sub in ["sessions", "archived_sessions"] { - let dir = home.join(sub); + let dir = home_resolved.join(sub); + let dir = fs::canonicalize(&dir).unwrap_or(dir); if !dir.is_dir() { continue; } - let home_key = home.to_string_lossy().to_string(); + let home_key = home_resolved.to_string_lossy().to_string(); collect_session_files(&dir, &dir, &home_key, sub, &mut files, &mut seen_relative); } - if !home.join("sessions").is_dir() && !home.join("archived_sessions").is_dir() { - let home_key = home.to_string_lossy().to_string(); - collect_session_files(home, home, &home_key, "", &mut files, &mut seen_relative); + if !home_resolved.join("sessions").is_dir() && !home_resolved.join("archived_sessions").is_dir() { + let home_key = home_resolved.to_string_lossy().to_string(); + collect_session_files( + &home_resolved, + &home_resolved, + &home_key, + "", + &mut files, + &mut seen_relative, + ); } } cap_log_files_by_mtime(&mut files, |f| f.mtime, |f| f.size); @@ -180,26 +194,24 @@ fn collect_session_files( if path.is_dir() { collect_session_files(root, &path, home_key, source, out, seen); } else if path.extension().is_some_and(|e| e == "jsonl") { + // Dedup by home + path relative to resolved sessions root (symlink-safe). let rel = path .strip_prefix(root) .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|_| path.to_string_lossy().to_string()); - let key = (home_key.to_string(), rel.clone()); - if source == "archived_sessions" && seen.contains(&(home_key.to_string(), rel.clone())) { + let key = (home_key.to_string(), rel); + if source == "archived_sessions" && seen.contains(&key) { continue; } - if source == "sessions" { - seen.insert(key.clone()); + if !seen.insert(key) { + continue; } - if seen.insert(key) { - if let Ok(meta) = entry.metadata() { - out.push(DiscoveredFile { - path, - size: meta.len(), - mtime: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH), - relative: rel, - }); - } + if let Ok(meta) = entry.metadata() { + out.push(DiscoveredFile { + path, + size: meta.len(), + mtime: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH), + }); } } } @@ -254,6 +266,14 @@ impl RawUsage { } } + fn equal_counts(&self, other: &RawUsage) -> bool { + self.input == other.input + && self.cached == other.cached + && self.output == other.output + && self.reasoning == other.reasoning + && self.total == other.total + } + fn subtracting(&self, previous: Option<&RawUsage>) -> RawUsage { let p = previous.cloned().unwrap_or_default(); RawUsage { @@ -266,6 +286,69 @@ impl RawUsage { } } +/// Child-session replay gate (upstream OpenUsage 0.7.6 #1001). +#[derive(Clone, Copy)] +enum ChildReplayGate { + UntilStartedAt(i64), + UntilSelfTimedTaskStarted, +} + +impl ChildReplayGate { + fn is_cleared(self, started_at: f64, line_timestamp: Option<&str>) -> bool { + match self { + ChildReplayGate::UntilStartedAt(gate) => started_at >= gate as f64, + ChildReplayGate::UntilSelfTimedTaskStarted => { + let Some(raw) = line_timestamp.map(str::trim).filter(|s| !s.is_empty()) else { + return false; + }; + let Some(line_dt) = claude_usage_scanner::parse_iso_timestamp(raw) else { + return false; + }; + started_at >= line_dt.unix_timestamp() as f64 + } + } + } +} + +fn has_non_null_value(v: Option<&Value>) -> bool { + match v { + None | Some(Value::Null) => false, + Some(Value::String(s)) => !s.trim().is_empty(), + Some(_) => true, + } +} + +fn is_child_session_meta(payload: &serde_json::Map) -> bool { + if has_non_null_value(payload.get("forked_from_id")) { + return true; + } + if has_non_null_value(payload.get("parent_thread_id")) { + return true; + } + if payload.get("thread_source").and_then(|t| t.as_str()) == Some("subagent") { + return true; + } + if let Some(source) = payload.get("source").and_then(|s| s.as_object()) { + if has_non_null_value(source.get("subagent")) { + return true; + } + } + false +} + +fn service_tier_in_payload(payload: &serde_json::Map) -> Option { + let settings = payload.get("thread_settings").and_then(|s| s.as_object()); + for value in [ + settings.and_then(|s| s.get("service_tier")), + payload.get("service_tier"), + ] { + if let Some(text) = value.and_then(|v| v.as_str()).map(str::trim).filter(|s| !s.is_empty()) { + return Some(text.to_string()); + } + } + None +} + fn parse_file(path: &Path) -> Vec { let data = match fs::read(path) { Ok(data) => data, @@ -274,22 +357,34 @@ fn parse_file(path: &Path) -> Vec { return vec![]; } }; - let subagent = data.len() >= 16 * 1024 && data[..16 * 1024].windows(12).any(|w| w == b"thread_spawn"); - let replay_second = if subagent { - detect_subagent_replay_second(&data) - } else { - None - }; let turn_marker = br#""type":"turn_context""#; let token_marker = br#""type":"token_count""#; + let session_meta_marker = br#""type":"session_meta""#; + let task_started_marker = br#""type":"task_started""#; + let thread_settings_marker = br#""type":"thread_settings_applied""#; + let mut events = Vec::new(); let mut previous_totals: Option = None; let mut current_model: Option = None; - let mut skip_replay = replay_second.is_some(); + let mut current_tier_is_fast = false; + let mut saw_session_meta = false; + let mut replay_gate: Option = None; for line in data.split(|&b| b == b'\n') { let is_turn = line.windows(turn_marker.len()).any(|w| w == turn_marker); - if !is_turn && !line.windows(token_marker.len()).any(|w| w == token_marker) { + let is_session_meta = + !saw_session_meta && line.windows(session_meta_marker.len()).any(|w| w == session_meta_marker); + let is_task_started = + replay_gate.is_some() && line.windows(task_started_marker.len()).any(|w| w == task_started_marker); + let is_thread_settings = line + .windows(thread_settings_marker.len()) + .any(|w| w == thread_settings_marker); + if !is_turn + && !is_session_meta + && !is_task_started + && !is_thread_settings + && !line.windows(token_marker.len()).any(|w| w == token_marker) + { continue; } let Ok(v) = serde_json::from_slice::(line) else { @@ -309,13 +404,61 @@ fn parse_file(path: &Path) -> Vec { } continue; } + if typ == "session_meta" && !saw_session_meta { + saw_session_meta = true; + if let Some(p) = payload { + if is_child_session_meta(p) { + if let Some(timestamp_raw) = obj.get("timestamp").and_then(|t| t.as_str()) { + if let Some(created) = + claude_usage_scanner::parse_iso_timestamp(timestamp_raw.trim()) + { + replay_gate = Some(ChildReplayGate::UntilStartedAt(created.unix_timestamp())); + } else { + replay_gate = Some(ChildReplayGate::UntilSelfTimedTaskStarted); + } + } else { + replay_gate = Some(ChildReplayGate::UntilSelfTimedTaskStarted); + } + } + } + continue; + } + if is_thread_settings + && typ == "event_msg" + && payload + .and_then(|p| p.get("type")) + .and_then(|t| t.as_str()) + == Some("thread_settings_applied") + { + if let Some(p) = payload { + if let Some(tier) = service_tier_in_payload(p) { + current_tier_is_fast = tier == "fast" || tier == "priority"; + } + } + continue; + } if typ != "event_msg" { continue; } let Some(p) = payload else { continue; }; - if p.get("type").and_then(|t| t.as_str()) != Some("token_count") { + let payload_type = p.get("type").and_then(|t| t.as_str()).unwrap_or(""); + if payload_type == "task_started" { + if let Some(gate) = replay_gate { + if let Some(started_at) = p + .get("started_at") + .and_then(|n| n.as_f64().or_else(|| n.as_i64().map(|i| i as f64))) + { + let line_ts = obj.get("timestamp").and_then(|t| t.as_str()); + if gate.is_cleared(started_at, line_ts) { + replay_gate = None; + } + } + } + continue; + } + if payload_type != "token_count" { continue; } let Some(timestamp_raw) = obj.get("timestamp").and_then(|t| t.as_str()) else { @@ -330,15 +473,16 @@ fn parse_file(path: &Path) -> Vec { .and_then(|u| u.as_object()) .map(RawUsage::from_json); - if skip_replay { - if let Some(replay) = &replay_second { - if timestamp_raw.trim().get(..19) == Some(replay.as_str()) { - if let Some(t) = totals { - previous_totals = Some(t); - } - continue; - } - skip_replay = false; + if replay_gate.is_some() { + if let Some(t) = totals { + previous_totals = Some(t); + } + continue; + } + + if let (Some(t), Some(prev)) = (&totals, &previous_totals) { + if t.equal_counts(prev) { + continue; } } @@ -370,6 +514,7 @@ fn parse_file(path: &Path) -> Vec { output: usage.output, reasoning: usage.reasoning, total: usage.total, + is_fast: current_tier_is_fast, }); } events @@ -407,51 +552,6 @@ fn resolve_model(parsed: Option, current_model: &mut Option) -> }) } -fn detect_subagent_replay_second(data: &[u8]) -> Option { - let marker = br#""type":"token_count""#; - let mut first_second: Option = None; - for line in data.split(|&b| b == b'\n') { - if !line.windows(marker.len()).any(|w| w == marker) { - continue; - } - let Ok(v) = serde_json::from_slice::(line) else { - continue; - }; - let Some(obj) = v.as_object() else { - continue; - }; - if obj.get("type").and_then(|t| t.as_str()) != Some("event_msg") { - continue; - } - let Some(payload) = obj.get("payload").and_then(|p| p.as_object()) else { - continue; - }; - if payload.get("type").and_then(|t| t.as_str()) != Some("token_count") { - continue; - } - let Some(info) = payload.get("info").and_then(|i| i.as_object()) else { - continue; - }; - if info.get("last_token_usage").is_none() && info.get("total_token_usage").is_none() { - continue; - } - let Some(ts) = obj.get("timestamp").and_then(|t| t.as_str()) else { - continue; - }; - let ts = ts.trim(); - if ts.len() < 19 { - continue; - } - let second = ts[..19].to_string(); - match &first_second { - None => first_second = Some(second), - Some(first) if first == &second => return Some(second), - _ => return None, - } - } - None -} - fn dedup_events(events: Vec) -> Vec { let mut seen: HashSet<(i128, String, i32, i32, i32, i32)> = HashSet::new(); let mut out = Vec::new(); @@ -484,22 +584,23 @@ fn aggregate(events: &[CodexEvent], since: OffsetDateTime, pricing: &ModelPricin continue; } let day = local_day_key_from_offset(&event.timestamp); - let tokens = TokenBreakdown { - input: (event.input - event.cached).max(0), - cache_write5m: 0, - cache_write1h: 0, - cache_read: event.cached, - output: event.output + event.reasoning, - is_fast: false, - }; - let cost = match pricing.estimated_cost_dollars(&event.model, &tokens) { - Some(c) => c, - None => continue, + let Some(cost) = codex_pricing::estimated_cost_dollars( + pricing, + &CodexCostInput { + model: &event.model, + input: event.input, + cached: event.cached, + output: event.output, + reasoning: event.reasoning, + is_fast: event.is_fast, + }, + ) else { + continue; }; let total = if event.total > 0 { event.total } else { - tokens.total_tokens() + (event.input - event.cached).max(0) + event.cached + event.output + event.reasoning }; *tokens_by_day.entry(day.clone()).or_default() += total; *cost_by_day.entry(day.clone()).or_default() += cost; @@ -556,3 +657,75 @@ fn aggregate(events: &[CodexEvent], since: OffsetDateTime, pricing: &ModelPricin }) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn child_session_meta_null_parents_are_root() { + let payload = json!({ + "forked_from_id": null, + "parent_thread_id": null, + "thread_source": "cli" + }); + let obj = payload.as_object().unwrap(); + assert!(!is_child_session_meta(obj)); + } + + #[test] + fn child_session_meta_detects_subagent_and_fork() { + let fork = json!({"forked_from_id": "abc"}).as_object().cloned().unwrap(); + assert!(is_child_session_meta(&fork)); + let sub = json!({"thread_source": "subagent"}).as_object().cloned().unwrap(); + assert!(is_child_session_meta(&sub)); + let nested = json!({"source": {"subagent": "worker"}}) + .as_object() + .cloned() + .unwrap(); + assert!(is_child_session_meta(&nested)); + } + + #[test] + fn equal_counts_detects_stale_snapshot() { + let a = RawUsage { + input: 10, + cached: 2, + output: 3, + reasoning: 1, + total: 16, + }; + let b = a.clone(); + assert!(a.equal_counts(&b)); + let mut c = a.clone(); + c.output = 4; + assert!(!a.equal_counts(&c)); + } + + #[test] + fn session_files_include_sessions_and_skip_archived_dupes() { + let root = std::env::temp_dir().join(format!( + "crossusage-codex-sessions-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + let sessions = root.join("sessions"); + let archived = root.join("archived_sessions"); + fs::create_dir_all(&sessions).unwrap(); + fs::create_dir_all(&archived).unwrap(); + fs::write(sessions.join("a.jsonl"), "{}\n").unwrap(); + fs::write(archived.join("a.jsonl"), "{}\n").unwrap(); + fs::write(archived.join("b.jsonl"), "{}\n").unwrap(); + + let files = session_files(&[root.clone()]); + let names: HashSet<_> = files + .iter() + .map(|f| f.path.file_name().unwrap().to_string_lossy().to_string()) + .collect(); + assert!(names.contains("a.jsonl")); + assert!(names.contains("b.jsonl")); + assert_eq!(files.len(), 2, "archived duplicate of sessions/a.jsonl skipped"); + let _ = fs::remove_dir_all(&root); + } +} diff --git a/crates/crossusage-core/src/lib.rs b/crates/crossusage-core/src/lib.rs index b08bb682d..c0df86256 100644 --- a/crates/crossusage-core/src/lib.rs +++ b/crates/crossusage-core/src/lib.rs @@ -10,7 +10,9 @@ pub mod cursor_paths; pub mod cursor_usage_export; pub mod cursor_usage_logs; pub mod claude_usage_scanner; +pub mod codex_pricing; pub mod codex_usage_scanner; +pub mod pi_usage_scanner; pub mod log_usage_types; pub mod model_pricing; pub mod usage_daily; diff --git a/crates/crossusage-core/src/log_usage_types.rs b/crates/crossusage-core/src/log_usage_types.rs index 3cc3bbd6c..abd939ba3 100644 --- a/crates/crossusage-core/src/log_usage_types.rs +++ b/crates/crossusage-core/src/log_usage_types.rs @@ -72,6 +72,58 @@ mod tests { assert!(files.iter().any(|f| f.2 == 599)); assert!(!files.iter().any(|f| f.2 == 0)); } + + #[test] + fn merge_daily_rows_sums_by_date_and_models() { + let a = vec![DailyUsageRow { + date: "2026-07-12".into(), + input_tokens: 10, + output_tokens: 5, + cache_creation_tokens: 0, + cache_read_tokens: 0, + total_tokens: 15, + total_cost: Some(1.0), + cost_usd: Some(1.0), + models: BTreeMap::from([( + "m".into(), + ModelDayUsage { + input_tokens: 0, + output_tokens: 15, + cache_creation_tokens: 0, + cache_read_tokens: 0, + total_tokens: 15, + total_cost: Some(1.0), + }, + )]), + }]; + let b = vec![DailyUsageRow { + date: "2026-07-12".into(), + input_tokens: 20, + output_tokens: 10, + cache_creation_tokens: 0, + cache_read_tokens: 0, + total_tokens: 30, + total_cost: Some(0.5), + cost_usd: Some(0.5), + models: BTreeMap::from([( + "m".into(), + ModelDayUsage { + input_tokens: 0, + output_tokens: 30, + cache_creation_tokens: 0, + cache_read_tokens: 0, + total_tokens: 30, + total_cost: Some(0.5), + }, + )]), + }]; + let merged = merge_daily_rows(a, b); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].total_tokens, 45); + assert_eq!(merged[0].total_cost, Some(1.5)); + assert_eq!(merged[0].models["m"].total_tokens, 45); + assert_eq!(merged[0].models["m"].total_cost, Some(1.5)); + } } #[derive(Debug, Clone, Default, Serialize)] @@ -168,3 +220,44 @@ pub fn host_query_response(status: LogScanStatus, daily: Vec) -> }) .to_string() } + +fn sum_opt_f64(a: Option, b: Option) -> Option { + match (a, b) { + (None, None) => None, + (a, b) => Some(a.unwrap_or(0.0) + b.unwrap_or(0.0)), + } +} + +/// Sum token/cost fields by date and merge per-model maps (native + pi fold-in). +pub fn merge_daily_rows(a: Vec, b: Vec) -> Vec { + let mut by_date: BTreeMap = BTreeMap::new(); + for row in a.into_iter().chain(b) { + if let Some(existing) = by_date.get_mut(&row.date) { + existing.input_tokens += row.input_tokens; + existing.output_tokens += row.output_tokens; + existing.cache_creation_tokens += row.cache_creation_tokens; + existing.cache_read_tokens += row.cache_read_tokens; + existing.total_tokens += row.total_tokens; + existing.total_cost = sum_opt_f64(existing.total_cost, row.total_cost); + existing.cost_usd = sum_opt_f64(existing.cost_usd, row.cost_usd); + for (model, usage) in row.models { + match existing.models.get_mut(&model) { + Some(e) => { + e.input_tokens += usage.input_tokens; + e.output_tokens += usage.output_tokens; + e.cache_creation_tokens += usage.cache_creation_tokens; + e.cache_read_tokens += usage.cache_read_tokens; + e.total_tokens += usage.total_tokens; + e.total_cost = sum_opt_f64(e.total_cost, usage.total_cost); + } + None => { + existing.models.insert(model, usage); + } + } + } + } else { + by_date.insert(row.date.clone(), row); + } + } + by_date.into_values().rev().collect() +} diff --git a/crates/crossusage-core/src/model_pricing.rs b/crates/crossusage-core/src/model_pricing.rs index 2b569d991..46fcdfe61 100644 --- a/crates/crossusage-core/src/model_pricing.rs +++ b/crates/crossusage-core/src/model_pricing.rs @@ -22,6 +22,10 @@ pub struct ModelRates { pub output_above_200k: Option, pub cache_write_above_200k: Option, pub cache_read_above_200k: Option, + /// Catalog published an explicit cache-read rate (not a synthesized 10%-of-input fallback). + pub cache_read_is_explicit: bool, + /// Prompt-token threshold for optional long-context rates (200k default; Codex uses 272k). + pub long_context_threshold_tokens: i32, pub fast_multiplier: f64, } @@ -36,13 +40,15 @@ impl ModelRates { output_above_200k: self.output_above_200k.map(|v| v * factor), cache_write_above_200k: self.cache_write_above_200k.map(|v| v * factor), cache_read_above_200k: self.cache_read_above_200k.map(|v| v * factor), + cache_read_is_explicit: self.cache_read_is_explicit, + long_context_threshold_tokens: self.long_context_threshold_tokens, fast_multiplier: 1.0, } } /// 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). + /// exceed `long_context_threshold_tokens`, the whole request uses long-context rates + /// (upstream OpenUsage 0.7.4 #885 / 0.7.6 #995). pub fn cost_dollars(&self, tokens: &TokenBreakdown) -> f64 { self.cost_dollars_with_options(tokens, true) } @@ -56,7 +62,8 @@ impl ModelRates { let mult = if tokens.is_fast { self.fast_multiplier } else { 1.0 }; 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 use_long = + apply_long_context_rates && prompt_tokens > self.long_context_threshold_tokens; 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 = @@ -146,6 +153,9 @@ struct CompactModel { cra: Option, #[serde(default)] fast: Option, + /// Omitted / true = explicit cache-read; `false` = synthesized fallback. + #[serde(default)] + cre: Option, } pub struct ModelPricing { @@ -174,6 +184,11 @@ impl ModelPricing { self.resolve(model).map(|r| r.cost_dollars(tokens)) } + /// Supplement alias canonical name when a rule matches; otherwise `None`. + pub fn canonical_name(&self, model: &str) -> Option { + self.supplement.canonical_name(model) + } + pub fn resolve(&self, model: &str) -> Option { if let Some(canonical) = self.supplement.canonical_name(model) { if canonical != model { @@ -280,6 +295,7 @@ fn load_supplement(json: &str) -> PricingSupplement { }); let mut pricing = HashMap::new(); for (model, entry) in file.pricing { + let cache_read_is_explicit = entry.cache_read_per_million.is_some(); pricing.insert( model, ModelRates { @@ -295,6 +311,8 @@ fn load_supplement(json: &str) -> PricingSupplement { output_above_200k: None, cache_write_above_200k: None, cache_read_above_200k: None, + cache_read_is_explicit, + long_context_threshold_tokens: 200_000, fast_multiplier: 1.0, }, ); @@ -336,6 +354,8 @@ fn load_compact_catalog(json: &str) -> HashMap { output_above_200k: m.oa, cache_write_above_200k: m.cwa, cache_read_above_200k: m.cra, + cache_read_is_explicit: m.cre.unwrap_or(true), + long_context_threshold_tokens: 200_000, fast_multiplier: m.fast.unwrap_or(1.0), }, ) @@ -394,6 +414,7 @@ mod tests { 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("cursor-grok-4.5-fast-xhigh")); assert!(p.can_price("kimi-k2.7-code")); assert!(p.can_price("kimi-k2p7")); } @@ -409,6 +430,8 @@ mod tests { output_above_200k: Some(20.0), cache_write_above_200k: Some(10.0), cache_read_above_200k: Some(1.0), + cache_read_is_explicit: true, + long_context_threshold_tokens: 200_000, fast_multiplier: 1.0, }; let tokens = TokenBreakdown { diff --git a/crates/crossusage-core/src/pi_usage_scanner.rs b/crates/crossusage-core/src/pi_usage_scanner.rs new file mode 100644 index 000000000..eaa3af831 --- /dev/null +++ b/crates/crossusage-core/src/pi_usage_scanner.rs @@ -0,0 +1,391 @@ +//! Pi coding-agent session log scanner (ports upstream PiUsageScanner). +//! Folds pi assistant usage into Claude/Codex cards via provider mapping. + +use crate::claude_usage_scanner::parse_iso_timestamp; +use crate::log_usage_types::{ + DailyUsageRow, ModelDayUsage, TokenBreakdown, cap_log_files_by_mtime, expand_tilde, + local_day_key_from_offset, since_local_midnight, warn_unreadable_usage_file, +}; +use crate::model_pricing::{ModelPricing, default_pricing}; +use serde_json::Value; +use std::collections::{BTreeMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; +use time::OffsetDateTime; + +#[derive(Clone)] +struct Entry { + id: Option, + timestamp: OffsetDateTime, + card_id: String, + model: String, + carried_cost: Option, + tokens: TokenBreakdown, + reported_total_tokens: i32, +} + +struct DiscoveredFile { + path: PathBuf, + size: u64, + mtime: SystemTime, +} + +/// pi `provider` → OpenUsage card id. +fn card_id_for_pi_provider(provider: &str) -> Option<&'static str> { + match provider { + "anthropic" | "claude-agent-sdk" => Some("claude"), + "openai-codex" => Some("codex"), + "cursor" => Some("cursor"), + "zai" | "zhipu" => Some("zai"), + "google-antigravity" => Some("antigravity"), + "github-copilot" => Some("copilot"), + _ => None, + } +} + +fn sessions_directory() -> PathBuf { + if let Ok(v) = std::env::var("PI_CODING_AGENT_SESSION_DIR") { + let t = v.trim(); + if !t.is_empty() { + return expand_tilde(t); + } + } + if let Ok(v) = std::env::var("PI_CODING_AGENT_DIR") { + let t = v.trim(); + if !t.is_empty() { + return expand_tilde(t).join("sessions"); + } + } + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".pi/agent/sessions") +} + +/// Daily rows for one card (`claude` / `codex`) from pi session logs since `since_compact`. +pub fn query_daily_for_card(card_id: &str, since_compact: &str) -> Vec { + let since = parse_since(since_compact); + scan(card_id, days_back_from_since(since), default_pricing()).unwrap_or_default() +} + +fn parse_since(since: &str) -> OffsetDateTime { + let digits: String = since.chars().filter(|c| c.is_ascii_digit()).collect(); + if digits.len() >= 8 { + if let (Ok(y), Ok(m), Ok(d)) = ( + digits[0..4].parse::(), + digits[4..6].parse::(), + digits[6..8].parse::(), + ) { + if let Ok(month) = time::Month::try_from(m) { + if let Ok(date) = time::Date::from_calendar_date(y, month, d) { + return date.with_hms(0, 0, 0).expect("midnight").assume_utc(); + } + } + } + } + since_local_midnight(30) +} + +fn days_back_from_since(since: OffsetDateTime) -> i32 { + let now = OffsetDateTime::now_utc(); + ((now.date() - since.date()).whole_days().max(0) + 1) as i32 +} + +fn scan(card_id: &str, days_back: i32, pricing: &ModelPricing) -> Option> { + let files = jsonl_files(&sessions_directory()); + if files.is_empty() { + return None; + } + let since = since_local_midnight(days_back); + let mut entries = Vec::new(); + for file in &files { + if file_mtime_before(&file.mtime, since) { + continue; + } + entries.extend(parse_file(&file.path)); + } + Some(aggregate(&dedup(entries), card_id, since, pricing)) +} + +fn jsonl_files(dir: &Path) -> Vec { + let mut files = Vec::new(); + walk_jsonl(dir, &mut files); + cap_log_files_by_mtime(&mut files, |f| f.mtime, |f| f.size); + files.sort_by(|a, b| a.path.cmp(&b.path)); + files +} + +fn walk_jsonl(dir: &Path, out: &mut Vec) { + let Ok(rd) = fs::read_dir(dir) else { + return; + }; + for entry in rd.flatten() { + let path = entry.path(); + if path.is_dir() { + walk_jsonl(&path, out); + } else if path.extension().is_some_and(|e| e == "jsonl") { + if let Ok(meta) = entry.metadata() { + out.push(DiscoveredFile { + path, + size: meta.len(), + mtime: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH), + }); + } + } + } +} + +fn file_mtime_before(mtime: &SystemTime, since: OffsetDateTime) -> bool { + let Ok(duration) = mtime.duration_since(SystemTime::UNIX_EPOCH) else { + return false; + }; + let Ok(file_dt) = OffsetDateTime::from_unix_timestamp(duration.as_secs() as i64) else { + return false; + }; + file_dt < since +} + +fn parse_file(path: &Path) -> 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(); + for line in data.split(|&b| b == b'\n') { + if line.windows(marker.len()).any(|w| w == marker) { + if let Some(entry) = parse_line(line) { + entries.push(entry); + } + } + } + entries +} + +fn parse_line(line: &[u8]) -> Option { + let v: Value = serde_json::from_slice(line).ok()?; + if v.get("type").and_then(|t| t.as_str()) != Some("message") { + return None; + } + let timestamp = parse_iso_timestamp(v.get("timestamp")?.as_str()?)?; + let message = v.get("message")?; + if message.get("role").and_then(|r| r.as_str()) != Some("assistant") { + return None; + } + let card_id = card_id_for_pi_provider(message.get("provider")?.as_str()?)?.to_string(); + let usage = message.get("usage")?; + let cache_write = json_i32(usage.get("cacheWrite")); + let cache_write_1h = json_i32(usage.get("cacheWrite1h")); + Some(Entry { + id: v.get("id").and_then(|id| id.as_str()).map(str::to_string), + timestamp, + card_id, + model: message + .get("model") + .and_then(|m| m.as_str()) + .map(str::trim) + .unwrap_or("") + .to_string(), + carried_cost: usage.get("cost").and_then(|c| c.get("total")).and_then(json_f64), + tokens: TokenBreakdown { + input: json_i32(usage.get("input")), + cache_write5m: (cache_write - cache_write_1h).max(0), + cache_write1h: cache_write_1h, + cache_read: json_i32(usage.get("cacheRead")), + output: json_i32(usage.get("output")), + is_fast: false, + }, + reported_total_tokens: json_i32(usage.get("totalTokens")), + }) +} + +fn json_i32(v: Option<&Value>) -> i32 { + v.and_then(|n| n.as_i64().or_else(|| n.as_f64().map(|f| f as i64))) + .unwrap_or(0) as i32 +} + +fn json_f64(v: &Value) -> Option { + v.as_f64().or_else(|| v.as_i64().map(|i| i as f64)) +} + +fn dedup(entries: Vec) -> Vec { + let mut seen = HashSet::new(); + let mut out = Vec::with_capacity(entries.len()); + for entry in entries { + if let Some(id) = &entry.id { + if !seen.insert(id.clone()) { + continue; + } + } + out.push(entry); + } + out +} + +fn aggregate( + entries: &[Entry], + card_id: &str, + since: OffsetDateTime, + pricing: &ModelPricing, +) -> Vec { + let mut tokens_by_day: BTreeMap = BTreeMap::new(); + let mut cost_by_day: BTreeMap = BTreeMap::new(); + let mut models_by_day: BTreeMap> = BTreeMap::new(); + let mut input_by_day: BTreeMap = BTreeMap::new(); + let mut output_by_day: BTreeMap = BTreeMap::new(); + let mut cache_create_by_day: BTreeMap = BTreeMap::new(); + let mut cache_read_by_day: BTreeMap = BTreeMap::new(); + + for entry in entries { + if entry.card_id != card_id || entry.timestamp < since { + continue; + } + let day = local_day_key_from_offset(&entry.timestamp); + let trimmed = entry.model.trim(); + let model_name = if trimmed.is_empty() { + "unattributed" + } else { + trimmed + }; + let cost = if let Some(carried) = entry.carried_cost.filter(|c| *c > 0.0) { + carried + } else if !trimmed.is_empty() { + match pricing.estimated_cost_dollars(trimmed, &entry.tokens) { + Some(c) => c, + None => continue, + } + } else { + continue; + }; + + let total = entry.reported_total_tokens; + *tokens_by_day.entry(day.clone()).or_default() += total; + *cost_by_day.entry(day.clone()).or_default() += cost; + *input_by_day.entry(day.clone()).or_default() += entry.tokens.input; + *output_by_day.entry(day.clone()).or_default() += entry.tokens.output; + *cache_create_by_day.entry(day.clone()).or_default() += + entry.tokens.cache_write5m + entry.tokens.cache_write1h; + *cache_read_by_day.entry(day.clone()).or_default() += entry.tokens.cache_read; + let e = models_by_day + .entry(day) + .or_default() + .entry(model_name.to_string()) + .or_insert((0, 0.0)); + e.0 += total; + e.1 += cost; + } + + tokens_by_day + .keys() + .rev() + .cloned() + .map(|day| { + let total_cost = cost_by_day.get(&day).copied(); + let models = models_by_day + .get(&day) + .map(|m| { + m.iter() + .map(|(name, (tokens, cost))| { + ( + name.clone(), + ModelDayUsage { + input_tokens: 0, + output_tokens: *tokens, + cache_creation_tokens: 0, + cache_read_tokens: 0, + total_tokens: *tokens, + total_cost: Some(*cost), + }, + ) + }) + .collect() + }) + .unwrap_or_default(); + DailyUsageRow { + date: day.clone(), + input_tokens: *input_by_day.get(&day).unwrap_or(&0), + output_tokens: *output_by_day.get(&day).unwrap_or(&0), + cache_creation_tokens: *cache_create_by_day.get(&day).unwrap_or(&0), + cache_read_tokens: *cache_read_by_day.get(&day).unwrap_or(&0), + total_tokens: *tokens_by_day.get(&day).unwrap_or(&0), + total_cost, + cost_usd: total_cost, + models, + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn line(provider: &str, cache_write: i32, cache_write_1h: i32, cost: Option) -> Vec { + let mut usage = serde_json::json!({ + "input": 100, + "output": 50, + "cacheRead": 0, + "cacheWrite": cache_write, + "cacheWrite1h": cache_write_1h, + "totalTokens": 150, + }); + if let Some(c) = cost { + usage["cost"] = serde_json::json!({ "total": c }); + } + serde_json::json!({ + "type": "message", + "id": "m1", + "timestamp": "2026-07-12T10:00:00.000Z", + "message": { + "role": "assistant", + "provider": provider, + "model": "claude-opus-4-8", + "usage": usage, + } + }) + .to_string() + .into_bytes() + } + + #[test] + fn maps_providers() { + assert_eq!(card_id_for_pi_provider("anthropic"), Some("claude")); + assert_eq!(card_id_for_pi_provider("claude-agent-sdk"), Some("claude")); + assert_eq!(card_id_for_pi_provider("openai-codex"), Some("codex")); + assert_eq!(card_id_for_pi_provider("nvidia-nim"), None); + } + + #[test] + fn parses_mapped_anthropic_line() { + let entry = parse_line(&line("anthropic", 0, 0, Some(0.5))).unwrap(); + assert_eq!(entry.card_id, "claude"); + assert_eq!(entry.model, "claude-opus-4-8"); + assert_eq!(entry.carried_cost, Some(0.5)); + assert_eq!(entry.reported_total_tokens, 150); + assert_eq!(entry.tokens.input, 100); + assert_eq!(entry.tokens.output, 50); + } + + #[test] + fn splits_cache_write_buckets_by_1h() { + let entry = parse_line(&line("anthropic", 1000, 400, Some(0.5))).unwrap(); + assert_eq!(entry.tokens.cache_write1h, 400); + assert_eq!(entry.tokens.cache_write5m, 600); + } + + #[test] + fn maps_codex_and_skips_unmapped_and_non_assistant() { + assert_eq!( + parse_line(&line("openai-codex", 0, 0, Some(0.5))) + .unwrap() + .card_id, + "codex" + ); + assert!(parse_line(&line("nvidia-nim", 0, 0, Some(0.5))).is_none()); + let user = br#"{"type":"message","timestamp":"2026-07-12T10:00:00.000Z","message":{"role":"user","provider":"anthropic","usage":{}}}"#; + assert!(parse_line(user).is_none()); + } +} diff --git a/docs/FORK-UPSTREAM.md b/docs/FORK-UPSTREAM.md index 43a5c1580..941190df8 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 ([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). +**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.3.3** → v0.7.6 ([PORT-0.7.6.md](./PORT-0.7.6.md)). **1.4.0** → fork-only features per [VERSIONING.md](./VERSIONING.md). ## What to port diff --git a/docs/PORT-0.7.6.md b/docs/PORT-0.7.6.md new file mode 100644 index 000000000..27174afca --- /dev/null +++ b/docs/PORT-0.7.6.md @@ -0,0 +1,129 @@ +# OpenUsage v0.7.6 port (CrossUsage 1.3.3) + +Upstream **v0.7.6** is Swift-only (`upstream/swift` tag, 2026-07-17). Port by reading Swift providers / log scanners / pricing code and rewriting to JS plugins + Rust scanners + React/Tauri. + +**Version:** [**1.3.3**](./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.6** behavior. Prioritize **spend accuracy** (#1001, #995) before polish. + +**Baseline:** CrossUsage **1.3.2** (shipped upstream **v0.7.4 + v0.7.5**). Tag: `v0.7.6`. + +## Status legend + +| Status | Meaning | +|--------|---------| +| **ship** | In **1.3.3** scope | +| **skip** | Not applicable on Linux/Windows fork (macOS-only or upstream repo infra) | +| **later** | Valid fork work; OK to defer past 1.3.3 if timeboxed | + +**Rule:** Every upstream 0.7.6 item below is **ship** in 1.3.3 unless listed under **Skip** or **Later**. + +## Coordination + +- [PR #15](https://github.com/barramee27/crossusage/pull/15) (agy Windows + quota summary) — merge when keyring fallback lands; can ship in **1.3.3** with or after 0.7.6 port. +- **1.3.2** is already released (`v1.3.2`); do not re-cut unless a hotfix is required before 1.3.3 ships. + +--- + +## Phase 1 — Spend accuracy & log scanners (**P0**) + +| Upstream | CrossUsage action | Status | +|----------|-------------------|--------| +| Fix ~20× Codex spend inflation from subagent replay logs (#1001) | `codex_usage_scanner.rs` — `ChildReplayGate`, equal-count skip; unit tests | **done** | +| Fix Claude and Codex token cost calculations (#995) | `model_pricing.rs` + `codex_pricing.rs` + Claude scanner fast/advisor | **done** | +| Price Codex fast tier per session from rollout logs (#995 / cfdfdd0) | `thread_settings_applied` → `is_fast` | **done** | +| Handle persisted Claude print usage + Codex fast aliases (#995) | Claude iterations + Codex `-fast` alias unwrap | **done** | +| Fold **pi** coding agent usage into Claude and Codex (#975) | `pi_usage_scanner.rs` + `merge_daily_rows` | **done** | +| Follow symlinked log directories when scanning (#973) | Canonicalize Codex session homes/dirs | **done** | +| Strip resolved dir prefix when deduping Codex session files (#b75e329) | Dedup by `(home, relative)`; skip archived dupes | **done** | + +### Reference (Swift / Rust upstream) + +```bash +git fetch upstream --tags +git log v0.7.5..v0.7.6 --oneline +git diff v0.7.5..v0.7.6 --stat +git show v0.7.6:Sources/OpenUsage/Providers/Codex/ +git show v0.7.6:Sources/OpenUsage/Providers/Claude/ +git show v0.7.6:Sources/OpenUsage/LogScanning/ +``` + +--- + +## Phase 2 — Plugins & pricing + +| Upstream | CrossUsage action | Status | +|----------|-------------------|--------| +| Restore Cursor Enterprise included-request and on-demand usage (#986) | `plugins/cursor` + nightly — `/api/usage` + `/api/usage-summary` | **done** | +| Recognize Cursor Grok 4.5 usage slugs (#981) | Pricing alias patterns | **done** | +| Accept `xhigh` effort suffix on Grok 4.5 alias rules (#999) | Alias table + tests | **done** | +| Read Claude Desktop login safely as read-only fallback (#962) | Desktop Safe Storage decrypt | **later** (macOS-heavy; Linux/Windows deferred) | +| Update Grok provider logo (#1005) | `plugins/grok/icon.svg` (`currentColor`) | **done** | + +--- + +## Phase 3 — UI / CLI (optional in 1.3.3) + +| Upstream | CrossUsage action | Status | +|----------|-------------------|--------| +| Hover-revealed screenshot copying on provider headers (#989) | Provider header / share affordance in Modern + Classic | **later** (no share-card pipeline yet; nice UX) | +| Machine-readable limits API + global `openusage` command (#982) | Evaluate overlap with `crossusage-cli limits` / local HTTP API | **later** (fork already has CLI; port only missing JSON shape if needed) | + +--- + +## Phase 4 — Tests & docs + +| Upstream | CrossUsage action | Status | +|----------|-------------------|--------| +| Codex subagent replay regression | `cargo test -p crossusage-core` codex unit tests | **done** | +| Token cost / fast-tier pricing tests | `model_pricing` + `codex_pricing` | **done** | +| Cursor enterprise usage tests | `plugins/cursor/plugin.test.js` (72) | **done** | +| Symlink log scan note | `docs/providers/codex.md` / `claude.md` | **done** | +| `CHANGELOG.md` **1.3.3** | Cite upstream **v0.7.6** | **done** | +| `README.md` | Plugin list if surface changes | **n/a** (no plugin list change) | + +--- + +## Skip (not 1.3.3 — wrong platform or upstream infra) + +| Upstream | Why **skip** | +|----------|----------------| +| Hide menu bar usage while screen is shared (#1013) | macOS menu-bar / screen-share API | +| Sync usage history across Macs with iCloud (#984) | macOS iCloud + Keychain device identity | +| Keep Sparkle updater windows in foreground (#985) | macOS Sparkle; fork uses Tauri updater | +| Fix menu-bar icon not closing panel on second click (#1012) | macOS status-item hit testing | +| Keep inactive issues open 30 days before warning (#998) | Upstream `.github/stale.yml` only | +| Ignore `.build-test/` directory (#e4b9fba) | Upstream dev convenience; optional cherry-pick | + +--- + +## Release checklist (1.3.3) + +1. Branch: `feat/port-openusage-0.7.6` from `feat/linux-windows-native-support` — **done** +2. Land Phase 1–2 (**ship** rows); Phase 3 **later** — **done** (code; not pushed) +3. Merge PR #15 when ready (agy; independent of 0.7.6 but same release is fine) +4. Bump **1.3.3** (`package.json`, `src-tauri/*`, crates) — **done** +5. `CHANGELOG.md` — cites upstream **v0.7.6** — **done** +6. `bun run test` + `cargo test -p crossusage-core` + `bun run build` — run before release cut +7. `scripts/build-all-artifacts.sh` → `scripts/collect-release-artifacts.sh` +8. Tag `v1.3.3`, GitHub Release, `sign-and-publish-updater.sh v1.3.3` — **after user tests** (**do not push** until approved) + +--- + +## PR split (optional) + +| PR | Contents | +|----|----------| +| A | Codex subagent replay dedup (#1001) + tests | +| B | Token cost / fast-tier pricing (#995) | +| C | Pi agent → Claude/Codex attribution (#975) | +| D | Symlink log dirs + Codex session dedup (#973) | +| E | Cursor enterprise + Grok 4.5 slugs/aliases (#986, #981, #999) | +| F | Claude Desktop read-only fallback (#962) — **later** | +| G | Grok logo + docs + CHANGELOG | + +--- + +## 1.4.0 (reserved) + +Per [VERSIONING.md](./VERSIONING.md): **MINOR** = new **CrossUsage** features. Items 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 explicitly pulled into 1.3.3 above. diff --git a/docs/breadcrumbs.md b/docs/breadcrumbs.md index 3dd8ee620..0b3be333f 100644 --- a/docs/breadcrumbs.md +++ b/docs/breadcrumbs.md @@ -1,41 +1,13 @@ -## 2026-07-13 +# Breadcrumbs -- **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-17 -## 2026-07-12 +- Port OpenUsage **v0.7.6** → CrossUsage **1.3.3** on `feat/port-openusage-0.7.6` (not pushed). +- New: `codex_pricing.rs`, `pi_usage_scanner.rs`; Codex `ChildReplayGate` + session canonicalize/dedup; Claude advisor iterations + fast speed; Cursor enterprise usage-summary; Grok icon + aliases. +- Fixed session discovery bug: double `seen.insert` skipped all `sessions/*.jsonl`. +- Version bump **1.3.3**; CHANGELOG + PORT-0.7.6 status; #962 Claude Desktop → later. -- **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. -- Security on release base: JSON error escaping, provider_accounts `0600`, plugin `..` guard; browser cross-origin block (no bearer auth). - -## 2026-06-21 - -- Cross-platform plugin paths: added `host.fs.firstExistingAppSupport` / `firstExisting` in `crossusage-core` host API; updated Cursor, Windsurf, Kiro, Antigravity, Gemini (fnm/pnpm/linux node_modules), Ollama (Firefox/LibreWolf Linux profile roots). -- **Antigravity CLI fix:** `host.keychain.readGenericPassword` now uses platform keyring on Linux/Windows (zalando/go-keyring: `service=gemini`, `username=antigravity`); plugin parses `token.access_token` JSON from `agy`. -- Remaining macOS-only by design or upstream: Perplexity (Mac app cache), Ollama keychain session on macOS. -- v0.6.26→1.0.11 merge: restored fork `plugins/cursor/plugin.js` (Linux `~/.config/Cursor/...` via `firstExistingAppSupport`); Devin multi-path credentials; merged `tray-primary-progress` (`trayLines` + `preferWeeklyLimit`). -- Usage history + ccusage merge: `host.usageDaily.ingest` persists ccusage `daily[]` into SQLite (`usage_daily`); Settings shows **Quota over time** + **Daily tokens (local logs)** under same toggle; Claude/Codex plugins call ingest after ccusage. -- **1.2.0 polish**: tray top insight line, `GET /v1/insights`, history retention setting, API docs. -- **Upstream #612** ported: Devin weekly-from-daily quota uses `buildQuotaLine` (remaining→used). -- **OpenUsage 0.7 port**: Modern UI + dual `uiLayout`; #613–#615 done; ship as 1.2.0 with insights after QA. +- Ported OpenUsage v0.7.6 PiUsageScanner → `crates/crossusage-core/src/pi_usage_scanner.rs`; wired into Claude/Codex via `merge_daily_rows`. +- Claude `parse_entries` emits `advisor_message` iterations as separate entries (`message_id:advisor:N`). +- Minor codex compile fix: dropped explicit `ref` in `if let` (edition 2024 binding mode). +- Cursor enterprise: `buildEnterpriseResult` now fetches `/api/usage` + `/api/usage-summary` (mirrors Swift `CursorUsageSummaryMapper`); synced to `cursor-nightly`. Grok icon replaced from `/tmp/grok.svg`. diff --git a/docs/choices.md b/docs/choices.md index b0dfd7617..b4c84e141 100644 --- a/docs/choices.md +++ b/docs/choices.md @@ -1,25 +1,10 @@ -## 2026-07-13 +# Choices -- 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-17 -## 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. -- Provider credentials: `provider_accounts.json` written `0600` on Unix only. -- Plugin host `fs`/`sqlite`: reject `..` path segments before `expand_path`. +- **#962 Claude Desktop Safe Storage:** **later** for 1.3.3 — macOS-centric decrypt; Linux/Windows has no equivalent store path worth shipping half-baked. +- **Phase 3 UI/CLI (#989, #982):** stay **later**; fork already has CLI/HTTP. +- **Pi fold-in at `query_daily_since`:** Claude/Codex merge pi rows even when the native scan returns no files/empty, so pi-only usage still shows on those cards (matches upstream `DailyUsageAccumulator.merged`). +- **Pi scanner:** no incremental file cache (simpler; ~350 LOC budget). Re-parse session files each query. +- **Unknown pi models:** skipped from totals (no `unknownModelsByDay` on `DailyUsageRow`); same as existing Claude/Codex Rust aggregate. +- **Cursor enterprise on-demand without limit:** CrossUsage has no `values` MetricLine; emit `text` (`$X.XX`) when usage-summary has used>0 but no positive limit (Swift uses `.values`). diff --git a/docs/providers/claude.md b/docs/providers/claude.md index 2e3520c78..12da5c461 100644 --- a/docs/providers/claude.md +++ b/docs/providers/claude.md @@ -117,3 +117,7 @@ Content-Type: application/json "expires_in": 3600 // seconds } ``` + +## Spend (log scan) + +Native Rust scanner reads Claude Code JSONL session logs. Pi coding-agent daily usage attributed to Claude is merged into Claude spend tiles. `advisor_message` iterations inside a usage event are counted as separate model entries. diff --git a/docs/providers/codex.md b/docs/providers/codex.md index f868c315e..64a7557a2 100644 --- a/docs/providers/codex.md +++ b/docs/providers/codex.md @@ -117,3 +117,7 @@ grant_type=refresh_token ``` Response returns new `access_token`, and optionally new `refresh_token` and `id_token`. + +## Spend (log scan) + +Native Rust scanner reads `~/.codex/sessions` (and `archived_sessions`). Symlinked Codex homes are resolved (`canonicalize`); duplicate session files under the same home (including archived copies of the same relative path) are deduped. Pi coding-agent daily usage is merged into Codex spend tiles when present. diff --git a/package.json b/package.json index e35560600..488f6bfab 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "crossusage", "private": true, - "version": "1.3.2", + "version": "1.3.3", "type": "module", "scripts": { "dev": "vite", diff --git a/plugins/cursor-nightly/plugin.js b/plugins/cursor-nightly/plugin.js index a8ae15e5b..12c614e80 100644 --- a/plugins/cursor-nightly/plugin.js +++ b/plugins/cursor-nightly/plugin.js @@ -10,6 +10,7 @@ globalThis.__OPENUSAGE_PLUGIN_REGISTRATION_ID__ = "cursor-nightly"; const USAGE_LIMIT_GRANTS_URL = BASE_URL + "/aiserver.v1.DashboardService/GetUsageLimitStatusAndActiveGrants" const REST_USAGE_URL = "https://cursor.com/api/usage" + const REST_USAGE_SUMMARY_URL = "https://cursor.com/api/usage-summary" const STRIPE_URL = "https://cursor.com/api/auth/stripe" const CLIENT_ID = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB" /** Must match `buildDevMockProviderCredentials` when `VITE_PROVIDER_ACCOUNT_DEV_MOCK` is enabled. */ @@ -932,13 +933,242 @@ globalThis.__OPENUSAGE_PLUGIN_REGISTRATION_ID__ = "cursor-nightly"; throw unavailableMessage } + function fetchUsageSummary(ctx, accessToken) { + var session = buildSessionToken(ctx, accessToken) + if (!session) { + ctx.host.log.warn("usage-summary: cannot build session token") + return null + } + try { + var resp = ctx.util.request({ + method: "GET", + url: REST_USAGE_SUMMARY_URL, + headers: { + Authorization: "Bearer " + accessToken, + Cookie: "WorkosCursorSessionToken=" + session.sessionToken, + Accept: "application/json", + Origin: "https://cursor.com", + Referer: "https://cursor.com/dashboard", + "User-Agent": CURSOR_WEB_USER_AGENT, + }, + timeoutMs: 15000, + }) + if (resp.status < 200 || resp.status >= 300) { + ctx.host.log.warn("usage-summary returned status=" + resp.status) + return null + } + var parsed = ctx.util.tryParseJson(resp.bodyText) + if (!parsed || typeof parsed !== "object") return null + return parsed + } catch (e) { + ctx.host.log.warn("usage-summary fetch failed: " + String(e)) + return null + } + } + + function enterpriseBillingCycle(ctx, summary, requestUsage) { + var defaultPeriodMs = 30 * 24 * 60 * 60 * 1000 + var startMs = summary && summary.billingCycleStart + ? ctx.util.parseDateMs(summary.billingCycleStart) + : null + var endMs = summary && summary.billingCycleEnd + ? ctx.util.parseDateMs(summary.billingCycleEnd) + : null + if (Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs) { + return { + resetsAt: ctx.util.toIso(endMs), + periodDurationMs: endMs - startMs, + } + } + var requestStart = requestUsage && requestUsage.startOfMonth + ? ctx.util.parseDateMs(requestUsage.startOfMonth) + : null + var cycleEndMs = Number.isFinite(requestStart) ? requestStart + defaultPeriodMs : null + return { + resetsAt: ctx.util.toIso(cycleEndMs), + periodDurationMs: defaultPeriodMs, + } + } + + function enterpriseDollarMeter(bucket) { + if (!bucket || typeof bucket !== "object" || bucket.enabled === false) return null + var limit = readFiniteNumber(bucket.limit) + if (!Number.isFinite(limit) || limit <= 0) return null + var reportedUsed = readFiniteNumber(bucket.used) + var remaining = readFiniteNumber(bucket.remaining) + var inferredUsed = Math.max(0, limit - (Number.isFinite(remaining) ? remaining : limit)) + var used = Number.isFinite(reportedUsed) && reportedUsed > 0 ? reportedUsed : inferredUsed + return { used: Math.max(0, used), limit: limit } + } + + function appendEnterpriseDollarProgress(ctx, lines, meter, label, cycle) { + lines.push(ctx.line.progress({ + label: label, + used: ctx.fmt.dollars(meter.used), + limit: ctx.fmt.dollars(meter.limit), + format: { kind: "dollars" }, + resetsAt: cycle.resetsAt, + periodDurationMs: cycle.periodDurationMs, + })) + } + + function appendEnterpriseRequests(ctx, requestUsage, cycle, lines) { + var gpt4 = requestUsage && requestUsage["gpt-4"] + if (!gpt4 || typeof gpt4 !== "object") return false + var limit = readFiniteNumber(gpt4.maxRequestUsage) + if (!Number.isFinite(limit) || limit <= 0) return false + var usedRaw = readFiniteNumber(gpt4.numRequests) + if (!Number.isFinite(usedRaw)) usedRaw = readFiniteNumber(gpt4.numRequestsTotal) + var used = Math.max(0, Number.isFinite(usedRaw) ? usedRaw : 0) + var labels = ["Total usage", "Requests"] + for (var i = 0; i < labels.length; i++) { + lines.push(ctx.line.progress({ + label: labels[i], + used: used, + limit: limit, + format: { kind: "count", suffix: "requests" }, + resetsAt: cycle.resetsAt, + periodDurationMs: cycle.periodDurationMs, + })) + } + return true + } + + function appendEnterpriseSummaryTotal(ctx, summary, cycle, lines) { + if (!summary || typeof summary !== "object") return + var individual = summary.individualUsage && typeof summary.individualUsage === "object" + ? summary.individualUsage + : null + var team = summary.teamUsage && typeof summary.teamUsage === "object" + ? summary.teamUsage + : null + var limitType = typeof summary.limitType === "string" + ? summary.limitType.toLowerCase() + : "" + var pooled + var overall + var plan + var percent + + if (limitType === "team") { + pooled = enterpriseDollarMeter(team && team.pooled) + if (pooled) { + appendEnterpriseDollarProgress(ctx, lines, pooled, "Total usage", cycle) + return + } + } + + plan = individual && individual.plan && typeof individual.plan === "object" + ? individual.plan + : null + percent = plan ? readFiniteNumber(plan.totalPercentUsed) : NaN + if (Number.isFinite(percent)) { + lines.push(ctx.line.progress({ + label: "Total usage", + used: percent, + limit: 100, + format: { kind: "percent" }, + resetsAt: cycle.resetsAt, + periodDurationMs: cycle.periodDurationMs, + })) + return + } + + overall = enterpriseDollarMeter(individual && individual.overall) + if (overall) { + appendEnterpriseDollarProgress(ctx, lines, overall, "Total usage", cycle) + return + } + + pooled = enterpriseDollarMeter(team && team.pooled) + if (pooled) { + appendEnterpriseDollarProgress(ctx, lines, pooled, "Total usage", cycle) + } + } + + function appendEnterpriseStructuredPercentages(ctx, summary, cycle, lines) { + if (!summary || typeof summary !== "object") return + var individual = summary.individualUsage + var plan = individual && typeof individual === "object" && individual.plan + && typeof individual.plan === "object" + ? individual.plan + : null + if (!plan) return + var pairs = [ + ["autoPercentUsed", "Auto usage"], + ["apiPercentUsed", "API usage"], + ] + for (var i = 0; i < pairs.length; i++) { + var percent = readFiniteNumber(plan[pairs[i][0]]) + if (!Number.isFinite(percent)) continue + lines.push(ctx.line.progress({ + label: pairs[i][1], + used: percent, + limit: 100, + format: { kind: "percent" }, + resetsAt: cycle.resetsAt, + periodDurationMs: cycle.periodDurationMs, + })) + } + } + + function appendEnterpriseOnDemandBucket(ctx, bucket, cycle, lines) { + if (!bucket || typeof bucket !== "object" || bucket.enabled === false) return false + var meter = enterpriseDollarMeter(bucket) + if (meter) { + appendEnterpriseDollarProgress(ctx, lines, meter, "On-demand", cycle) + return true + } + var usedCents = readFiniteNumber(bucket.used) + if (Number.isFinite(usedCents) && usedCents > 0) { + lines.push(ctx.line.text({ + label: "On-demand", + value: "$" + String(ctx.fmt.dollars(usedCents)), + })) + return true + } + return false + } + + function appendEnterpriseOnDemand(ctx, summary, cycle, lines) { + if (!summary || typeof summary !== "object") return + var individual = summary.individualUsage && typeof summary.individualUsage === "object" + ? summary.individualUsage + : null + var team = summary.teamUsage && typeof summary.teamUsage === "object" + ? summary.teamUsage + : null + if (appendEnterpriseOnDemandBucket(ctx, individual && individual.onDemand, cycle, lines)) { + return + } + appendEnterpriseOnDemandBucket(ctx, team && team.onDemand, cycle, lines) + } + + /** Combines /api/usage (included requests) + /api/usage-summary (percents / on-demand). */ function buildEnterpriseResult(ctx, accessToken, planName) { - return buildRequestBasedResult( - ctx, - accessToken, - planName, - "Enterprise usage data unavailable. Try again later." - ) + var unavailableMessage = "Enterprise usage data unavailable. Try again later." + var requestUsage = fetchRequestBasedUsage(ctx, accessToken) + var summary = fetchUsageSummary(ctx, accessToken) + var cycle = enterpriseBillingCycle(ctx, summary, requestUsage) + var lines = [] + + var hasRequests = appendEnterpriseRequests(ctx, requestUsage, cycle, lines) + if (!hasRequests) { + appendEnterpriseSummaryTotal(ctx, summary, cycle, lines) + } + appendEnterpriseStructuredPercentages(ctx, summary, cycle, lines) + appendEnterpriseOnDemand(ctx, summary, cycle, lines) + + if (lines.length === 0) { + ctx.host.log.warn("enterprise: no usage data from /api/usage or /api/usage-summary") + throw unavailableMessage + } + + var plan = planName + if (!plan && summary && typeof summary.membershipType === "string") { + plan = summary.membershipType + } + return finalizePlanResult(ctx, plan, lines) } function buildTeamRequestBasedResult(ctx, accessToken, planName) { diff --git a/plugins/cursor/plugin.js b/plugins/cursor/plugin.js index f7f8c1db4..f0a9b31b4 100644 --- a/plugins/cursor/plugin.js +++ b/plugins/cursor/plugin.js @@ -9,6 +9,7 @@ const USAGE_LIMIT_GRANTS_URL = BASE_URL + "/aiserver.v1.DashboardService/GetUsageLimitStatusAndActiveGrants" const REST_USAGE_URL = "https://cursor.com/api/usage" + const REST_USAGE_SUMMARY_URL = "https://cursor.com/api/usage-summary" const STRIPE_URL = "https://cursor.com/api/auth/stripe" const CLIENT_ID = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB" /** Must match `buildDevMockProviderCredentials` when `VITE_PROVIDER_ACCOUNT_DEV_MOCK` is enabled. */ @@ -931,13 +932,242 @@ throw unavailableMessage } + function fetchUsageSummary(ctx, accessToken) { + var session = buildSessionToken(ctx, accessToken) + if (!session) { + ctx.host.log.warn("usage-summary: cannot build session token") + return null + } + try { + var resp = ctx.util.request({ + method: "GET", + url: REST_USAGE_SUMMARY_URL, + headers: { + Authorization: "Bearer " + accessToken, + Cookie: "WorkosCursorSessionToken=" + session.sessionToken, + Accept: "application/json", + Origin: "https://cursor.com", + Referer: "https://cursor.com/dashboard", + "User-Agent": CURSOR_WEB_USER_AGENT, + }, + timeoutMs: 15000, + }) + if (resp.status < 200 || resp.status >= 300) { + ctx.host.log.warn("usage-summary returned status=" + resp.status) + return null + } + var parsed = ctx.util.tryParseJson(resp.bodyText) + if (!parsed || typeof parsed !== "object") return null + return parsed + } catch (e) { + ctx.host.log.warn("usage-summary fetch failed: " + String(e)) + return null + } + } + + function enterpriseBillingCycle(ctx, summary, requestUsage) { + var defaultPeriodMs = 30 * 24 * 60 * 60 * 1000 + var startMs = summary && summary.billingCycleStart + ? ctx.util.parseDateMs(summary.billingCycleStart) + : null + var endMs = summary && summary.billingCycleEnd + ? ctx.util.parseDateMs(summary.billingCycleEnd) + : null + if (Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs) { + return { + resetsAt: ctx.util.toIso(endMs), + periodDurationMs: endMs - startMs, + } + } + var requestStart = requestUsage && requestUsage.startOfMonth + ? ctx.util.parseDateMs(requestUsage.startOfMonth) + : null + var cycleEndMs = Number.isFinite(requestStart) ? requestStart + defaultPeriodMs : null + return { + resetsAt: ctx.util.toIso(cycleEndMs), + periodDurationMs: defaultPeriodMs, + } + } + + function enterpriseDollarMeter(bucket) { + if (!bucket || typeof bucket !== "object" || bucket.enabled === false) return null + var limit = readFiniteNumber(bucket.limit) + if (!Number.isFinite(limit) || limit <= 0) return null + var reportedUsed = readFiniteNumber(bucket.used) + var remaining = readFiniteNumber(bucket.remaining) + var inferredUsed = Math.max(0, limit - (Number.isFinite(remaining) ? remaining : limit)) + var used = Number.isFinite(reportedUsed) && reportedUsed > 0 ? reportedUsed : inferredUsed + return { used: Math.max(0, used), limit: limit } + } + + function appendEnterpriseDollarProgress(ctx, lines, meter, label, cycle) { + lines.push(ctx.line.progress({ + label: label, + used: ctx.fmt.dollars(meter.used), + limit: ctx.fmt.dollars(meter.limit), + format: { kind: "dollars" }, + resetsAt: cycle.resetsAt, + periodDurationMs: cycle.periodDurationMs, + })) + } + + function appendEnterpriseRequests(ctx, requestUsage, cycle, lines) { + var gpt4 = requestUsage && requestUsage["gpt-4"] + if (!gpt4 || typeof gpt4 !== "object") return false + var limit = readFiniteNumber(gpt4.maxRequestUsage) + if (!Number.isFinite(limit) || limit <= 0) return false + var usedRaw = readFiniteNumber(gpt4.numRequests) + if (!Number.isFinite(usedRaw)) usedRaw = readFiniteNumber(gpt4.numRequestsTotal) + var used = Math.max(0, Number.isFinite(usedRaw) ? usedRaw : 0) + var labels = ["Total usage", "Requests"] + for (var i = 0; i < labels.length; i++) { + lines.push(ctx.line.progress({ + label: labels[i], + used: used, + limit: limit, + format: { kind: "count", suffix: "requests" }, + resetsAt: cycle.resetsAt, + periodDurationMs: cycle.periodDurationMs, + })) + } + return true + } + + function appendEnterpriseSummaryTotal(ctx, summary, cycle, lines) { + if (!summary || typeof summary !== "object") return + var individual = summary.individualUsage && typeof summary.individualUsage === "object" + ? summary.individualUsage + : null + var team = summary.teamUsage && typeof summary.teamUsage === "object" + ? summary.teamUsage + : null + var limitType = typeof summary.limitType === "string" + ? summary.limitType.toLowerCase() + : "" + var pooled + var overall + var plan + var percent + + if (limitType === "team") { + pooled = enterpriseDollarMeter(team && team.pooled) + if (pooled) { + appendEnterpriseDollarProgress(ctx, lines, pooled, "Total usage", cycle) + return + } + } + + plan = individual && individual.plan && typeof individual.plan === "object" + ? individual.plan + : null + percent = plan ? readFiniteNumber(plan.totalPercentUsed) : NaN + if (Number.isFinite(percent)) { + lines.push(ctx.line.progress({ + label: "Total usage", + used: percent, + limit: 100, + format: { kind: "percent" }, + resetsAt: cycle.resetsAt, + periodDurationMs: cycle.periodDurationMs, + })) + return + } + + overall = enterpriseDollarMeter(individual && individual.overall) + if (overall) { + appendEnterpriseDollarProgress(ctx, lines, overall, "Total usage", cycle) + return + } + + pooled = enterpriseDollarMeter(team && team.pooled) + if (pooled) { + appendEnterpriseDollarProgress(ctx, lines, pooled, "Total usage", cycle) + } + } + + function appendEnterpriseStructuredPercentages(ctx, summary, cycle, lines) { + if (!summary || typeof summary !== "object") return + var individual = summary.individualUsage + var plan = individual && typeof individual === "object" && individual.plan + && typeof individual.plan === "object" + ? individual.plan + : null + if (!plan) return + var pairs = [ + ["autoPercentUsed", "Auto usage"], + ["apiPercentUsed", "API usage"], + ] + for (var i = 0; i < pairs.length; i++) { + var percent = readFiniteNumber(plan[pairs[i][0]]) + if (!Number.isFinite(percent)) continue + lines.push(ctx.line.progress({ + label: pairs[i][1], + used: percent, + limit: 100, + format: { kind: "percent" }, + resetsAt: cycle.resetsAt, + periodDurationMs: cycle.periodDurationMs, + })) + } + } + + function appendEnterpriseOnDemandBucket(ctx, bucket, cycle, lines) { + if (!bucket || typeof bucket !== "object" || bucket.enabled === false) return false + var meter = enterpriseDollarMeter(bucket) + if (meter) { + appendEnterpriseDollarProgress(ctx, lines, meter, "On-demand", cycle) + return true + } + var usedCents = readFiniteNumber(bucket.used) + if (Number.isFinite(usedCents) && usedCents > 0) { + lines.push(ctx.line.text({ + label: "On-demand", + value: "$" + String(ctx.fmt.dollars(usedCents)), + })) + return true + } + return false + } + + function appendEnterpriseOnDemand(ctx, summary, cycle, lines) { + if (!summary || typeof summary !== "object") return + var individual = summary.individualUsage && typeof summary.individualUsage === "object" + ? summary.individualUsage + : null + var team = summary.teamUsage && typeof summary.teamUsage === "object" + ? summary.teamUsage + : null + if (appendEnterpriseOnDemandBucket(ctx, individual && individual.onDemand, cycle, lines)) { + return + } + appendEnterpriseOnDemandBucket(ctx, team && team.onDemand, cycle, lines) + } + + /** Combines /api/usage (included requests) + /api/usage-summary (percents / on-demand). */ function buildEnterpriseResult(ctx, accessToken, planName) { - return buildRequestBasedResult( - ctx, - accessToken, - planName, - "Enterprise usage data unavailable. Try again later." - ) + var unavailableMessage = "Enterprise usage data unavailable. Try again later." + var requestUsage = fetchRequestBasedUsage(ctx, accessToken) + var summary = fetchUsageSummary(ctx, accessToken) + var cycle = enterpriseBillingCycle(ctx, summary, requestUsage) + var lines = [] + + var hasRequests = appendEnterpriseRequests(ctx, requestUsage, cycle, lines) + if (!hasRequests) { + appendEnterpriseSummaryTotal(ctx, summary, cycle, lines) + } + appendEnterpriseStructuredPercentages(ctx, summary, cycle, lines) + appendEnterpriseOnDemand(ctx, summary, cycle, lines) + + if (lines.length === 0) { + ctx.host.log.warn("enterprise: no usage data from /api/usage or /api/usage-summary") + throw unavailableMessage + } + + var plan = planName + if (!plan && summary && typeof summary.membershipType === "string") { + plan = summary.membershipType + } + return finalizePlanResult(ctx, plan, lines) } function buildTeamRequestBasedResult(ctx, accessToken, planName) { diff --git a/plugins/cursor/plugin.test.js b/plugins/cursor/plugin.test.js index b8ad4b88e..f0d6e11da 100644 --- a/plugins/cursor/plugin.test.js +++ b/plugins/cursor/plugin.test.js @@ -588,6 +588,9 @@ describe("cursor plugin", () => { }), } } + if (String(opts.url).includes("cursor.com/api/usage-summary")) { + return { status: 200, bodyText: "{}" } + } if (String(opts.url).includes("cursor.com/api/usage")) { return { status: 200, @@ -608,6 +611,11 @@ describe("cursor plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) expect(result.plan).toBe("Enterprise") + const totalLine = result.lines.find((l) => l.label === "Total usage") + expect(totalLine).toBeTruthy() + expect(totalLine.used).toBe(422) + expect(totalLine.limit).toBe(500) + expect(totalLine.format).toEqual({ kind: "count", suffix: "requests" }) const reqLine = result.lines.find((l) => l.label === "Requests") expect(reqLine).toBeTruthy() expect(reqLine.used).toBe(422) @@ -615,6 +623,193 @@ describe("cursor plugin", () => { expect(reqLine.format).toEqual({ kind: "count", suffix: "requests" }) }) + it("maps enterprise included requests plus usage-summary Auto/API/on-demand", async () => { + const ctx = makeCtx() + const accessToken = makeJwt({ sub: "google-oauth2|user_abc123", exp: 9999999999 }) + + ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: accessToken }])) + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("GetCurrentPeriodUsage")) { + return { + status: 200, + bodyText: JSON.stringify({ + billingCycleStart: "1770539602363", + billingCycleEnd: "1770539602363", + }), + } + } + if (String(opts.url).includes("GetPlanInfo")) { + return { + status: 200, + bodyText: JSON.stringify({ + planInfo: { planName: "Enterprise" }, + }), + } + } + if (String(opts.url).includes("cursor.com/api/usage-summary")) { + return { + status: 200, + bodyText: JSON.stringify({ + billingCycleStart: "2026-02-01T00:00:00.000Z", + billingCycleEnd: "2026-03-01T00:00:00.000Z", + membershipType: "enterprise", + individualUsage: { + plan: { + autoPercentUsed: 18.5, + apiPercentUsed: 42, + }, + onDemand: { + enabled: true, + used: 2500, + limit: 10000, + remaining: 7500, + }, + }, + }), + } + } + if (String(opts.url).includes("cursor.com/api/usage")) { + return { + status: 200, + bodyText: JSON.stringify({ + "gpt-4": { + numRequests: 120, + maxRequestUsage: 500, + }, + startOfMonth: "2026-02-01T06:12:57.000Z", + }), + } + } + return { status: 200, bodyText: "{}" } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.plan).toBe("Enterprise") + + const totalLine = result.lines.find((l) => l.label === "Total usage") + expect(totalLine).toBeTruthy() + expect(totalLine.used).toBe(120) + expect(totalLine.limit).toBe(500) + expect(totalLine.format).toEqual({ kind: "count", suffix: "requests" }) + expect(totalLine.resetsAt).toBe("2026-03-01T00:00:00.000Z") + expect(totalLine.periodDurationMs).toBe( + Date.parse("2026-03-01T00:00:00.000Z") - Date.parse("2026-02-01T00:00:00.000Z") + ) + + const reqLine = result.lines.find((l) => l.label === "Requests") + expect(reqLine).toBeTruthy() + expect(reqLine.used).toBe(120) + expect(reqLine.limit).toBe(500) + + const autoLine = result.lines.find((l) => l.label === "Auto usage") + expect(autoLine).toBeTruthy() + expect(autoLine.used).toBe(18.5) + expect(autoLine.limit).toBe(100) + expect(autoLine.format).toEqual({ kind: "percent" }) + + const apiLine = result.lines.find((l) => l.label === "API usage") + expect(apiLine).toBeTruthy() + expect(apiLine.used).toBe(42) + expect(apiLine.format).toEqual({ kind: "percent" }) + + const onDemand = result.lines.find((l) => l.label === "On-demand") + expect(onDemand).toBeTruthy() + expect(onDemand.used).toBe(25) + expect(onDemand.limit).toBe(100) + expect(onDemand.format).toEqual({ kind: "dollars" }) + }) + + it("uses usage-summary pooled Total usage when enterprise has no request allowance", async () => { + const ctx = makeCtx() + const accessToken = makeJwt({ sub: "google-oauth2|user_abc123", exp: 9999999999 }) + + ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: accessToken }])) + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("GetCurrentPeriodUsage")) { + return { + status: 200, + bodyText: JSON.stringify({ + billingCycleStart: "1770539602363", + billingCycleEnd: "1770539602363", + }), + } + } + if (String(opts.url).includes("GetPlanInfo")) { + return { + status: 200, + bodyText: JSON.stringify({ + planInfo: { planName: "Enterprise" }, + }), + } + } + if (String(opts.url).includes("cursor.com/api/usage-summary")) { + return { + status: 200, + bodyText: JSON.stringify({ + billingCycleStart: "2026-07-01T00:00:00.000Z", + billingCycleEnd: "2026-08-01T00:00:00.000Z", + limitType: "team", + individualUsage: { + plan: { + autoPercentUsed: 5, + apiPercentUsed: 10, + }, + }, + teamUsage: { + pooled: { + enabled: true, + used: 3479810, + limit: 60000000, + remaining: 56520190, + }, + onDemand: { + enabled: true, + used: 0, + limit: 5000000, + remaining: 5000000, + }, + }, + }), + } + } + if (String(opts.url).includes("cursor.com/api/usage")) { + return { + status: 200, + bodyText: JSON.stringify({ + "gpt-4": { + numRequests: 0, + maxRequestUsage: null, + }, + startOfMonth: "2026-07-01T00:00:00.000Z", + }), + } + } + return { status: 200, bodyText: "{}" } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + const totalLine = result.lines.find((l) => l.label === "Total usage") + expect(totalLine).toBeTruthy() + expect(totalLine.format).toEqual({ kind: "dollars" }) + expect(totalLine.used).toBe(34798.1) + expect(totalLine.limit).toBe(600000) + expect(result.lines.find((l) => l.label === "Requests")).toBeUndefined() + + const autoLine = result.lines.find((l) => l.label === "Auto usage") + expect(autoLine.used).toBe(5) + const apiLine = result.lines.find((l) => l.label === "API usage") + expect(apiLine.used).toBe(10) + + const onDemand = result.lines.find((l) => l.label === "On-demand") + expect(onDemand).toBeTruthy() + expect(onDemand.used).toBe(0) + expect(onDemand.limit).toBe(50000) + expect(onDemand.format).toEqual({ kind: "dollars" }) + }) + it("falls back to enterprise request-based usage when planUsage.limit is missing", async () => { const ctx = makeCtx() const accessToken = makeJwt({ sub: "google-oauth2|user_abc123", exp: 9999999999 }) diff --git a/plugins/grok/icon.svg b/plugins/grok/icon.svg index 21d5b8fde..873d00562 100644 --- a/plugins/grok/icon.svg +++ b/plugins/grok/icon.svg @@ -1,7 +1,4 @@ - - - - - - + + + diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 16b7bec09..6b8fee6ff 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "crossusage" -version = "1.3.2" +version = "1.3.3" 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/tauri.conf.json b/src-tauri/tauri.conf.json index 9a572a519..e0d7e5b8c 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.2", + "version": "1.3.3", "identifier": "com.barramee27.crossusage", "build": { "beforeDevCommand": "node scripts/tauri-before-dev.cjs",