diff --git a/.gitattributes b/.gitattributes
index ceb10cdb3b..9da598bc8b 100755
--- a/.gitattributes
+++ b/.gitattributes
@@ -10,3 +10,6 @@ rust/src/agent_sessions/fixtures/** -text
# Bash scripts must remain LF for Linux and WSL execution.
*.sh text eol=lf
+
+# Upstream Claude PTY captures are byte-exact copies (they contain CR bytes); never EOL-convert them.
+rust/src/providers/fixtures/claude/*.ansi -text
diff --git a/.gitignore b/.gitignore
index e1961644d0..c24ff2f978 100755
--- a/.gitignore
+++ b/.gitignore
@@ -64,6 +64,8 @@ nul
/.local/
# Docs children ignored generally; ADRs are product docs and stay trackable
/docs/*
+!/docs/prometheus/
+!/docs/grafana/
!/docs/adr/
/docs/adr/*
!/docs/adr/*.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c054768f2c..7f28c2434f 100755
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,112 @@
# Changelog
-## [Windows] 0.61.0 - Unreleased
+## [Windows] 0.70.0 - 2026-10-03
+
+Windows port of upstream CodexBar **0.60.3 → 0.70.0**: every provider, cost/usage, privacy, tray, CLI and reliability port from upstream 0.60.4 through 0.70.0 integrated across the release branch, plus six community pull requests and fixes for open issues. This is also the first release to ship the changes in the unreleased 0.61.0 changelog section.
+
+### Added
+
+- Providers: port Hyper (session-first Hypercredit balance), GitKraken AI (personal weekly credits and org pool sharing), Bifrost (self-hosted gateway budgets, rate limits and model spend), Atlas Cloud (account USD balance), Aixy (key usage and applicable budgets), ElevenLabs (endpoint override and `XI_API_KEY` alias), Helmcode (reset fallback and tenant dashboard URL), v0 (billing dashboard URL), and Hugging Face (identity cache, billing-permission message, API-only without cookies) ([#619](https://github.com/nesszer/Win-CodexBar/pull/619), [#618](https://github.com/nesszer/Win-CodexBar/pull/618), [#676](https://github.com/nesszer/Win-CodexBar/pull/676), [#661](https://github.com/nesszer/Win-CodexBar/pull/661), [#726](https://github.com/nesszer/Win-CodexBar/pull/726), [#723](https://github.com/nesszer/Win-CodexBar/pull/723), [#724](https://github.com/nesszer/Win-CodexBar/pull/724), [#725](https://github.com/nesszer/Win-CodexBar/pull/725)).
+- Providers: save labeled token accounts for Kimi web sessions, Doubao Ark API keys, and OpenCode Go keys-or-sessions, with per-account source routing and fail-closed credential isolation ([#620](https://github.com/nesszer/Win-CodexBar/pull/620)).
+- Providers: StepFun credit plans with a Credit label and no invented reset; StepFun pricing parity for Sol repricing cutoffs, Cyber rates, and Daybreak aliases ([#710](https://github.com/nesszer/Win-CodexBar/pull/710), [#734](https://github.com/nesszer/Win-CodexBar/pull/734)).
+- Providers: prefer twelve preferred currencies from one shared catalog ([#679](https://github.com/nesszer/Win-CodexBar/pull/679), [#609](https://github.com/nesszer/Win-CodexBar/pull/609)).
+- Providers: Antigravity Gemini 3.1 Pro pricing aliases with effort tiers, priced through the shared models.dev catalog ([#732](https://github.com/nesszer/Win-CodexBar/pull/732)).
+- Providers: Kimi Code API responses that carry only a monthly pool or a partial ratio pool now parse and render correctly ([#730](https://github.com/nesszer/Win-CodexBar/pull/730)).
+- Providers: Kimi web access tokens import from Chromium local storage, including `user-*` profiles, via the shared browser detector ([#649](https://github.com/nesszer/Win-CodexBar/pull/649)).
+- Providers: Venice accepts Clerk session cookies with Bearer auth and falls back per browser for web sessions ([#626](https://github.com/nesszer/Win-CodexBar/pull/626), [#729](https://github.com/nesszer/Win-CodexBar/pull/729)).
+- Providers: OpenRouter diagnostics and Activity summary ([#703](https://github.com/nesszer/Win-CodexBar/pull/703)).
+- Usage & Spend: re-land the daily ledger, capped to the newest 30 rows with a "Show all" expander ([#715](https://github.com/nesszer/Win-CodexBar/pull/715)).
+- Usage & Spend: attribute Nous OpenCodex ledger rows to Nous Portal spend and price unpriced OpenCodex rows by billing route instead of reporting zero ([#682](https://github.com/nesszer/Win-CodexBar/pull/682), [#731](https://github.com/nesszer/Win-CodexBar/pull/731)).
+- Usage & Spend: price Codex Priority turns from local trace evidence, with a historical cutoff so pre-cutover snapshots keep their original rates ([#698](https://github.com/nesszer/Win-CodexBar/pull/698)).
+- Usage & Spend: cost reporting periods — a `CostReportingPeriod` core with range-aware cost scanners, `cost --period` and period-aware serve `/cost`, and a History window setting with period-aware spend surfaces ([#648](https://github.com/nesszer/Win-CodexBar/pull/648), [#653](https://github.com/nesszer/Win-CodexBar/pull/653), [#665](https://github.com/nesszer/Win-CodexBar/pull/665)).
+- Usage & Spend: provider quota burndown charting in the tray panel details ([#739](https://github.com/nesszer/Win-CodexBar/pull/739)).
+- Tray: re-land stranded tray pace colors ([#716](https://github.com/nesszer/Win-CodexBar/pull/716)).
+- Tray: refresh sixteen provider brand accents, dropping the retired Crof entry ([#719](https://github.com/nesszer/Win-CodexBar/pull/719)).
+- Codex: re-land the stranded Codex scanner with OpenCodex numeric parsing and reserve pricing ([#714](https://github.com/nesszer/Win-CodexBar/pull/714)).
+- Integrations: disclose the provider lane label in Settings usage bars and the metric picker ([#738](https://github.com/nesszer/Win-CodexBar/pull/738)).
+- Codex: persist and record quota reset observations so reset history survives restarts and refreshes ([#743](https://github.com/nesszer/Win-CodexBar/pull/743)).
+- Providers: new llmman, DevPass (plan credits and premium weekly), xKiro (daily free tokens), Raycast (monthly AI credits), Vercel AI Gateway (credit balance) and Sakana (pay-as-you-go balance) providers ([#650](https://github.com/nesszer/Win-CodexBar/pull/650), [#658](https://github.com/nesszer/Win-CodexBar/pull/658), [#663](https://github.com/nesszer/Win-CodexBar/pull/663), [#668](https://github.com/nesszer/Win-CodexBar/pull/668), [#678](https://github.com/nesszer/Win-CodexBar/pull/678), [#685](https://github.com/nesszer/Win-CodexBar/pull/685)).
+- Providers: Alibaba Token Plan and Qwen Cloud monthly windows, Doubao Agent Plan tray icon fallback, Chutes quota classification, Perplexity session retries and credit lanes, Command Code granted monthly credits, and Abacus concurrent credits and billing ([#662](https://github.com/nesszer/Win-CodexBar/pull/662), [#637](https://github.com/nesszer/Win-CodexBar/pull/637), [#639](https://github.com/nesszer/Win-CodexBar/pull/639), [#643](https://github.com/nesszer/Win-CodexBar/pull/643), [#632](https://github.com/nesszer/Win-CodexBar/pull/632), [#628](https://github.com/nesszer/Win-CodexBar/pull/628)).
+- Providers: Grok reads bearer credits from the CLI credits proxy and shows a product usage breakdown, also on the grok.com billing fallback; the Grok local session scan covers exact local calendar days ([#652](https://github.com/nesszer/Win-CodexBar/pull/652), [#656](https://github.com/nesszer/Win-CodexBar/pull/656), [#692](https://github.com/nesszer/Win-CodexBar/pull/692), [#687](https://github.com/nesszer/Win-CodexBar/pull/687)).
+- Providers: LiteLLM spend-report fallback, identity and team/user budgets, and opt-in model activity with Claude workspace spend; LLM Proxy base URL setting with private-network HTTP and tolerant quota groups ([#630](https://github.com/nesszer/Win-CodexBar/pull/630), [#702](https://github.com/nesszer/Win-CodexBar/pull/702), [#664](https://github.com/nesszer/Win-CodexBar/pull/664), [#704](https://github.com/nesszer/Win-CodexBar/pull/704)).
+- Providers: Mistral counts plan-covered tokens, shows plan amounts as detail lines, offers a Monthly Plan metric, and prices usage by event type, API zone and service tier ([#647](https://github.com/nesszer/Win-CodexBar/pull/647), [#657](https://github.com/nesszer/Win-CodexBar/pull/657), [#717](https://github.com/nesszer/Win-CodexBar/pull/717), [#718](https://github.com/nesszer/Win-CodexBar/pull/718)).
+- Providers: OpenAI Admin pagination, token totals and retry, plus per-day usage history with a daily usage chart ([#645](https://github.com/nesszer/Win-CodexBar/pull/645), [#699](https://github.com/nesszer/Win-CodexBar/pull/699), [#701](https://github.com/nesszer/Win-CodexBar/pull/701)).
+- Providers: Muse reads the selected browser team quota, ClinePass reuses the Cline auth session, OpenCode Go counts local tokens, Zed gets opt-in browser billing, Bifrost parity polish, and MiniMax browser storage discovery over a shared Chromium LevelDB reader ([#655](https://github.com/nesszer/Win-CodexBar/pull/655), [#681](https://github.com/nesszer/Win-CodexBar/pull/681), [#671](https://github.com/nesszer/Win-CodexBar/pull/671), [#674](https://github.com/nesszer/Win-CodexBar/pull/674), [#675](https://github.com/nesszer/Win-CodexBar/pull/675), [#651](https://github.com/nesszer/Win-CodexBar/pull/651), [#646](https://github.com/nesszer/Win-CodexBar/pull/646)).
+- Providers: Devin manual paste auth guidance, and the saved Kimi API region is honored in CLI and serve fetches ([#736](https://github.com/nesszer/Win-CodexBar/pull/736), [#737](https://github.com/nesszer/Win-CodexBar/pull/737)).
+- Settings: portable preferences export and import (Settings and `codexbar config preferences`), credential expiry alerts, and Stay Awake while local agent sessions are live ([#654](https://github.com/nesszer/Win-CodexBar/pull/654), [#660](https://github.com/nesszer/Win-CodexBar/pull/660), [#659](https://github.com/nesszer/Win-CodexBar/pull/659)).
+- Settings: provider switcher shortcut model, default navigation keys and a shortcut editor ([#693](https://github.com/nesszer/Win-CodexBar/pull/693), [#700](https://github.com/nesszer/Win-CodexBar/pull/700)).
+- Usage & Spend: provider grouping, a consolidated chart selector and clear-selected-day; Codex sessions are named and ranked by cost; Claude incomplete requests are marked ([#705](https://github.com/nesszer/Win-CodexBar/pull/705), [#673](https://github.com/nesszer/Win-CodexBar/pull/673), [#707](https://github.com/nesszer/Win-CodexBar/pull/707)).
+- Serve: dashboard usage display control (Follow server, Used, Remaining) ([#680](https://github.com/nesszer/Win-CodexBar/pull/680)).
+- Tray: stacked provider tray mode, and the reviewed provider, history and tray ports batch ([#603](https://github.com/nesszer/Win-CodexBar/pull/603), [#610](https://github.com/nesszer/Win-CodexBar/pull/610)).
+- Language: Brazilian Portuguese (pt-BR) UI, contributed by @mdnbras ([#617](https://github.com/nesszer/Win-CodexBar/pull/617)).
+- Browser cookies: Chrome Beta, Dev, Canary, Chrome for Testing and Chromium profiles as cookie sources, contributed by @ciltocruz ([#614](https://github.com/nesszer/Win-CodexBar/pull/614)).
+- Metrics: Codex reset-credit count and next expiry in `serve --metrics`, with Grafana dashboard, scrape and alert examples, contributed by @le-shi ([#623](https://github.com/nesszer/Win-CodexBar/pull/623)).
+- CLI: `usage` and serve `/usage` follow the enabled providers; `config providers --json`; `-p/--provider` on config subcommands; `config claude-code-credentials allow|deny|status`; `serve --request-timeout`; `CODEXBAR_CONFIG`; and an `errorKind` on JSON error rows ([#640](https://github.com/nesszer/Win-CodexBar/issues/640)).
+
+### Fixed
+
+- Antigravity: honor unusable `ANTIGRAVITY_CLI_PATH` overrides instead of silently discovering another CLI, explain the offline fallback reason, and keep cost follow-ups — lower bounds, withheld reads, and a `--refresh` path for unknown models ([#615](https://github.com/nesszer/Win-CodexBar/pull/615), [#672](https://github.com/nesszer/Win-CodexBar/pull/672), [#706](https://github.com/nesszer/Win-CodexBar/pull/706)).
+- Antigravity: preserve grouped quotas including weekly-only Starter allowances and honor explicit quota-window cadence; usage probes no longer leave MCP server processes behind ([#694](https://github.com/nesszer/Win-CodexBar/pull/694), [#695](https://github.com/nesszer/Win-CodexBar/pull/695)).
+- Claude: scan claude-swap session homes for cost reporting without double counting ([#670](https://github.com/nesszer/Win-CodexBar/pull/670)).
+- Claude: show Web limit reset credits with opt-in retry semantics ([#689](https://github.com/nesszer/Win-CodexBar/pull/689)).
+- Claude: preserve threshold warnings across repeated CLI identity gaps ([#667](https://github.com/nesszer/Win-CodexBar/pull/667)).
+- Claude: replay CLI cursor redraws onto a virtual screen so differential redraws keep unchanged cells ([#633](https://github.com/nesszer/Win-CodexBar/pull/633)).
+- Claude: respect read-only claude-swap adapters by hiding switching and re-authentication actions ([#631](https://github.com/nesszer/Win-CodexBar/pull/631)).
+- Claude: replace unusable `claude setup-token` recovery advice with sign-in guidance and OAuth-override removal instructions ([#742](https://github.com/nesszer/Win-CodexBar/pull/742)).
+- Kimi: give stale CLI credentials clear sign-in guidance and mark windows blocked by an exhausted monthly limit ([#691](https://github.com/nesszer/Win-CodexBar/pull/691), [#697](https://github.com/nesszer/Win-CodexBar/pull/697)).
+- Kimi: check the reset arithmetic before building reset timestamps so oversized dashboard values can no longer abort the refresh ([#740](https://github.com/nesszer/Win-CodexBar/pull/740)).
+- Codex: confirm unused rolling weekly resets and reset the weekly baseline on plan change ([#629](https://github.com/nesszer/Win-CodexBar/pull/629), [#690](https://github.com/nesszer/Win-CodexBar/pull/690)).
+- Codex: retry auth.json publication races ([#688](https://github.com/nesszer/Win-CodexBar/pull/688)).
+- Codex: retain the inherited counter origin for direct fork chains and accept unused Codex analytics dashboard URLs ([#677](https://github.com/nesszer/Win-CodexBar/pull/677), [#627](https://github.com/nesszer/Win-CodexBar/pull/627)).
+- Codex: skip unchanged Codex cost cache rewrites ([#666](https://github.com/nesszer/Win-CodexBar/pull/666)).
+- DeepSeek: read the Chrome `platform.deepseek.com` session and keep the last balance visible through same-session transport failures ([#709](https://github.com/nesszer/Win-CodexBar/pull/709)).
+- GitKraken: honor `Retry-After` on 429 responses ([#727](https://github.com/nesszer/Win-CodexBar/pull/727)).
+- OpenRouter: add API key and Management key guidance ([#686](https://github.com/nesszer/Win-CodexBar/pull/686)).
+- CLI: show a provider history line with provenance and token totals ([#708](https://github.com/nesszer/Win-CodexBar/pull/708)).
+- Privacy: redact stored process environments shown in diagnostics ([#722](https://github.com/nesszer/Win-CodexBar/pull/722)).
+- Runtime: stop the app from stealing focus at launch ([#713](https://github.com/nesszer/Win-CodexBar/pull/713)).
+- Runtime: fix the reset refresh timer for resets more than 24.8 days away ([#720](https://github.com/nesszer/Win-CodexBar/pull/720)).
+- Tray: fix float bar pills for informational metrics ([#733](https://github.com/nesszer/Win-CodexBar/pull/733)).
+- Tray: make the tray panel the default layout ([#712](https://github.com/nesszer/Win-CodexBar/pull/712)).
+- CI: make the bootstrap catalog test hermetic so it no longer reads host settings ([#711](https://github.com/nesszer/Win-CodexBar/pull/711)).
+- CI: keep Codex cost fixtures on the local day to stop the 00:00-01:00 UTC test flake ([#721](https://github.com/nesszer/Win-CodexBar/pull/721)).
+- Usage: local usage token totals include cache tokens per provider rule (30-day, latest and daily rows), contributed by @Pyaoya ([#611](https://github.com/nesszer/Win-CodexBar/pull/611)).
+- Claude: the Windows `/usage` PTY probe retries, takes turns across processes with a lock, shares a fresh result, and kills its child tree on close, contributed by @marcus7989 ([#641](https://github.com/nesszer/Win-CodexBar/pull/641)).
+- Gemini: read OAuth client constants from the bundled Gemini CLI layout, contributed by @marcus7989 ([#642](https://github.com/nesszer/Win-CodexBar/pull/642)).
+- Codex: keep users distinct in a shared Team workspace, contributed by @gleaming9 ([#593](https://github.com/nesszer/Win-CodexBar/pull/593)); exclude inherited fork baselines ([#597](https://github.com/nesszer/Win-CodexBar/pull/597)); redact every Codex System Account label under Hide Personal Info ([#741](https://github.com/nesszer/Win-CodexBar/pull/741)).
+- Codex: cost history also scans archived and flat legacy session rollouts (refs [#616](https://github.com/nesszer/Win-CodexBar/issues/616); this may not be the cause of that report, which is still open).
+- Groq: use the correct metrics URL, rewrite a `GROQ_API_URL` still set to the old `https://api.groq.com/openai/v1` default, and explain a 404 as an Enterprise-plan limit ([#606](https://github.com/nesszer/Win-CodexBar/issues/606)).
+- Float bar: moves made by Windows (display sleep, resolution or DPI changes) no longer overwrite the saved placement, and the bar returns once the monitor layout is back ([#625](https://github.com/nesszer/Win-CodexBar/issues/625)).
+- Kimi: reconcile zero ratio placeholders, honor the manual cookie policy, and route through the selected region ([#587](https://github.com/nesszer/Win-CodexBar/pull/587), [#598](https://github.com/nesszer/Win-CodexBar/pull/598), [#599](https://github.com/nesszer/Win-CodexBar/pull/599)).
+- Providers: Neuralwatt retry and endpoint parity, ZenMux expiry and PAYG handling, Manus iterates browser sessions, Cursor backs off forbidden cost requests for six hours, Ollama explains an empty manual cookie, Qoder regional cookie routing, DeepInfra retries transient billing requests, and z.ai shows unavailable quota instead of a fabricated 0% ([#624](https://github.com/nesszer/Win-CodexBar/pull/624), [#634](https://github.com/nesszer/Win-CodexBar/pull/634), [#635](https://github.com/nesszer/Win-CodexBar/pull/635), [#636](https://github.com/nesszer/Win-CodexBar/pull/636), [#638](https://github.com/nesszer/Win-CodexBar/pull/638), [#644](https://github.com/nesszer/Win-CodexBar/pull/644), [#683](https://github.com/nesszer/Win-CodexBar/pull/683), [#696](https://github.com/nesszer/Win-CodexBar/pull/696)).
+- Antigravity: estimate local history costs; Claude: contain oversized history values ([#602](https://github.com/nesszer/Win-CodexBar/pull/602), [#605](https://github.com/nesszer/Win-CodexBar/pull/605)).
+- Credentials: stage credential writes and publish them atomically ([#669](https://github.com/nesszer/Win-CodexBar/pull/669)).
+
+### Changed
+
+- Docs: remove the retired Crof provider from the Rust README ([#728](https://github.com/nesszer/Win-CodexBar/pull/728)).
+- Docs: describe the tray-panel-only layout in the Windows docs after the pop-out retirement (glue commit `ed2440c3`).
+- Overview: show every quota row in the compact Overview, keep provider detail sections visible and deduplicated, and default the layout to detailed ([#744](https://github.com/nesszer/Win-CodexBar/pull/744)).
+- Overview: per-section visibility for provider details via `detailSection:` IDs ([#745](https://github.com/nesszer/Win-CodexBar/pull/745)).
+- Settings: show the running app version on the General tab ([#746](https://github.com/nesszer/Win-CodexBar/pull/746)).
+- Settings: animate the budget disclosure chevron ([#608](https://github.com/nesszer/Win-CodexBar/pull/608)).
+- Providers: retire the Crof provider safely ([#596](https://github.com/nesszer/Win-CodexBar/pull/596)).
+
+### Issues fixed
+
+- Fixed in this release: [#606](https://github.com/nesszer/Win-CodexBar/issues/606) Groq metrics URL; [#612](https://github.com/nesszer/Win-CodexBar/issues/612) Vercel AI Gateway provider (via #678); [#613](https://github.com/nesszer/Win-CodexBar/issues/613) Chrome channels and Chromium (via #614); [#622](https://github.com/nesszer/Win-CodexBar/issues/622) Codex reset credits in metrics (via #623); [#625](https://github.com/nesszer/Win-CodexBar/issues/625) float bar placement; [#640](https://github.com/nesszer/Win-CodexBar/issues/640) CLI parity for embedding apps (with #641 and #642).
+- Already fixed on this release line, now pinned by regression tests: [#591](https://github.com/nesszer/Win-CodexBar/issues/591) OpenCode Go console URL; [#592](https://github.com/nesszer/Win-CodexBar/issues/592) distinct users in a shared Team workspace (via #593).
+- Related, not confirmed fixed: [#616](https://github.com/nesszer/Win-CodexBar/issues/616) Codex cost history shows $0.00. Archived and flat legacy rollouts are now scanned, but the reporter's root cause is not confirmed.
+
+### Community contributions
+
+Thanks to @Pyaoya ([#611](https://github.com/nesszer/Win-CodexBar/pull/611)), @ciltocruz ([#614](https://github.com/nesszer/Win-CodexBar/pull/614)), @mdnbras ([#617](https://github.com/nesszer/Win-CodexBar/pull/617)), @le-shi ([#623](https://github.com/nesszer/Win-CodexBar/pull/623)), @marcus7989 ([#641](https://github.com/nesszer/Win-CodexBar/pull/641), [#642](https://github.com/nesszer/Win-CodexBar/pull/642)) and @gleaming9 ([#593](https://github.com/nesszer/Win-CodexBar/pull/593)).
+
+> Release artifacts are unsigned (SignPath onboarding pending); verify them against the attached `.sha256` sidecar files.
+
+---
+
+## [Windows] 0.61.0 - 2026-09-15
Windows port of upstream CodexBar **0.60.3 → 0.61.0**: new subscription and
spend providers (Nous Portal, Replicate, CodeRabbit, Muse Code), richer
@@ -12,10 +118,18 @@ menu-bar layout.
- Provider charts: show recent Codex and Claude quota-window history with explicit account scope and estimated-boundary markers; historical data remains display-only.
- Grok: Settings and tray **Add account** flow matching Codex/Claude — isolated `grok login --oauth`, save current CLI login, switch, and remove without logging out the active session.
- Replicate: cookie-authenticated monthly spend and optional prepaid credit balance from the billing page, with user and organization account isolation.
+- Raycast: read monthly AI credits from the signed-in `www.raycast.com` session (Chrome auto-import or a pasted Cookie header), showing credits left, plan, and renewal date. Off makes no request and a manual header never falls back to a browser.
- DeepSeek: show reported per-model spend in the provider details while preserving the billing currency, reporting period, zero values, and incomplete-total safeguards.
+- CLI: show provider-supplied history in usage text with its source period, known zero, and cost provenance (reported, estimated, or includes estimates), plus input-plus-output token totals for OpenRouter Activity.
### Fixed
+- Antigravity: a set but unusable `ANTIGRAVITY_CLI_PATH` (empty, missing, or not a file) now turns off automatic `agy` discovery instead of falling back to another CLI that could start an interactive login during a background refresh; a running desktop app still answers first, offline history is still shown, and unsetting the variable restores discovery.
+- Kimi: when the Kimi Code CLI credential is stale or rejected and web auth has no token to try, direct the user to run `kimi` or add a Kimi Code API key in Settings, keeping the web fallback and leaving CLI-owned credentials read-only. A rejected web token still reports the web error, and a Kimi Code API 403 reports a permission or quota denial instead of a sign-in problem.
+- Kimi Code: while the monthly membership pool is exhausted, show the shorter Code windows as “Blocked by monthly limit” without their own usage bar, reset, or pace forecast, keeping the raw usage in the CLI, tray icon, and float bar; the block lifts at the pool's reset even before the next refresh.
+- Kimi Code: accept ratio-pool Code API responses that report only some windows, keep an unreported weekly quota absent instead of showing 0%, and show the monthly Total usage reported by the Code API without browser authentication.
- Claude: when Hide Personal Info is enabled, keep saved account rows distinguishable with stable localized `Account N` labels and matching redacted tooltips.
+- Codex: a fork of a fork whose parent has no token snapshot before the child forked now inherits the whole chain's cumulative counter origin instead of staying unresolved; ancestor chains deeper than 64 stay unresolved, and the Codex cost cache is rebuilt once (cache schema 5).
+- Codex: a fork larger than the per-refresh scan budget continues where the last refresh stopped instead of restarting from the top and never finishing, and a finished fork that grows is no longer billed for usage it inherited from its parent.
---
diff --git a/Cargo.lock b/Cargo.lock
index defe3b590a..bc2e338c05 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -655,7 +655,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "codexbar"
-version = "0.60.3"
+version = "0.70.0"
dependencies = [
"aes-gcm",
"anyhow",
@@ -695,7 +695,7 @@ dependencies = [
[[package]]
name = "codexbar-desktop-tauri"
-version = "0.60.3"
+version = "0.70.0"
dependencies = [
"chrono",
"codexbar",
diff --git a/README.es-MX.md b/README.es-MX.md
index dbd389b54f..0091ef2506 100644
--- a/README.es-MX.md
+++ b/README.es-MX.md
@@ -1,6 +1,6 @@
# Win-CodexBar
-[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Türkçe](./README.tr-TR.md)
+[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Português (Brasil)](./README.pt-BR.md) | [Türkçe](./README.tr-TR.md)
Win-CodexBar es una aplicación de bandeja del sistema para Windows que mantiene visible el uso de herramientas de codificación con IA sin necesidad de abrir una docena de paneles. Traslada el espíritu de [CodexBar](https://github.com/steipete/CodexBar) a un entorno de escritorio Tauri + React respaldado por lógica compartida de proveedores en Rust.
@@ -102,7 +102,6 @@ Consulta el historial completo en [CHANGELOG.md](CHANGELOG.md).
| Xiaomi MiMo | Cookies | Saldo, Plan de tokens |
| Doubao | Clave API | Límites de solicitudes |
| Command Code | Cookies | Créditos mensuales, Créditos comprados |
-| Crof | Clave API | Créditos, Cuota de solicitudes |
| StepFun | Token Oasis | 5h, Semanal, Refresco de token |
| Venice | Clave API | Saldo USD / DIEM |
| OpenAI | Admin API / Clave API | Uso, Solicitudes, Costo con ámbito de proyecto, Saldo de créditos |
diff --git a/README.ja-JP.md b/README.ja-JP.md
index df131b12e1..0990f379bf 100644
--- a/README.ja-JP.md
+++ b/README.ja-JP.md
@@ -1,6 +1,6 @@
# Win-CodexBar
-[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Türkçe](./README.tr-TR.md)
+[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Português (Brasil)](./README.pt-BR.md) | [Türkçe](./README.tr-TR.md)
Win-CodexBar は、複数の AI コーディングツールの使用量を Windows のシステムトレイから確認できるデスクトップアプリです。[CodexBar](https://github.com/steipete/CodexBar) の考え方を、Tauri + React のデスクトップシェルと共有 Rust バックエンドで Windows 向けに移植しています。
diff --git a/README.ko-KR.md b/README.ko-KR.md
index 1037de8619..73fcb6a6e8 100644
--- a/README.ko-KR.md
+++ b/README.ko-KR.md
@@ -1,6 +1,6 @@
# Win-CodexBar
-[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Türkçe](./README.tr-TR.md)
+[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Português (Brasil)](./README.pt-BR.md) | [Türkçe](./README.tr-TR.md)
Win-CodexBar는 여러 AI 코딩 도구의 사용량을 Windows 시스템 트레이에서 바로 확인할 수 있게 해 주는 데스크톱 앱입니다. [CodexBar](https://github.com/steipete/CodexBar)의 아이디어를 Tauri + React 데스크톱 셸과 공유 Rust 백엔드로 Windows에 맞게 포팅했습니다.
diff --git a/README.md b/README.md
index 2e052ad2f9..8c7a35e0f8 100755
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Win-CodexBar
-[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Türkçe](./README.tr-TR.md)
+[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Português (Brasil)](./README.pt-BR.md) | [Türkçe](./README.tr-TR.md)
Win-CodexBar is a Windows system-tray app for keeping AI coding-tool usage visible without opening a dozen dashboards. It ports the spirit of [CodexBar](https://github.com/steipete/CodexBar) to a Tauri + React desktop shell backed by shared Rust provider logic.
@@ -21,7 +21,7 @@ Win-CodexBar is a Windows system-tray app for keeping AI coding-tool usage visib
- **Tray-first workflow** with a compact provider grid, usage cards, refresh action, settings shortcut, and quit control.
- **Provider settings** for source selection, credentials, cookie import, token accounts, API keys, regions, and tray-display preferences.
- **Windows credential protection** for app-managed API keys, manual cookies, and token accounts, using user-scoped DPAPI where available.
-- **Browser cookie import** for Chrome, Edge, Brave, and Firefox, kept opt-in per provider.
+- **Browser cookie import** for Chrome Stable, Beta, Dev, Canary, Chrome for Testing, Chromium, Edge, Brave, and Firefox, kept opt-in per provider.
- **Installed local CLI** for scripting usage, cost, config, diagnostics, and loopback integrations.
- **Installer + portable builds** with WebView2 runtime bootstrap, VC++ runtime bootstrap, and SHA-256 checksum files.
@@ -81,6 +81,7 @@ See the full history in [CHANGELOG.md](CHANGELOG.md).
| Kiro | Cookies / CLI | Monthly Credits, Overage |
| Vertex AI | gcloud OAuth | Cost |
| v0 | API Key | Billing quota, API rate limits, on-demand balance |
+| Vercel AI Gateway | API Key | Team credit balance, lifetime spend |
| Augment | Cookies | Credits |
| OpenCode | Local Config | Usage |
| Kimi | Cookies | 5h Rate, Weekly |
@@ -109,17 +110,21 @@ See the full history in [CHANGELOG.md](CHANGELOG.md).
| Xiaomi MiMo | Cookies | Balance, Token Plan |
| Doubao | API Key | Request Limits |
| Command Code | Cookies | Monthly Credits, Purchased Credits |
-| Crof | API Key | Credits, Request Quota |
| StepFun | Oasis Token | 5h, Weekly, Token refresh |
-| Venice | API Key | USD / DIEM Balance |
+| Venice | API Key / Web session | USD / DIEM Balance, Bundled credits (web session expires in about 60 s) |
| OpenAI | Admin API / API Key | Usage, Requests, Project-scoped cost, Credit Balance |
| Grok | Cookies / auth.json | Billing |
| Helmcode (also NaN Builders) | Browser cookies / manual Cookie header | Per-model token quotas, reset windows, Helmcode prepaid balance |
+| Raycast | Cookies (Chrome auto or manual) | Monthly AI credits, plan, renewal |
| Replicate | Cookies / token accounts | Monthly spend, credit balance |
+| Aixy | API Key / token accounts | Applicable budget balances, 7-day key usage |
| ElevenLabs | API Key | Subscription Credits, Voice Slots |
| Deepgram | API Key | Project Usage |
| Groq | API Key | Enterprise Metrics |
| LLM Proxy | API Key | Quota Stats |
+| llmman | Local daemon / optional API Key | Memory in use, loaded and stored models |
+| DevPass | API Key | Plan credits, Premium weekly, API-key spend |
+| xKiro | API Key | Daily free tokens |
@@ -133,6 +138,7 @@ The UI and contributor reporting currently support:
- 日本語
- 한국어
- Español mexicano
+- Português (Brasil)
- Türkçe
## Build From Source
diff --git a/README.pt-BR.md b/README.pt-BR.md
new file mode 100644
index 0000000000..1c454dff7a
--- /dev/null
+++ b/README.pt-BR.md
@@ -0,0 +1,205 @@
+# Win-CodexBar
+
+[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Português (Brasil)](./README.pt-BR.md) | [Türkçe](./README.tr-TR.md)
+
+O Win-CodexBar é um aplicativo para a bandeja do sistema do Windows que mantém visível o uso das suas ferramentas de programação com IA, sem precisar abrir vários painéis. Ele traz a proposta do [CodexBar](https://github.com/steipete/CodexBar) para uma aplicação desktop em Tauri + React, apoiada por uma lógica compartilhada de provedores escrita em Rust.
+
+
+
+
+
+
+
+
+
+
+
+
+## Destaques
+
+- **51 provedores**, incluindo Codex, Claude, Copilot, OpenRouter, Cursor, Gemini, DeepSeek, MiniMax, Kiro, Antigravity, Groq e muitos outros.
+- **Fluxo centrado na bandeja**, com uma grade compacta de provedores, cartões de uso, ação de atualização, atalho para as configurações e controle para sair.
+- **Configurações por provedor** para selecionar fontes de dados, credenciais, importação de cookies, contas de token, chaves de API, regiões e preferências de exibição na bandeja.
+- **Proteção de credenciais no Windows** para chaves de API, cookies manuais e contas de token gerenciados pelo aplicativo, usando DPAPI no escopo do usuário quando disponível.
+- **Importação de cookies do navegador** para Chrome, Edge, Brave e Firefox, ativada individualmente para cada provedor.
+- **CLI local instalada** para automatizar consultas de uso, custos, configurações, diagnósticos e integrações via loopback.
+- **Versões instalável e portátil**, com inicialização do runtime do WebView2 e do runtime do VC++, além de arquivos de verificação SHA-256.
+
+## Instalação
+
+Instale com o Gerenciador de Pacotes do Windows:
+
+```powershell
+winget install Finesssee.Win-CodexBar
+```
+
+Ou baixe a versão mais recente do instalador ou do executável portátil em [GitHub Releases](https://github.com/nesszer/Win-CodexBar/releases).
+
+- Instalador: `CodexBar--Setup.exe`
+- Portátil: `CodexBar--portable.exe`
+- Checksums: cada versão inclui arquivos `.sha256`
+
+A distribuição pelo Winget foi aprovada no [microsoft/winget-pkgs](https://github.com/microsoft/winget-pkgs/tree/master/manifests/f/Finesssee/Win-CodexBar). Novas versões podem demorar um pouco para aparecer, pois cada atualização no Winget é vinculada a uma URL de versão e a um hash específico do instalador.
+
+## Assinatura de código
+
+> **Assinatura de código:** o SignPath.io está integrado ao fluxo de lançamento do GitHub Actions, mas a assinatura em produção permanece bloqueada até a emissão do certificado Release 2026 e a validação da política `release-signing`. Consulte [docs/CODE_SIGNING.md](docs/CODE_SIGNING.md) para conhecer a política de assinatura.
+> A versão v0.60.3 é imutável e não está assinada; verifique os arquivos SHA-256. A primeira versão assinada será a próxima versão normal após a integração com o SignPath.
+
+## Primeira execução
+
+1. Inicie o **CodexBar** pelo menu Iniciar ou pelo executável portátil.
+2. Clique no ícone da bandeja para abrir o painel de uso.
+3. Abra **Configurações -> Provedores**.
+4. Ative os provedores que você utiliza.
+5. Adicione o tipo de credencial correspondente: login OAuth/dispositivo, chave de API, cookies do navegador, login na CLI local ou conta de token.
+
+Para o Claude, cookies do navegador/sessionKey são recomendados porque correspondem ao uso exibido na página de configurações do Claude. OAuth e CLI continuam disponíveis como alternativas. Para provedores baseados em CLI, como Codex e Gemini, primeiro faça login pela CLI do provedor.
+
+## Histórico de versões
+
+Consulte todas as alterações em [CHANGELOG.md](CHANGELOG.md).
+
+## Provedores compatíveis
+
+
+Matriz de provedores
+
+| Provedor | Autenticação | Dados monitorados |
+|---|---|---|
+| Codex | OAuth / CLI | Sessão, semanal, créditos |
+| Claude | Cookies / alternativa OAuth / alternativa CLI | Sessão (5h), semanal |
+| Cursor | Cookies | Plano, uso, cobrança |
+| Factory | Cookies | Uso |
+| Gemini | OAuth do gcloud | Cota |
+| Copilot | Fluxo de dispositivo do GitHub / CLI gh / token legado | Uso do plano, Chat |
+| Antigravity | LSP local | Uso, cotas por modelo |
+| z.ai | Token de API | Cota |
+| MiniMax | API / Cookies | Uso, resumo de cobrança |
+| Kiro | Cookies / CLI | Créditos mensais, excedentes |
+| Vertex AI | OAuth do gcloud | Custo |
+| v0 | Chave de API | Cota de cobrança, limites de requisição da API, saldo sob demanda |
+| Augment | Cookies | Créditos |
+| OpenCode | Configuração local | Uso |
+| Kimi | Cookies | Limite de 5h, semanal |
+| Kimi K2 | Chave de API | Créditos |
+| Amp | Cookies | Uso |
+| Warp | Configuração local | Uso |
+| Ollama | Cookies / Chave de API | Uso, modelos na nuvem, janelas de ritmo |
+| Azure OpenAI | Chave de API | Implantação |
+| T3 Chat | Cookies / cURL | Base, excedentes |
+| TypeSafe | Cookies do navegador / cabeçalho Cookie manual | Gastos do ciclo de cobrança, saldo, créditos a expirar |
+| OpenRouter | Chave de API | Créditos |
+| JetBrains AI | Configuração local | Uso |
+| Alibaba | Cookies | Uso |
+| Alibaba Token Plan | Cookies | Créditos do plano de tokens, data de renovação |
+| NanoGPT | Chave de API | Créditos |
+| Infini | Chave de API | Sessão, semanal, cota |
+| Perplexity | Cookies | Créditos, plano |
+| Abacus AI | Cookies | Créditos |
+| Mistral | Cookies | Cobrança, uso |
+| OpenCode Go | Cookies | Uso, saldo Zen |
+| Kilo | Chave de API / CLI | Uso |
+| Codebuff | Chave de API / Configuração local | Créditos, semanal |
+| DeepSeek | Chave de API | Saldo, resumos de uso, custo |
+| Windsurf | Cache local | Diário, semanal |
+| Manus | Cookies | Créditos, créditos de atualização |
+| Xiaomi MiMo | Cookies | Saldo, plano de tokens |
+| Doubao | Chave de API | Limites de requisição |
+| Command Code | Cookies | Créditos mensais, créditos comprados |
+| StepFun | Token Oasis | 5h, semanal, atualização de token |
+| Venice | Chave de API / sessão web | Saldo em USD / DIEM, créditos incluídos (a sessão web expira em cerca de 60 s) |
+| OpenAI | API Admin / Chave de API | Uso, requisições, custo por projeto, saldo de créditos |
+| Grok | Cookies / auth.json | Cobrança |
+| Helmcode (também NaN Builders) | Cookies do navegador / cabeçalho Cookie manual | Cotas de tokens por modelo, janelas de renovação, saldo pré-pago do Helmcode |
+| Replicate | Cookies / contas de token | Gastos mensais, saldo de créditos |
+| Aixy | Chave de API / contas de token | Saldos de orçamento aplicáveis, uso da chave em 7 dias |
+| ElevenLabs | Chave de API | Créditos da assinatura, slots de voz |
+| Deepgram | Chave de API | Uso do projeto |
+| Groq | Chave de API | Métricas corporativas |
+| LLM Proxy | Chave de API | Estatísticas de cota |
+
+
+
+## Idiomas disponíveis
+
+A interface e os relatórios para colaboradores estão disponíveis atualmente em:
+
+- English
+- 简体中文
+- 繁體中文(臺灣)
+- 日本語
+- 한국어
+- Español mexicano
+- Português (Brasil)
+- Türkçe
+
+## Compilação a partir do código-fonte
+
+```powershell
+# Pré-requisitos: Node.js + pnpm. Rust e MinGW são instalados pelo script quando necessário.
+git clone https://github.com/nesszer/Win-CodexBar.git
+cd Win-CodexBar
+.\scripts\dev.ps1
+```
+
+Opções úteis para desenvolvimento:
+
+```powershell
+.\scripts\dev.ps1 -Release # compilação otimizada
+.\scripts\dev.ps1 -SkipBuild # reinicia a última compilação
+```
+
+Exemplos da CLI:
+
+```bash
+codexbar-cli --help
+codexbar-cli diagnose --pretty
+codexbar-cli usage -p claude
+codexbar-cli usage -p all
+codexbar-cli cost -p codex
+```
+
+As compilações do instalador incluem `codexbar.exe` como aplicativo da bandeja e `codexbar-cli.exe` como CLI de console. Os atalhos do menu Iniciar executam o aplicativo desktop; os comandos do terminal usam `codexbar-cli.exe`. O `codexbar-desktop.exe` ainda é instalado como um alias de compatibilidade para atalhos antigos e entradas de inicialização automática.
+
+## Compilações de lançamento
+
+Para criar versões locais no Windows, use o gerador de versões com cache:
+
+```powershell
+.\scripts\windows-release-build.ps1 -Ref v0.33.2 -SmokeInstall
+```
+
+O script compila o binário de lançamento real do Tauri e a CLI de console, verifica as dependências assinadas do instalador, empacota com o Inno Setup, gera os artefatos instalável e portátil, cria os arquivos SHA-256 correspondentes e pode executar um teste rápido de instalação e desinstalação silenciosas.
+
+Mais informações sobre a automação de versões estão em [docs/release/ci-cd.md](docs/release/ci-cd.md).
+
+## Privacidade
+
+- **Processamento local por padrão**: os dados dos provedores são lidos de caminhos locais conhecidos ou das APIs que você configurar.
+- **Cookies opcionais**: a extração de cookies do navegador só é executada para os provedores ativados por você.
+- **Segredos protegidos**: chaves de API, cookies manuais e contas de token utilizam a camada de arquivo seguro; no Windows, ela usa DPAPI no escopo do usuário quando disponível.
+- **Diagnósticos seguros**: os diagnósticos mostram apenas metadados de provedor, fonte e status, nunca cookies, chaves de API, bearer tokens ou valores OAuth brutos.
+- **Atualizações verificadas**: downloads do instalador exigem um resumo SHA-256 do GitHub e são verificados novamente imediatamente antes da aplicação.
+
+## Documentação
+
+| Tópico | Link |
+|---|---|
+| Compilação a partir do código-fonte | [docs/BUILDING.md](docs/BUILDING.md) |
+| Configuração do WSL e dicas de autenticação | [docs/WSL.md](docs/WSL.md) |
+| Detalhes dos cookies do navegador | [docs/COOKIES.md](docs/COOKIES.md) |
+
+## Integrações locais
+
+- [AI Usage Limits](https://github.com/lenadweb/stream-deck-ai-limits) — integração com o Elgato Stream Deck que utiliza o painel/API local de `codexbar serve` para exibir métricas de provedor, conta, cota ou payload.
+- [AI Monitor](https://github.com/tobymarks/esp32-ai-monitor) — monitor de mesa com ESP32 (Cheap Yellow Display) e aplicativo complementar para Windows (beta), que chama a CLI instalada (`codexbar-cli usage -p --json`) e transmite os limites de Claude, Codex, Copilot, Cursor, Gemini ou Antigravity para o monitor por uma conexão serial USB.
+
+## Créditos
+
+- Aplicativo original para macOS: [steipete/CodexBar](https://github.com/steipete/CodexBar), de Peter Steinberger
+- Inspirado no [ccusage](https://github.com/ryoppippi/ccusage) para o acompanhamento de custos
+
+## Licença
+
+MIT, a mesma licença do CodexBar original.
diff --git a/README.tr-TR.md b/README.tr-TR.md
index 47ab7bb13b..841ac9f23c 100644
--- a/README.tr-TR.md
+++ b/README.tr-TR.md
@@ -1,6 +1,6 @@
# Win-CodexBar
-[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Türkçe](./README.tr-TR.md)
+[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Português (Brasil)](./README.pt-BR.md) | [Türkçe](./README.tr-TR.md)
Win-CodexBar, bir düzine pano açmadan yapay zekâ kodlama araçlarının kullanımını görebilmenizi sağlayan bir Windows sistem tepsisi uygulamasıdır. [CodexBar](https://github.com/steipete/CodexBar) ruhunu, ortak Rust sağlayıcı mantığıyla desteklenen Tauri + React masaüstü kabuğuna taşır.
@@ -107,7 +107,6 @@ Tüm geçmişi [CHANGELOG.md](CHANGELOG.md) dosyasında görebilirsiniz.
| Xiaomi MiMo | Çerezler | Bakiye, Token Planı |
| Doubao | API Anahtarı | İstek Sınırları |
| Command Code | Çerezler | Aylık Krediler, Satın Alınan Krediler |
-| Crof | API Anahtarı | Krediler, İstek Kotası |
| StepFun | Oasis Tokenı | 5 saatlik, Haftalık, Token yenileme |
| Venice | API Anahtarı | USD / DIEM Bakiyesi |
| OpenAI | Yönetici API'si / API Anahtarı | Kullanım, İstekler, Proje kapsamlı maliyet, Kredi Bakiyesi |
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 2776c0fa95..25685118d5 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -1,6 +1,6 @@
# Win-CodexBar
-[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Türkçe](./README.tr-TR.md)
+[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Português (Brasil)](./README.pt-BR.md) | [Türkçe](./README.tr-TR.md)
[CodexBar](https://github.com/steipete/CodexBar) 的 Windows 移植版 —— 一个系统托盘应用,让你随时掌握各个 AI 编程工具的用量额度。
@@ -14,7 +14,7 @@
## 功能特性
-- **56 个 AI 服务商** — Codex、Claude、Cursor、Factory、Gemini、Copilot、Antigravity、z.ai、MiniMax、Kiro、Vertex AI、Augment、OpenCode、Kimi、Kimi K2、Amp、Warp、Ollama、Azure OpenAI、T3 Chat、OpenRouter、JetBrains AI、Alibaba、Alibaba Token Plan、NanoGPT、Infini、Perplexity、Abacus AI、Mistral、OpenCode Go、Kilo、AWS Bedrock、Codebuff、DeepSeek、Windsurf、Manus、小米 MiMo、Doubao、Command Code、Crof、StepFun、Venice、OpenAI、Grok、ElevenLabs、Deepgram、Groq、LLM Proxy、Chutes、LiteLLM、Poe、Devin、Zed、CrossModel、Qoder、Sakana AI
+- **56 个 AI 服务商** — Codex、Claude、Cursor、Factory、Gemini、Copilot、Antigravity、z.ai、MiniMax、Kiro、Vertex AI、Augment、OpenCode、Kimi、Kimi K2、Amp、Warp、Ollama、Azure OpenAI、T3 Chat、OpenRouter、JetBrains AI、Alibaba、Alibaba Token Plan、NanoGPT、Infini、Perplexity、Abacus AI、Mistral、OpenCode Go、Kilo、AWS Bedrock、Codebuff、DeepSeek、Windsurf、Manus、小米 MiMo、Doubao、Command Code、StepFun、Venice、OpenAI、Grok、ElevenLabs、Deepgram、Groq、LLM Proxy、Chutes、LiteLLM、Poe、Devin、Zed、CrossModel、Qoder、Sakana AI
- **系统托盘图标** — 动态双条进度显示会话与周用量
- **Floating Bar** — 可选的置顶透明用量条,支持方向、透明度和点击穿透控制
- **浏览器 Cookie 导入** — Chrome、Edge、Brave、Firefox(Windows DPAPI 解密)
@@ -217,7 +217,6 @@ codexbar cost -p codex # 本地成本(JSONL 日志)
| 小米 MiMo | Cookies | 余额、Token 套餐 |
| Doubao | API Key | 请求限制 |
| Command Code | Cookies | 月度 Credits、已购 Credits |
-| Crof | API Key | Credits、请求配额 |
| StepFun | Oasis Token | 5h、周用量 |
| Venice | API Key | USD / DIEM 余额 |
| OpenAI | Admin API / API Key | 用量、请求数、余额 |
diff --git a/README.zh-TW.md b/README.zh-TW.md
index cbda22a135..7fc2b438c4 100644
--- a/README.zh-TW.md
+++ b/README.zh-TW.md
@@ -1,6 +1,6 @@
# Win-CodexBar
-[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Türkçe](./README.tr-TR.md)
+[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文(臺灣)](./README.zh-TW.md) | [日本語](./README.ja-JP.md) | [한국어](./README.ko-KR.md) | [Español mexicano](./README.es-MX.md) | [Português (Brasil)](./README.pt-BR.md) | [Türkçe](./README.tr-TR.md)
[CodexBar](https://github.com/steipete/CodexBar) 的 Windows 移植版 —— 一個系統系統匣應用,讓你隨時掌握各個 AI 程式設計工具的用量額度。
@@ -14,7 +14,7 @@
## 功能特性
-- **56 個 AI 提供者** — Codex、Claude、Cursor、Factory、Gemini、Copilot、Antigravity、z.ai、MiniMax、Kiro、Vertex AI、Augment、OpenCode、Kimi、Kimi K2、Amp、Warp、Ollama、Azure OpenAI、T3 Chat、OpenRouter、JetBrains AI、Alibaba、Alibaba Token Plan、NanoGPT、Infini、Perplexity、Abacus AI、Mistral、OpenCode Go、Kilo、AWS Bedrock、Codebuff、DeepSeek、Windsurf、Manus、小米 MiMo、Doubao、Command Code、Crof、StepFun、Venice、OpenAI、Grok、ElevenLabs、Deepgram、Groq、LLM Proxy、Chutes、LiteLLM、Poe、Devin、Zed、CrossModel、Qoder、Sakana AI
+- **56 個 AI 提供者** — Codex、Claude、Cursor、Factory、Gemini、Copilot、Antigravity、z.ai、MiniMax、Kiro、Vertex AI、Augment、OpenCode、Kimi、Kimi K2、Amp、Warp、Ollama、Azure OpenAI、T3 Chat、OpenRouter、JetBrains AI、Alibaba、Alibaba Token Plan、NanoGPT、Infini、Perplexity、Abacus AI、Mistral、OpenCode Go、Kilo、AWS Bedrock、Codebuff、DeepSeek、Windsurf、Manus、小米 MiMo、Doubao、Command Code、StepFun、Venice、OpenAI、Grok、ElevenLabs、Deepgram、Groq、LLM Proxy、Chutes、LiteLLM、Poe、Devin、Zed、CrossModel、Qoder、Sakana AI
- **系統系統匣圖示** — 動態雙條進度顯示會話與周用量
- **Floating Bar** — 可選的置頂透明用量條,支援方向、透明度和點選穿透控制
- **瀏覽器 Cookie 匯入** — Chrome、Edge、Brave、Firefox(Windows DPAPI 解密)
@@ -217,7 +217,6 @@ codexbar cost -p codex # 本機成本(JSONL 日誌)
| 小米 MiMo | Cookies | 餘額、Token 套餐 |
| Doubao | API Key | 請求限制 |
| Command Code | Cookies | 月度 Credits、已購 Credits |
-| Crof | API Key | Credits、請求配額 |
| StepFun | Oasis Token | 5h、周用量 |
| Venice | API Key | USD / DIEM 餘額 |
| OpenAI | Admin API / API Key | 用量、請求數、餘額 |
diff --git a/apps/desktop-tauri/package.json b/apps/desktop-tauri/package.json
index 7eb5637e84..3bbaacadba 100644
--- a/apps/desktop-tauri/package.json
+++ b/apps/desktop-tauri/package.json
@@ -1,7 +1,7 @@
{
"name": "desktop-tauri",
"private": true,
- "version": "0.60.3",
+ "version": "0.70.0",
"packageManager": "pnpm@11.25.0",
"type": "module",
"scripts": {
diff --git a/apps/desktop-tauri/src-tauri/Cargo.toml b/apps/desktop-tauri/src-tauri/Cargo.toml
index f447d1363a..baa6e1c3ee 100644
--- a/apps/desktop-tauri/src-tauri/Cargo.toml
+++ b/apps/desktop-tauri/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "codexbar-desktop-tauri"
-version = "0.60.3"
+version = "0.70.0"
edition = "2024"
publish = false
diff --git a/apps/desktop-tauri/src-tauri/src/auto_resume.rs b/apps/desktop-tauri/src-tauri/src/auto_resume.rs
index 030fcaeff3..35260c2648 100644
--- a/apps/desktop-tauri/src-tauri/src/auto_resume.rs
+++ b/apps/desktop-tauri/src-tauri/src/auto_resume.rs
@@ -6,13 +6,12 @@
use codexbar::agent_sessions::{
AgentSession, AgentSessionProvider, AgentSessionSource, AgentSessionState,
- LocalAgentSessionScanner, SessionFocusResult,
+ LocalAgentSessionScanner,
};
use codexbar::core::{ProviderId, TokenAccountStore};
use codexbar::settings::Settings;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
-use std::process::Command;
use std::sync::Mutex;
use crate::commands::{ProviderUsageSnapshot, RateWindowSnapshot};
@@ -20,10 +19,6 @@ use crate::state::AppState;
use tauri::Manager;
const MAX_SESSION_ID_LEN: usize = 256;
-#[cfg(windows)]
-const CREATE_NEW_CONSOLE: u32 = 0x0000_0010;
-#[cfg(windows)]
-const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum QuotaSlot {
@@ -633,19 +628,22 @@ async fn resume_captured_session(
}
if let Some(session) = matching_session(&target, &result.sessions) {
// A live process already owns this exact session. Reopening it would
- // create a duplicate terminal, so focus the existing window instead.
+ // create a duplicate terminal, so point the user at the existing
+ // window instead. This runs after a background refresh, not a user
+ // action: flash the window's taskbar button rather than restoring and
+ // activating it over the app the user is working in.
if session.pid.is_some() {
if !resume_attempt_is_still_valid(app, &target, operation, account_identity) {
clear_provider_if_resume_owner(app, target.provider, operation);
return;
}
- let focus_result = codexbar::agent_sessions::focus_session(session);
- let succeeded = matches!(focus_result, SessionFocusResult::Focused);
+ let attention = codexbar::agent_sessions::request_session_attention(session);
+ let succeeded = attention.is_ok();
tracing::info!(
provider = target.provider.cli_name(),
- focus_result = ?focus_result,
+ attention = ?attention,
succeeded,
- "captured CLI session is already running; attempting to focus it"
+ "captured CLI session is already running; flashing its window"
);
finish_resume_attempt(app, &target, operation, succeeded);
return;
@@ -804,14 +802,15 @@ fn launch_resume(target: &ResumeTarget) -> Result<(), String> {
.ok_or_else(|| format!("{} CLI was not found", target.provider.display_name()))?;
let command = build_resume_command(target, executable)?;
- let mut process = Command::new(&command.program);
- process.args(&command.args).current_dir(&command.cwd);
- #[cfg(windows)]
- {
- use std::os::windows::process::CommandExt;
- process.creation_flags(CREATE_NEW_CONSOLE | CREATE_NEW_PROCESS_GROUP);
- }
- process.spawn().map(|_| ()).map_err(|error| {
+ // This runs after a background refresh, not a user action: the console
+ // starts minimized and inactive, so it waits on the taskbar instead of
+ // taking focus from the app the user is working in.
+ codexbar::host::console_launch::spawn_minimized_console(
+ &command.program,
+ &command.args,
+ &command.cwd,
+ )
+ .map_err(|error| {
format!(
"failed to launch {} CLI: {error}",
target.provider.display_name()
@@ -835,28 +834,9 @@ fn build_resume_command(
_ => return Err("provider does not support auto-resume".to_string()),
};
- #[cfg(windows)]
- if executable
- .extension()
- .and_then(|extension| extension.to_str())
- .is_some_and(|extension| matches!(extension.to_ascii_lowercase().as_str(), "cmd" | "bat"))
- {
- let command_line = std::iter::once(quote_cmd_arg(&executable.to_string_lossy()))
- .chain(cli_args.iter().map(|arg| quote_cmd_arg(arg)))
- .collect::>()
- .join(" ");
- return Ok(ResumeCommand {
- program: PathBuf::from("cmd.exe"),
- args: vec![
- "/d".to_string(),
- "/s".to_string(),
- "/c".to_string(),
- command_line,
- ],
- cwd: target.cwd.clone(),
- });
- }
-
+ // Batch shims such as npm's `claude.cmd` stay the program here;
+ // `spawn_minimized_console` runs them through cmd.exe with quoting cmd.exe
+ // parses.
Ok(ResumeCommand {
program: executable,
args: cli_args,
@@ -864,11 +844,6 @@ fn build_resume_command(
})
}
-#[cfg(windows)]
-fn quote_cmd_arg(value: &str) -> String {
- format!("\"{}\"", value.replace('"', "\\\""))
-}
-
#[cfg(test)]
#[path = "auto_resume_tests.rs"]
mod auto_resume_tests;
diff --git a/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs b/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs
index 07b289498f..b8eadc0275 100644
--- a/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs
+++ b/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs
@@ -42,6 +42,8 @@ mod tests {
reserve_description: None,
reserve_will_last_to_reset: false,
reserve_eta_seconds: None,
+ monthly_limit_block: None,
+ description_is_detail: false,
}
}
@@ -86,22 +88,24 @@ mod tests {
);
}
- #[cfg(windows)]
#[test]
- fn cmd_wrapper_preserves_exact_resume_arguments() {
+ fn batch_shims_keep_exact_resume_arguments() {
+ // cmd.exe wrapping for `.cmd` shims happens at launch, in
+ // `codexbar::host::console_launch`, which tests its own quoting.
let command = build_resume_command(
&target(ProviderId::Claude),
PathBuf::from(r"C:\Program Files\Claude\claude.cmd"),
)
.unwrap();
- assert_eq!(command.program, PathBuf::from("cmd.exe"));
- assert_eq!(&command.args[..3], ["/d", "/s", "/c"]);
assert_eq!(
- command.args[3],
- r#""C:\Program Files\Claude\claude.cmd" "--resume" "12345678-1234-1234-1234-123456789abc""#
+ command.program,
+ PathBuf::from(r"C:\Program Files\Claude\claude.cmd")
+ );
+ assert_eq!(
+ command.args,
+ ["--resume", "12345678-1234-1234-1234-123456789abc"]
);
- assert!(!command.args[3].contains("prompt"));
}
#[test]
diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs
index ab5efbef8a..ccb9b43ce4 100644
--- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs
+++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs
@@ -1,8 +1,15 @@
+mod openai_usage;
+#[cfg(test)]
+mod openai_usage_tests;
pub(crate) mod pace;
+mod quota_block;
mod status;
+pub(crate) use openai_usage::OpenAiApiUsageSnapshot;
+pub use quota_block::MonthlyLimitBlockSnapshot;
pub(crate) use status::{compact_tray_status_label, friendly_provider_error};
use super::*;
+use codexbar::core::BlockedWindows;
// ── Bridge snapshot types ────────────────────────────────────────────
@@ -23,6 +30,9 @@ pub struct RateWindowSnapshot {
pub is_exhausted: bool,
#[serde(default)]
pub is_informational: bool,
+ /// `reset_description` is a detail line (for example spend amounts), not reset wording.
+ #[serde(default)]
+ pub description_is_detail: bool,
#[serde(default)]
pub reserve_percent: Option,
#[serde(default)]
@@ -31,6 +41,10 @@ pub struct RateWindowSnapshot {
pub reserve_will_last_to_reset: bool,
#[serde(default)]
pub reserve_eta_seconds: Option,
+ /// Set while a longer exhausted pool (Kimi's monthly membership) blocks
+ /// this window; presentation only, the raw percentages above stay as is.
+ #[serde(default)]
+ pub monthly_limit_block: Option,
}
/// Serde default for [`RateWindowSnapshot::remaining_percent`] — the common
@@ -53,6 +67,8 @@ impl RateWindowSnapshot {
reserve_description: None,
reserve_will_last_to_reset: false,
reserve_eta_seconds: None,
+ monthly_limit_block: None,
+ description_is_detail: rw.description_is_detail,
}
}
@@ -125,7 +141,19 @@ fn default_cost_period() -> String {
/// Format a cost amount using the snapshot's currency symbol when available,
/// otherwise falling back to the currency-code prefix. Used by tray surfaces
/// that render a spend amount without a rate-window percent (MonthlyPlan).
-pub(crate) fn format_cost_amount(cost: &CostSnapshotBridge) -> String {
+pub(crate) fn format_cost_amount(
+ cost: &CostSnapshotBridge,
+ rates_cache: Option<&CurrencyRateCache>,
+) -> String {
+ if let Some((amount, currency)) =
+ crate::commands::convert_preferred_amount(rates_cache, cost.used, &cost.currency_code)
+ {
+ // The canonical core symbol table (shared with CLI/tray formatting).
+ return codexbar::core::format_currency(amount, ¤cy);
+ }
+ if !cost.formatted_used.is_empty() {
+ return cost.formatted_used.clone();
+ }
if let Some(ref symbol) = cost.currency_symbol {
format!("{}{:.2}", symbol, cost.used)
} else {
@@ -143,6 +171,10 @@ pub struct NamedRateWindowSnapshot {
/// when the provider reports no real core quota window.
#[serde(default)]
pub fallback_lane: bool,
+ /// Provider-declared tray-icon lane this window stands in for when the
+ /// snapshot has no real core window in that lane.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub icon_fallback: Option,
}
/// Pace prediction snapshot for tray/bridge display.
@@ -159,6 +191,32 @@ pub struct PaceSnapshot {
pub expected_used_percent: f64,
#[serde(default)]
pub actual_used_percent: f64,
+ /// Block of the window this pace comes from (upstream hides its pace).
+ #[serde(default)]
+ pub monthly_limit_block: Option,
+}
+
+/// One burndown chart point (RFC 3339 capture time + remaining percent).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct QuotaBurndownPointSnapshot {
+ pub captured_at: String,
+ pub remaining_percent: f64,
+}
+
+/// Recorded remaining-quota burndown for one series (session / weekly),
+/// upstream 0.70.0 #4085. `captured_at` of the last sample drives the
+/// capture-age caption; the chart is empty when there is no current window.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct QuotaBurndownSnapshot {
+ /// `session` or `weekly`.
+ pub series: String,
+ pub window_minutes: u32,
+ pub start: String,
+ pub reset: String,
+ pub samples: Vec,
+ pub ideal: [QuotaBurndownPointSnapshot; 2],
}
/// Session-equivalent weekly forecast for Claude/Codex menu secondary line.
@@ -206,6 +264,8 @@ pub struct ProviderDisplayProgressSnapshot {
#[serde(rename_all = "camelCase")]
pub struct ProviderDisplayDetailSnapshot {
pub id: String,
+ #[serde(default)]
+ pub section_title: Option,
pub title: String,
pub value: String,
pub secondary_value: Option,
@@ -267,8 +327,15 @@ pub struct ProviderUsageSnapshot {
pub fetch_duration_ms: Option,
#[serde(default)]
pub wayfinder_usage: Option,
+ /// Per-day OpenAI Admin API history for the daily usage chart.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub open_ai_api_usage: Option,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub session_equivalent_forecast: Option,
+ /// Recorded remaining-quota burndown for the selected window
+ /// (upstream 0.70.0 #4085); Codex and Claude only.
+ #[serde(skip_serializing_if = "Option::is_none", default)]
+ pub quota_burndown: Option,
}
fn default_display_name() -> String {
@@ -337,6 +404,7 @@ impl ProviderUsageSnapshot {
});
let primary_pace = primary_pace.flatten();
+ let blocked = BlockedWindows::evaluate(id, usage, chrono::Utc::now());
let pace = primary_pace.as_ref().map(|p| PaceSnapshot {
stage: pace::stage_str(p.stage).to_string(),
delta_percent: p.delta_percent,
@@ -344,6 +412,7 @@ impl ProviderUsageSnapshot {
eta_seconds: p.eta_seconds,
expected_used_percent: p.expected_used_percent,
actual_used_percent: p.actual_used_percent,
+ monthly_limit_block: MonthlyLimitBlockSnapshot::for_pace(usage, &blocked),
});
// Compute pace for secondary window (weekly) to derive reserve info
@@ -355,10 +424,12 @@ impl ProviderUsageSnapshot {
});
let secondary_pace = secondary_pace.flatten();
- let primary_snap = RateWindowSnapshot::from_rate_window(&usage.primary);
+ let primary_snap = RateWindowSnapshot::from_rate_window(&usage.primary)
+ .with_quota_block(blocked.primary, &blocked);
let secondary_snap = usage.secondary.as_ref().map(|sw| {
- let mut s = RateWindowSnapshot::from_rate_window(sw);
+ let mut s = RateWindowSnapshot::from_rate_window(sw)
+ .with_quota_block(blocked.secondary, &blocked);
if let Some(ref p) = secondary_pace {
s = s.with_pace_reserve(p);
}
@@ -376,6 +447,12 @@ impl ProviderUsageSnapshot {
&usage.primary,
usage.secondary.as_ref(),
);
+ let quota_burndown = quota_burndown_for(
+ id,
+ account_key.as_deref(),
+ &usage.primary,
+ usage.secondary.as_ref(),
+ );
Self {
provider_id: id.cli_name().to_string(),
@@ -394,14 +471,13 @@ impl ProviderUsageSnapshot {
.clone()
.unwrap_or_else(|| metadata.weekly_label.to_string())
}),
- model_specific: usage
- .model_specific
- .as_ref()
- .map(RateWindowSnapshot::from_rate_window),
- tertiary: usage
- .tertiary
- .as_ref()
- .map(RateWindowSnapshot::from_rate_window),
+ model_specific: usage.model_specific.as_ref().map(|w| {
+ RateWindowSnapshot::from_rate_window(w)
+ .with_quota_block(blocked.model_specific, &blocked)
+ }),
+ tertiary: usage.tertiary.as_ref().map(|w| {
+ RateWindowSnapshot::from_rate_window(w).with_quota_block(blocked.tertiary, &blocked)
+ }),
// F5 (upstream 0.48.0): label the tertiary lane by its duration cadence
// so surfaces (MenuCard, CLI, tray) can show "Monthly" instead of the
// generic "DetailWindowTertiary" slot key.
@@ -416,11 +492,14 @@ impl ProviderUsageSnapshot {
extra_rate_windows: usage
.extra_rate_windows
.iter()
- .map(|extra| NamedRateWindowSnapshot {
+ .zip(&blocked.extra)
+ .map(|(extra, &is_blocked)| NamedRateWindowSnapshot {
id: extra.id.clone(),
title: extra.title.clone(),
- window: RateWindowSnapshot::from_rate_window(&extra.window),
+ window: RateWindowSnapshot::from_rate_window(&extra.window)
+ .with_quota_block(is_blocked, &blocked),
fallback_lane: extra.fallback_lane,
+ icon_fallback: extra.icon_fallback,
})
.collect(),
inventory: result
@@ -438,6 +517,7 @@ impl ProviderUsageSnapshot {
.iter()
.map(|detail| ProviderDisplayDetailSnapshot {
id: detail.id().to_string(),
+ section_title: detail.section_title().map(ToOwned::to_owned),
title: detail.title().to_string(),
value: detail.value().to_string(),
secondary_value: detail.secondary_value().map(ToOwned::to_owned),
@@ -492,7 +572,9 @@ impl ProviderUsageSnapshot {
tray_status_label: None,
fetch_duration_ms: None,
wayfinder_usage: result.wayfinder_usage.clone(),
+ open_ai_api_usage: result.open_ai_api_usage.as_ref().map(Into::into),
session_equivalent_forecast,
+ quota_burndown,
}
}
@@ -518,6 +600,8 @@ impl ProviderUsageSnapshot {
reserve_description: None,
reserve_will_last_to_reset: false,
reserve_eta_seconds: None,
+ monthly_limit_block: None,
+ description_is_detail: false,
},
primary_label: Some(metadata.session_label.to_string()),
secondary: None,
@@ -542,7 +626,9 @@ impl ProviderUsageSnapshot {
tray_status_label: None,
fetch_duration_ms: None,
wayfinder_usage: None,
+ open_ai_api_usage: None,
session_equivalent_forecast: None,
+ quota_burndown: None,
}
}
}
@@ -615,6 +701,74 @@ fn session_equivalent_forecast_for(
})
}
+/// Build the recorded burndown for the live window (session or weekly lane)
+/// from the persisted history; `None` when the window is expired or unknown.
+/// Codex and Claude only, mirroring the forecast gate.
+fn quota_burndown_for(
+ id: ProviderId,
+ account_key: Option<&str>,
+ session: &RateWindow,
+ weekly: Option<&RateWindow>,
+) -> Option {
+ if !matches!(id, ProviderId::Claude | ProviderId::Codex) {
+ return None;
+ }
+ let now = chrono::Utc::now();
+ let provider_id = id.cli_name();
+ // Persisted history feeds the chart; loading merges it into the
+ // process-local store once per scope.
+ let histories = codexbar::core::load_persisted_history(provider_id, account_key);
+
+ let live = |series: &str, window: &RateWindow| -> Option {
+ let model = codexbar::core::QuotaBurndownModel::build(
+ &series_entries(&histories, series),
+ window,
+ now,
+ )?;
+ Some(QuotaBurndownSnapshot {
+ series: series.to_string(),
+ window_minutes: window.window_minutes?,
+ start: model.start.to_rfc3339(),
+ reset: model.reset.to_rfc3339(),
+ samples: model
+ .samples
+ .iter()
+ .map(|sample| QuotaBurndownPointSnapshot {
+ captured_at: sample.captured_at.to_rfc3339(),
+ remaining_percent: sample.remaining_percent,
+ })
+ .collect(),
+ ideal: [
+ QuotaBurndownPointSnapshot {
+ captured_at: model.ideal[0].captured_at.to_rfc3339(),
+ remaining_percent: model.ideal[0].remaining_percent,
+ },
+ QuotaBurndownPointSnapshot {
+ captured_at: model.ideal[1].captured_at.to_rfc3339(),
+ remaining_percent: model.ideal[1].remaining_percent,
+ },
+ ],
+ })
+ };
+
+ // Upstream prefers the shortest current window (session over weekly).
+ live("session", session).or_else(|| weekly.and_then(|w| live("weekly", w)))
+}
+
+fn series_entries(
+ histories: &[codexbar::core::PlanUtilizationSeriesHistory],
+ series: &str,
+) -> Vec {
+ histories
+ .iter()
+ .find(|history| match series {
+ "session" => history.name == codexbar::core::PlanUtilizationSeriesName::Session,
+ _ => history.name == codexbar::core::PlanUtilizationSeriesName::Weekly,
+ })
+ .map(|history| history.entries.clone())
+ .unwrap_or_default()
+}
+
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BootstrapState {
@@ -641,6 +795,7 @@ pub struct ProviderCatalogEntry {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SettingsSnapshot {
+ preferred_currency_code: String,
enabled_providers: Vec,
provider_order: Vec,
refresh_interval_secs: u64,
@@ -659,11 +814,15 @@ pub struct SettingsSnapshot {
provider_usage_thresholds:
std::collections::HashMap,
predictive_pace_warning_enabled: bool,
+ credential_expiry_notifications_enabled: bool,
show_pace: bool,
tray_icon_mode: &'static str,
+ stacked_tray_top_provider: Option,
+ stacked_tray_bottom_provider: Option,
switcher_shows_icons: bool,
menu_bar_shows_highest_usage: bool,
menu_bar_shows_percent: bool,
+ menu_bar_color_pace: bool,
show_as_used: bool,
show_all_token_accounts_in_menu: bool,
enable_animations: bool,
@@ -676,8 +835,10 @@ pub struct SettingsSnapshot {
auto_download_updates: bool,
install_updates_on_quit: bool,
global_shortcut: String,
+ switcher_shortcuts: std::collections::BTreeMap,
codex_custom_sessions_dirs: Vec,
agent_sessions_enabled: bool,
+ stay_awake_enabled: bool,
agent_session_ssh_hosts: Vec,
hooks_enabled: bool,
http_proxy_enabled: bool,
@@ -716,12 +877,18 @@ pub struct SettingsSnapshot {
cost_summary_display_style: &'static str,
open_codex_usage_logs_enabled: bool,
hide_native_codex_cost_when_open_codex_present: bool,
+ /// History window as its persisted raw form (`rolling:N`,
+ /// `month-to-date`, `all`).
+ cost_reporting_period: String,
provider_accent_colors: std::collections::HashMap,
}
#[tauri::command]
pub fn get_bootstrap_state() -> BootstrapState {
- let settings = Settings::load();
+ bootstrap_state_for(Settings::load())
+}
+
+pub(crate) fn bootstrap_state_for(settings: Settings) -> BootstrapState {
BootstrapState {
contract_version: "v1",
providers: provider_catalog_for(&settings),
@@ -763,6 +930,7 @@ impl From for SettingsSnapshot {
.collect();
Self {
+ preferred_currency_code: settings.preferred_currency_code,
enabled_providers,
provider_order,
refresh_interval_secs: settings.refresh_interval_secs,
@@ -781,11 +949,16 @@ impl From for SettingsSnapshot {
critical_usage_threshold: settings.critical_usage_threshold,
provider_usage_thresholds: settings.provider_usage_thresholds,
predictive_pace_warning_enabled: settings.predictive_pace_warning_enabled,
+ credential_expiry_notifications_enabled: settings
+ .credential_expiry_notifications_enabled,
show_pace: settings.show_pace,
tray_icon_mode: tray_icon_mode_label(settings.tray_icon_mode),
+ stacked_tray_top_provider: settings.stacked_tray_top_provider,
+ stacked_tray_bottom_provider: settings.stacked_tray_bottom_provider,
switcher_shows_icons: settings.switcher_shows_icons,
menu_bar_shows_highest_usage: settings.menu_bar_shows_highest_usage,
menu_bar_shows_percent: settings.menu_bar_shows_percent,
+ menu_bar_color_pace: settings.menu_bar_color_pace,
show_as_used: settings.show_as_used,
show_all_token_accounts_in_menu: settings.show_all_token_accounts_in_menu,
enable_animations: settings.enable_animations,
@@ -797,9 +970,13 @@ impl From for SettingsSnapshot {
update_channel: update_channel_label(settings.update_channel),
auto_download_updates: settings.auto_download_updates,
install_updates_on_quit: settings.install_updates_on_quit,
+ switcher_shortcuts: codexbar::switcher_shortcuts::resolve_or_default(
+ &settings.switcher_shortcuts,
+ ),
global_shortcut: settings.global_shortcut,
codex_custom_sessions_dirs: settings.codex_custom_sessions_dirs,
agent_sessions_enabled: settings.agent_sessions_enabled,
+ stay_awake_enabled: settings.stay_awake_enabled,
agent_session_ssh_hosts: settings.agent_session_ssh_hosts,
hooks_enabled: settings.hooks_enabled,
http_proxy_enabled: settings.http_proxy_enabled,
@@ -842,6 +1019,7 @@ impl From for SettingsSnapshot {
open_codex_usage_logs_enabled: settings.open_codex_usage_logs_enabled,
hide_native_codex_cost_when_open_codex_present: settings
.hide_native_codex_cost_when_open_codex_present,
+ cost_reporting_period: settings.cost_reporting_period.raw(),
provider_accent_colors: settings
.provider_configs
.iter()
@@ -876,6 +1054,7 @@ fn tray_icon_mode_label(mode: TrayIconMode) -> &'static str {
match mode {
TrayIconMode::Single => "single",
TrayIconMode::PerProvider => "perProvider",
+ TrayIconMode::Stacked => "stacked",
}
}
@@ -980,6 +1159,8 @@ mod tests {
reserve_description: None,
reserve_will_last_to_reset: false,
reserve_eta_seconds: None,
+ monthly_limit_block: None,
+ description_is_detail: false,
}
}
@@ -1009,6 +1190,26 @@ mod tests {
);
}
+ #[test]
+ fn credit_balance_detail_crosses_the_bridge_and_is_not_a_reset_phrase() {
+ let rw = RateWindow::with_details(25.0, None, None, Some("750 / 1000 credits left".into()))
+ .with_description_as_detail();
+ let window = RateWindowSnapshot::from_rate_window(&rw);
+
+ assert!(window.description_is_detail);
+ assert_eq!(
+ window.reset_description.as_deref(),
+ Some("750 / 1000 credits left")
+ );
+ let json = serde_json::to_value(&window).unwrap();
+ assert_eq!(json["descriptionIsDetail"], true);
+ assert_eq!(compact_tray_status_label(&window, Language::English), "25%");
+
+ let plain = RateWindowSnapshot::from_rate_window(&RateWindow::new(10.0));
+ let json = serde_json::to_value(&plain).unwrap();
+ assert_eq!(json["descriptionIsDetail"], false);
+ }
+
#[test]
fn japanese_tray_status_label_has_no_english_reset_text() {
use codexbar::settings::Language;
diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/openai_usage.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/openai_usage.rs
new file mode 100644
index 0000000000..58c91192a6
--- /dev/null
+++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/openai_usage.rs
@@ -0,0 +1,103 @@
+//! Bridge DTOs for the OpenAI Admin API per-day usage history (`openAiApiUsage`).
+//!
+//! Mirrors `OpenAiApiUsageSnapshot` in `src/types/bridge.ts`. Times are epoch seconds and
+//! counts are non-negative integers, as in upstream 0.66.0's card payload.
+
+use codexbar::core::{
+ OpenAiApiDailyUsage, OpenAiApiLineItemCost, OpenAiApiModelUsage, OpenAiApiUsageHistory,
+};
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct OpenAiApiUsageSnapshot {
+ pub history_days: u32,
+ #[serde(default)]
+ pub project_id: Option,
+ #[serde(default)]
+ pub daily: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct OpenAiApiDailyUsageSnapshot {
+ pub start_time: i64,
+ pub end_time: i64,
+ pub cost_usd: f64,
+ pub requests: u64,
+ pub input_tokens: u64,
+ pub cached_input_tokens: u64,
+ pub output_tokens: u64,
+ pub total_tokens: u64,
+ #[serde(default)]
+ pub line_items: Vec,
+ #[serde(default)]
+ pub models: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct OpenAiApiLineItemSnapshot {
+ pub name: String,
+ pub cost_usd: f64,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct OpenAiApiModelUsageSnapshot {
+ pub name: String,
+ pub requests: u64,
+ pub input_tokens: u64,
+ pub cached_input_tokens: u64,
+ pub output_tokens: u64,
+ pub total_tokens: u64,
+}
+
+impl From<&OpenAiApiUsageHistory> for OpenAiApiUsageSnapshot {
+ fn from(history: &OpenAiApiUsageHistory) -> Self {
+ Self {
+ history_days: history.history_days,
+ project_id: history.project_id.clone(),
+ daily: history.daily.iter().map(Into::into).collect(),
+ }
+ }
+}
+
+impl From<&OpenAiApiDailyUsage> for OpenAiApiDailyUsageSnapshot {
+ fn from(day: &OpenAiApiDailyUsage) -> Self {
+ Self {
+ start_time: day.start_time,
+ end_time: day.end_time,
+ cost_usd: day.cost_usd,
+ requests: day.requests,
+ input_tokens: day.input_tokens,
+ cached_input_tokens: day.cached_input_tokens,
+ output_tokens: day.output_tokens,
+ total_tokens: day.total_tokens,
+ line_items: day.line_items.iter().map(Into::into).collect(),
+ models: day.models.iter().map(Into::into).collect(),
+ }
+ }
+}
+
+impl From<&OpenAiApiLineItemCost> for OpenAiApiLineItemSnapshot {
+ fn from(item: &OpenAiApiLineItemCost) -> Self {
+ Self {
+ name: item.name.clone(),
+ cost_usd: item.cost_usd,
+ }
+ }
+}
+
+impl From<&OpenAiApiModelUsage> for OpenAiApiModelUsageSnapshot {
+ fn from(model: &OpenAiApiModelUsage) -> Self {
+ Self {
+ name: model.name.clone(),
+ requests: model.requests,
+ input_tokens: model.input_tokens,
+ cached_input_tokens: model.cached_input_tokens,
+ output_tokens: model.output_tokens,
+ total_tokens: model.total_tokens,
+ }
+ }
+}
diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/openai_usage_tests.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/openai_usage_tests.rs
new file mode 100644
index 0000000000..5c0343c32f
--- /dev/null
+++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/openai_usage_tests.rs
@@ -0,0 +1,108 @@
+use super::openai_usage::OpenAiApiUsageSnapshot;
+use crate::commands::ProviderUsageSnapshot;
+use codexbar::core::{
+ OpenAiApiDailyUsage, OpenAiApiLineItemCost, OpenAiApiModelUsage, OpenAiApiUsageHistory,
+ ProviderFetchResult, ProviderId, RateWindow, UsageSnapshot, instantiate_provider,
+};
+
+fn history() -> OpenAiApiUsageHistory {
+ OpenAiApiUsageHistory {
+ history_days: 30,
+ project_id: Some("proj_abc".to_string()),
+ daily: vec![OpenAiApiDailyUsage {
+ start_time: 1_700_000_000,
+ end_time: 1_700_086_400,
+ cost_usd: 14.75,
+ requests: 10,
+ input_tokens: 1300,
+ cached_input_tokens: 250,
+ output_tokens: 700,
+ total_tokens: 2000,
+ line_items: vec![OpenAiApiLineItemCost {
+ name: "Text tokens".to_string(),
+ cost_usd: 12.5,
+ }],
+ models: vec![OpenAiApiModelUsage {
+ name: "gpt-5.2".to_string(),
+ requests: 7,
+ input_tokens: 1000,
+ cached_input_tokens: 250,
+ output_tokens: 500,
+ total_tokens: 1500,
+ }],
+ }],
+ }
+}
+
+fn snapshot(history: Option) -> ProviderUsageSnapshot {
+ let metadata = instantiate_provider(ProviderId::OpenAIApi)
+ .metadata()
+ .clone();
+ let mut result =
+ ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(0.0)), "admin-api");
+ result.open_ai_api_usage = history;
+ ProviderUsageSnapshot::from_fetch_result(ProviderId::OpenAIApi, &metadata, &result, None)
+}
+
+#[test]
+fn fetch_result_history_reaches_the_bridge_as_camel_case_epoch_seconds() {
+ let json = serde_json::to_value(snapshot(Some(history()))).unwrap();
+ assert_eq!(
+ json["openAiApiUsage"],
+ serde_json::json!({
+ "historyDays": 30,
+ "projectId": "proj_abc",
+ "daily": [{
+ "startTime": 1_700_000_000,
+ "endTime": 1_700_086_400,
+ "costUsd": 14.75,
+ "requests": 10,
+ "inputTokens": 1300,
+ "cachedInputTokens": 250,
+ "outputTokens": 700,
+ "totalTokens": 2000,
+ "lineItems": [{"name": "Text tokens", "costUsd": 12.5}],
+ "models": [{
+ "name": "gpt-5.2",
+ "requests": 7,
+ "inputTokens": 1000,
+ "cachedInputTokens": 250,
+ "outputTokens": 500,
+ "totalTokens": 1500,
+ }],
+ }],
+ })
+ );
+}
+
+#[test]
+fn missing_history_is_omitted_from_the_bridge_payload() {
+ let json = serde_json::to_value(snapshot(None)).unwrap();
+ assert!(json.get("openAiApiUsage").is_none());
+}
+
+#[test]
+fn history_round_trips_through_the_bridge_payload() {
+ let original = snapshot(Some(history()));
+ let decoded: ProviderUsageSnapshot =
+ serde_json::from_value(serde_json::to_value(&original).unwrap()).unwrap();
+ assert_eq!(decoded.open_ai_api_usage, original.open_ai_api_usage);
+ assert_eq!(
+ decoded.open_ai_api_usage.unwrap().project_id.as_deref(),
+ Some("proj_abc")
+ );
+}
+
+#[test]
+fn an_empty_history_serializes_a_null_project_and_no_days() {
+ let empty = OpenAiApiUsageHistory {
+ history_days: 30,
+ project_id: None,
+ daily: Vec::new(),
+ };
+ let json = serde_json::to_value(OpenAiApiUsageSnapshot::from(&empty)).unwrap();
+ assert_eq!(
+ json,
+ serde_json::json!({"historyDays": 30, "projectId": null, "daily": []})
+ );
+}
diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/quota_block.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/quota_block.rs
new file mode 100644
index 0000000000..91865dfc23
--- /dev/null
+++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/quota_block.rs
@@ -0,0 +1,155 @@
+//! Bridge shape of a longer exhausted quota that blocks a shorter window
+//! (upstream 0.69.0 #4091, Kimi's monthly membership pool).
+//!
+//! Upstream evaluates `menuCard.blockingQuota` whenever it builds the menu
+//! card, so a blocked row recovers as soon as the pool resets. Bridge
+//! snapshots outlive their fetch (the provider cache keeps the last good
+//! snapshot through failed refreshes), so each blocked window carries the
+//! pool's reset and the frontend re-checks it at render time.
+
+use super::*;
+
+/// Set on a window while a longer exhausted pool blocks it. Presentation only:
+/// the window's raw percentages stay the provider's data.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct MonthlyLimitBlockSnapshot {
+ /// RFC 3339 reset of the exhausted pool; the block lifts then. `None` when
+ /// the pool's reset is unknown, so the block holds until a refresh.
+ #[serde(default)]
+ pub resets_at: Option,
+}
+
+impl MonthlyLimitBlockSnapshot {
+ pub(super) fn for_window(blocked: bool, blocks: &BlockedWindows) -> Option {
+ blocked.then(|| Self {
+ resets_at: blocks.resets_at.map(|reset| reset.to_rfc3339()),
+ })
+ }
+
+ /// Block of the window the provider-level pace derives from: the weekly
+ /// lane when the primary is an informational stand-in, else the primary.
+ pub(super) fn for_pace(
+ usage: &codexbar::core::UsageSnapshot,
+ blocks: &BlockedWindows,
+ ) -> Option {
+ let blocked = if usage.primary.is_informational {
+ blocks.secondary
+ } else {
+ blocks.primary
+ };
+ Self::for_window(blocked, blocks)
+ }
+}
+
+impl RateWindowSnapshot {
+ pub(super) fn with_quota_block(mut self, blocked: bool, blocks: &BlockedWindows) -> Self {
+ self.monthly_limit_block = MonthlyLimitBlockSnapshot::for_window(blocked, blocks);
+ self
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use chrono::{Duration, Utc};
+ use codexbar::core::UsageSnapshot;
+
+ fn monthly_pool(reset: Option>) -> RateWindow {
+ RateWindow::with_details(100.0, Some(43_200), reset, None)
+ }
+
+ fn bridge_snapshot(id: ProviderId, usage: UsageSnapshot) -> ProviderUsageSnapshot {
+ let result = ProviderFetchResult::new(usage, "web");
+ let metadata = instantiate_provider(id).metadata().clone();
+ ProviderUsageSnapshot::from_fetch_result(id, &metadata, &result, None)
+ }
+
+ #[test]
+ fn exhausted_kimi_monthly_pool_marks_shorter_windows_without_changing_raw_usage() {
+ let reset = Utc::now() + Duration::days(20);
+ let primary = RateWindow::with_details(
+ 0.0,
+ Some(10_080),
+ Some(Utc::now() + Duration::days(3)),
+ None,
+ );
+ let usage = UsageSnapshot::new(primary).with_extra_rate_window(
+ "kimi-monthly",
+ "Total usage",
+ monthly_pool(Some(reset)),
+ );
+
+ let snapshot = bridge_snapshot(ProviderId::Kimi, usage.clone());
+ let block = MonthlyLimitBlockSnapshot {
+ resets_at: Some(reset.to_rfc3339()),
+ };
+ assert_eq!(snapshot.primary.monthly_limit_block, Some(block.clone()));
+ assert_eq!(snapshot.primary.used_percent, 0.0);
+ assert_eq!(
+ snapshot.extra_rate_windows[0].window.monthly_limit_block,
+ None
+ );
+ // Upstream drops the blocked metric's pace forecast; the provider-level
+ // pace comes from the primary here, so it carries the same block.
+ let pace = snapshot.pace.as_ref().expect("primary pace");
+ assert_eq!(pace.monthly_limit_block, Some(block));
+
+ let json = serde_json::to_value(&snapshot).unwrap();
+ assert_eq!(
+ json["primary"]["monthlyLimitBlock"]["resetsAt"],
+ reset.to_rfc3339()
+ );
+ assert!(json["extraRateWindows"][0]["window"]["monthlyLimitBlock"].is_null());
+ assert!(json["pace"]["monthlyLimitBlock"].is_object());
+
+ // Other providers never derive a blocker from a same-named window.
+ let snapshot = bridge_snapshot(ProviderId::Codex, usage);
+ assert_eq!(snapshot.primary.monthly_limit_block, None);
+ assert_eq!(snapshot.pace.as_ref().unwrap().monthly_limit_block, None);
+ }
+
+ #[test]
+ fn unknown_monthly_reset_blocks_without_a_lift_time() {
+ let usage = UsageSnapshot::new(RateWindow::with_details(0.0, Some(10_080), None, None))
+ .with_extra_rate_window("kimi-monthly", "Total usage", monthly_pool(None));
+
+ let snapshot = bridge_snapshot(ProviderId::Kimi, usage);
+ assert_eq!(
+ snapshot.primary.monthly_limit_block,
+ Some(MonthlyLimitBlockSnapshot { resets_at: None })
+ );
+ let json = serde_json::to_value(&snapshot).unwrap();
+ assert!(json["primary"]["monthlyLimitBlock"].is_object());
+ assert!(json["primary"]["monthlyLimitBlock"]["resetsAt"].is_null());
+ }
+
+ #[test]
+ fn informational_primary_takes_the_pace_block_from_the_weekly_lane() {
+ let reset = Utc::now() + Duration::days(20);
+ let mut usage = UsageSnapshot::new(RateWindow::informational("No active session"))
+ .with_extra_rate_window("kimi-monthly", "Total usage", monthly_pool(Some(reset)));
+ usage.secondary = Some(RateWindow::with_details(
+ 10.0,
+ Some(10_080),
+ Some(Utc::now() + Duration::days(3)),
+ None,
+ ));
+
+ let snapshot = bridge_snapshot(ProviderId::Kimi, usage);
+ assert_eq!(snapshot.primary.monthly_limit_block, None);
+ assert!(snapshot.secondary.unwrap().monthly_limit_block.is_some());
+ assert!(snapshot.pace.unwrap().monthly_limit_block.is_some());
+ }
+
+ #[test]
+ fn snapshots_without_the_field_deserialize_unblocked() {
+ let window: RateWindowSnapshot =
+ serde_json::from_value(serde_json::json!({ "usedPercent": 40.0 })).unwrap();
+ assert_eq!(window.monthly_limit_block, None);
+ let pace: PaceSnapshot =
+ serde_json::from_value(serde_json::json!({ "stage": "on_track", "deltaPercent": 0.0 }))
+ .unwrap();
+ assert_eq!(pace.monthly_limit_block, None);
+ }
+}
diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/status.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/status.rs
index 6498eec07d..04bbb1ac48 100644
--- a/apps/desktop-tauri/src-tauri/src/commands/bridge/status.rs
+++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/status.rs
@@ -32,6 +32,10 @@ fn compact_reset_description(
return Some(format_compact_reset_countdown(dt, lang));
}
+ if window.description_is_detail {
+ return None;
+ }
+
window
.reset_description
.as_deref()
@@ -128,3 +132,44 @@ pub(crate) fn friendly_provider_error(id: ProviderId, error: &str) -> String {
trimmed.to_string()
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use codexbar::core::RateWindow;
+ use codexbar::settings::Language;
+
+ #[test]
+ fn detail_backed_description_is_never_a_tray_reset_label() {
+ let window = RateWindowSnapshot::from_rate_window(
+ &RateWindow::with_details(
+ 13.0,
+ None,
+ None,
+ Some("34.07 EUR / 255.00 EUR · 220.93 EUR remaining".to_string()),
+ )
+ .with_description_as_detail(),
+ );
+
+ assert!(window.description_is_detail);
+ assert_eq!(compact_tray_status_label(&window, Language::English), "13%");
+ }
+
+ #[test]
+ fn detail_backed_window_keeps_its_countdown_when_reset_is_known() {
+ let window = RateWindowSnapshot::from_rate_window(
+ &RateWindow::with_details(
+ 13.0,
+ None,
+ Some(chrono::Utc::now() + chrono::Duration::minutes(125)),
+ Some("34.07 EUR / 255.00 EUR · 220.93 EUR remaining".to_string()),
+ )
+ .with_description_as_detail(),
+ );
+
+ let label = compact_tray_status_label(&window, Language::English);
+
+ assert!(label.starts_with("13% • "), "{label}");
+ assert!(!label.contains("EUR"), "{label}");
+ }
+}
diff --git a/apps/desktop-tauri/src-tauri/src/commands/browser_import.rs b/apps/desktop-tauri/src-tauri/src/commands/browser_import.rs
index 40d8ff79a8..8ea322b7c7 100644
--- a/apps/desktop-tauri/src-tauri/src/commands/browser_import.rs
+++ b/apps/desktop-tauri/src-tauri/src/commands/browser_import.rs
@@ -23,7 +23,7 @@ pub fn list_detected_browsers() -> Vec {
BrowserDetector::detect_all()
.into_iter()
.map(|b| DetectedBrowserBridge {
- browser_type: browser_type_key(b.browser_type).to_string(),
+ browser_type: b.browser_type.key().to_string(),
display_name: b.browser_type.display_name().to_string(),
profile_count: b.profiles.len(),
})
@@ -62,7 +62,7 @@ pub fn import_browser_cookies(
let browsers = BrowserDetector::detect_all();
let browser = browsers
.into_iter()
- .find(|b| browser_type_key(b.browser_type) == browser_type.as_str())
+ .find(|b| b.browser_type.key() == browser_type.as_str())
.ok_or_else(|| format!("Browser '{browser_type}' not found or not installed"))?;
// Extract the cookie header.
@@ -86,16 +86,3 @@ pub fn import_browser_cookies(
Ok(get_manual_cookies())
}
-
-/// Map `BrowserType` to a stable lowercase string key used in the IPC bridge.
-fn browser_type_key(bt: codexbar::browser::detection::BrowserType) -> &'static str {
- use codexbar::browser::detection::BrowserType;
- match bt {
- BrowserType::Chrome => "chrome",
- BrowserType::Edge => "edge",
- BrowserType::Brave => "brave",
- BrowserType::Arc => "arc",
- BrowserType::Firefox => "firefox",
- BrowserType::Chromium => "chromium",
- }
-}
diff --git a/apps/desktop-tauri/src-tauri/src/commands/chart.rs b/apps/desktop-tauri/src-tauri/src/commands/chart.rs
index 4561dd9129..f40702b7e7 100644
--- a/apps/desktop-tauri/src-tauri/src/commands/chart.rs
+++ b/apps/desktop-tauri/src-tauri/src/commands/chart.rs
@@ -9,6 +9,7 @@ use crate::commands::bridge::RateWindowSnapshot;
use crate::state::AppState;
use chrono::{DateTime, Utc};
use codexbar::core::{OpenAIDashboardCacheStore, RateWindow};
+use codexbar::cost_reporting_period::{CostReportingPeriod, CostTimeZone};
use codexbar::cost_scanner::{
CostScanner, CostSummary, get_daily_cost_history, get_daily_token_history,
};
@@ -30,6 +31,10 @@ const LOCAL_USAGE_TTL: Duration = Duration::from_secs(30);
pub struct DailyCostPoint {
pub date: String,
pub value: Option,
+ /// Claude requests that only produced a preliminary proxy usage row that
+ /// day (upstream 0.60.5 #3688). Omitted when zero or not applicable.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub incomplete_request_count: Option,
}
/// A single (date, tokens) point for the Tokens chart mode (upstream 0.50.0
@@ -63,12 +68,26 @@ pub struct DailyUsageBreakdown {
#[serde(rename_all = "camelCase")]
pub struct ProviderLocalUsageSummary {
pub today_cost: Option,
+ /// Always the trailing 30 days (the published PowerToys pipe reads it).
pub thirty_day_cost: Option,
pub thirty_day_tokens: Option,
+ /// Cost over the selected History window (`reporting_period`).
+ #[serde(default)]
+ pub period_cost: Option,
+ /// Tokens over the selected History window.
+ #[serde(default)]
+ pub period_tokens: Option,
+ /// Raw reporting period the `period_*` fields cover; empty when unknown.
+ #[serde(default)]
+ pub reporting_period: String,
pub latest_tokens: Option,
pub top_model: Option,
pub estimate_note: String,
pub token_cost_updated_at_ms: i64,
+ /// Incomplete Claude requests excluded from the selected-period totals
+ /// (`period_cost` / `period_tokens`). Omitted when zero or not applicable.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub incomplete_request_count: Option,
}
/// One display-only quota-window history row. Completeness is tracked per
@@ -186,7 +205,18 @@ fn build_provider_chart_data_with_cancel(
.daily_cost
.iter()
.cloned()
- .map(|(date, value)| DailyCostPoint { date, value })
+ .map(|(date, value)| {
+ let incomplete_request_count = snapshot
+ .daily_incomplete
+ .iter()
+ .find(|(day, _)| *day == date)
+ .map(|(_, count)| *count);
+ DailyCostPoint {
+ date,
+ value,
+ incomplete_request_count,
+ }
+ })
.collect();
let tokens_history = snapshot
.daily_tokens
@@ -194,10 +224,17 @@ fn build_provider_chart_data_with_cancel(
.cloned()
.map(|(date, tokens)| DailyTokenPoint { date, tokens })
.collect();
- let local_usage = snapshot
- .local_summary
- .as_ref()
- .and_then(|summary| local_usage_summary_from_cost_summary(&provider_id, summary));
+ let period = current_reporting_period();
+ let local_usage = snapshot.local_summary.as_ref().and_then(|summary| {
+ let period_summary =
+ period_summary_for(&provider_id, period, summary, cancel.as_deref());
+ local_usage_summary_from_cost_summary(
+ &provider_id,
+ summary,
+ period,
+ period_summary.as_ref(),
+ )
+ });
store_local_usage_summary(&provider_id, local_usage.clone());
(
cost_history,
@@ -221,7 +258,14 @@ fn build_provider_chart_data_with_cancel(
tokens: day.total_tokens,
})
.collect();
- let local_usage = muse_local_usage_summary(&report, locale::current_language());
+ let period = current_reporting_period();
+ let period_report = muse_period_report(period, &report, cancel.as_deref());
+ let local_usage = muse_local_usage_summary(
+ &report,
+ &period_report,
+ period,
+ locale::current_language(),
+ );
(
Vec::new(),
tokens_history,
@@ -233,7 +277,11 @@ fn build_provider_chart_data_with_cancel(
let raw_cost = get_daily_cost_history(&provider_id, 30);
let cost_history: Vec = raw_cost
.into_iter()
- .map(|(date, value)| DailyCostPoint { date, value })
+ .map(|(date, value)| DailyCostPoint {
+ date,
+ value,
+ incomplete_request_count: None,
+ })
.collect();
let (raw_tokens, tokens_incomplete) = get_daily_token_history(&provider_id, 30);
@@ -331,20 +379,50 @@ fn map_quota_window_history(
}
}
+/// The History window the desktop is configured to report.
+fn current_reporting_period() -> CostReportingPeriod {
+ codexbar::settings::Settings::load().cost_reporting_period
+}
+
+/// The period summary for `provider_id`, reusing the 30-day scan when the
+/// selected window is exactly the trailing 30 days.
+fn period_summary_for(
+ provider_id: &str,
+ period: CostReportingPeriod,
+ thirty_day: &CostSummary,
+ cancel: Option<&AtomicBool>,
+) -> Option {
+ if period == CostReportingPeriod::Rolling(30) {
+ return Some(thirty_day.clone());
+ }
+ scan_local_cost(provider_id, period, cancel)
+}
+
fn local_usage_summary_from_cost_summary(
provider_id: &str,
summary: &CostSummary,
+ period: CostReportingPeriod,
+ period_summary: Option<&CostSummary>,
) -> Option {
- let total_tokens = total_tokens(summary);
- let has_usage = summary.sessions_count > 0 || summary.total_cost_usd > 0.0 || total_tokens > 0;
+ let thirty_tokens = total_tokens(provider_id, summary);
+ let has_usage = summary.sessions_count > 0
+ || summary.total_cost_usd > 0.0
+ || thirty_tokens > 0
+ || summary.incomplete_request_count > 0;
has_usage.then(|| ProviderLocalUsageSummary {
today_cost: None,
thirty_day_cost: non_zero_f64(summary.total_cost_usd),
- thirty_day_tokens: non_zero_u64(total_tokens),
+ thirty_day_tokens: non_zero_u64(thirty_tokens),
+ period_cost: period_summary.and_then(|s| non_zero_f64(s.total_cost_usd)),
+ period_tokens: period_summary.and_then(|s| non_zero_u64(total_tokens(provider_id, s))),
+ reporting_period: period.raw(),
latest_tokens: None,
- top_model: top_model(summary),
+ top_model: top_model(provider_id, summary),
estimate_note: localized_estimate_note(provider_id, locale::current_language()),
token_cost_updated_at_ms: current_unix_ms(),
+ // The note sits under the selected-period totals, so count that window.
+ incomplete_request_count: period_summary
+ .and_then(|s| non_zero_u32(s.incomplete_request_count)),
})
}
@@ -388,32 +466,42 @@ fn load_local_usage_summary_with_unknown_models(
provider_id: &str,
cancel: Option<&AtomicBool>,
) -> (Option, HashSet) {
+ let period = current_reporting_period();
if provider_id == "muse" {
let summary = if cancel.is_some_and(|flag| flag.load(Ordering::Relaxed)) {
None
} else {
let report = muse_local_usage::scan(30, cancel);
- muse_local_usage_summary(&report, locale::current_language())
+ let period_report = muse_period_report(period, &report, cancel);
+ muse_local_usage_summary(&report, &period_report, period, locale::current_language())
};
return (summary, HashSet::new());
}
- let Some(thirty_day) = scan_local_cost(provider_id, 30, cancel) else {
+ let Some(thirty_day) = scan_local_cost(provider_id, CostReportingPeriod::Rolling(30), cancel)
+ else {
return (None, HashSet::new());
};
if cancel.is_some_and(|flag| flag.load(Ordering::Relaxed)) {
return (None, HashSet::new());
}
- let today = scan_local_cost(provider_id, 1, cancel).unwrap_or_default();
- let unknown_models = thirty_day
+ let today =
+ scan_local_cost(provider_id, CostReportingPeriod::Rolling(1), cancel).unwrap_or_default();
+ let period_summary = period_summary_for(provider_id, period, &thirty_day, cancel);
+ let mut unknown_models: HashSet = thirty_day
.unknown_models
.union(&today.unknown_models)
.cloned()
.collect();
+ if let Some(period_summary) = period_summary.as_ref() {
+ unknown_models.extend(period_summary.unknown_models.iter().cloned());
+ }
- let thirty_day_tokens = total_tokens(&thirty_day);
- let latest_tokens = total_tokens(&today);
- let has_usage =
- thirty_day.sessions_count > 0 || thirty_day.total_cost_usd > 0.0 || thirty_day_tokens > 0;
+ let thirty_day_tokens = total_tokens(provider_id, &thirty_day);
+ let latest_tokens = total_tokens(provider_id, &today);
+ let has_usage = thirty_day.sessions_count > 0
+ || thirty_day.total_cost_usd > 0.0
+ || thirty_day_tokens > 0
+ || thirty_day.incomplete_request_count > 0;
if !has_usage {
return (None, unknown_models);
}
@@ -424,17 +512,42 @@ fn load_local_usage_summary_with_unknown_models(
today_cost: non_zero_f64(today.total_cost_usd),
thirty_day_cost: non_zero_f64(thirty_day.total_cost_usd),
thirty_day_tokens: non_zero_u64(thirty_day_tokens),
+ period_cost: period_summary
+ .as_ref()
+ .and_then(|s| non_zero_f64(s.total_cost_usd)),
+ period_tokens: period_summary
+ .as_ref()
+ .and_then(|s| non_zero_u64(total_tokens(provider_id, s))),
+ reporting_period: period.raw(),
latest_tokens: non_zero_u64(latest_tokens),
- top_model: top_model(&thirty_day),
+ top_model: top_model(provider_id, &thirty_day),
estimate_note: localized_estimate_note(provider_id, lang),
token_cost_updated_at_ms: current_unix_ms(),
+ incomplete_request_count: period_summary
+ .as_ref()
+ .and_then(|s| non_zero_u32(s.incomplete_request_count)),
}),
unknown_models,
)
}
+/// The Muse report for the selected period, reusing the 30-day scan when the
+/// selection is exactly the trailing 30 days.
+fn muse_period_report(
+ period: CostReportingPeriod,
+ thirty_day: &muse_local_usage::Report,
+ cancel: Option<&AtomicBool>,
+) -> muse_local_usage::Report {
+ if period == CostReportingPeriod::Rolling(30) {
+ return thirty_day.clone();
+ }
+ muse_local_usage::scan(period.scan_days(Utc::now()), cancel)
+}
+
fn muse_local_usage_summary(
report: &muse_local_usage::Report,
+ period_report: &muse_local_usage::Report,
+ period: CostReportingPeriod,
lang: codexbar::settings::Language,
) -> Option {
if !report.is_available() || !report.is_complete() {
@@ -445,10 +558,17 @@ fn muse_local_usage_summary(
today_cost: None,
thirty_day_cost: None,
thirty_day_tokens: Some(total_tokens),
+ period_cost: None,
+ period_tokens: period_report
+ .is_complete()
+ .then_some(period_report.total_tokens)
+ .flatten(),
+ reporting_period: period.raw(),
latest_tokens: report.today_tokens,
top_model: report.top_model.clone(),
estimate_note: locale::get_text(lang, LocaleKey::PanelEstimatedFromLocalLogsMuse),
token_cost_updated_at_ms: current_unix_ms(),
+ incomplete_request_count: None,
})
}
@@ -460,9 +580,24 @@ pub(crate) fn load_provider_local_usage_summary(
struct CachedLocalUsage {
loaded_at: Instant,
+ /// `CostReportingPeriod::identity` the entry was built for; a period or
+ /// month change makes the entry stale before its TTL.
+ period_identity: String,
summary: Option,
}
+fn local_usage_period_identity() -> String {
+ current_reporting_period().identity(Utc::now(), CostTimeZone::Local)
+}
+
+/// Cache a completed scan under the reporting window it actually used. The
+/// summary timestamp preserves the month for month-to-date identities.
+fn local_usage_summary_period_identity(summary: &ProviderLocalUsageSummary) -> Option {
+ let period = CostReportingPeriod::parse(&summary.reporting_period)?;
+ let scanned_at = DateTime::::from_timestamp_millis(summary.token_cost_updated_at_ms)?;
+ Some(period.identity(scanned_at, CostTimeZone::Local))
+}
+
fn local_usage_cache() -> &'static Mutex> {
static CACHE: OnceLock>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
@@ -547,8 +682,10 @@ fn load_local_usage_summary_cached(
cancel: Option<&AtomicBool>,
) -> Option {
let cache = local_usage_cache();
+ let period_identity = local_usage_period_identity();
if let Ok(guard) = cache.lock()
&& let Some(entry) = guard.get(provider_id)
+ && entry.period_identity == period_identity
&& token_cost_cache_is_fresh(Some(entry.loaded_at), Instant::now(), LOCAL_USAGE_TTL)
{
return entry.summary.clone();
@@ -568,11 +705,22 @@ fn load_local_usage_summary_cached(
}
fn store_local_usage_summary(provider_id: &str, summary: Option) {
+ let period_identity = match summary.as_ref() {
+ Some(summary) => {
+ let Some(period_identity) = local_usage_summary_period_identity(summary) else {
+ tracing::warn!("Skipping local usage cache entry with invalid period metadata");
+ return;
+ };
+ period_identity
+ }
+ None => local_usage_period_identity(),
+ };
if let Ok(mut guard) = local_usage_cache().lock() {
guard.insert(
provider_id.to_string(),
CachedLocalUsage {
loaded_at: Instant::now(),
+ period_identity,
summary,
},
);
@@ -585,11 +733,13 @@ fn record_local_usage_fetch_failure(provider_id: &str, failure: CostFetchFailure
} else {
Instant::now()
};
+ let period_identity = local_usage_period_identity();
if let Ok(mut guard) = local_usage_cache().lock() {
guard.insert(
provider_id.to_string(),
CachedLocalUsage {
loaded_at,
+ period_identity,
summary: None,
},
);
@@ -637,10 +787,10 @@ fn localized_estimate_note(provider_id: &str, lang: codexbar::settings::Language
fn scan_local_cost(
provider_id: &str,
- days: u32,
+ period: CostReportingPeriod,
cancel: Option<&AtomicBool>,
) -> Option {
- let scanner = CostScanner::new(days);
+ let scanner = CostScanner::for_period(period);
match provider_id {
"codex" => Some(scanner.scan_codex_with_cancel(cancel)),
"claude" => Some(scanner.scan_claude_with_cancel(cancel)),
@@ -650,8 +800,8 @@ fn scan_local_cost(
}
}
-fn total_tokens(summary: &CostSummary) -> u64 {
- summary.input_tokens + summary.output_tokens
+fn total_tokens(provider_id: &str, summary: &CostSummary) -> u64 {
+ summary.total_tokens_for_provider(provider_id)
}
fn non_zero_f64(value: f64) -> Option {
@@ -662,11 +812,15 @@ fn non_zero_u64(value: u64) -> Option {
(value > 0).then_some(value)
}
-fn top_model(summary: &CostSummary) -> Option {
+fn non_zero_u32(value: u32) -> Option {
+ (value > 0).then_some(value)
+}
+
+fn top_model(provider_id: &str, summary: &CostSummary) -> Option {
summary
.by_model_tokens
.iter()
- .max_by_key(|(_, counts)| counts.total())
+ .max_by_key(|(_, counts)| counts.total_for_provider(provider_id))
.map(|(model, _)| model.clone())
.or_else(|| {
summary
@@ -712,6 +866,7 @@ fn load_openai_dashboard_chart_data(
.map(|d| DailyCostPoint {
date: d.day.clone(),
value: Some(d.total_credits_used),
+ incomplete_request_count: None,
})
.collect();
@@ -744,120 +899,4 @@ pub(crate) fn load_openai_dashboard_chart_data_for_test(
}
#[cfg(test)]
-mod tests {
- use super::{
- CostFetchFailure, ProviderLocalUsageSummary, cost_fetch_failure_allows_early_retry,
- localized_estimate_note, muse_local_usage_summary, token_cost_cache_is_fresh,
- };
- use crate::commands::is_provider_cache_fresh;
- use codexbar::providers::muse::local_usage::{DailyUsage, Report};
- use codexbar::settings::Language;
- use codexbar::spend_contract::LocalHistoryCoverage;
- use std::time::{Duration, Instant};
-
- #[test]
- fn token_cost_age_does_not_use_provider_quota_age() {
- let now = Instant::now();
- let token_loaded = now - Duration::from_secs(31);
- let provider_updated = now;
- assert!(!token_cost_cache_is_fresh(
- Some(token_loaded),
- now,
- Duration::from_secs(30)
- ));
- assert!(is_provider_cache_fresh(
- Some(provider_updated),
- Duration::from_secs(30)
- ));
- }
-
- #[test]
- fn fast_cost_failures_allow_the_next_pass_to_retry() {
- assert!(cost_fetch_failure_allows_early_retry(
- CostFetchFailure::Failed
- ));
- assert!(!cost_fetch_failure_allows_early_retry(
- CostFetchFailure::TimedOut
- ));
- }
-
- #[test]
- fn local_usage_summary_serializes_token_cost_timestamp() {
- let summary = ProviderLocalUsageSummary {
- today_cost: Some(1.0),
- thirty_day_cost: Some(2.0),
- thirty_day_tokens: Some(300),
- latest_tokens: Some(40),
- top_model: Some("gpt-5".to_string()),
- estimate_note: "estimated".to_string(),
- token_cost_updated_at_ms: 1234,
- };
-
- let json = serde_json::to_value(summary).expect("serialize summary");
- assert_eq!(
- json.get("tokenCostUpdatedAtMs").and_then(|v| v.as_i64()),
- Some(1234)
- );
- }
-
- #[test]
- fn muse_local_usage_summary_exposes_complete_tokens_without_cost() {
- let report = Report {
- daily: vec![DailyUsage {
- day: "2026-09-20".to_string(),
- input_tokens: 10,
- output_tokens: 2,
- cache_read_tokens: 0,
- cache_write_tokens: 0,
- reasoning_tokens: 0,
- total_tokens: 12,
- request_count: 1,
- models: vec![("muse-spark-1.3".to_string(), 12)],
- }],
- total_tokens: Some(12),
- today_tokens: Some(12),
- session_count: 1,
- top_model: Some("muse-spark-1.3".to_string()),
- coverage: LocalHistoryCoverage::Complete,
- };
- let summary = muse_local_usage_summary(&report, codexbar::settings::Language::default())
- .expect("complete history is visible");
- assert_eq!(summary.today_cost, None);
- assert_eq!(summary.thirty_day_cost, None);
- assert_eq!(summary.thirty_day_tokens, Some(12));
- assert_eq!(summary.latest_tokens, Some(12));
- assert_eq!(summary.top_model.as_deref(), Some("muse-spark-1.3"));
-
- let partial = Report {
- coverage: LocalHistoryCoverage::Partial,
- ..report
- };
- assert!(
- muse_local_usage_summary(&partial, codexbar::settings::Language::default()).is_none()
- );
- }
-
- #[test]
- fn japanese_estimate_note_is_localized() {
- assert_eq!(
- localized_estimate_note("codex", Language::Japanese),
- "ローカルログから推定したもので、請求書と異なる場合があります"
- );
- assert_eq!(
- localized_estimate_note("claude", Language::Japanese),
- "ClaudeのローカルログからAPIレートで推定したもので、トークン総数が請求書と異なる場合があります"
- );
- }
-
- #[test]
- fn english_estimate_note_is_localized() {
- assert_eq!(
- localized_estimate_note("codex", Language::English),
- "Estimated from local logs; may differ from your bill"
- );
- assert_eq!(
- localized_estimate_note("claude", Language::English),
- "Estimated from local Claude logs at API rates; token totals may differ from your bill"
- );
- }
-}
+mod tests;
diff --git a/apps/desktop-tauri/src-tauri/src/commands/chart/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/chart/tests.rs
new file mode 100644
index 0000000000..3b53f45b40
--- /dev/null
+++ b/apps/desktop-tauri/src-tauri/src/commands/chart/tests.rs
@@ -0,0 +1,189 @@
+use super::{
+ CostFetchFailure, ProviderLocalUsageSummary, cost_fetch_failure_allows_early_retry,
+ local_usage_summary_from_cost_summary, local_usage_summary_period_identity,
+ localized_estimate_note, muse_local_usage_summary, token_cost_cache_is_fresh,
+};
+use crate::commands::is_provider_cache_fresh;
+use codexbar::cost_reporting_period::{CostReportingPeriod, CostTimeZone};
+use codexbar::cost_scanner::CostSummary;
+use codexbar::providers::muse::local_usage::{DailyUsage, Report};
+use codexbar::settings::Language;
+use codexbar::spend_contract::LocalHistoryCoverage;
+use std::time::{Duration, Instant};
+
+#[test]
+fn token_cost_age_does_not_use_provider_quota_age() {
+ let now = Instant::now();
+ let token_loaded = now - Duration::from_secs(31);
+ let provider_updated = now;
+ assert!(!token_cost_cache_is_fresh(
+ Some(token_loaded),
+ now,
+ Duration::from_secs(30)
+ ));
+ assert!(is_provider_cache_fresh(
+ Some(provider_updated),
+ Duration::from_secs(30)
+ ));
+}
+
+#[test]
+fn fast_cost_failures_allow_the_next_pass_to_retry() {
+ assert!(cost_fetch_failure_allows_early_retry(
+ CostFetchFailure::Failed
+ ));
+ assert!(!cost_fetch_failure_allows_early_retry(
+ CostFetchFailure::TimedOut
+ ));
+}
+
+#[test]
+fn local_usage_summary_serializes_token_cost_timestamp() {
+ let summary = ProviderLocalUsageSummary {
+ today_cost: Some(1.0),
+ thirty_day_cost: Some(2.0),
+ thirty_day_tokens: Some(300),
+ period_cost: Some(3.0),
+ period_tokens: Some(400),
+ reporting_period: "month-to-date".to_string(),
+ latest_tokens: Some(40),
+ top_model: Some("gpt-5".to_string()),
+ estimate_note: "estimated".to_string(),
+ token_cost_updated_at_ms: 1234,
+ incomplete_request_count: None,
+ };
+
+ let json = serde_json::to_value(summary).expect("serialize summary");
+ assert_eq!(
+ json.get("tokenCostUpdatedAtMs").and_then(|v| v.as_i64()),
+ Some(1234)
+ );
+}
+
+#[test]
+fn local_usage_cache_identity_uses_the_summary_period_and_timestamp() {
+ let scanned_at = chrono::DateTime::parse_from_rfc3339("2026-09-30T23:00:00Z")
+ .expect("valid timestamp")
+ .with_timezone(&chrono::Utc);
+ let summary = ProviderLocalUsageSummary {
+ today_cost: None,
+ thirty_day_cost: None,
+ thirty_day_tokens: None,
+ period_cost: None,
+ period_tokens: None,
+ reporting_period: "month-to-date".to_string(),
+ latest_tokens: None,
+ top_model: None,
+ estimate_note: String::new(),
+ token_cost_updated_at_ms: scanned_at.timestamp_millis(),
+ incomplete_request_count: None,
+ };
+
+ assert_eq!(
+ local_usage_summary_period_identity(&summary),
+ Some(CostReportingPeriod::MonthToDate.identity(scanned_at, CostTimeZone::Local))
+ );
+}
+
+#[test]
+fn muse_local_usage_summary_exposes_complete_tokens_without_cost() {
+ let report = Report {
+ daily: vec![DailyUsage {
+ day: "2026-09-20".to_string(),
+ input_tokens: 10,
+ output_tokens: 2,
+ cache_read_tokens: 0,
+ cache_write_tokens: 0,
+ reasoning_tokens: 0,
+ total_tokens: 12,
+ request_count: 1,
+ models: vec![("muse-spark-1.3".to_string(), 12)],
+ }],
+ total_tokens: Some(12),
+ today_tokens: Some(12),
+ session_count: 1,
+ top_model: Some("muse-spark-1.3".to_string()),
+ coverage: LocalHistoryCoverage::Complete,
+ };
+ let period = CostReportingPeriod::Rolling(30);
+ let summary = muse_local_usage_summary(
+ &report,
+ &report,
+ period,
+ codexbar::settings::Language::default(),
+ )
+ .expect("complete history is visible");
+ assert_eq!(summary.period_tokens, Some(12));
+ assert_eq!(summary.reporting_period, "rolling:30");
+ assert_eq!(summary.today_cost, None);
+ assert_eq!(summary.thirty_day_cost, None);
+ assert_eq!(summary.thirty_day_tokens, Some(12));
+ assert_eq!(summary.latest_tokens, Some(12));
+ assert_eq!(summary.top_model.as_deref(), Some("muse-spark-1.3"));
+
+ let partial = Report {
+ coverage: LocalHistoryCoverage::Partial,
+ ..report
+ };
+ assert!(
+ muse_local_usage_summary(
+ &partial,
+ &partial,
+ period,
+ codexbar::settings::Language::default()
+ )
+ .is_none()
+ );
+}
+
+#[test]
+fn local_usage_keeps_thirty_day_fixed_while_period_follows_selection() {
+ let thirty = CostSummary {
+ total_cost_usd: 30.0,
+ input_tokens: 3_000,
+ output_tokens: 0,
+ ..CostSummary::default()
+ };
+ let month = CostSummary {
+ total_cost_usd: 5.0,
+ input_tokens: 400,
+ output_tokens: 100,
+ ..CostSummary::default()
+ };
+ let summary = local_usage_summary_from_cost_summary(
+ "claude",
+ &thirty,
+ CostReportingPeriod::MonthToDate,
+ Some(&month),
+ )
+ .expect("usage is visible");
+ assert_eq!(summary.thirty_day_cost, Some(30.0));
+ assert_eq!(summary.thirty_day_tokens, Some(3_000));
+ assert_eq!(summary.period_cost, Some(5.0));
+ assert_eq!(summary.period_tokens, Some(500));
+ assert_eq!(summary.reporting_period, "month-to-date");
+}
+
+#[test]
+fn japanese_estimate_note_is_localized() {
+ assert_eq!(
+ localized_estimate_note("codex", Language::Japanese),
+ "ローカルログから推定したもので、請求書と異なる場合があります"
+ );
+ assert_eq!(
+ localized_estimate_note("claude", Language::Japanese),
+ "ClaudeのローカルログからAPIレートで推定したもので、トークン総数が請求書と異なる場合があります"
+ );
+}
+
+#[test]
+fn english_estimate_note_is_localized() {
+ assert_eq!(
+ localized_estimate_note("codex", Language::English),
+ "Estimated from local logs; may differ from your bill"
+ );
+ assert_eq!(
+ localized_estimate_note("claude", Language::English),
+ "Estimated from local Claude logs at API rates; token totals may differ from your bill"
+ );
+}
diff --git a/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs b/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs
index 317cdc9f5e..da3d6b90e4 100644
--- a/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs
+++ b/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs
@@ -191,15 +191,22 @@ impl ClaudeSwapMutationOutcome {
fn validate_claude_swap_operation(
operation: ClaudeSwapAccountOperation,
+ list: &ClaudeSwapAccountList,
account: &ClaudeSwapAccountRow,
) -> Result<(), String> {
let expected_action = match operation {
ClaudeSwapAccountOperation::Switch => ClaudeSwapAccountAction::Switch,
ClaudeSwapAccountOperation::Reauthenticate => ClaudeSwapAccountAction::Reauthenticate,
};
- if claude_swap::action_for_account(account) == Some(expected_action) {
+ if claude_swap::action_for_account(list, account) == Some(expected_action) {
return Ok(());
}
+ if !list.supports_account_switching {
+ return Err(
+ "This claude-swap adapter is read-only and does not support account switching."
+ .to_string(),
+ );
+ }
match operation {
ClaudeSwapAccountOperation::Switch if account.is_active => {
Err("That claude-swap account is already active.".to_string())
@@ -230,7 +237,7 @@ fn run_claude_swap_operation(
let before = claude_swap::read_account_list(&config.executable_path)
.map_err(|error| error.to_string())?;
let account = account_row_for_slot(&before, slot)?;
- validate_claude_swap_operation(operation, account)?;
+ validate_claude_swap_operation(operation, &before, account)?;
let result = claude_swap::switch_account(&config.executable_path, slot)
.map_err(|error| error.to_string())?;
@@ -413,10 +420,15 @@ pub async fn claude_account_switch(
mod tests {
use super::*;
- fn account_row(active: bool, status: &str) -> ClaudeSwapAccountRow {
+ fn account_list(
+ active: bool,
+ status: &str,
+ supports_account_switching: bool,
+ ) -> ClaudeSwapAccountList {
let raw = serde_json::json!({
"schemaVersion": 1,
"activeAccountNumber": if active { serde_json::json!(1) } else { serde_json::Value::Null },
+ "supportsAccountSwitching": supports_account_switching,
"accounts": [{
"number": 1,
"email": "test@example.com",
@@ -426,6 +438,10 @@ mod tests {
});
codexbar::providers::claude::claude_swap::parse_account_list(&raw.to_string())
.expect("fixture should parse")
+ }
+
+ fn account_row(active: bool, status: &str) -> ClaudeSwapAccountRow {
+ account_list(active, status, true)
.accounts
.into_iter()
.next()
@@ -434,18 +450,63 @@ mod tests {
#[test]
fn operation_validation_uses_the_projected_action_state() {
- let switchable = account_row(false, "ok");
+ let switchable = account_list(false, "ok", true);
assert!(
- validate_claude_swap_operation(ClaudeSwapAccountOperation::Switch, &switchable).is_ok()
+ validate_claude_swap_operation(
+ ClaudeSwapAccountOperation::Switch,
+ &switchable,
+ &switchable.accounts[0]
+ )
+ .is_ok()
);
- let blocked = account_row(false, "no_credentials");
+ let blocked = account_list(false, "no_credentials", true);
assert_eq!(
- validate_claude_swap_operation(ClaudeSwapAccountOperation::Switch, &blocked),
+ validate_claude_swap_operation(
+ ClaudeSwapAccountOperation::Switch,
+ &blocked,
+ &blocked.accounts[0]
+ ),
Err("That claude-swap account is not available for switching.".to_string())
);
}
+ #[test]
+ fn read_only_adapter_rejects_switch_and_reauthenticate() {
+ let expected = Err(
+ "This claude-swap adapter is read-only and does not support account switching."
+ .to_string(),
+ );
+ let inactive = account_list(false, "ok", false);
+ assert_eq!(
+ validate_claude_swap_operation(
+ ClaudeSwapAccountOperation::Switch,
+ &inactive,
+ &inactive.accounts[0]
+ ),
+ expected
+ );
+ let foreign = account_list(true, "foreign_credential", false);
+ assert_eq!(
+ validate_claude_swap_operation(
+ ClaudeSwapAccountOperation::Reauthenticate,
+ &foreign,
+ &foreign.accounts[0]
+ ),
+ expected
+ );
+ // The same row is accepted when the adapter supports switching.
+ let switching = account_list(true, "foreign_credential", true);
+ assert!(
+ validate_claude_swap_operation(
+ ClaudeSwapAccountOperation::Reauthenticate,
+ &switching,
+ &switching.accounts[0]
+ )
+ .is_ok()
+ );
+ }
+
#[test]
fn reauthentication_confirmation_requires_a_live_ok_status() {
assert!(reauthentication_is_repaired(&account_row(true, "ok")));
diff --git a/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs b/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs
index eede8b8a3b..848901c28d 100644
--- a/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs
+++ b/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs
@@ -5,12 +5,16 @@ use uuid::Uuid;
use codexbar::codex_accounts::{
AccountStore, CodexAccount, CodexAccountApi, CodexAccountManager, CodexAccountManagerError,
- CodexAccountRuntime, CodexApiError, CodexSwitchResult, SnapshotStore, display_names_by_id,
- ordinals_by_id, restart_codex_desktop,
+ CodexAccountRuntime, CodexAccountSource, CodexApiError, CodexSwitchResult, SnapshotStore,
+ display_names_by_id, ordinals_by_id, restart_codex_desktop,
};
+use codexbar::notifications::CredentialAlertPolicy;
+use codexbar::settings::Settings;
use crate::state::AppState;
+use super::credential_alerts::FetchAttempt;
+use super::providers::quota_notification_account_identity_for;
use super::*;
// ── Codex multi-account (ADR 0003, milestone 2) ──────────────────────
@@ -115,10 +119,10 @@ pub(crate) async fn refresh_codex_account_lanes(
return;
}
- let mut handles = Vec::with_capacity(accounts.len());
+ let mut handles = tokio::task::JoinSet::new();
for account in accounts {
let permits = Arc::clone(&fetch_permits);
- handles.push(tokio::spawn(async move {
+ handles.spawn(async move {
let Ok(_permit) = permits.acquire_owned().await else {
return None;
};
@@ -126,7 +130,7 @@ pub(crate) async fn refresh_codex_account_lanes(
let home_path = account.codex_home_path.clone();
let email_hint = account.email_hint.clone();
let workspace_account_id = account.effective_workspace_account_id();
- match tokio::time::timeout(
+ let result = tokio::time::timeout(
std::time::Duration::from_secs(DEFAULT_FETCH_TIMEOUT_SECONDS),
api.fetch_snapshot_for_workspace(
&home_path,
@@ -135,36 +139,73 @@ pub(crate) async fn refresh_codex_account_lanes(
true,
),
)
- .await
- {
- Ok(Ok(snapshot)) => Some((account, snapshot)),
- Ok(Err(e)) => {
- tracing::debug!(
- "codex account lane {} failed: {}",
- account.id,
- into_api_message(e)
- );
- None
+ .await;
+ match result {
+ Ok(result) => {
+ if let Err(error) = &result {
+ tracing::debug!("codex account lane {} failed: {}", account.id, error);
+ }
+ Some((account, result))
}
Err(_) => {
tracing::debug!("codex account lane {} timed out", account.id);
None
}
}
- }));
+ });
}
- let mut updates = Vec::new();
- for handle in handles {
- if let Ok(Some((fetched_account, snapshot))) = handle.await {
- updates.push((fetched_account, snapshot));
+ let mut outcomes = Vec::new();
+ while let Some(result) = handles.join_next().await {
+ if let Ok(Some(outcome)) = result {
+ outcomes.push(outcome);
}
}
+
+ let current_accounts = match load_codex_accounts() {
+ Ok(accounts) => accounts,
+ Err(e) => {
+ tracing::warn!("codex account lanes: failed to reload accounts: {e}");
+ return;
+ }
+ };
// Hold the generation owner through the read/merge/write so an invalidated
// batch cannot overwrite a replacement batch's account snapshots.
let state = app.state::>();
- let Ok(state) = state.lock() else { return };
- match save_codex_lane_results(&state, generation, updates) {
+ let Ok(mut state) = state.lock() else { return };
+ if !is_current_provider_refresh_generation(&state, generation) {
+ return;
+ }
+ let current_by_id: HashMap = current_accounts
+ .iter()
+ .map(|account| (account.id, account))
+ .collect();
+ let mut updates = Vec::new();
+ for (fetched_account, result) in outcomes {
+ let Some(current) = current_by_id.get(&fetched_account.id).copied() else {
+ continue;
+ };
+ if !account_lane_is_current(&fetched_account, current) {
+ continue;
+ }
+ match result {
+ Ok(snapshot) if account_snapshot_belongs_to(current, &snapshot) => {
+ observe_codex_account_lane_outcome(
+ &mut state.notification_manager,
+ &fetched_account,
+ FetchAttempt::Succeeded,
+ );
+ updates.push((fetched_account, snapshot));
+ }
+ Ok(_) => {}
+ Err(error) => observe_codex_account_lane_outcome(
+ &mut state.notification_manager,
+ &fetched_account,
+ FetchAttempt::Failed(error.state_kind()),
+ ),
+ }
+ }
+ match save_codex_lane_results(&state, generation, updates, ¤t_accounts) {
Ok(false) => return,
Err(e) => tracing::warn!("codex account lanes: failed to persist snapshots: {e}"),
Ok(true) => {}
@@ -172,20 +213,48 @@ pub(crate) async fn refresh_codex_account_lanes(
events::emit_codex_accounts_updated(&app);
}
+fn observe_codex_account_lane_outcome(
+ manager: &mut codexbar::notifications::NotificationManager,
+ account: &CodexAccount,
+ attempt: FetchAttempt,
+) {
+ if account.source != CodexAccountSource::ManagedByApp {
+ return;
+ }
+
+ let account_scope = quota_notification_account_identity_for(
+ ProviderId::Codex,
+ "",
+ account.email_hint.as_deref(),
+ None,
+ Some(account.id),
+ );
+ match attempt {
+ FetchAttempt::Succeeded => {
+ manager.observe_credential_recovery(ProviderId::Codex, &account_scope);
+ }
+ FetchAttempt::Failed(kind) if kind.needs_sign_in() => {
+ let policy = CredentialAlertPolicy::from_settings(&Settings::load());
+ manager.observe_credential_failure(policy, ProviderId::Codex, &account_scope, kind);
+ }
+ FetchAttempt::Failed(_) => {}
+ }
+}
+
fn save_codex_lane_results(
state: &AppState,
generation: u64,
updates: Vec<(CodexAccount, codexbar::codex_accounts::AccountUsageSnapshot)>,
+ current_accounts: &[CodexAccount],
) -> Result {
if !is_current_provider_refresh_generation(state, generation) {
return Ok(false);
}
- let current_accounts = load_codex_accounts().map_err(std::io::Error::other)?;
let current_by_id: HashMap = current_accounts
.iter()
.map(|account| (account.id, account))
.collect();
- let mut snapshots = snapshots_for_accounts(¤t_accounts, SnapshotStore::new().load()?);
+ let mut snapshots = snapshots_for_accounts(current_accounts, SnapshotStore::new().load()?);
for (fetched_account, snapshot) in updates {
if current_by_id
.get(&fetched_account.id)
@@ -511,7 +580,10 @@ fn into_user_message(error: CodexAccountManagerError) -> String {
fn into_api_message(error: CodexApiError) -> String {
match error {
- CodexApiError::Message(msg) => msg,
+ CodexApiError::AuthenticationRequired(msg)
+ | CodexApiError::SessionExpired(msg)
+ | CodexApiError::PermissionDenied(msg)
+ | CodexApiError::Message(msg) => msg,
CodexApiError::Network(e) => format!("network error: {e}"),
CodexApiError::Parse(e) => format!("failed to parse Codex payload: {e}"),
}
@@ -650,7 +722,8 @@ mod tests {
save_codex_lane_results(
&state,
state.provider_refresh_generation,
- vec![(account.clone(), snapshot.clone())]
+ vec![(account.clone(), snapshot.clone())],
+ &[account.clone()]
)
.unwrap()
);
@@ -659,7 +732,15 @@ mod tests {
email: Some("old@example.com".into()),
..snapshot
};
- assert!(!save_codex_lane_results(&state, old_generation, vec![(account, stale)]).unwrap());
+ assert!(
+ !save_codex_lane_results(
+ &state,
+ old_generation,
+ vec![(account.clone(), stale)],
+ &[account]
+ )
+ .unwrap()
+ );
assert_eq!(
std::fs::read(file_locations::snapshots_file()).unwrap(),
before
diff --git a/apps/desktop-tauri/src-tauri/src/commands/credential_alerts.rs b/apps/desktop-tauri/src-tauri/src/commands/credential_alerts.rs
new file mode 100644
index 0000000000..bf8637f615
--- /dev/null
+++ b/apps/desktop-tauri/src-tauri/src/commands/credential_alerts.rs
@@ -0,0 +1,126 @@
+//! Bridges provider refresh outcomes to the core credential-expiry episode
+//! tracker (`codexbar::notifications`). Classification stays typed: the
+//! failure kind is captured from the fresh fetch before any last-good
+//! fallback replaces the published snapshot, so cached data can neither end
+//! nor restart an episode.
+
+use codexbar::core::{ProviderId, ProviderStateKind};
+use codexbar::notifications::{CredentialAlertPolicy, NotificationManager};
+
+use super::bridge::ProviderUsageSnapshot;
+use super::providers::quota_notification_account_identity;
+
+/// Result of the fresh fetch, before last-good preservation.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(super) enum FetchAttempt {
+ Succeeded,
+ Failed(ProviderStateKind),
+}
+
+impl FetchAttempt {
+ pub(super) fn of(fresh: &ProviderUsageSnapshot) -> Self {
+ if fresh.error.is_none() {
+ Self::Succeeded
+ } else {
+ Self::Failed(fresh.error_state)
+ }
+ }
+}
+
+/// Report one accepted refresh outcome. `published` is the snapshot that
+/// replaced the cache; `last_good` is the previous error-free snapshot, the
+/// only source of account identity when the fetch itself failed.
+pub(super) fn observe_attempt(
+ manager: &mut NotificationManager,
+ policy: Option,
+ id: ProviderId,
+ token_account_id: Option,
+ attempt: FetchAttempt,
+ published: &ProviderUsageSnapshot,
+ last_good: Option<&ProviderUsageSnapshot>,
+) {
+ match attempt {
+ FetchAttempt::Succeeded => {
+ let account = quota_notification_account_identity(published, token_account_id);
+ manager.observe_credential_recovery(id, &account);
+ }
+ FetchAttempt::Failed(kind) => {
+ if let Some(policy) = policy {
+ let account = quota_notification_account_identity(
+ last_good.unwrap_or(published),
+ token_account_id,
+ );
+ manager.observe_credential_failure(policy, id, &account, kind);
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use codexbar::core::{ProviderError, instantiate_provider};
+ use codexbar::settings::Settings;
+
+ fn failed(kind: ProviderStateKind) -> ProviderUsageSnapshot {
+ ProviderUsageSnapshot::from_error(
+ ProviderId::Codex,
+ instantiate_provider(ProviderId::Codex).metadata(),
+ "Authentication required".to_string(),
+ kind,
+ )
+ }
+
+ fn succeeded() -> ProviderUsageSnapshot {
+ let mut snapshot = failed(ProviderStateKind::Ready);
+ snapshot.error = None;
+ snapshot.account_email = Some("person@example.test".to_string());
+ snapshot
+ }
+
+ #[test]
+ fn attempt_reads_the_fresh_error_state() {
+ assert_eq!(
+ FetchAttempt::of(&failed(ProviderStateKind::ExpiredSession)),
+ FetchAttempt::Failed(ProviderStateKind::ExpiredSession)
+ );
+ assert_eq!(FetchAttempt::of(&succeeded()), FetchAttempt::Succeeded);
+ }
+
+ #[test]
+ fn typed_state_matches_provider_error_classification() {
+ let kind = ProviderError::AuthRequired.state_kind();
+ assert_eq!(
+ FetchAttempt::of(&failed(kind)),
+ FetchAttempt::Failed(ProviderStateKind::NeedsAuthentication)
+ );
+ }
+
+ #[test]
+ fn observing_outcomes_with_alerts_off_never_delivers() {
+ // Default settings leave credential alerts off, so no toast can fire.
+ let policy = CredentialAlertPolicy::from_settings(&Settings::default());
+ let mut manager = NotificationManager::new();
+ let good = succeeded();
+ let failure = failed(ProviderStateKind::NeedsAuthentication);
+
+ observe_attempt(
+ &mut manager,
+ Some(policy),
+ ProviderId::Codex,
+ None,
+ FetchAttempt::of(&failure),
+ &failure,
+ Some(&good),
+ );
+ observe_attempt(
+ &mut manager,
+ Some(policy),
+ ProviderId::Codex,
+ None,
+ FetchAttempt::Succeeded,
+ &good,
+ None,
+ );
+ }
+}
diff --git a/apps/desktop-tauri/src-tauri/src/commands/currency.rs b/apps/desktop-tauri/src-tauri/src/commands/currency.rs
new file mode 100644
index 0000000000..3f508c5981
--- /dev/null
+++ b/apps/desktop-tauri/src-tauri/src/commands/currency.rs
@@ -0,0 +1,231 @@
+use std::{collections::HashMap, path::PathBuf};
+
+use codexbar::currency::{
+ CURRENCIES, PreferredCurrency, convert_amount, fetch_exchange_rates, sanitize_rates,
+};
+use serde::{Deserialize, Serialize};
+use tauri::State;
+use tokio::sync::Mutex;
+
+const CACHE_MAX_AGE_SECS: i64 = 24 * 60 * 60;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct PersistedRates {
+ fetched_at_unix: i64,
+ rates: HashMap,
+}
+
+/// Everything the web UI needs to display preferred currencies. Rust owns the
+/// model (supported codes, fallback rates, sanitizing, USD-pivot conversion);
+/// the frontend formats with `Intl` only.
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CurrencyRatesSnapshot {
+ /// Merged offline-fallback + persisted/fetched rates, sanitized.
+ pub rates: HashMap,
+ /// Codes `format_display_currency` accepts for `preferredCode`.
+ pub supported_codes: Vec<&'static str>,
+ /// Normalized preference the snapshot was built for ("AUTO" or a code).
+ pub preferred_code: String,
+}
+
+/// Shared cache behind the settings command and the tray's cost formatting.
+/// Owns the merged rate table and the preference so both paths agree between
+/// a fetch and the next read (no second source of truth on disk).
+#[derive(Default)]
+pub struct CurrencyRateCache {
+ inner: Mutex,
+}
+
+#[derive(Default)]
+struct CacheState {
+ loaded: bool,
+ persisted: Option,
+ /// Merged, sanitized rates (fallback + persisted), cached alongside.
+ rates: HashMap,
+ /// Normalized preference the cached table was built for.
+ preferred_code: String,
+}
+
+#[tauri::command]
+pub async fn get_currency_rates(
+ app: tauri::AppHandle,
+ cache: State<'_, CurrencyRateCache>,
+ preferred_currency_code: String,
+) -> Result {
+ let preferred = PreferredCurrency::parse(&preferred_currency_code);
+ let mut state = cache.inner.lock().await;
+ if !state.loaded {
+ state.persisted = read_persisted_rates();
+ state.rates = merged_rates(state.persisted.as_ref());
+ state.loaded = true;
+ }
+
+ if !preferred.is_auto() {
+ let now = unix_now();
+ let fresh = state.persisted.as_ref().is_some_and(|cached| {
+ let age = now.saturating_sub(cached.fetched_at_unix);
+ (0..CACHE_MAX_AGE_SECS).contains(&age)
+ });
+ if !fresh {
+ match fetch_exchange_rates().await {
+ Ok(rates) => {
+ let entry = PersistedRates {
+ fetched_at_unix: now,
+ rates,
+ };
+ persist_rates(&entry);
+ state.persisted = Some(entry);
+ state.rates = merged_rates(state.persisted.as_ref());
+ crate::tray_bridge::refresh_tray_presentation(&app);
+ }
+ Err(error) => {
+ tracing::debug!(%error, "currency rates unavailable; using cached or offline rates")
+ }
+ }
+ }
+ }
+ state.preferred_code = preferred.raw();
+
+ Ok(CurrencyRatesSnapshot {
+ rates: state.rates.clone(),
+ supported_codes: CURRENCIES.iter().map(|c| c.code).collect(),
+ preferred_code: preferred.raw(),
+ })
+}
+
+/// Read the shared cache for the tray path (blocking on the async mutex; the
+/// tray runs off the async runtime). Returns merged rates plus the last
+/// command-observed preference, or `None` when the cache was never loaded —
+/// the caller then falls back to offline fallback rates via `merged_rates`.
+pub(crate) fn cached_rates_and_preference(
+ cache: &CurrencyRateCache,
+) -> Option<(HashMap, String)> {
+ let state = cache.inner.blocking_lock();
+ (!state.rates.is_empty() || state.loaded)
+ .then(|| (state.rates.clone(), state.preferred_code.clone()))
+ .filter(|_| state.loaded)
+}
+
+/// Convert `amount` from `source_code` to the saved preferred currency for
+/// the tray. Reads the shared cache (synced by `get_currency_rates`); falls
+/// back to persisted disk rates when the cache was never touched so a tray
+/// render before the first settings load still converts consistently.
+pub(crate) fn convert_preferred_amount(
+ cache: Option<&CurrencyRateCache>,
+ amount: f64,
+ source_code: &str,
+) -> Option<(f64, String)> {
+ let PreferredCurrency::Code(target) =
+ PreferredCurrency::parse(&codexbar::settings::Settings::load().preferred_currency_code)
+ else {
+ return None;
+ };
+ let rates = match cache {
+ Some(cache) => match cached_rates_and_preference(cache) {
+ Some((rates, _)) if !rates.is_empty() => rates,
+ _ => merged_rates(read_persisted_rates().as_ref()),
+ },
+ None => merged_rates(read_persisted_rates().as_ref()),
+ };
+ convert_amount(amount, source_code, target, &rates)
+ .map(|converted| (converted, (*target).to_string()))
+}
+
+fn unix_now() -> i64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|duration| duration.as_secs().min(i64::MAX as u64) as i64)
+ .unwrap_or_default()
+}
+
+fn cache_path() -> Option {
+ codexbar::settings::Settings::settings_path()?
+ .parent()
+ .map(|parent| parent.join("currency-rates.json"))
+}
+
+fn read_persisted_rates() -> Option {
+ let path = cache_path()?;
+ let bytes = std::fs::read(path).ok()?;
+ let mut cached: PersistedRates = serde_json::from_slice(&bytes).ok()?;
+ // Sanitizing is core-owned (single model, see the thermo review): the
+ // shell only delegates.
+ cached.rates = sanitize_rates(&cached.rates);
+ (!cached.rates.is_empty()).then_some(cached)
+}
+
+fn merged_rates(cached: Option<&PersistedRates>) -> HashMap {
+ let mut rates = codexbar::currency::fallback_rates();
+ if let Some(cached) = cached {
+ for (code, rate) in sanitize_rates(&cached.rates) {
+ rates.insert(code, rate);
+ }
+ }
+ rates
+}
+
+fn persist_rates(cached: &PersistedRates) {
+ let Some(path) = cache_path() else { return };
+ let Some(parent) = path.parent() else { return };
+ let write_result = (|| -> Result<(), Box> {
+ std::fs::create_dir_all(parent)?;
+ let bytes = serde_json::to_vec(cached)?;
+ codexbar::atomic_file::write_atomic(&path, &bytes)?;
+ Ok(())
+ })();
+ if let Err(error) = write_result {
+ tracing::debug!(%error, "could not persist currency rate cache");
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn fallback_table_covers_every_preferred_currency() {
+ let rates = codexbar::currency::fallback_rates();
+ assert_eq!(rates.len(), CURRENCIES.len());
+ for currency in CURRENCIES {
+ assert!(
+ rates
+ .get(currency.code)
+ .is_some_and(|rate| rate.is_finite() && *rate > 0.0)
+ );
+ }
+ }
+
+ #[test]
+ fn persisted_rates_are_sanitized_before_merging() {
+ let rates = HashMap::from([
+ ("USD".to_string(), 1.0),
+ ("TRY".to_string(), 48.0),
+ ("EUR".to_string(), f64::NAN),
+ ("BTC".to_string(), 100.0),
+ ]);
+ let clean = sanitize_rates(&rates);
+ assert_eq!(clean.get("TRY"), Some(&48.0));
+ assert!(!clean.contains_key("EUR"));
+ assert!(!clean.contains_key("BTC"));
+ }
+
+ #[test]
+ fn merged_rates_prefer_cached_values_over_fallback() {
+ let cached = PersistedRates {
+ fetched_at_unix: unix_now(),
+ rates: HashMap::from([("USD".to_string(), 1.0), ("TRY".to_string(), 40.0)]),
+ };
+ let rates = merged_rates(Some(&cached));
+ assert_eq!(rates.get("TRY"), Some(&40.0));
+ // Unsupported codes in the cache never widen the table.
+ assert_eq!(rates.len(), CURRENCIES.len());
+ }
+
+ #[test]
+ fn cache_reads_return_loaded_state_and_skip_when_empty() {
+ let cache = CurrencyRateCache::default();
+ assert!(cached_rates_and_preference(&cache).is_none());
+ }
+}
diff --git a/apps/desktop-tauri/src-tauri/src/commands/last_good_owner_tests.rs b/apps/desktop-tauri/src-tauri/src/commands/last_good_owner_tests.rs
new file mode 100644
index 0000000000..e9c7753d8c
--- /dev/null
+++ b/apps/desktop-tauri/src-tauri/src/commands/last_good_owner_tests.rs
@@ -0,0 +1,160 @@
+//! Owner-checked last-good retention in the provider refresh shell.
+//!
+//! A failure tied to a live session may keep the cached snapshot only when the
+//! same session produced that snapshot. Scenarios use DeepSeek's Chrome
+//! session balance, the provider that declares owned transport failures.
+
+use super::ProviderUsageSnapshot;
+use super::providers::{preserve_last_good_transient_failure, record_last_good_owner};
+use crate::state::AppState;
+use codexbar::core::{
+ LastGoodOwner, ProviderError, ProviderFetchResult, ProviderId, ProviderStateKind, RateWindow,
+ UsageSnapshot, instantiate_provider,
+};
+
+const MEASURED_AT: &str = "2026-09-01T00:00:00Z";
+
+fn owner(profile: &str, token: &str) -> LastGoodOwner {
+ LastGoodOwner::derive("deepseek-platform-balance", profile, token).expect("owner")
+}
+
+fn cached_balance() -> ProviderUsageSnapshot {
+ let metadata = instantiate_provider(ProviderId::DeepSeek)
+ .metadata()
+ .clone();
+ let result = ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(0.0)), "web");
+ let mut snapshot =
+ ProviderUsageSnapshot::from_fetch_result(ProviderId::DeepSeek, &metadata, &result, None);
+ snapshot.updated_at = MEASURED_AT.to_string();
+ snapshot
+}
+
+fn failed_refresh() -> ProviderUsageSnapshot {
+ let metadata = instantiate_provider(ProviderId::DeepSeek)
+ .metadata()
+ .clone();
+ ProviderUsageSnapshot::from_error(
+ ProviderId::DeepSeek,
+ &metadata,
+ "Timeout".to_string(),
+ ProviderStateKind::Unknown,
+ )
+}
+
+fn state_with_cached_balance(cached_owner: Option) -> AppState {
+ let mut state = AppState::new();
+ state.provider_cache.push(cached_balance());
+ record_last_good_owner(&mut state, ProviderId::DeepSeek, cached_owner);
+ state
+}
+
+fn refresh_failure(state: &mut AppState, error: &ProviderError) -> ProviderUsageSnapshot {
+ preserve_last_good_transient_failure(state, ProviderId::DeepSeek, failed_refresh(), error)
+}
+
+#[test]
+fn matching_session_keeps_the_balance_and_its_original_time() {
+ let session = owner("chrome:Default", "token-a");
+ let mut state = state_with_cached_balance(Some(session.clone()));
+
+ let kept = refresh_failure(
+ &mut state,
+ &ProviderError::Timeout.with_failure_owner(Some(session)),
+ );
+
+ assert_eq!(kept.error, None);
+ assert_eq!(kept.updated_at, MEASURED_AT);
+}
+
+#[test]
+fn different_profile_or_token_shows_the_error() {
+ for failed_session in [
+ owner("chrome:Profile 1", "token-a"),
+ owner("chrome:Default", "token-b"),
+ ] {
+ let mut state = state_with_cached_balance(Some(owner("chrome:Default", "token-a")));
+ let shown = refresh_failure(
+ &mut state,
+ &ProviderError::Timeout.with_failure_owner(Some(failed_session)),
+ );
+ assert_eq!(shown.error.as_deref(), Some("Timeout"));
+ }
+}
+
+#[test]
+fn failure_without_a_session_owner_fails_closed() {
+ let mut state = state_with_cached_balance(Some(owner("chrome:Default", "token-a")));
+
+ let shown = refresh_failure(&mut state, &ProviderError::Timeout.with_failure_owner(None));
+
+ assert_eq!(shown.error.as_deref(), Some("Timeout"));
+}
+
+#[test]
+fn balance_without_an_owner_is_never_retained() {
+ // An API-key balance, a decoded cache, or a seeded snapshot has no owner.
+ let session = owner("chrome:Default", "token-a");
+ let mut state = state_with_cached_balance(None);
+
+ let shown = refresh_failure(
+ &mut state,
+ &ProviderError::Timeout.with_failure_owner(Some(session)),
+ );
+
+ assert_eq!(shown.error.as_deref(), Some("Timeout"));
+}
+
+#[test]
+fn unattributed_transport_failure_does_not_retain_deepseek_balances() {
+ let mut state = state_with_cached_balance(Some(owner("chrome:Default", "token-a")));
+
+ let shown = refresh_failure(&mut state, &ProviderError::Timeout);
+
+ assert_eq!(shown.error.as_deref(), Some("Timeout"));
+}
+
+#[test]
+fn fresh_snapshot_replaces_or_clears_the_owner() {
+ let first = owner("chrome:Default", "token-a");
+ let second = owner("chrome:Default", "token-b");
+ let mut state = state_with_cached_balance(Some(first.clone()));
+
+ record_last_good_owner(&mut state, ProviderId::DeepSeek, Some(second.clone()));
+ assert_eq!(
+ state.last_good_owners.get(&ProviderId::DeepSeek),
+ Some(&second)
+ );
+ let shown = refresh_failure(
+ &mut state,
+ &ProviderError::Timeout.with_failure_owner(Some(first)),
+ );
+ assert_eq!(shown.error.as_deref(), Some("Timeout"));
+
+ record_last_good_owner(&mut state, ProviderId::DeepSeek, None);
+ assert!(state.last_good_owners.is_empty());
+}
+
+#[test]
+fn other_providers_keep_their_existing_failure_policy() {
+ let metadata = instantiate_provider(ProviderId::Codex).metadata().clone();
+ let result = ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(42.0)), "OAuth");
+ let good =
+ ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result, None);
+ let failed = ProviderUsageSnapshot::from_error(
+ ProviderId::Codex,
+ &metadata,
+ "Timeout".to_string(),
+ ProviderStateKind::Unknown,
+ );
+ let mut state = AppState::new();
+ state.provider_cache.push(good);
+
+ let kept = preserve_last_good_transient_failure(
+ &mut state,
+ ProviderId::Codex,
+ failed,
+ &ProviderError::Timeout,
+ );
+
+ assert_eq!(kept.error, None);
+}
diff --git a/apps/desktop-tauri/src-tauri/src/commands/locale_cmd.rs b/apps/desktop-tauri/src-tauri/src/commands/locale_cmd.rs
index a71224de8f..81c4d632e3 100644
--- a/apps/desktop-tauri/src-tauri/src/commands/locale_cmd.rs
+++ b/apps/desktop-tauri/src-tauri/src/commands/locale_cmd.rs
@@ -164,6 +164,17 @@ mod locale_tests {
assert_eq!(bundle.entries.len(), locale::LocaleKey::ALL.len());
}
+ #[test]
+ fn locale_strings_roundtrip_brazilian_portuguese() {
+ let bundle = locale_strings_for(Language::PortugueseBrazil);
+ assert_eq!(bundle.language, "portuguesebrazil");
+ assert_eq!(
+ bundle.entries.get("TabGeneral").map(String::as_str),
+ Some("Geral")
+ );
+ assert_eq!(bundle.entries.len(), locale::LocaleKey::ALL.len());
+ }
+
#[test]
fn locale_strings_contains_every_variant() {
let bundle = locale_strings_for(Language::English);
@@ -190,6 +201,7 @@ mod locale_tests {
"japanese",
"korean",
"spanish",
+ "portuguesebrazil",
"russian",
"turkish"
]
@@ -203,6 +215,7 @@ mod locale_tests {
"日本語",
"한국어",
"Español",
+ "Português (Brasil)",
"Русский",
"Türkçe"
]
@@ -287,6 +300,14 @@ mod locale_tests {
parse_locale_language("español"),
Some(Language::Spanish)
));
+ assert!(matches!(
+ parse_locale_language("pt-BR"),
+ Some(Language::PortugueseBrazil)
+ ));
+ assert!(matches!(
+ parse_locale_language("Português (Brasil)"),
+ Some(Language::PortugueseBrazil)
+ ));
assert!(matches!(
parse_locale_language("tr-TR"),
Some(Language::Turkish)
diff --git a/apps/desktop-tauri/src-tauri/src/commands/mod.rs b/apps/desktop-tauri/src-tauri/src/commands/mod.rs
index fdfaeda967..f8d6033e81 100644
--- a/apps/desktop-tauri/src-tauri/src/commands/mod.rs
+++ b/apps/desktop-tauri/src-tauri/src/commands/mod.rs
@@ -24,6 +24,7 @@ use crate::surface::SurfaceMode;
use crate::surface_target::SurfaceTarget;
mod chart;
+mod currency;
mod spend_contract;
mod tokens;
mod updater;
@@ -36,11 +37,13 @@ mod claude_accounts;
pub(crate) mod claude_reconciliation;
mod codex_accounts;
mod codex_workspaces;
+mod credential_alerts;
mod credential_detection;
mod credentials;
mod diagnostics;
mod grok_accounts;
mod locale_cmd;
+mod preferences_transfer;
mod provider_detail;
mod provider_refresh;
mod provider_settings;
@@ -63,6 +66,7 @@ pub use credentials::*;
pub use diagnostics::*;
pub use grok_accounts::*;
pub use locale_cmd::*;
+pub use preferences_transfer::*;
pub use provider_detail::*;
#[cfg(test)]
pub(crate) use provider_refresh::is_provider_cache_fresh;
@@ -75,10 +79,18 @@ pub use surface::*;
pub use system::*;
pub(crate) use usage_items::*;
+#[cfg(test)]
+mod last_good_owner_tests;
+#[cfg(test)]
+mod session_cookie_scope_tests;
#[cfg(test)]
mod tests;
+#[cfg(test)]
+mod token_account_source_tests;
+
pub use chart::*;
+pub use currency::*;
pub use spend_contract::*;
pub use tokens::*;
pub use updater::*;
@@ -104,6 +116,29 @@ fn canonical_provider_arg(provider_id: &str) -> Result {
Ok(parse_provider_arg(provider_id)?.cli_name().to_string())
}
+fn provider_dashboard_url(id: ProviderId, settings: &Settings) -> Option {
+ match id {
+ ProviderId::MiniMax => Some(
+ codexbar::providers::MiniMaxProvider::dashboard_url_for_region(Some(
+ settings.api_region(id),
+ )),
+ ),
+ ProviderId::Kimi => Some(
+ codexbar::providers::KimiRegion::from_settings(Some(settings.api_region(id)))
+ .console_url()
+ .to_string(),
+ ),
+ // The llmman dashboard is the configured daemon itself.
+ ProviderId::LLMMan => Some(codexbar::providers::llmman::dashboard_url(Some(
+ settings.workspace_id(id),
+ ))),
+ _ => instantiate_provider(id)
+ .metadata()
+ .dashboard_url
+ .map(str::to_string),
+ }
+}
+
fn validate_single_line_secret(value: &str, field: &str, max_len: usize) -> Result<(), String> {
let trimmed = value.trim();
if trimmed.is_empty() {
diff --git a/apps/desktop-tauri/src-tauri/src/commands/preferences_transfer.rs b/apps/desktop-tauri/src-tauri/src/commands/preferences_transfer.rs
new file mode 100644
index 0000000000..cf1c66baf7
--- /dev/null
+++ b/apps/desktop-tauri/src-tauri/src/commands/preferences_transfer.rs
@@ -0,0 +1,109 @@
+//! Export and import of the portable preferences document.
+//!
+//! The document format, allowlist and validation live in
+//! `codexbar::settings::PreferencesDocument`; this module only adds the
+//! desktop side effects that a settings save normally triggers.
+
+use std::collections::HashSet;
+use std::path::Path;
+use std::sync::Mutex;
+
+use codexbar::core::ProviderId;
+use codexbar::settings::{PreferencesDocument, Settings};
+use tauri::{Emitter, Manager};
+
+use super::{SettingsSnapshot, language_label};
+use crate::events;
+use crate::state::AppState;
+
+fn checked_path(path: &str) -> Result<&Path, String> {
+ let path = path.trim();
+ if path.is_empty() {
+ return Err("Preferences path must not be empty".to_string());
+ }
+ Ok(Path::new(path))
+}
+
+fn enabled_provider_set_changed(before: &[ProviderId], after: &[ProviderId]) -> bool {
+ before.iter().copied().collect::>() != after.iter().copied().collect()
+}
+
+/// Write the current portable preferences to `path`; returns how many were written.
+#[tauri::command]
+pub fn export_preferences(path: String) -> Result {
+ let path = checked_path(&path)?;
+ let document =
+ PreferencesDocument::from_settings(&Settings::load()).map_err(|error| error.to_string())?;
+ document
+ .write_file(path)
+ .map_err(|error| error.to_string())?;
+ tracing::info!(count = document.len(), "exported portable preferences");
+ Ok(document.len())
+}
+
+/// Apply the preferences file at `path` to this machine's settings.
+///
+/// Nothing is saved when the file is rejected. On success the same live
+/// updates as `update_settings` follow: locale, float bar, tray and dependent
+/// windows, a local cost cache reset when the reporting period changed, plus a
+/// provider cache prune and refresh when the enabled set changed.
+#[tauri::command]
+pub async fn import_preferences(
+ app: tauri::AppHandle,
+ path: String,
+) -> Result {
+ let path = checked_path(&path)?;
+ let document = PreferencesDocument::read_file(path).map_err(|error| error.to_string())?;
+ let mut settings = Settings::load();
+ let previous_language = settings.ui_language;
+ let previous_period = settings.cost_reporting_period;
+ let previous_enabled = settings.get_enabled_provider_ids();
+ let applied = document
+ .apply_to(&mut settings)
+ .map_err(|error| error.to_string())?;
+ settings.save().map_err(|error| error.to_string())?;
+ tracing::info!(applied, "imported portable preferences");
+ if settings.cost_reporting_period != previous_period {
+ // Cached local cost summaries cover the old period's window.
+ super::clear_provider_local_usage_cache();
+ }
+
+ if settings.ui_language != previous_language {
+ let _ = app.emit(events::LOCALE_CHANGED, language_label(settings.ui_language));
+ }
+ let enabled_ids = settings.get_enabled_provider_ids();
+ let providers_changed = enabled_provider_set_changed(&previous_enabled, &enabled_ids);
+ if providers_changed {
+ let state = app.state::>();
+ let _ = super::invalidate_provider_refresh_and_prune_disabled(&state, &enabled_ids);
+ }
+ crate::floatbar::notify_settings_changed(&app);
+ crate::floatbar::apply_state(&app, &settings);
+ crate::tray_bridge::rebuild_tray_menu(&app);
+ crate::tray_bridge::refresh_tray_presentation(&app);
+ events::emit_settings_changed(&app);
+
+ if providers_changed {
+ let refresh_app = app.clone();
+ tauri::async_runtime::spawn(async move {
+ let _ = super::do_refresh_providers(&refresh_app).await;
+ });
+ }
+ Ok(SettingsSnapshot::from(settings))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::enabled_provider_set_changed;
+ use codexbar::core::ProviderId;
+
+ #[test]
+ fn refresh_decision_tracks_provider_membership() {
+ let before = [ProviderId::Claude, ProviderId::Codex];
+ assert!(!enabled_provider_set_changed(
+ &before,
+ &[ProviderId::Codex, ProviderId::Claude]
+ ));
+ assert!(enabled_provider_set_changed(&before, &[ProviderId::Claude]));
+ }
+}
diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs
index 418bd5c146..9a0eeb8da0 100644
--- a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs
+++ b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs
@@ -11,6 +11,9 @@ pub struct ProviderDetail {
pub enabled: bool,
pub auto_resume_after_quota_reset: bool,
pub auto_resume_supported: bool,
+ /// LiteLLM/Claude expose one opt-in extra breakdown; other providers do not.
+ pub optional_details_supported: bool,
+ pub optional_details_enabled: bool,
// Identity
pub email: Option,
@@ -23,11 +26,24 @@ pub struct ProviderDetail {
// Usage windows — reuse existing RateWindowSnapshot shape.
pub session: Option,
pub weekly: Option,
+ /// Provider-declared label for the session (primary) lane; None when the
+ /// provider uses the generic "Session" wording.
+ pub primary_label: Option,
+ /// Provider-declared label for the weekly (secondary) lane; None when the
+ /// provider uses the generic "Weekly" wording.
+ pub secondary_label: Option,
pub model_specific: Option,
pub tertiary: Option,
/// Locale key naming the tertiary lane when it carries a semantic label
/// beyond "Tertiary" (upstream F5). Drives the settings metric picker.
pub tertiary_label_key: Option<&'static str>,
+ /// Id of the extra rate window holding the provider's monthly plan
+ /// allowance (upstream 0.70.0 #4072). Offers the Monthly Plan metric in
+ /// the settings metric picker.
+ pub monthly_plan_window_id: Option<&'static str>,
+ /// Metric picker label for the primary lane when it is not a session
+ /// window (upstream `menuBarLayoutPrimaryLabel`).
+ pub primary_metric_label: Option<&'static str>,
pub extra_rate_windows: Vec,
pub usage_items: Vec,
pub hidden_usage_item_ids: Vec,
@@ -37,6 +53,9 @@ pub struct ProviderDetail {
// Cost / pace.
pub cost: Option,
pub pace: Option,
+ /// Per-day OpenAI Admin API history for the Settings daily usage chart.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub open_ai_api_usage: Option,
// Error / state.
pub last_error: Option,
@@ -57,6 +76,31 @@ pub struct ProviderDetail {
pub usage_source: Option,
pub cookie_source: Option,
pub region: Option,
+ /// True when the Manual cookie source has no usable header and the
+ /// provider opted in to failing closed (`manual_empty_cookie_policy`).
+ /// Drives the "No cookie header pasted." hint and the switch-to-Auto action.
+ pub manual_cookie_missing: bool,
+}
+
+/// Same decision the refresh path makes in `build_fetch_context`, gated on the
+/// provider's fail-closed policy so opening the pane never triggers a browser
+/// cookie import.
+fn manual_cookie_missing_for(id: ProviderId, settings: &Settings) -> bool {
+ if settings.cookie_source(id) != "manual"
+ || instantiate_provider(id).manual_empty_cookie_policy()
+ != ManualEmptyCookiePolicy::FailClosedWeb
+ {
+ return false;
+ }
+ let token_accounts = TokenAccountStore::new().load().unwrap_or_default();
+ build_fetch_context(
+ id,
+ settings,
+ &ManualCookies::load(),
+ &ApiKeys::load(),
+ &token_accounts,
+ )
+ .manual_cookie_missing
}
pub(crate) fn build_provider_detail(
@@ -73,15 +117,7 @@ pub(crate) fn build_provider_detail(
let provider = instantiate_provider(id);
let metadata = provider.metadata();
let resume_supported = auto_resume_supported(id);
- let dashboard_url = if id == codexbar::core::ProviderId::MiniMax {
- Some(
- codexbar::providers::MiniMaxProvider::dashboard_url_for_region(Some(
- settings.api_region(id),
- )),
- )
- } else {
- metadata.dashboard_url.map(|s| s.to_string())
- };
+ let dashboard_url = provider_dashboard_url(id, &settings);
let detail = ProviderDetail {
id: id.cli_name().to_string(),
@@ -89,6 +125,8 @@ pub(crate) fn build_provider_detail(
enabled,
auto_resume_after_quota_reset: settings.auto_resume_after_quota_reset(id),
auto_resume_supported: resume_supported,
+ optional_details_supported: codexbar::settings::provider_has_optional_details(id),
+ optional_details_enabled: settings.optional_details_enabled(id),
email: None,
plan: None,
auth_type: None,
@@ -97,9 +135,13 @@ pub(crate) fn build_provider_detail(
last_updated: None,
session: None,
weekly: None,
+ primary_label: None,
+ secondary_label: None,
model_specific: None,
tertiary: None,
tertiary_label_key: metadata.tertiary_label_key,
+ monthly_plan_window_id: provider.monthly_plan_window_id(),
+ primary_metric_label: provider.menu_bar_primary_label(),
extra_rate_windows: Vec::new(),
usage_items: Vec::new(),
hidden_usage_item_ids: settings.hidden_usage_item_ids(id),
@@ -107,6 +149,7 @@ pub(crate) fn build_provider_detail(
display_details: Vec::new(),
cost: None,
pace: None,
+ open_ai_api_usage: None,
last_error: None,
error_state: None,
dashboard_url: dashboard_url.clone(),
@@ -122,6 +165,7 @@ pub(crate) fn build_provider_detail(
usage_source: provider_usage_source_lookup(&settings, id.cli_name()),
cookie_source: provider_cookie_source_lookup(&settings, id.cli_name()),
region: provider_region_lookup(&settings, id.cli_name()),
+ manual_cookie_missing: manual_cookie_missing_for(id, &settings),
};
Ok((detail, settings, id))
@@ -154,6 +198,14 @@ pub fn get_provider_detail(
detail.email = snapshot.account_email.clone();
detail.plan = snapshot.plan_name.clone();
detail.organization = snapshot.account_organization.clone();
+ if parsed_provider_id == ProviderId::Helmcode {
+ detail.dashboard_url = Some(
+ codexbar::providers::helmcode::dashboard_url_for_organization(
+ snapshot.account_organization.as_deref(),
+ )
+ .to_string(),
+ );
+ }
detail.source_label = if snapshot.source_label.is_empty() {
None
} else {
@@ -165,13 +217,15 @@ pub fn get_provider_detail(
super::usage_item_descriptors(Some(&snapshot), &settings, parsed_provider_id);
detail.session = Some(snapshot.primary.clone());
detail.weekly = snapshot.secondary.clone();
- detail.model_specific = snapshot.model_specific.clone();
+ detail.primary_label = snapshot.primary_label.clone();
+ detail.secondary_label = snapshot.secondary_label.clone();
detail.tertiary = snapshot.tertiary.clone();
detail.extra_rate_windows = snapshot.extra_rate_windows.clone();
detail.inventory = snapshot.inventory.clone();
detail.display_details = snapshot.display_details.clone();
detail.cost = snapshot.cost.clone();
detail.pace = snapshot.pace.clone();
+ detail.open_ai_api_usage = snapshot.open_ai_api_usage.clone();
}
detail.last_error = snapshot.error.clone();
detail.error_state = Some(snapshot.error_state);
diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_refresh.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_refresh.rs
index 80675e1eca..a92aa1bdc4 100644
--- a/apps/desktop-tauri/src-tauri/src/commands/provider_refresh.rs
+++ b/apps/desktop-tauri/src-tauri/src/commands/provider_refresh.rs
@@ -87,7 +87,7 @@ fn provider_cache_can_skip_refresh(
.iter()
.any(|snapshot| snapshot.provider_id == id.cli_name())
});
- if !force && crate::proof_harness::seed_usage_json_active() && cache_has_all {
+ if !force && state.provider_cache_seeded && cache_has_all {
return true;
}
!force
@@ -125,6 +125,38 @@ pub(super) fn complete_provider_refresh(
mod tests {
use super::*;
+ #[test]
+ fn validated_seed_pins_only_complete_nonforced_cache() {
+ let mut state = AppState::new();
+ state.provider_cache.push(
+ crate::proof_harness::parse_seed_usage_snapshot(
+ r#"{"providerId":"codex","primary":{"usedPercent":25.0}}"#,
+ )
+ .unwrap(),
+ );
+ assert!(!provider_cache_can_skip_refresh(
+ &state,
+ false,
+ &[ProviderId::Codex]
+ ));
+ state.provider_cache_seeded = true;
+ assert!(provider_cache_can_skip_refresh(
+ &state,
+ false,
+ &[ProviderId::Codex]
+ ));
+ assert!(!provider_cache_can_skip_refresh(
+ &state,
+ true,
+ &[ProviderId::Codex]
+ ));
+ assert!(!provider_cache_can_skip_refresh(
+ &state,
+ false,
+ &[ProviderId::Codex, ProviderId::Claude]
+ ));
+ }
+
#[test]
fn stale_inputs_cannot_reserve_a_new_generation_after_invalidation() {
let mut state = AppState::new();
diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs
index f9344fcfba..e3d0a5bc71 100644
--- a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs
+++ b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs
@@ -132,6 +132,27 @@ pub fn set_provider_auto_resume_after_quota_reset(
Ok(())
}
+/// Persist the opt-in for a provider's optional extra breakdown (LiteLLM model
+/// activity, Claude workspace spend). Takes effect on the next refresh.
+#[tauri::command]
+pub fn set_provider_optional_details(
+ app: tauri::AppHandle,
+ provider_id: String,
+ enabled: bool,
+) -> Result<(), String> {
+ let id = parse_provider_arg(&provider_id)?;
+ if !codexbar::settings::provider_has_optional_details(id) {
+ return Err(format!(
+ "Provider '{provider_id}' has no optional detail breakdown"
+ ));
+ }
+ let mut settings = Settings::load();
+ settings.set_optional_details_enabled(id, enabled);
+ settings.save().map_err(|e| e.to_string())?;
+ crate::events::emit_settings_changed(&app);
+ Ok(())
+}
+
// ── OpenRouter Management API key ────────────────────────────────────
#[tauri::command]
@@ -223,9 +244,12 @@ fn cookie_source_provider(provider_id: &str) -> Option ProviderId::Sakana,
"notion" => ProviderId::Notion,
"grok" => ProviderId::Grok,
+ "muse" => ProviderId::Muse,
"replicate" => ProviderId::Replicate,
+ "raycast" => ProviderId::Raycast,
"helmcode" => ProviderId::Helmcode,
"typesafe" => ProviderId::TypeSafe,
+ "hyper" => ProviderId::Hyper,
_ => return None,
})
}
@@ -272,6 +296,7 @@ fn region_provider(provider_id: &str) -> Option {
"alibabatokenplan" => ProviderId::AlibabaTokenPlan,
"zai" => ProviderId::Zai,
"minimax" => ProviderId::MiniMax,
+ "kimi" => ProviderId::Kimi,
_ => return None,
})
}
@@ -284,6 +309,10 @@ pub(crate) fn provider_region_lookup(settings: &Settings, provider_id: &str) ->
))
.settings_value()
.to_string()
+ } else if id == codexbar::core::ProviderId::Kimi {
+ codexbar::providers::KimiRegion::from_settings(Some(settings.api_region(id)))
+ .settings_value()
+ .to_string()
} else {
settings.api_region(id).to_string()
}
@@ -323,12 +352,16 @@ fn workspace_provider(provider_id: &str) -> Option {
Some(match provider_id {
"openaiapi" => ProviderId::OpenAIApi,
"litellm" => ProviderId::LiteLLM,
+ "llmman" => ProviderId::LLMMan,
"devin" => ProviderId::Devin,
"opencodego" => ProviderId::OpenCodeGo,
"zed" => ProviderId::Zed,
+ "llmproxy" => ProviderId::LLMProxy,
"xai" => ProviderId::Xai,
"v0" => ProviderId::V0,
"helmcode" => ProviderId::Helmcode,
+ "gitkraken" => ProviderId::GitKraken,
+ "muse" => ProviderId::Muse,
_ => return None,
})
}
@@ -384,7 +417,22 @@ fn litellm_workspace_change_allowed(
mod tests {
use codexbar::core::ProviderId;
- use super::{litellm_workspace_change_allowed, workspace_provider};
+ use super::{gateway_provider, litellm_workspace_change_allowed, workspace_provider};
+
+ #[test]
+ fn muse_exposes_cookie_source_and_browser_team_settings() {
+ assert_eq!(workspace_provider("muse"), Some(ProviderId::Muse));
+ assert_eq!(
+ super::cookie_source_provider("muse"),
+ Some(ProviderId::Muse)
+ );
+ let values: Vec =
+ super::cookie_source_options_for("muse", codexbar::settings::Language::English)
+ .into_iter()
+ .map(|option| option.value)
+ .collect();
+ assert_eq!(values, ["auto", "manual", "off"]);
+ }
#[test]
fn maps_opencode_go_workspace_provider() {
@@ -394,6 +442,56 @@ mod tests {
);
}
+ #[test]
+ fn fetch_context_carries_saved_gateway_urls_for_every_gateway_provider() {
+ use codexbar::settings::{ApiKeys, ManualCookies, Settings};
+ use std::collections::HashMap;
+
+ let mut settings = Settings::default();
+ for (id, url) in [
+ (ProviderId::Wayfinder, "http://localhost:8787"),
+ (ProviderId::Bifrost, "https://bifrost.example.com"),
+ (ProviderId::Aixy, "https://aixy.example.com/prefix"),
+ ] {
+ settings.set_gateway_url(id, url);
+ let ctx = super::super::providers::build_fetch_context(
+ id,
+ &settings,
+ &ManualCookies::default(),
+ &ApiKeys::default(),
+ &HashMap::new(),
+ );
+ assert_eq!(ctx.gateway_url.as_deref(), Some(url), "{id:?}");
+ }
+
+ let ctx = super::super::providers::build_fetch_context(
+ ProviderId::Codex,
+ &settings,
+ &ManualCookies::default(),
+ &ApiKeys::default(),
+ &HashMap::new(),
+ );
+ assert_eq!(ctx.gateway_url, None);
+ }
+
+ #[test]
+ fn maps_gitkraken_organization_provider() {
+ assert_eq!(workspace_provider("gitkraken"), Some(ProviderId::GitKraken));
+ }
+
+ #[test]
+ fn gateway_provider_exposes_gateway_providers_only() {
+ assert_eq!(gateway_provider("wayfinder"), Some(ProviderId::Wayfinder));
+ assert_eq!(gateway_provider("bifrost"), Some(ProviderId::Bifrost));
+ assert_eq!(gateway_provider("aixy"), Some(ProviderId::Aixy));
+ assert_eq!(gateway_provider("codex"), None);
+ }
+
+ #[test]
+ fn maps_llmman_workspace_provider() {
+ assert_eq!(workspace_provider("llmman"), Some(ProviderId::LLMMan));
+ }
+
#[test]
fn litellm_endpoint_change_requires_reentering_saved_key() {
assert!(
@@ -445,7 +543,19 @@ pub fn get_provider_workspace_id(provider_id: String) -> Result