Merge remote-tracking branch 'upstream/main' into HEAD
23
CHANGELOG.md
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
## 0.9.21 - 2026-08-24
|
||||
|
||||
### Added
|
||||
- **Copilot AI Credit plans.** `codeburn plan set copilot-pro` (1,500 credits / $15 equivalent, not the $10 sticker), `copilot-pro-plus` (7,000), `copilot-max` (20,000), and `custom --credits N --provider copilot`. Spend is `sum(finite nanoAiu) / 1e9`. Token-priced `costUSD` does not fill the credits bar. Claude / cursor / grok / custom-USD plans stay on `costUSD`. (#943)
|
||||
- **`codeburn models --unpriced`.** The dashboard warns about models that price at $0 and points at `codeburn model-alias`, but the list itself was hard to get out of the TUI. This filters the plain-stdout `models` report to exactly those rows, reusing `findUnpricedModels` so local, free, aliased and price-overridden models are treated the same way the warning treats them, and defaulting that mode's min-cost to 0 so $0 rows are not pre-filtered away. Thanks @kocaemre. (#969)
|
||||
|
|
@ -11,6 +13,8 @@
|
|||
|
||||
- **`CODEBURN_CACHE_SCOPE=all` forces a full session-cache read.** A ranged query reads only the month shards that can contribute a turn to it, which is a real behaviour change on a warm cache; this is the escape hatch for the case where a number looks wrong and you want to know whether the scoped read is why. Set it and every load ignores its scope and reads every shard, one-shot runs and the resident `codeburn serve` alike. It is a read policy, not an input to any cache fingerprint: setting or unsetting it re-parses nothing and invalidates nothing.
|
||||
|
||||
- **The interactive dashboard opens on Today, and falls back to 7 days when today is empty.** Only the CLI TUI ever defaulted to 7 days; the desktop app and the web dashboard already opened on Today and needed the fallback half alone. The TUI takes the auto path only when `-p` came from the default rather than from you, with no `--day` and no `--from`/`--to`, and only on a TTY — so piped runs, `--format json`/`csv`/`markdown`, `report`, `sessions` and `models` stay on week, byte for byte. The probe costs nothing: it is the today slice of the parse the dashboard already runs, and it counts sessions rather than projects, because a project can survive the slice on a subagent anchor with no sessions behind it. The period strip shows what actually opened, keys 1-6 and every explicit selection win over the fallback, and the app's persisted `defaultPeriod` preference always wins. On a cold cache an empty today costs one extra repaint — the first paint's file floor is Today's, so the 7-day view is re-painted once on the wider floor from the cache pass one just wrote, about 2.3s on a 16.7k-file corpus and only when the cache is empty and the day unused. (#1113)
|
||||
|
||||
### Added (OrcaRouter)
|
||||
- **OrcaRouter sessions now price and label like the model they route to.** OrcaRouter is a gateway that exposes route ids (`orcarouter/auto`, `orcarouter/fusion`, …) plus plain upstream ids (`deepseek/deepseek-v4-pro`), and reports the upstream id that actually ran in the completion response's `model` field. The `orcarouter/` prefix is now a routing wrapper like `cmd/` / `antigravity/`, so a routed spelling prices at its upstream LiteLLM row instead of $0; `orcarouter/auto` stays unpriced (the smart route lands on a Qwen/Llama flash model — a Sonnet alias would overprice it) and the fusion routes alias to their current `openai/gpt-oss-120b` target (live completion `model` field, 2026-08); and an unknown vendor nested inside the route still fails closed. Daily cache v29 so warm days re-derive the new prices.
|
||||
|
||||
|
|
@ -22,6 +26,10 @@
|
|||
- **Copilot input/cache tokens are read per request from `~/.copilot/session-store.db`.** Previously, codeburn relied on `session.shutdown` rollups from the Copilot CLI and GitHub Copilot desktop app. Those rollups are written only after a clean shutdown, stamp all usage on the shutdown day, and reset their counters at in-session compaction — so a crash could lose an entire session's input/cache usage, and even cleanly-closed long sessions were silently truncated. On one machine with long history, reading the per-request rows recovered about 35% of actual Copilot spend. Covered sessions now use per-request tokens with their real timestamps, counted exactly once against existing rollups and never added as extra calls or turns. Pre-store CLI sessions continue using the unchanged rollup path, and a locked or unreadable store defers only its own re-read instead of prematurely sealing daily history. Copilot reasoning tokens are also no longer double-billed: they are a subset of output already priced through the per-turn calls. This triggers a one-time re-parse, with the daily cache bumped to v26 to re-derive finalized days. Because that reconciliation keeps changing while a session is live, `codeburn sync push` holds a Copilot session back until it has been quiet for 24 hours and then sends it once, final — the sent-ledger is append-once, so a value sent mid-reconciliation could never be corrected at the receiver (#988). Sync also pins each Copilot session to whichever of the two shapes it was first synced in — the `session.shutdown` rollup, or per-request rows plus a residual — because a usage span cannot be retracted and sending the same tokens in the other shape would double them at the receiver permanently. That holds in both directions: a session synced before this release keeps its rollup and never sends rows, and a session synced as rows never sends the rollup that starts serving again once the 90-day age-out prunes them. `--dry-run` reports the frozen count, and `codeburn sync reset --confirm` re-pushes everything under the new breakdown for anyone who can clear the receiver too. (#946)
|
||||
- **DeepSeek Harness (`dsh`) is now a supported provider.** Reads DeepSeek's open-source agent harness from `~/.dsh/sessions` (`DSH_HOME` relocates the root), both the default zstd logs and the uncompressed `session.jsonl` variant. A `.zstd` log is a concatenation of independent zstd frames, one per write batch, so it is decoded frame by frame behind a structural frame scan and a torn trailing frame from a crashed writer is ignored rather than failing the file (needs Node 22.15+ for `zlib` zstd; below that dsh is skipped with a notice instead of counted as $0). One call per `(turn, step)`, with the step's final `assistant/message` usage superseding the streamed `assistant/chunk` sample of the same call rather than adding to it, the model taken from the message that served the step, and reasoning tokens billed at the output rate. DSH records tokens but no cost, so calls are priced from the shared tables. The events a forked session replays from its parent are skipped, since codeburn already counts the parent's own log. The session format is pinned at version 0 upstream with no compatibility implied, so a log stamped with any other version is skipped with a notice instead of read under today's assumptions.
|
||||
|
||||
### Added (Desktop & Menubar)
|
||||
- **Live quota for Gemini, Copilot and Antigravity in the desktop app.** The app's quota module knew claude and codex only; it now carries three more providers, all read from local credentials the tools already wrote — no cookies, no logins, no credential writes anywhere. Gemini goes through Code Assist (`loadCodeAssist` / `retrieveUserQuota`) using the Gemini CLI's own on-disk OAuth material, refreshing a token only when the CLI's documented env overrides are set and keeping the refreshed token in memory; a retired consumer tier degrades to a terminal failure carrying migration guidance rather than a wrong number. Copilot reads the editor-plugin token from `~/.config/github-copilot/hosts.json` → `apps.json` and calls `copilot_internal/user`, re-reading once on a 401 — an internal API that may drift, so every failure degrades to an honest connection state. Antigravity probes the local language server over loopback only (`RetrieveUserQuotaSummary`, falling back to `GetUserStatus`), discovering it through `ps`/`lsof` with regex-validated pids and relaxing TLS strictly for 127.0.0.1 self-signed certificates. Settings gains a per-provider toggle, defaulting on for detected providers; a disabled provider's fetcher never runs, so it reads no credential and makes no request. Provider display names come from a map rather than hardcoded pairs, sign-in guidance is honest per provider, and error sanitization now redacts Google `ya29.` and GitHub `gh*_` token shapes. Kimi live quota already exists in the macOS menubar and was audited rather than duplicated. (#1114)
|
||||
- **The macOS menubar reaches quota parity with the desktop app, and gets a System Settings-style Settings window.** The three providers above are ported into the menubar read-only from the app's Electron services, on the same load-state machine Kimi, Claude and Codex already use: Gemini through Code Assist and `~/.gemini/oauth_creds.json` (in-memory refresh only, handling Google's CLI-OAuth tier retirement), Copilot through `copilot_internal/user` and the editor plugin token in `~/.config/github-copilot` (preferring the github.com host), and Antigravity through `ps`+`lsof` discovery and Connect-RPC probes over loopback HTTPS with the self-signed-certificate trust override scoped strictly to 127.0.0.1. Settings is rebuilt as a System Settings-style window modeled on CodexBar (MIT, steipete/CodexBar): a fixed 260pt sidebar with native search, SF Symbol gradient chips, official provider marks (LobeHub icon set) with connected dots and an N-on counter, and a grouped About pane whose Updates row is wired to the existing update checker. The window is resizable and fullscreen-capable, titles itself after the visible pane, and recenters when it would be restored off-screen. Alongside it: an animated flame-gradient wordmark in the popover header at parity with the site, respecting Reduce Motion; the website's binary `0101` flame as the app icon, the full-colour flame in the About hero and a solid silhouette in the About sidebar row; one tagline everywhere; and em-dashes removed from UI copy. `package-app.sh` now ships the SwiftPM resource bundle inside the `.app` — without it `Bundle.module` aborts at the first icon render, which the release workflow would have shipped. (#1133)
|
||||
|
||||
### Changed
|
||||
- **Report, sessions, overview, compare, export, report JSON and menubar `localModelSavings` now use `billableOutputTokens` for displayed output.** Cost already did. Exclusive providers (Grok and the rest) were under-reporting output by exactly their reasoning tokens; optimize added reasoning on top of output for the inclusive set `{claude, codex, copilot}` and double-counted it. Displayed aggregates now bill per call while the provider is known, joined onto an existing `modelBreakdown` key (parser short name first, then the raw id) so a leftover raw-id bucket cannot mint a $0 `Opus 4.8` Unpriced orphan. Daily cache v28 re-derives finalized days so `report` matches the live parse. Thanks @saulcanina. (#1115)
|
||||
- **The desktop app's cold start paints as soon as the selected period is readable, instead of after the whole history.** The resident `codeburn serve` child now does for its JSON clients what the TUI got in #1107: on a cold cache it answers the first `status --format menubar-json` from the files whose mtime allows them to hold data the requested period can show (the same floor and 48h clock-skew margin), then indexes the rest behind the answer. The payload says so in-band through a new optional `hydration: { complete, indexedFiles, totalFiles }` block, and the desktop app and web dashboard render `indexing history · N/M files · totals below cover what is indexed so far` until it reports `complete: true`. On a 21k-file corpus the app's first payload lands in 7.3s instead of 31.3s, fully indexed at 36.2s. Only the resident serve process ever emits `hydration`, and only it may answer partially — its clients poll, so they converge. Every one-shot output is unchanged and always a full parse: `--format json`/`csv`/`markdown`, `report`, `sessions`, `models`, MCP, the Swift menubar and GNOME clients, and the desktop app's own spawn fallback, none of which have a second poll to converge with. Absence of `hydration` therefore always means complete, which is also what an older CLI's payload means. It is not `stale` (#1100) and never sets it: a first paint is fresh but partial, where a stale payload is complete but old. Nothing is stamped complete until the fill has actually parsed everything, so a process killed mid-fill comes back cold and finishes the job. (#1110)
|
||||
|
|
@ -38,6 +46,9 @@
|
|||
- **A date-ranged report classifies only the turns it keeps.** Every cached turn went through the turn classifier — category, retries, edit detection, and a full reconstruction of its API calls — before the date slice discarded most of them, so a week view paid to classify all of history to keep a few percent of it. The keep/drop decision is now taken on the raw cached turn and only the survivors are classified, still from their complete call list, with the branch and pull-request carries still walking the full ordered turn list. Output is byte-identical.
|
||||
- **One rule for every cache file.** `CODEBURN_CACHE_DIR` when set, otherwise `~/.cache/codeburn`. `XDG_CACHE_HOME` is no longer consulted; the sync ledger, the only file that ever honored it, is merged into the canonical location on first read and the legacy copy is retired, so nothing is re-uploaded after the move. (#972)
|
||||
|
||||
- **A cold interactive launch paints the dated view first and indexes the rest behind it.** An empty cache parsed all ~21k files before painting a dashboard that shows 7 days. On a cold interactive TTY launch with a dated default view, the first paint now parses only the files that can hold in-range data — `fp.mtimeMs >= rangeStart - 48h`, the clock-skew margin — which is safe because a session log's last event is never later than its mtime, so an older file provably cannot move the dated view; a file that already has a cache entry is never deferred, and network sources always load. The deferred files are parsed by the existing background reload without blocking keys, writing the per-file cache exactly as a full cold parse would and refreshing the panels when it lands, with an `indexing history · N/M files · totals below cover what is indexed so far` banner until it converges. Nothing is marked seen without being parsed: a first paint that deferred anything cannot stamp the cache complete, so a run killed mid-fill re-enters cold and converges, and the floored parse's memo key carries a `:paint<floor>` suffix so it can never be served to an unfloored request. The path is gated on `isTTY` with no `--day` and no `--from`/`--to`, so one-shot JSON/CSV/markdown, `report`/`sessions`/`models` and every serve, menubar and app payload never enter it and never return partial data. On a real 21k-file corpus with an empty cache, time to first paint drops from 36.3s to 9.9s, and full indexing finishes at 47.5s instead of 36.1s — about eleven extra background seconds, paid while you are already looking at data. Two deliberate limits: a period switch during the fill queues behind it rather than slicing partially, and the plan-usage bar can read low until the fill lands, which the banner covers. (#1109)
|
||||
- **The dashboard parses once per launch instead of up to three times.** Rendering the dashboard issued three `parseAllSessions` calls — the scan, the plan usages and the durable overview — whose ranges differed only in their ends, about a second apart, so the exact-key memo never hit and each one paid a full parse. The dashboard now declares its widest range up front, and any request inside it that is a pure narrowing — same start, an end no later, the same month-shard scope — is served by slicing that single parse. `month` goes from 2 parses to 1 and `today` from 3 to 2; the plan window starts on the 1st while the scan starts at midnight, and two different starts cannot be merged under the rule that makes this provably lossless, because a wider parse reads files a narrower one never sees and its dedup seeding can drop an in-range turn the narrower parse keeps. Alongside it the cross-provider PR correlation stops re-filtering the session list per child and the launch list per candidate, using a one-shot agent index and a windowed scan over a sorted array instead; the match sets are identical. On a frozen corpus, warm `today` in the TUI goes 5.12s to 3.81s and `month` 5.46s to 3.41s, with every checked warm and cold output byte-identical. (#1108)
|
||||
|
||||
### Changed (Linux packaging)
|
||||
- **The snap asks for the log directories it reads, not each tool's whole home.** The first Snap Store submission declared a `personal-files` read of every AI tool's root — `$HOME/.claude`, `$HOME/.codex`, `$HOME/.cursor` and the rest — and that interface is recursive, so it granted read of every credential file those roots hold. Each entry now names the subdirectory the provider actually opens (`.claude/projects`, `.codex/sessions`, `.cline/data`, `.vibe/logs/session`, `.dsh/sessions`, `.kiro/sessions`, `.quickwork/{profiles.json,sessions,metrics}`, `.config/Claude/local-agent-mode-sessions`, `.config/Open Design/{runs,data/runs,namespaces}`), two are single files (`.forge/.forge.db`, `.zcode/cli/db/db.sqlite`), and the editor entries name only the extension folders holding transcripts instead of the editor's whole configuration. Five providers that were missing entirely and would have shown no data are declared — opencode, crush, goose, kilo, kimi-code — and four roots stay roots only because the file the provider opens sits directly in them (`.config/github-copilot`, `.local/share/{opencode,crush,kilo}`). One credential file is now requested openly rather than implicitly: `.claude/.credentials.json`, read-only, for the live plan gauge. Codex's equivalent would need write access to the Codex CLI's own `auth.json` to rotate the token, so neither it nor a Codex root is declared and the Codex live gauge is disabled under `$SNAP`; Codex usage and cost are unaffected, they come from the session rollouts. Two consequences inside the snap: `.lingtai` is dropped, because its per-agent log directory needs a wildcard the interface has no form for, and `optimize`, `context-budget` and `act` no longer see the user-scope `~/.claude/settings.json`, `agents/`, `skills/` and `commands/` — project-scope copies still work through the `home` plug. Nothing outside the snap changes.
|
||||
|
||||
|
|
@ -48,6 +59,8 @@
|
|||
- **First launch no longer asks to control System Events.** The macOS menubar registered its login item by driving System Events over AppleScript, which made macOS put up an Automation consent dialog the first time the app ran. It now registers itself through `SMAppService.mainApp`, an in-process call that needs no Automation grant; there is no AppleScript fallback, so a failure logs and leaves the login item unset rather than bringing the prompt back. The same `codeburn.loginItemRegistered` guard still limits this to the first launch, so a login item you removed by hand stays removed. (#1026)
|
||||
- **The resident `codeburn serve` child.** The first real panel request is also the cache warm-up, so startup never runs an artificial warm-up query beside a duplicate one-shot child; each served command carries its own read-only option allowlist, and anything outside it falls back to a normal spawn; the child exits when its stdin closes, so it can never outlive the app. Requests whose response exceeds the 16 MiB frame limit still replace the child, but that deliberate kill no longer spends the resident's unexpected-death budget. (#972)
|
||||
|
||||
- **The desktop app's Pull requests tab says why it is empty.** A PR row exists only where a transcript actually contains a pull-request URL, and the default period is Today, so a week of work through providers that never write those URLs left the tab blank with copy that named neither the period nor the reason — indistinguishable from a broken extractor. The empty note now names the selected period, and when the All period does hold rows it says how many and points at the period control. Attribution itself is untouched: no link is inferred, no PR is invented, and the default period is unchanged. (#1098)
|
||||
|
||||
### Fixed
|
||||
- **Hermes sessions that keep running after a day is sealed no longer lose later token and cost growth.** A sidecar ledger under the CodeBurn cache records last-seen lifetime totals per `(profile, sessionId)` and emits observation-time deltas (weight 0) so a sealed day stays put while today's growth still lands. An all-zero reset is visible to the cursor after discovery filtering, so a later 40 is `+40` rather than a silent shrink. Explicit `$0` is treated as recorded. Ledger publication failures are retryable and hold the daily watermark. (#916)
|
||||
- **`codeburn doctor` now probes the five remaining fixed-location providers.** `codebuff`, `devin`, `gemini`, `kiro`, and `mistral-vibe` implement `probeRoots()` through the same resolvers discovery uses, so a silent zero is distinguishable from a missing install. Codebuff reports all three manicode channels unless a factory or `CODEBUFF_DATA_DIR` pins one; Devin reports `transcripts` plus `sessions.db`, not the parent; Gemini reports only `~/.gemini/tmp`; Kiro reports pre-filter candidates (empty CLI/v2 skipped, empty agent/workspace fall back); Mistral Vibe reports the joined sessions dir. Missing defaults still appear. Thanks @therickfactr. (#899)
|
||||
|
|
@ -75,6 +88,16 @@
|
|||
- **Bash command splitting was quadratic on long whitespace-heavy commands.** The separator regex retried its leading `\s*` from every offset; matching the separator alone and widening over whitespace by hand makes cold parse ~24% and warm ~40% faster on large corpora, output unchanged.
|
||||
- **Cold parse no longer retains full message bodies through cached previews.** `flatSlice` skipped its Buffer round-trip for strings already within the bound, but provider adapters pre-truncate user-message previews with `.slice(0, 500)` before the cache-site call — those pre-sliced views are still V8 SlicedStrings pinning their large parent, so the retention that OOM'd cold parses of large histories survived. The round-trip now always runs.
|
||||
- **Kiro sessions carry the real `projectPath`** (CLI meta.cwd, v2 `workspacePaths[0]`, workspace sessions' `workspaceDirectory`), so git-repo attribution can resolve them; previously they were attribution-blind. Bumps the kiro parse version, so the first run after upgrade re-parses kiro history once, and kiro sessions in linked git worktrees now group under the main repo.
|
||||
- **A ranged parse can no longer write a day outside the range it was asked for.** A turn that straddles local midnight keeps its original anchor through range slicing, so the day aggregator could emit a residue day just outside the parsed window — cost, calls and tokens all zero, category counts only — and both ranged call sites, the re-derive and the gap fill, wrote it into durable history. The merge guards below defuse the overwrite, but the residue should never reach the cache at all, so both ranged sites now filter to the days actually in range. The aggregator and the slicer are untouched, so whole-corpus callers are unaffected. (#1131)
|
||||
- **The daily cache's gap-fill path can no longer overwrite a good day with a degraded parse, and a day left holding only midnight residue heals itself.** `ensureCacheHydrated`'s gap fill wrote days into durable history through a blind overwrite with no completeness gate, so a parse degraded by the refresh lock — the menubar refreshing concurrently is the ordinary way this happens — could permanently replace a settled day with its undercount; the re-derive path had completeness and partial-survival guards, the gap path had neither. A gap merge is now completeness-gated: a complete parse wins per (date, provider) through the same guarded merge the re-derive uses, while a partial parse can only fill gaps and never shrinks the baseline, and a `pendingRederive` is no longer silently dropped. A day holding only a straddling turn's category counts, with cost, calls and tokens at zero, now pulls the watermark back so the next launch re-derives it — bounded to the settle window and never the oldest cached day. `codeburn doctor` reports cache health read-only, listing residue-only dates and failed or empty session-cache entries, so the next report of this shape is one command rather than archaeology. There is no cache-version bump: every affected cache heals on its next launch. (#1129)
|
||||
- **`codeburn sync` no longer puts a filesystem path or a raw output count on the wire.** Every synced span carried `ai.project` as the slugified absolute path (`-Users-you-Projects-thing`), contradicting both `docs/sync/README.md` ("paths stay local") and the compatibility contract's "safe project basename"; the wire name is now the leaf directory name, derived at the single choke point after the parse, and `codeburn yield`'s local output is untouched. Two directories with the same leaf collapse to one wire identity — that is what the contract's project identity means — and `git.repo` disambiguates them. Separately, `ai.output_tokens` was the raw output field, so exclusive-reasoning providers were under-reported on the wire by exactly their reasoning volume (measured on one corpus: an opencode session short by 37,848 tokens, its summed reasoning); it now goes through the same `billableOutputTokens` helper the display layer took, leaving the inclusive providers byte-identical. Cost is unaffected, being priced before export. Neither field is part of a span id, so nothing is re-sent and nothing duplicates at the receiver: usage span ids and commit attribution keys carry no project, and session attribution re-emits once per session under an upsert keyed on (org, trace). (#1126)
|
||||
- **Outbound project metadata now requires proven provenance instead of failing open.** The basename mapper above still accepted whatever the cache held, so a Claude path slug, a generated session title, a home root, a relative path, a provider's own storage directory, a credential-shaped basename or a provider-owned container path could all become an outbound project label. The privacy decision moves to the OTLP boundary and demands explicit provenance: a usage span's `ai.project` comes only from a provider-recorded absolute cwd, attribution resolves repositories only from that same trusted cwd and derives its `ai.project` from the normalized `git.repo`, and a legacy cached cwd with no provenance fails closed. A relative cwd can never be resolved against the directory that happens to be running `sync`; Hermes prompt-derived paths, including the legacy Windows `Current working directory:` messages, stay local grouping labels and never become outbound provenance — a deliberate privacy correction; Goose propagates its own `sessions.working_dir` field while `/sessions/<container>` paths are rejected centrally; and provider, model, tool and span-name identifiers are sanitized at serialization, with real routed identifiers still supported. `ai.project` remains optional, so a receiver must accept an unattributed span; where a trace carries both labels and they differ, the attribution repository basename is authoritative and the provisional usage basename must not be counted as a second project. (#1128)
|
||||
- **Attribution spans no longer carry the session id in cleartext.** `codeburn sync push --attribution` copied `ai.session_id` onto session and commit spans even though `deriveTraceId(sessionId)` already keys both usage and attribution spans, and usage spans never carried the field; it is dropped from both, and the docs now match the wire. This is hygiene rather than a leak — the id existed on the wire only as hash input before attribution shipped — but a receiver that keyed session rows on `ai.session_id` must key on `traceId`, the same id usage spans already carry. The join to usage is unchanged, and the local sent-ledger keys still use the session id. (#1073)
|
||||
- **`codeburn doctor` names Buzz as a launcher instead of showing it as a second Codex tree.** Buzz sits on top of Codex, so a Buzz usage parser or a walk of `~/.buzz/.codex` as a second Codex home would count the same billed seat twice — but doctor only knew providers, so a nest holding no usage store of its own read as "nothing found". Buzz is now listed as a launcher billed through Codex, with no session count, as is Grok Bot when `~/.grok` is the real store, and Codex discovery returns nothing for a home nested under `~/.buzz` while a distinct primary Codex home exists. A sole Codex home that does live under `.buzz` is still counted. Spend stays on the Codex row; no Buzz parser and no rates were added. (#1099)
|
||||
- **`codeburn models` resolves raw ids the way `report` does, and stops splitting one SKU across two rows.** The report bucketed by raw id after asking each provider for a label, so a provider whose local table missed fell back to the raw id (`gpt-5.6-sol`, `accounts/fireworks/models/kimi-k2p6`) — and a later merge keyed on the display string, which summed genuinely distinct SKUs whose labels happen to collide into a single row. Provider-first resolution stays and local labels still win (the Cursor estimated suffix, provider overrides); a provider that merely echoes the raw id now falls through to the global short-name table, which `cursor-agent` and `vercel-gateway` also consult after their own transform. Rows merge on provider plus alias-resolved canonical id, so a path-form id merges with its bare slug and display names go back to being cosmetic: two SKUs that only share a label stay two rows. The first-seen raw id is kept, so `models --format json` is not rewritten to a lexically smallest spelling, and a Codex credit row mixing rated and unrated buckets partial-sums the rated ones and reports `creditsIncomplete` rather than a confident total. `audit` still buckets by raw id, because it recomputes rates per id. (#1053)
|
||||
- **A resumed Copilot CLI session's stampless shutdown legs no longer collapse onto one day.** Each leg of a resumed session appends its own `session.shutdown` rollup, and a journal whose legs carry `sessionStartTime` but no per-leg timestamp fell back to that start time — identical across every leg — so all of them billed onto one date and, with that stamp in the dedup key, onto one row. The timestamp fallback is now the event's own stamp, then the last event seen, and only then the session start, so a stampless leg lands on its last stamped event; the occurrence keys (`copilot:<sid>:shutdown:<model>:<n>`) are unchanged, so nothing already synced is re-sent under a new name. (#1054)
|
||||
- **`codeburn optimize --provider <x>` no longer tells you to ask Claude.** Provider filtering already skipped the Claude-only detectors, but the cross-provider ones — `retry-heavy-capabilities`, `low-worth-sessions`, `context-heavy-sessions`, `cost-outliers`, `mcp-project-scope` — baked "Ask Claude" and `CLAUDE.md` into their fix labels, and the CLI and TUI destination headers said Claude whatever the run was scoped to, with the JSON report carrying the same copy through. One remediation table now drives all of them: an unscoped run, `--provider all` and `--provider claude` keep the shipped Claude and `CLAUDE.md` copy; `codex` says Codex and `AGENTS.md`, the only other instruction file CodeBurn already names; every other provider gets its display name and a generic "project instructions" rather than an invented filename. Apply plans still write `CLAUDE.md` only for Claude-only findings, and the paste-destination values are unchanged. (#1049)
|
||||
- **Resizing the terminal or zooming its font produces one settled reflow instead of a burst of intermediate widths.** CodeBurn re-rendered on every `resize` event from `process.stdout` with the live `columns`/`rows`, and debouncing that listener alone cannot stop Ink, which runs its own resize handler. During a burst the dashboard now sees a frozen stdout facade and a private resize emitter, and on settle it re-renders once and lets Ink see a single resize — no write interceptor, which was the earlier shape's trap: swallowing frames still advanced Ink's last-output state, so a settle producing the same string wrote nothing and lost a mid-burst update forever. One transient old-width frame during the burst is accepted. Static and non-interactive output and the periodic refresh are unchanged. (#1038)
|
||||
|
||||
## 0.9.20 - 2026-08-10
|
||||
|
||||
|
|
|
|||
14
README.md
|
|
@ -32,12 +32,12 @@
|
|||
<td align="center" width="50%">
|
||||
<strong>Desktop</strong><br/>
|
||||
<img src="https://raw.githubusercontent.com/getagentseal/codeburn/main/assets/desktop.jpg" alt="CodeBurn Desktop" /><br/>
|
||||
<a href="https://github.com/getagentseal/codeburn/releases/download/desktop-v0.9.20/CodeBurn-0.9.20-arm64.dmg"><img src="https://img.shields.io/badge/macOS-Apple_Silicon-F97316?logo=apple&logoColor=white" alt="Download for macOS (Apple Silicon)" /></a>
|
||||
<a href="https://github.com/getagentseal/codeburn/releases/download/desktop-v0.9.20/CodeBurn-0.9.20.dmg"><img src="https://img.shields.io/badge/macOS-Intel-F97316?logo=apple&logoColor=white" alt="Download for macOS (Intel)" /></a>
|
||||
<a href="https://github.com/getagentseal/codeburn/releases/download/desktop-v0.9.21/CodeBurn-0.9.21-arm64.dmg"><img src="https://img.shields.io/badge/macOS-Apple_Silicon-F97316?logo=apple&logoColor=white" alt="Download for macOS (Apple Silicon)" /></a>
|
||||
<a href="https://github.com/getagentseal/codeburn/releases/download/desktop-v0.9.21/CodeBurn-0.9.21.dmg"><img src="https://img.shields.io/badge/macOS-Intel-F97316?logo=apple&logoColor=white" alt="Download for macOS (Intel)" /></a>
|
||||
<a href="https://apps.microsoft.com/detail/9P0R4ZL5XMB8"><img src="https://img.shields.io/badge/Windows-Microsoft_Store-F97316?logo=microsoft&logoColor=white" alt="Get CodeBurn from the Microsoft Store" /></a>
|
||||
<a href="https://github.com/getagentseal/codeburn/releases/download/desktop-v0.9.20/codeburn-desktop_0.9.20_amd64.deb"><img src="https://img.shields.io/badge/Linux-.deb-F97316?logo=debian&logoColor=white" alt="Download for Linux (.deb)" /></a>
|
||||
<a href="https://github.com/getagentseal/codeburn/releases/download/desktop-v0.9.20/codeburn-desktop-0.9.20.x86_64.rpm"><img src="https://img.shields.io/badge/Linux-.rpm-F97316?logo=redhat&logoColor=white" alt="Download for Linux (.rpm)" /></a>
|
||||
<a href="https://github.com/getagentseal/codeburn/releases/download/desktop-v0.9.20/CodeBurn-0.9.20.AppImage"><img src="https://img.shields.io/badge/Linux-AppImage-F97316?logo=linux&logoColor=white" alt="Download for Linux (AppImage)" /></a>
|
||||
<a href="https://github.com/getagentseal/codeburn/releases/download/desktop-v0.9.21/codeburn-desktop_0.9.21_amd64.deb"><img src="https://img.shields.io/badge/Linux-.deb-F97316?logo=debian&logoColor=white" alt="Download for Linux (.deb)" /></a>
|
||||
<a href="https://github.com/getagentseal/codeburn/releases/download/desktop-v0.9.21/codeburn-desktop-0.9.21.x86_64.rpm"><img src="https://img.shields.io/badge/Linux-.rpm-F97316?logo=redhat&logoColor=white" alt="Download for Linux (.rpm)" /></a>
|
||||
<a href="https://github.com/getagentseal/codeburn/releases/download/desktop-v0.9.21/CodeBurn-0.9.21.AppImage"><img src="https://img.shields.io/badge/Linux-AppImage-F97316?logo=linux&logoColor=white" alt="Download for Linux (AppImage)" /></a>
|
||||
</td>
|
||||
<td align="center" width="50%">
|
||||
<strong>Web</strong><br/>
|
||||
|
|
@ -55,7 +55,7 @@
|
|||
<strong>Menubar</strong><br/>
|
||||
<img src="https://raw.githubusercontent.com/getagentseal/codeburn/main/assets/menubar-app.jpg" alt="CodeBurn macOS menubar" /><br/>
|
||||
<code>codeburn menubar</code><br/>
|
||||
<a href="https://github.com/getagentseal/codeburn/releases/tag/windows-v0.9.20"><img src="https://img.shields.io/badge/Windows-Tray_app_(.msi)-F97316?logo=windows&logoColor=white" alt="Download the CodeBurn Windows menubar" /></a>
|
||||
<a href="https://github.com/getagentseal/codeburn/releases/tag/windows-v0.9.21"><img src="https://img.shields.io/badge/Windows-Tray_app_(.msi)-F97316?logo=windows&logoColor=white" alt="Download the CodeBurn Windows menubar" /></a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
|
@ -347,7 +347,7 @@ Windows gets the same ambient view from the system tray, from the same one comma
|
|||
codeburn menubar
|
||||
```
|
||||
|
||||
It downloads the `.msi` for your CLI version, verifies its sha256, runs it through `msiexec /passive`, and launches the tray app. Re-run with `--force` to reinstall; an already-installed matching version is just launched. You can also download the `.msi` yourself from the [latest Windows Menubar release](https://github.com/getagentseal/codeburn/releases/tag/windows-v0.9.20).
|
||||
It downloads the `.msi` for your CLI version, verifies its sha256, runs it through `msiexec /passive`, and launches the tray app. Re-run with `--force` to reinstall; an already-installed matching version is just launched. You can also download the `.msi` yourself from the [latest Windows Menubar release](https://github.com/getagentseal/codeburn/releases/tag/windows-v0.9.21).
|
||||
|
||||
Today's spend sits in the tray as a number beside the flame icon (turn it off in Settings, and the tooltip always carries it). Click for the same popover the macOS app shows: agent tabs, period switcher, Trend, Forecast, Pulse, Stats and Plan insights, activity and model breakdowns, optimize findings, and CSV/JSON export. Settings covers launch at login, the tray number, theme, and currency. It refreshes every 60 seconds while the popover is open and every 2 minutes while it is closed.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "codeburn-desktop",
|
||||
"private": true,
|
||||
"version": "0.9.20",
|
||||
"version": "0.9.21",
|
||||
"description": "CodeBurn Desktop \u2014 Electron app fed by the codeburn CLI",
|
||||
"main": "dist/electron/main.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 256 KiB After Width: | Height: | Size: 876 KiB |
|
|
@ -17,6 +17,9 @@ let package = Package(
|
|||
.executableTarget(
|
||||
name: "CodeBurnMenubar",
|
||||
path: "Sources/CodeBurnMenubar",
|
||||
resources: [
|
||||
.process("Resources/ProviderIcons")
|
||||
],
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency")
|
||||
]
|
||||
|
|
|
|||
|
|
@ -48,6 +48,13 @@ mkdir -p "${BUNDLE}/Contents/Resources"
|
|||
cp "${BUILT_BINARY}" "${BUNDLE}/Contents/MacOS/${EXECUTABLE_NAME}"
|
||||
cp "${ICON_SOURCE}" "${BUNDLE}/Contents/Resources/menubar-logo.png"
|
||||
|
||||
# SwiftPM emits target resources as a sibling bundle; Bundle.module traps at
|
||||
# runtime if it is missing from Contents/Resources, so ship it when present.
|
||||
SPM_RESOURCE_BUNDLE="${BIN_PATH}/${EXECUTABLE_NAME}_${EXECUTABLE_NAME}.bundle"
|
||||
if [[ -d "${SPM_RESOURCE_BUNDLE}" ]]; then
|
||||
cp -R "${SPM_RESOURCE_BUNDLE}" "${BUNDLE}/Contents/Resources/"
|
||||
fi
|
||||
|
||||
ICONSET="${DIST_DIR}/AppIcon.iconset"
|
||||
rm -rf "${ICONSET}"
|
||||
mkdir -p "${ICONSET}"
|
||||
|
|
|
|||
|
|
@ -145,12 +145,36 @@ final class AppStore {
|
|||
// first refresh tick.
|
||||
var kimiLoadState: SubscriptionLoadState = KimiSubscriptionService.hasCredential ? .dormant : .notBootstrapped
|
||||
|
||||
var geminiUsage: GeminiUsage?
|
||||
var geminiError: String?
|
||||
// Same file-based activation as Kimi — reading ~/.gemini/oauth_creds.json
|
||||
// is prompt-free, so we start dormant and auto-activate on the first
|
||||
// refresh tick.
|
||||
var geminiLoadState: SubscriptionLoadState = GeminiSubscriptionService.hasCredential ? .dormant : .notBootstrapped
|
||||
|
||||
var copilotUsage: CopilotUsage?
|
||||
var copilotError: String?
|
||||
// Same file-based activation as Kimi/Gemini — reading
|
||||
// ~/.config/github-copilot is prompt-free, so we start dormant and
|
||||
// auto-activate on the first refresh tick.
|
||||
var copilotLoadState: SubscriptionLoadState = CopilotSubscriptionService.hasCredential ? .dormant : .notBootstrapped
|
||||
|
||||
var antigravityUsage: AntigravityUsage?
|
||||
var antigravityError: String?
|
||||
// No credential file at all — quota comes from probing the Antigravity
|
||||
// app's local language server, which is prompt-free, so we start dormant
|
||||
// and auto-activate (probe) on the first refresh tick.
|
||||
var antigravityLoadState: SubscriptionLoadState = .dormant
|
||||
|
||||
/// Generation tokens for the in-flight refresh tasks. Incremented on every
|
||||
/// disconnect / reset so a fetch that started before the disconnect cannot
|
||||
/// resume after the await and re-populate the freshly-cleared state.
|
||||
private var claudeRefreshGen: Int = 0
|
||||
private var codexRefreshGen: Int = 0
|
||||
private var kimiRefreshGen: Int = 0
|
||||
private var geminiRefreshGen: Int = 0
|
||||
private var copilotRefreshGen: Int = 0
|
||||
private var antigravityRefreshGen: Int = 0
|
||||
|
||||
private var cache: [PayloadCacheKey: CachedPayload] = [:]
|
||||
private var cacheDate: String = ""
|
||||
|
|
@ -1252,6 +1276,260 @@ final class AppStore {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Gemini
|
||||
|
||||
/// Same prompt-free activation as Kimi: reading the CLI's credential file
|
||||
/// needs no keychain, so the first refresh tick activates dormant state.
|
||||
func bootstrapGemini() async {
|
||||
// Capture the generation before the await so a disconnect that lands
|
||||
// mid-fetch cannot be resurrected into .loaded when the fetch returns.
|
||||
let gen = geminiRefreshGen
|
||||
geminiLoadState = .bootstrapping
|
||||
do {
|
||||
let usage = try await GeminiSubscriptionService.refresh()
|
||||
guard gen == geminiRefreshGen else { return }
|
||||
geminiUsage = usage
|
||||
geminiError = nil
|
||||
geminiLoadState = .loaded
|
||||
} catch let err as GeminiSubscriptionService.FetchError {
|
||||
guard gen == geminiRefreshGen else { return }
|
||||
applyGeminiFetchError(err)
|
||||
} catch {
|
||||
guard gen == geminiRefreshGen else { return }
|
||||
geminiError = sanitizeForUI(error.localizedDescription)
|
||||
geminiLoadState = .failed
|
||||
}
|
||||
}
|
||||
|
||||
func refreshGemini() async {
|
||||
_ = await refreshGeminiReportingSuccess()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func refreshGeminiReportingSuccess() async -> Bool {
|
||||
if case .dormant = geminiLoadState {
|
||||
await bootstrapGemini()
|
||||
return geminiLoadState == .loaded
|
||||
}
|
||||
guard GeminiSubscriptionService.hasCredential else {
|
||||
if geminiLoadState != .notBootstrapped { geminiLoadState = .notBootstrapped }
|
||||
return false
|
||||
}
|
||||
let gen = geminiRefreshGen
|
||||
if geminiUsage == nil { geminiLoadState = .loading }
|
||||
do {
|
||||
let usage = try await GeminiSubscriptionService.refresh()
|
||||
guard gen == geminiRefreshGen else { return false }
|
||||
geminiUsage = usage
|
||||
geminiError = nil
|
||||
geminiLoadState = .loaded
|
||||
return true
|
||||
} catch let err as GeminiSubscriptionService.FetchError {
|
||||
guard gen == geminiRefreshGen else { return false }
|
||||
applyGeminiFetchError(err)
|
||||
return false
|
||||
} catch {
|
||||
guard gen == geminiRefreshGen else { return false }
|
||||
geminiError = sanitizeForUI(error.localizedDescription)
|
||||
geminiLoadState = .failed
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func disconnectGemini() {
|
||||
GeminiSubscriptionService.disconnect()
|
||||
geminiRefreshGen &+= 1
|
||||
geminiUsage = nil
|
||||
geminiError = nil
|
||||
geminiLoadState = .notBootstrapped
|
||||
NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil)
|
||||
}
|
||||
|
||||
private func applyGeminiFetchError(_ err: GeminiSubscriptionService.FetchError) {
|
||||
let sanitized = sanitizeForUI(err.errorDescription)
|
||||
geminiError = sanitized
|
||||
if case .noCredentials = err {
|
||||
geminiLoadState = .noCredentials
|
||||
} else if err.isTerminal {
|
||||
geminiLoadState = .terminalFailure(reason: sanitized)
|
||||
} else if let retryAt = err.rateLimitRetryAt {
|
||||
geminiLoadState = .transientFailure(retryAt: retryAt)
|
||||
} else {
|
||||
// 5xx / network blips back off automatically, mirroring the
|
||||
// Electron provider's transientFailure mapping.
|
||||
geminiLoadState = .transientFailure(retryAt: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Copilot
|
||||
|
||||
/// Same prompt-free activation as Kimi/Gemini: reading the editor plugins'
|
||||
/// credential files needs no keychain, so the first refresh tick activates
|
||||
/// dormant state.
|
||||
func bootstrapCopilot() async {
|
||||
// Capture the generation before the await so a disconnect that lands
|
||||
// mid-fetch cannot be resurrected into .loaded when the fetch returns.
|
||||
let gen = copilotRefreshGen
|
||||
copilotLoadState = .bootstrapping
|
||||
do {
|
||||
let usage = try await CopilotSubscriptionService.refresh()
|
||||
guard gen == copilotRefreshGen else { return }
|
||||
copilotUsage = usage
|
||||
copilotError = nil
|
||||
copilotLoadState = .loaded
|
||||
} catch let err as CopilotSubscriptionService.FetchError {
|
||||
guard gen == copilotRefreshGen else { return }
|
||||
applyCopilotFetchError(err)
|
||||
} catch {
|
||||
guard gen == copilotRefreshGen else { return }
|
||||
copilotError = sanitizeForUI(error.localizedDescription)
|
||||
copilotLoadState = .failed
|
||||
}
|
||||
}
|
||||
|
||||
func refreshCopilot() async {
|
||||
_ = await refreshCopilotReportingSuccess()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func refreshCopilotReportingSuccess() async -> Bool {
|
||||
if case .dormant = copilotLoadState {
|
||||
await bootstrapCopilot()
|
||||
return copilotLoadState == .loaded
|
||||
}
|
||||
guard CopilotSubscriptionService.hasCredential else {
|
||||
if copilotLoadState != .notBootstrapped { copilotLoadState = .notBootstrapped }
|
||||
return false
|
||||
}
|
||||
let gen = copilotRefreshGen
|
||||
if copilotUsage == nil { copilotLoadState = .loading }
|
||||
do {
|
||||
let usage = try await CopilotSubscriptionService.refresh()
|
||||
guard gen == copilotRefreshGen else { return false }
|
||||
copilotUsage = usage
|
||||
copilotError = nil
|
||||
copilotLoadState = .loaded
|
||||
return true
|
||||
} catch let err as CopilotSubscriptionService.FetchError {
|
||||
guard gen == copilotRefreshGen else { return false }
|
||||
applyCopilotFetchError(err)
|
||||
return false
|
||||
} catch {
|
||||
guard gen == copilotRefreshGen else { return false }
|
||||
copilotError = sanitizeForUI(error.localizedDescription)
|
||||
copilotLoadState = .failed
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func disconnectCopilot() {
|
||||
CopilotSubscriptionService.disconnect()
|
||||
copilotRefreshGen &+= 1
|
||||
copilotUsage = nil
|
||||
copilotError = nil
|
||||
copilotLoadState = .notBootstrapped
|
||||
NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil)
|
||||
}
|
||||
|
||||
private func applyCopilotFetchError(_ err: CopilotSubscriptionService.FetchError) {
|
||||
let sanitized = sanitizeForUI(err.errorDescription)
|
||||
copilotError = sanitized
|
||||
if case .noCredentials = err {
|
||||
copilotLoadState = .noCredentials
|
||||
} else if err.isTerminal {
|
||||
copilotLoadState = .terminalFailure(reason: sanitized)
|
||||
} else if let retryAt = err.rateLimitRetryAt {
|
||||
copilotLoadState = .transientFailure(retryAt: retryAt)
|
||||
} else {
|
||||
// 5xx / network blips and a rejected-but-unchanged token back off
|
||||
// automatically, mirroring the Electron provider's
|
||||
// transientFailure mapping.
|
||||
copilotLoadState = .transientFailure(retryAt: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Antigravity
|
||||
|
||||
/// No credential dance at all: discovery is `ps` + loopback probes of the
|
||||
/// local language server, so the first refresh tick activates dormant
|
||||
/// state, like Kimi/Gemini/Copilot.
|
||||
func bootstrapAntigravity() async {
|
||||
// Capture the generation before the await so a disconnect that lands
|
||||
// mid-fetch cannot be resurrected into .loaded when the fetch returns.
|
||||
let gen = antigravityRefreshGen
|
||||
antigravityLoadState = .bootstrapping
|
||||
do {
|
||||
let usage = try await AntigravitySubscriptionService.refresh()
|
||||
guard gen == antigravityRefreshGen else { return }
|
||||
antigravityUsage = usage
|
||||
antigravityError = nil
|
||||
antigravityLoadState = .loaded
|
||||
} catch let err as AntigravitySubscriptionService.FetchError {
|
||||
guard gen == antigravityRefreshGen else { return }
|
||||
applyAntigravityFetchError(err)
|
||||
} catch {
|
||||
guard gen == antigravityRefreshGen else { return }
|
||||
antigravityError = sanitizeForUI(error.localizedDescription)
|
||||
antigravityLoadState = .failed
|
||||
}
|
||||
}
|
||||
|
||||
func refreshAntigravity() async {
|
||||
_ = await refreshAntigravityReportingSuccess()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func refreshAntigravityReportingSuccess() async -> Bool {
|
||||
if case .dormant = antigravityLoadState {
|
||||
await bootstrapAntigravity()
|
||||
return antigravityLoadState == .loaded
|
||||
}
|
||||
// Only an explicit Disconnect stops the cadence probe; there is no
|
||||
// credential file to poll for, the probe IS the availability check.
|
||||
if case .notBootstrapped = antigravityLoadState { return false }
|
||||
let gen = antigravityRefreshGen
|
||||
if antigravityUsage == nil { antigravityLoadState = .loading }
|
||||
do {
|
||||
let usage = try await AntigravitySubscriptionService.refresh()
|
||||
guard gen == antigravityRefreshGen else { return false }
|
||||
antigravityUsage = usage
|
||||
antigravityError = nil
|
||||
antigravityLoadState = .loaded
|
||||
return true
|
||||
} catch let err as AntigravitySubscriptionService.FetchError {
|
||||
guard gen == antigravityRefreshGen else { return false }
|
||||
applyAntigravityFetchError(err)
|
||||
return false
|
||||
} catch {
|
||||
guard gen == antigravityRefreshGen else { return false }
|
||||
antigravityError = sanitizeForUI(error.localizedDescription)
|
||||
antigravityLoadState = .failed
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func disconnectAntigravity() {
|
||||
antigravityRefreshGen &+= 1
|
||||
antigravityUsage = nil
|
||||
antigravityError = nil
|
||||
antigravityLoadState = .notBootstrapped
|
||||
NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil)
|
||||
}
|
||||
|
||||
private func applyAntigravityFetchError(_ err: AntigravitySubscriptionService.FetchError) {
|
||||
let sanitized = sanitizeForUI(err.errorDescription)
|
||||
antigravityError = sanitized
|
||||
if case .disconnected = err {
|
||||
// No local server answered — the routine "app not running" state,
|
||||
// not an error; the UI shows the Connect affordance.
|
||||
antigravityLoadState = .noCredentials
|
||||
} else {
|
||||
// Unexpected discovery/probe failures back off automatically,
|
||||
// mirroring the Electron provider's transientFailure mapping.
|
||||
antigravityLoadState = .transientFailure(retryAt: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyFetchError(_ err: ClaudeSubscriptionService.FetchError) {
|
||||
let sanitized = sanitizeForUI(err.errorDescription)
|
||||
subscriptionError = sanitized
|
||||
|
|
@ -1324,6 +1602,18 @@ final class AppStore {
|
|||
let worst = max(usage.primary?.usedPercent ?? 0, usage.details.map(\.usedPercent).max() ?? 0)
|
||||
if worst > 0 { providers.append(("Kimi Code", worst)) }
|
||||
}
|
||||
if let usage = geminiUsage, shouldIncludeCachedQuota(loadState: geminiLoadState) {
|
||||
let worst = usage.details.map(\.usedPercent).max() ?? 0
|
||||
if worst > 0 { providers.append(("Gemini", worst)) }
|
||||
}
|
||||
if let usage = copilotUsage, shouldIncludeCachedQuota(loadState: copilotLoadState) {
|
||||
let worst = usage.details.map(\.usedPercent).max() ?? 0
|
||||
if worst > 0 { providers.append(("Copilot", worst)) }
|
||||
}
|
||||
if let usage = antigravityUsage, shouldIncludeCachedQuota(loadState: antigravityLoadState) {
|
||||
let worst = usage.details.map(\.usedPercent).max() ?? 0
|
||||
if worst > 0 { providers.append(("Antigravity", worst)) }
|
||||
}
|
||||
let worst = providers.map(\.percent).max() ?? 0
|
||||
let severity = QuotaSummary.severity(for: worst / 100)
|
||||
let sorted = providers.sorted { $0.percent > $1.percent }
|
||||
|
|
@ -1345,6 +1635,9 @@ final class AppStore {
|
|||
case .claude: return claudeQuotaSummary(filter: filter)
|
||||
case .codex: return codexQuotaSummary(filter: filter)
|
||||
case .kimiCode: return kimiQuotaSummary(filter: filter)
|
||||
case .gemini: return geminiQuotaSummary(filter: filter)
|
||||
case .copilot: return copilotQuotaSummary(filter: filter)
|
||||
case .antigravity: return antigravityQuotaSummary(filter: filter)
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
|
@ -1506,6 +1799,104 @@ final class AppStore {
|
|||
return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: kimiUsage?.plan ?? "Kimi Code", footerLines: [])
|
||||
}
|
||||
|
||||
private func geminiQuotaSummary(filter: ProviderFilter) -> QuotaSummary? {
|
||||
if case .notBootstrapped = geminiLoadState { return nil }
|
||||
if case .bootstrapping = geminiLoadState { return nil }
|
||||
if case .noCredentials = geminiLoadState { return nil }
|
||||
|
||||
let connection: QuotaSummary.Connection = {
|
||||
switch geminiLoadState {
|
||||
case .notBootstrapped, .dormant, .bootstrapping, .noCredentials: return .disconnected
|
||||
case .loading: return geminiUsage == nil ? .loading : .stale
|
||||
case .loaded: return .connected
|
||||
case .failed: return geminiUsage == nil ? .loading : .stale
|
||||
// An expired Gemini login without a refresh path sits terminal
|
||||
// until the user runs the CLI. Keep the last-known bars (marked
|
||||
// stale) instead of flapping the chip to a reconnect card.
|
||||
case let .terminalFailure(reason): return geminiUsage == nil ? .terminalFailure(reason: reason) : .stale
|
||||
case .transientFailure: return .transientFailure
|
||||
}
|
||||
}()
|
||||
|
||||
var primary: QuotaSummary.Window?
|
||||
var details: [QuotaSummary.Window] = []
|
||||
if let usage = geminiUsage {
|
||||
// details is sorted most-constrained first, so the first row is
|
||||
// the headline bar.
|
||||
for w in usage.details {
|
||||
let row = QuotaSummary.Window(label: w.label, percent: w.usedPercent / 100, resetsAt: w.resetsAt)
|
||||
if primary == nil { primary = row }
|
||||
details.append(row)
|
||||
}
|
||||
}
|
||||
return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: geminiUsage?.plan ?? "Gemini", footerLines: [])
|
||||
}
|
||||
|
||||
private func copilotQuotaSummary(filter: ProviderFilter) -> QuotaSummary? {
|
||||
if case .notBootstrapped = copilotLoadState { return nil }
|
||||
if case .bootstrapping = copilotLoadState { return nil }
|
||||
if case .noCredentials = copilotLoadState { return nil }
|
||||
|
||||
let connection: QuotaSummary.Connection = {
|
||||
switch copilotLoadState {
|
||||
case .notBootstrapped, .dormant, .bootstrapping, .noCredentials: return .disconnected
|
||||
case .loading: return copilotUsage == nil ? .loading : .stale
|
||||
case .loaded: return .connected
|
||||
case .failed: return copilotUsage == nil ? .loading : .stale
|
||||
// A revoked Copilot token sits terminal until the user signs in via
|
||||
// an editor's Copilot plugin again. Keep the last-known bars
|
||||
// (marked stale) instead of flapping the chip to a reconnect card.
|
||||
case let .terminalFailure(reason): return copilotUsage == nil ? .terminalFailure(reason: reason) : .stale
|
||||
case .transientFailure: return .transientFailure
|
||||
}
|
||||
}()
|
||||
|
||||
var primary: QuotaSummary.Window?
|
||||
var details: [QuotaSummary.Window] = []
|
||||
if let usage = copilotUsage {
|
||||
// details leads with the premium-requests window, so the first row
|
||||
// is the headline bar.
|
||||
for w in usage.details {
|
||||
let row = QuotaSummary.Window(label: w.label, percent: w.usedPercent / 100, resetsAt: w.resetsAt)
|
||||
if primary == nil { primary = row }
|
||||
details.append(row)
|
||||
}
|
||||
}
|
||||
return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: copilotUsage?.plan ?? "Copilot", footerLines: [])
|
||||
}
|
||||
|
||||
private func antigravityQuotaSummary(filter: ProviderFilter) -> QuotaSummary? {
|
||||
if case .notBootstrapped = antigravityLoadState { return nil }
|
||||
if case .bootstrapping = antigravityLoadState { return nil }
|
||||
if case .noCredentials = antigravityLoadState { return nil }
|
||||
|
||||
let connection: QuotaSummary.Connection = {
|
||||
switch antigravityLoadState {
|
||||
case .notBootstrapped, .dormant, .bootstrapping, .noCredentials: return .disconnected
|
||||
case .loading: return antigravityUsage == nil ? .loading : .stale
|
||||
case .loaded: return .connected
|
||||
case .failed: return antigravityUsage == nil ? .loading : .stale
|
||||
// A vanished local server maps to noCredentials above, so terminal
|
||||
// is unreachable today; kept for switch exhaustivity.
|
||||
case let .terminalFailure(reason): return antigravityUsage == nil ? .terminalFailure(reason: reason) : .stale
|
||||
case .transientFailure: return .transientFailure
|
||||
}
|
||||
}()
|
||||
|
||||
var primary: QuotaSummary.Window?
|
||||
var details: [QuotaSummary.Window] = []
|
||||
if let usage = antigravityUsage {
|
||||
// details is sorted most-constrained first, so the first row is
|
||||
// the headline bar.
|
||||
for w in usage.details {
|
||||
let row = QuotaSummary.Window(label: w.label, percent: w.usedPercent / 100, resetsAt: w.resetsAt)
|
||||
if primary == nil { primary = row }
|
||||
details.append(row)
|
||||
}
|
||||
}
|
||||
return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: antigravityUsage?.plan ?? "Antigravity", footerLines: [])
|
||||
}
|
||||
|
||||
/// Persist one snapshot per window so we can answer "what did the prior cycle end at?"
|
||||
/// when the current window has just reset and projection from current data isn't meaningful.
|
||||
/// Also computes the effective_tokens consumed inside each 7-day window from local history,
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ struct CodeBurnApp: App {
|
|||
Settings {
|
||||
SettingsView()
|
||||
.environment(delegate.store)
|
||||
.environment(delegate.updateChecker)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -530,6 +531,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
|
|||
fileprivate var lastSubscriptionRefreshAt: Date?
|
||||
fileprivate var lastCodexRefreshAt: Date?
|
||||
fileprivate var lastKimiRefreshAt: Date?
|
||||
fileprivate var lastGeminiRefreshAt: Date?
|
||||
fileprivate var lastCopilotRefreshAt: Date?
|
||||
fileprivate var lastAntigravityRefreshAt: Date?
|
||||
private var claudeQuotaFailureCount = 0
|
||||
private var nextClaudeQuotaRefreshAt: Date?
|
||||
|
||||
|
|
@ -625,23 +629,51 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
|
|||
if let task = codexQuotaRefreshTask {
|
||||
return await task.value
|
||||
}
|
||||
// Kimi Code rides the same tick, but with its own cadence anchor:
|
||||
// when Codex is not connected its refresh returns false immediately,
|
||||
// so lastCodexRefreshAt never advances — anchoring Kimi on it would
|
||||
// poll api.kimi.com on every payload tick instead of the configured
|
||||
// quota cadence. Anchor on attempt (not success) so a failing Kimi
|
||||
// endpoint also respects the cadence.
|
||||
// Kimi Code, Gemini, Copilot, and Antigravity ride the same tick,
|
||||
// each with its own cadence anchor: when Codex is not connected its
|
||||
// refresh returns false
|
||||
// immediately, so lastCodexRefreshAt never advances — anchoring them
|
||||
// on it would poll the quota endpoints on every payload tick instead
|
||||
// of the configured quota cadence. Anchor on attempt (not success)
|
||||
// so a failing endpoint also respects the cadence.
|
||||
let kimiDue: Bool = {
|
||||
let cadence = SubscriptionRefreshCadence.current
|
||||
guard cadence != .manual else { return false }
|
||||
return Date().timeIntervalSince(lastKimiRefreshAt ?? .distantPast) >= TimeInterval(cadence.rawValue)
|
||||
}()
|
||||
if kimiDue { lastKimiRefreshAt = Date() }
|
||||
let task = Task { [store, kimiDue] in
|
||||
let geminiDue: Bool = {
|
||||
let cadence = SubscriptionRefreshCadence.current
|
||||
guard cadence != .manual else { return false }
|
||||
return Date().timeIntervalSince(lastGeminiRefreshAt ?? .distantPast) >= TimeInterval(cadence.rawValue)
|
||||
}()
|
||||
if geminiDue { lastGeminiRefreshAt = Date() }
|
||||
let copilotDue: Bool = {
|
||||
let cadence = SubscriptionRefreshCadence.current
|
||||
guard cadence != .manual else { return false }
|
||||
return Date().timeIntervalSince(lastCopilotRefreshAt ?? .distantPast) >= TimeInterval(cadence.rawValue)
|
||||
}()
|
||||
if copilotDue { lastCopilotRefreshAt = Date() }
|
||||
let antigravityDue: Bool = {
|
||||
let cadence = SubscriptionRefreshCadence.current
|
||||
guard cadence != .manual else { return false }
|
||||
return Date().timeIntervalSince(lastAntigravityRefreshAt ?? .distantPast) >= TimeInterval(cadence.rawValue)
|
||||
}()
|
||||
if antigravityDue { lastAntigravityRefreshAt = Date() }
|
||||
let task = Task { [store, kimiDue, geminiDue, copilotDue, antigravityDue] in
|
||||
async let codex = store.refreshCodexReportingSuccess()
|
||||
if kimiDue {
|
||||
_ = await store.refreshKimiReportingSuccess()
|
||||
}
|
||||
if geminiDue {
|
||||
_ = await store.refreshGeminiReportingSuccess()
|
||||
}
|
||||
if copilotDue {
|
||||
_ = await store.refreshCopilotReportingSuccess()
|
||||
}
|
||||
if antigravityDue {
|
||||
_ = await store.refreshAntigravityReportingSuccess()
|
||||
}
|
||||
return await codex
|
||||
}
|
||||
codexQuotaRefreshTask = task
|
||||
|
|
@ -853,14 +885,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
|
|||
|
||||
/// Reset the cadence anchor so the next loop tick re-evaluates from "now"
|
||||
/// rather than measuring against a timestamp from the previous connection.
|
||||
/// Triggered on disconnect of any provider — the cost of clearing both
|
||||
/// anchors is one extra refresh tick on the unaffected provider, far less
|
||||
/// Triggered on disconnect of any provider — the cost of clearing all
|
||||
/// anchors is one extra refresh tick on the unaffected providers, far less
|
||||
/// disruptive than waiting a full cadence after a reconnect.
|
||||
@MainActor
|
||||
func resetSubscriptionCadenceAnchor() {
|
||||
lastSubscriptionRefreshAt = nil
|
||||
lastCodexRefreshAt = nil
|
||||
lastKimiRefreshAt = nil
|
||||
lastGeminiRefreshAt = nil
|
||||
lastCopilotRefreshAt = nil
|
||||
lastAntigravityRefreshAt = nil
|
||||
claudeQuotaFailureCount = 0
|
||||
nextClaudeQuotaRefreshAt = nil
|
||||
}
|
||||
|
|
@ -892,11 +927,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
|
|||
// relying on payload/menubarPayload happening to touch the same cache.
|
||||
_ = self.store.isOverDailyBudget
|
||||
// Track the live-quota state too so the flame icon re-tints on
|
||||
// every subscription / codex usage update, not just every 30s.
|
||||
// every connected provider's usage update, not just every 30s.
|
||||
_ = self.store.subscription
|
||||
_ = self.store.subscriptionLoadState
|
||||
_ = self.store.codexUsage
|
||||
_ = self.store.codexLoadState
|
||||
_ = self.store.geminiUsage
|
||||
_ = self.store.geminiLoadState
|
||||
_ = self.store.copilotUsage
|
||||
_ = self.store.copilotLoadState
|
||||
_ = self.store.antigravityUsage
|
||||
_ = self.store.antigravityLoadState
|
||||
} onChange: { [weak self] in
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
|
|
@ -921,14 +962,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
|
|||
statusItem = NSStatusBar.system.statusItem(withLength: statusItemWidth)
|
||||
guard let button = statusItem.button else { return }
|
||||
|
||||
// Set a simple SF Symbol image immediately to ensure the status item renders.
|
||||
// Set the bundled flame image immediately to ensure the status item renders.
|
||||
// On macOS Tahoe, status items may fail to appear if only an attributed title
|
||||
// is set during initial setup.
|
||||
let flameConfig = NSImage.SymbolConfiguration(pointSize: menubarTitleFontSize, weight: .medium)
|
||||
let flame = NSImage(systemSymbolName: "flame.fill", accessibilityDescription: "CodeBurn")?
|
||||
.withSymbolConfiguration(flameConfig)
|
||||
flame?.isTemplate = true
|
||||
button.image = flame
|
||||
button.image = Self.menubarFlameImage(tint: nil)
|
||||
button.imagePosition = .imageLeading
|
||||
|
||||
button.target = self
|
||||
|
|
@ -982,6 +1019,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
|
|||
}
|
||||
}
|
||||
|
||||
/// Loads the bundled binary-flame PNG (Resources/ProviderIcons/flame.png) at the
|
||||
/// menubar text point size. With no tint it stays a template image so the system
|
||||
/// auto-adapts to the menu bar; a tint returns a recolored non-template copy for
|
||||
/// the budget/quota warning states.
|
||||
private static func menubarFlameImage(tint: NSColor?) -> NSImage? {
|
||||
let config = NSImage.SymbolConfiguration(pointSize: menubarTitleFontSize, weight: .medium)
|
||||
guard let symbol = NSImage(systemSymbolName: "flame.fill", accessibilityDescription: "CodeBurn")?
|
||||
.withSymbolConfiguration(config) else { return nil }
|
||||
guard let tint else {
|
||||
symbol.isTemplate = true
|
||||
return symbol
|
||||
}
|
||||
let recolored = NSImage(size: symbol.size, flipped: false) { rect in
|
||||
symbol.draw(in: rect)
|
||||
tint.set()
|
||||
rect.fill(using: .sourceAtop)
|
||||
return true
|
||||
}
|
||||
recolored.isTemplate = false
|
||||
return recolored
|
||||
}
|
||||
|
||||
private func refreshStatusButton() {
|
||||
guard let button = statusItem.button else { return }
|
||||
// Skip while the popover is anchored to this button. Rewriting the
|
||||
|
|
@ -997,7 +1056,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
|
|||
button.imagePosition = .noImage
|
||||
|
||||
let font = NSFont.monospacedDigitSystemFont(ofSize: menubarTitleFontSize, weight: .regular)
|
||||
let baseConfig = NSImage.SymbolConfiguration(pointSize: menubarTitleFontSize, weight: .medium)
|
||||
// Tint the flame based on the worst-affected connected provider's quota.
|
||||
// Normal (<70%) keeps the template (auto white-on-dark / black-on-light);
|
||||
// warning/critical/danger override with a fixed palette color so the
|
||||
|
|
@ -1007,15 +1065,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
|
|||
if tint == nil, store.isOverDailyBudget {
|
||||
tint = NSColor.systemYellow
|
||||
}
|
||||
let flameConfig: NSImage.SymbolConfiguration
|
||||
if let tint {
|
||||
flameConfig = baseConfig.applying(.init(paletteColors: [tint]))
|
||||
} else {
|
||||
flameConfig = baseConfig
|
||||
}
|
||||
let flame = NSImage(systemSymbolName: "flame.fill", accessibilityDescription: "CodeBurn")?
|
||||
.withSymbolConfiguration(flameConfig)
|
||||
flame?.isTemplate = (tint == nil)
|
||||
let flame = Self.menubarFlameImage(tint: tint)
|
||||
|
||||
let attachment = NSTextAttachment()
|
||||
attachment.image = flame
|
||||
|
|
@ -1294,7 +1344,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
|
|||
}
|
||||
|
||||
let hosting = NSHostingController(
|
||||
rootView: SettingsView().environment(store)
|
||||
rootView: SettingsView().environment(store).environment(updateChecker)
|
||||
)
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 520, height: 380),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
import Foundation
|
||||
|
||||
/// Pure display-decision helpers for the Antigravity quota surfaces.
|
||||
///
|
||||
/// Antigravity quota comes from probing the local language server — there is
|
||||
/// no credential file and no refresh path, so a vanished server maps to
|
||||
/// `.noCredentials` ("start the Antigravity app") rather than a terminal
|
||||
/// login state. Like the other file-free providers, the always-visible
|
||||
/// surfaces keep showing the last good snapshot with a quiet caption instead
|
||||
/// of flapping to a reconnect screen; the reconnect screen is reserved for
|
||||
/// the no-data case, where there is genuinely nothing to show.
|
||||
enum AntigravityQuotaPresentation {
|
||||
/// Which Plan-tab subview to render, given the load state and whether a
|
||||
/// last-known snapshot exists.
|
||||
enum PlanContent: Equatable {
|
||||
case noCredentials
|
||||
case loading
|
||||
case failed
|
||||
case transientFailed
|
||||
case reconnect(reason: String?)
|
||||
/// Render the loaded usage bars. `idle` is true when the connection
|
||||
/// has gone terminal but a snapshot is still on hand — the caller
|
||||
/// stamps a quiet "start the app" caption instead of hiding the data.
|
||||
case usage(idle: Bool)
|
||||
}
|
||||
|
||||
static func planContent(loadState: SubscriptionLoadState, hasUsage: Bool) -> PlanContent {
|
||||
switch loadState {
|
||||
case .notBootstrapped, .noCredentials:
|
||||
return .noCredentials
|
||||
case .dormant, .bootstrapping:
|
||||
return .loading
|
||||
case .loading, .loaded:
|
||||
return hasUsage ? .usage(idle: false) : .loading
|
||||
case .failed:
|
||||
return .failed
|
||||
case .transientFailure:
|
||||
return hasUsage ? .usage(idle: false) : .transientFailed
|
||||
case .terminalFailure(let reason):
|
||||
return hasUsage ? .usage(idle: true) : .reconnect(reason: reason)
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot age past which a loaded view stamps an "as of <time>" caption,
|
||||
/// so a bar can never silently masquerade as current.
|
||||
static let stalenessThreshold: TimeInterval = 10 * 60
|
||||
|
||||
static func isStale(fetchedAt: Date, now: Date = Date()) -> Bool {
|
||||
now.timeIntervalSince(fetchedAt) > stalenessThreshold
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,380 @@
|
|||
import Foundation
|
||||
|
||||
/// Live quota snapshot for Antigravity. The local language server reports
|
||||
/// per-group quota buckets (summary payload) or flat per-model rows (legacy
|
||||
/// GetUserStatus payload) as fractions REMAINING; windows render percent
|
||||
/// USED, so `usedPercent` is the inverted value. `details` is sorted
|
||||
/// most-constrained first so `primary` is the window that throttles soonest.
|
||||
struct AntigravityUsage: Sendable, Equatable {
|
||||
struct Window: Sendable, Equatable {
|
||||
/// "Group · Bucket" (summary) or model name (legacy status payload).
|
||||
let label: String
|
||||
let usedPercent: Double // 0.0 ... 100.0
|
||||
/// Only the legacy status payload carries reset times.
|
||||
let resetsAt: Date?
|
||||
}
|
||||
|
||||
/// Per-group/per-model windows, most-constrained first.
|
||||
let details: [Window]
|
||||
var primary: Window? { details.first }
|
||||
/// Plan label from the legacy GetUserStatus payload (summary payloads
|
||||
/// carry none).
|
||||
let plan: String?
|
||||
let fetchedAt: Date
|
||||
}
|
||||
|
||||
/// Live Antigravity quota from LOCAL surfaces only — the Antigravity app's own
|
||||
/// language server or a signed-in `agy` CLI's embedded server (prior art:
|
||||
/// steipete/CodexBar docs/antigravity.md, which marks this protocol internal
|
||||
/// and experimental). No Google OAuth fallback: when no local server answers,
|
||||
/// the provider reports disconnected and the UI shows its Connect affordance.
|
||||
///
|
||||
/// Endpoints (loopback only, Connect-RPC JSON):
|
||||
/// - POST https://127.0.0.1:<port>/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary
|
||||
/// (preferred; falls back to)
|
||||
/// - POST https://127.0.0.1:<port>/exa.language_server_pb.LanguageServerService/GetUserStatus
|
||||
///
|
||||
/// Discovery mirrors CodexBar: `ps` finds candidate processes (app language
|
||||
/// servers need their `--csrf_token`; the `agy` CLI needs none), then `lsof`
|
||||
/// lists each pid's listening TCP ports. Local HTTPS uses a self-signed cert,
|
||||
/// so TLS verification is relaxed ONLY for the 127.0.0.1 loopback probes —
|
||||
/// see LoopbackTrustDelegate.
|
||||
enum AntigravitySubscriptionService {
|
||||
private static let summaryPath = "/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary"
|
||||
private static let statusPath = "/exa.language_server_pb.LanguageServerService/GetUserStatus"
|
||||
private static let localTimeoutSeconds: TimeInterval = 3
|
||||
|
||||
enum FetchError: Error, LocalizedError {
|
||||
/// No running language server answered any port probe. This is the
|
||||
/// routine "app not running" state, not an error — the UI shows the
|
||||
/// Connect affordance.
|
||||
case disconnected
|
||||
/// Unexpected discovery failure (e.g. `ps` blew up). Transient: the
|
||||
/// next cadence tick retries.
|
||||
case network(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .disconnected:
|
||||
return "No running Antigravity language server found. Start the Antigravity app, then click Reconnect."
|
||||
case let .network(err):
|
||||
return "Local probe failed: \(err.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Injectable seams (tests drive fixtures through these)
|
||||
|
||||
struct Deps: Sendable {
|
||||
/// Runs a local discovery tool and returns stdout. Live: `ps`/`lsof`
|
||||
/// via Process.
|
||||
var exec: @Sendable (_ executable: String, _ arguments: [String]) async throws -> String
|
||||
/// POSTs the Connect-RPC probe to 127.0.0.1:<port>. Nil means
|
||||
/// timeout / transport error — the probe simply moves on.
|
||||
var request: @Sendable (_ port: Int, _ tls: Bool, _ path: String, _ body: String, _ csrf: String?) async -> (status: Int, text: String)?
|
||||
var now: @Sendable () -> Date
|
||||
/// Log sink for sanitized diagnostics; tests capture it.
|
||||
var log: @Sendable (String) -> Void
|
||||
|
||||
static let live = Deps(
|
||||
exec: { executable, arguments in
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: executable)
|
||||
process.arguments = arguments
|
||||
let result = try await DataClient.runProcess(
|
||||
process,
|
||||
timeoutSeconds: 10,
|
||||
label: "antigravity \(executable.components(separatedBy: "/").last ?? executable)"
|
||||
)
|
||||
return String(decoding: result.stdout, as: UTF8.self)
|
||||
},
|
||||
request: { port, tls, path, body, csrf in
|
||||
guard let url = URL(string: "\(tls ? "https" : "http")://127.0.0.1:\(port)\(path)") else { return nil }
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.timeoutInterval = localTimeoutSeconds
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("1", forHTTPHeaderField: "Connect-Protocol-Version")
|
||||
if let csrf {
|
||||
request.setValue(csrf, forHTTPHeaderField: "X-Codeium-Csrf-Token")
|
||||
}
|
||||
request.httpBody = Data(body.utf8)
|
||||
do {
|
||||
let (data, response) = try await AntigravitySubscriptionService.loopbackSession.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else { return nil }
|
||||
return (http.statusCode, String(decoding: data, as: UTF8.self))
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
},
|
||||
now: { Date() },
|
||||
log: { NSLog("%@", $0) }
|
||||
)
|
||||
}
|
||||
|
||||
/// TLS trust override scoped to the loopback probes ONLY: the Antigravity
|
||||
/// language server serves a self-signed cert, so a server-trust challenge
|
||||
/// from 127.0.0.1 is accepted as presented. Every other host (this
|
||||
/// session is never pointed at one) and every non-server-trust challenge
|
||||
/// gets default handling — TLS is never relaxed off-loopback.
|
||||
private final class LoopbackTrustDelegate: NSObject, URLSessionDelegate, @unchecked Sendable {
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
let space = challenge.protectionSpace
|
||||
guard space.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
||||
space.host == "127.0.0.1",
|
||||
let trust = space.serverTrust else {
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
return
|
||||
}
|
||||
completionHandler(.useCredential, URLCredential(trust: trust))
|
||||
}
|
||||
}
|
||||
|
||||
private static let loopbackSession = URLSession(
|
||||
configuration: .ephemeral,
|
||||
delegate: LoopbackTrustDelegate(),
|
||||
delegateQueue: nil
|
||||
)
|
||||
|
||||
// MARK: - Process discovery
|
||||
|
||||
/// Process kinds, mirroring CodexBar's classification. The app language
|
||||
/// server carries richer quota data than the IDE variant, so IDE matches
|
||||
/// are skipped; a CLI (`agy`) match is accepted because its tokenless
|
||||
/// server exposes the same summary payload.
|
||||
struct Candidate: Sendable, Equatable {
|
||||
let pid: String
|
||||
let cli: Bool
|
||||
let csrf: String?
|
||||
/// `--extension_server_port` fallback, probed when lsof misses it.
|
||||
let extPort: Int?
|
||||
}
|
||||
|
||||
private static let languageServerPattern = #"language[_-]server(_macos)?(_arm)?"#
|
||||
private static let ideMarkerPattern = #"(?i)antigravity[-_]ide"#
|
||||
private static let cliMarkerPattern = #"(?i)antigravity[-_]cli"#
|
||||
private static let agyBinaryPattern = #"(^|/|\s)agy(\s|$)"#
|
||||
private static let appMarkerPattern = #"(?i)--app_data_dir[= ]"?antigravity(?![\w-])|/antigravity/"#
|
||||
|
||||
private static func matches(_ pattern: String, in line: String) -> Bool {
|
||||
line.range(of: pattern, options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
private static func flagValue(line: String, flag: String) -> String? {
|
||||
let pattern = NSRegularExpression.escapedPattern(for: flag) + #"[= ](\S+)"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern),
|
||||
let match = regex.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)),
|
||||
let range = Range(match.range(at: 1), in: line) else { return nil }
|
||||
return String(line[range])
|
||||
}
|
||||
|
||||
static func classifyProcessLine(_ line: String) -> Candidate? {
|
||||
let isServer = matches(languageServerPattern, in: line)
|
||||
let isCli = !isServer && (matches(cliMarkerPattern, in: line) || matches(agyBinaryPattern, in: line))
|
||||
guard isServer || isCli else { return nil }
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
guard let pidRange = trimmed.range(of: #"^\d+"#, options: .regularExpression) else { return nil }
|
||||
let pid = String(trimmed[pidRange])
|
||||
// A tokenless desktop language-server match is skipped so a later,
|
||||
// valid server can be found; the CLI exposes no CSRF flag and needs
|
||||
// none. IDE language servers are excluded too — their payloads lack
|
||||
// the weekly groups.
|
||||
if !isCli && (!matches(appMarkerPattern, in: line) || matches(ideMarkerPattern, in: line)) { return nil }
|
||||
let csrf = flagValue(line: line, flag: "--csrf_token")
|
||||
if !isCli && csrf == nil { return nil }
|
||||
let extPort = flagValue(line: line, flag: "--extension_server_port").flatMap(Int.init)
|
||||
return Candidate(pid: pid, cli: isCli, csrf: csrf, extPort: extPort.flatMap { $0 > 0 ? $0 : nil })
|
||||
}
|
||||
|
||||
private static func discoverCandidates(deps: Deps) async throws -> [Candidate] {
|
||||
let stdout = try await deps.exec("/bin/ps", ["-ax", "-o", "pid=,command="])
|
||||
var seen = Set<String>()
|
||||
var candidates: [Candidate] = []
|
||||
for line in stdout.split(separator: "\n") {
|
||||
guard let candidate = classifyProcessLine(String(line)), !seen.contains(candidate.pid) else { continue }
|
||||
seen.insert(candidate.pid)
|
||||
candidates.append(candidate)
|
||||
}
|
||||
// The app source ranks above the CLI: richer quota data beats
|
||||
// availability. Swift's sort is stable, so discovery order survives
|
||||
// within each group.
|
||||
return candidates.sorted { ($0.cli ? 1 : 0) < ($1.cli ? 1 : 0) }
|
||||
}
|
||||
|
||||
private static func listeningPorts(deps: Deps, pid: String) async -> [Int] {
|
||||
guard let stdout = try? await deps.exec("/usr/sbin/lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-a", "-p", pid]),
|
||||
let regex = try? NSRegularExpression(pattern: #":(\d+)\s"#) else { return [] }
|
||||
var seen = Set<Int>()
|
||||
var ports: [Int] = []
|
||||
let range = NSRange(stdout.startIndex..., in: stdout)
|
||||
for match in regex.matches(in: stdout, range: range) {
|
||||
guard let portRange = Range(match.range(at: 1), in: stdout),
|
||||
let port = Int(stdout[portRange]), port > 0, !seen.contains(port) else { continue }
|
||||
seen.insert(port)
|
||||
ports.append(port)
|
||||
}
|
||||
return ports
|
||||
}
|
||||
|
||||
// MARK: - Decode (internal so tests can drive fixtures)
|
||||
|
||||
/// Preferred payload: two named quota groups of model buckets.
|
||||
static func decodeSummary(_ body: Any?) -> [AntigravityUsage.Window] {
|
||||
guard let data = body as? [String: Any] else { return [] }
|
||||
var windows: [AntigravityUsage.Window] = []
|
||||
for group in data["groups"] as? [Any] ?? [] {
|
||||
guard let group = group as? [String: Any] else { continue }
|
||||
let groupName = group["displayName"] as? String ?? ""
|
||||
for bucket in group["buckets"] as? [Any] ?? [] {
|
||||
guard let bucket = bucket as? [String: Any] else { continue }
|
||||
let bucketName = (bucket["displayName"] as? String) ?? (bucket["bucketId"] as? String) ?? ""
|
||||
let name = [groupName, bucketName].filter { !$0.isEmpty }.joined(separator: " · ")
|
||||
if name.isEmpty { continue }
|
||||
let remaining = (bucket["remaining"] as? [String: Any])?["remainingFraction"]
|
||||
if let window = makeWindow(label: name, remainingFraction: remaining, resetTime: nil) {
|
||||
windows.append(window)
|
||||
}
|
||||
}
|
||||
}
|
||||
return windows
|
||||
}
|
||||
|
||||
/// Legacy payload: flat per-model quota rows under GetUserStatus.
|
||||
static func decodeStatus(_ body: Any?) -> [AntigravityUsage.Window] {
|
||||
guard let data = body as? [String: Any],
|
||||
let userStatus = data["userStatus"] as? [String: Any],
|
||||
let configData = userStatus["cascadeModelConfigData"] as? [String: Any] else { return [] }
|
||||
var windows: [AntigravityUsage.Window] = []
|
||||
for config in configData["clientModelConfigs"] as? [Any] ?? [] {
|
||||
guard let config = config as? [String: Any],
|
||||
let name = config["modelName"] as? String, !name.isEmpty else { continue }
|
||||
let quota = config["quotaInfo"] as? [String: Any]
|
||||
if let window = makeWindow(label: name, remainingFraction: quota?["remainingFraction"], resetTime: quota?["resetTime"]) {
|
||||
windows.append(window)
|
||||
}
|
||||
}
|
||||
return windows
|
||||
}
|
||||
|
||||
/// planName may sit at the top level, under userStatus, or (legacy) as
|
||||
/// account_plan; the first non-blank string wins.
|
||||
static func planFromStatus(_ body: Any?) -> String? {
|
||||
guard let data = body as? [String: Any] else { return nil }
|
||||
let candidates = [
|
||||
data["planName"],
|
||||
(data["userStatus"] as? [String: Any])?["planName"],
|
||||
data["account_plan"],
|
||||
]
|
||||
for candidate in candidates {
|
||||
guard let raw = candidate as? String else { continue }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespaces)
|
||||
if !trimmed.isEmpty { return trimmed }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func makeWindow(label: String, remainingFraction: Any?, resetTime: Any?) -> AntigravityUsage.Window? {
|
||||
guard let remaining = number(remainingFraction) else { return nil }
|
||||
let used = min(100, max(0, (1 - remaining) * 100))
|
||||
return AntigravityUsage.Window(label: label, usedPercent: used, resetsAt: resetDate(resetTime))
|
||||
}
|
||||
|
||||
/// JSON numbers only — booleans bridge to NSNumber and must not parse.
|
||||
private static func number(_ value: Any?) -> Double? {
|
||||
guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil }
|
||||
let double = number.doubleValue
|
||||
return double.isFinite ? double : nil
|
||||
}
|
||||
|
||||
/// Reset times arrive as epoch seconds (epoch ms past 1e12) or ISO strings.
|
||||
private static func resetDate(_ value: Any?) -> Date? {
|
||||
if let epoch = number(value) {
|
||||
return Date(timeIntervalSince1970: epoch > 1e12 ? epoch / 1000 : epoch)
|
||||
}
|
||||
if let raw = value as? String {
|
||||
let fractional = ISO8601DateFormatter()
|
||||
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = fractional.date(from: raw) { return date }
|
||||
let plain = ISO8601DateFormatter()
|
||||
plain.formatOptions = [.withInternetDateTime]
|
||||
return plain.date(from: raw)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func parseJson(_ text: String) -> Any? {
|
||||
try? JSONSerialization.jsonObject(with: Data(text.utf8))
|
||||
}
|
||||
|
||||
// MARK: - Probe + fetch
|
||||
|
||||
private static func probePort(
|
||||
deps: Deps,
|
||||
port: Int,
|
||||
csrf: String?
|
||||
) async -> (windows: [AntigravityUsage.Window], plan: String?)? {
|
||||
let body = "{}"
|
||||
for tls in [true, false] {
|
||||
if let summary = await deps.request(port, tls, summaryPath, body, csrf), summary.status == 200 {
|
||||
let windows = decodeSummary(parseJson(summary.text))
|
||||
if !windows.isEmpty { return (windows, nil) }
|
||||
}
|
||||
if let status = await deps.request(port, tls, statusPath, body, csrf), status.status == 200 {
|
||||
let parsed = parseJson(status.text)
|
||||
let windows = decodeStatus(parsed)
|
||||
if !windows.isEmpty { return (windows, planFromStatus(parsed)) }
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func refresh(deps: Deps = .live) async throws -> AntigravityUsage {
|
||||
do {
|
||||
let candidates = try await discoverCandidates(deps: deps)
|
||||
if candidates.isEmpty { throw FetchError.disconnected }
|
||||
for candidate in candidates {
|
||||
var ports = await listeningPorts(deps: deps, pid: candidate.pid)
|
||||
if let extPort = candidate.extPort, !ports.contains(extPort) {
|
||||
ports.append(extPort)
|
||||
}
|
||||
for port in ports {
|
||||
if let found = await probePort(deps: deps, port: port, csrf: candidate.csrf) {
|
||||
let windows = found.windows.sorted { $0.usedPercent > $1.usedPercent }
|
||||
return AntigravityUsage(details: windows, plan: found.plan, fetchedAt: deps.now())
|
||||
}
|
||||
}
|
||||
}
|
||||
throw FetchError.disconnected
|
||||
} catch let error as FetchError {
|
||||
throw error
|
||||
} catch {
|
||||
deps.log("Antigravity quota unavailable: \(sanitizeForLog(error.localizedDescription))")
|
||||
throw FetchError.network(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip control characters and any token-shaped substrings from error
|
||||
/// strings before they land in NSLog — a `ps` failure message can quote
|
||||
/// arbitrary command lines. Mirrors the Electron side's sanitizeError.
|
||||
static func sanitizeForLog(_ raw: String) -> String {
|
||||
var cleaned = raw.replacingOccurrences(of: "\u{0000}", with: "")
|
||||
let patterns = [
|
||||
#"(?i)Bearer\s+[^\s,;"']+"#,
|
||||
#"(?i)sk-ant-[A-Za-z0-9_-]+"#,
|
||||
#"(?i)sk-[A-Za-z0-9_-]+"#,
|
||||
// Google (ya29.) and GitHub (gho_/ghu_/ghp_) OAuth token shapes.
|
||||
#"ya29\.[A-Za-z0-9._-]+"#,
|
||||
#"gh[opusr]_[A-Za-z0-9_]+"#,
|
||||
#"eyJ[A-Za-z0-9._-]+"#,
|
||||
]
|
||||
for pattern in patterns {
|
||||
cleaned = cleaned.replacingOccurrences(of: pattern, with: "[REDACTED]", options: .regularExpression)
|
||||
}
|
||||
if cleaned.count > 240 { cleaned = String(cleaned.prefix(240)) }
|
||||
return cleaned
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import Foundation
|
||||
|
||||
/// Pure display-decision helpers for the Copilot quota surfaces.
|
||||
///
|
||||
/// Copilot tokens are read straight from the editor plugins' on-disk files
|
||||
/// with no refresh path, so a revoked token sits in `.terminalFailure` until
|
||||
/// the user signs in via an editor's Copilot plugin again. Like Kimi and
|
||||
/// Gemini, the always-visible surfaces keep showing the last good snapshot
|
||||
/// with a quiet caption instead of flapping to a reconnect screen; the
|
||||
/// reconnect screen is reserved for the no-data case, where there is
|
||||
/// genuinely nothing to show.
|
||||
enum CopilotQuotaPresentation {
|
||||
/// Which Plan-tab subview to render, given the load state and whether a
|
||||
/// last-known snapshot exists.
|
||||
enum PlanContent: Equatable {
|
||||
case noCredentials
|
||||
case loading
|
||||
case failed
|
||||
case transientFailed
|
||||
case reconnect(reason: String?)
|
||||
/// Render the loaded usage bars. `idle` is true when the login has
|
||||
/// gone terminal but a snapshot is still on hand — the caller stamps a
|
||||
/// quiet "sign in again" caption instead of hiding the data.
|
||||
case usage(idle: Bool)
|
||||
}
|
||||
|
||||
static func planContent(loadState: SubscriptionLoadState, hasUsage: Bool) -> PlanContent {
|
||||
switch loadState {
|
||||
case .notBootstrapped, .noCredentials:
|
||||
return .noCredentials
|
||||
case .dormant, .bootstrapping:
|
||||
return .loading
|
||||
case .loading, .loaded:
|
||||
return hasUsage ? .usage(idle: false) : .loading
|
||||
case .failed:
|
||||
return .failed
|
||||
case .transientFailure:
|
||||
return hasUsage ? .usage(idle: false) : .transientFailed
|
||||
case .terminalFailure(let reason):
|
||||
return hasUsage ? .usage(idle: true) : .reconnect(reason: reason)
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot age past which a loaded view stamps an "as of <time>" caption,
|
||||
/// so a bar can never silently masquerade as current.
|
||||
static let stalenessThreshold: TimeInterval = 10 * 60
|
||||
|
||||
static func isStale(fetchedAt: Date, now: Date = Date()) -> Bool {
|
||||
now.timeIntervalSince(fetchedAt) > stalenessThreshold
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,294 @@
|
|||
import Foundation
|
||||
|
||||
/// Live quota snapshot for a GitHub Copilot plan. The API reports per-feature
|
||||
/// quota snapshots ("Premium requests", "Chat") as percent REMAINING; windows
|
||||
/// render percent USED, so `usedPercent` is the inverted value. `details`
|
||||
/// keeps the endpoint's order (premium first), so `primary` is the premium
|
||||
/// window when present and chat otherwise.
|
||||
struct CopilotUsage: Sendable, Equatable {
|
||||
struct Window: Sendable, Equatable {
|
||||
let label: String
|
||||
let usedPercent: Double // 0.0 ... 100.0
|
||||
/// The endpoint reports no reset times — always nil.
|
||||
let resetsAt: Date?
|
||||
}
|
||||
|
||||
/// Premium-requests window first, then chat; either may be absent.
|
||||
let details: [Window]
|
||||
var primary: Window? { details.first }
|
||||
/// Plan label derived from copilot_plan (Free / Individual / Pro /
|
||||
/// Business / Enterprise / Educators, unknown tiers title-cased).
|
||||
let plan: String?
|
||||
let fetchedAt: Date
|
||||
}
|
||||
|
||||
/// Live GitHub Copilot quota via the editor plugins' internal usage endpoint
|
||||
/// (prior art: steipete/CodexBar docs/copilot.md):
|
||||
///
|
||||
/// - GET https://api.github.com/copilot_internal/user → plan + quota snapshots
|
||||
///
|
||||
/// This is an INTERNAL, UNDOCUMENTED API that may drift without notice; every
|
||||
/// failure must degrade to the normal connection states and never crash the
|
||||
/// panel.
|
||||
///
|
||||
/// Credential: the GitHub OAuth token already on disk from a signed-in
|
||||
/// Copilot plugin — ~/.config/github-copilot/hosts.json (keyed by host,
|
||||
/// github.com preferred) falling back to apps.json. Read-only; nothing is
|
||||
/// copied or written, and there is no refresh path: an expired or revoked
|
||||
/// token stays dead until the user signs in via an editor's Copilot plugin
|
||||
/// again, which rewrites the file.
|
||||
enum CopilotSubscriptionService {
|
||||
private static let usageURL = URL(string: "https://api.github.com/copilot_internal/user")!
|
||||
private static let usageBlockedUntilKey = "codeburn.copilot.usage.blockedUntil"
|
||||
|
||||
enum FetchError: Error, LocalizedError {
|
||||
case noCredentials
|
||||
/// 401 where re-reading the file yielded the same (or no) token. An
|
||||
/// active editor session rotates this token, so this is transient —
|
||||
/// the next refresh picks up the rotated token from disk.
|
||||
case tokenRejected
|
||||
case rateLimited(retryAt: Date)
|
||||
case usageHTTPError(Int)
|
||||
case usageDecodeFailed
|
||||
case network(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noCredentials:
|
||||
return "No GitHub Copilot credentials found. Sign in via an editor's Copilot plugin first."
|
||||
case .tokenRejected:
|
||||
return "GitHub rejected the stored Copilot token. It will be retried once an editor session refreshes it."
|
||||
case let .rateLimited(retryAt):
|
||||
let f = RelativeDateTimeFormatter()
|
||||
f.unitsStyle = .short
|
||||
return "GitHub rate-limited the Copilot quota endpoint. Retrying \(f.localizedString(for: retryAt, relativeTo: Date()))."
|
||||
case let .usageHTTPError(code):
|
||||
return "Copilot quota fetch failed (HTTP \(code)). Sign in via an editor's Copilot plugin, then Reconnect."
|
||||
case .usageDecodeFailed:
|
||||
return "Copilot quota response was malformed."
|
||||
case let .network(err):
|
||||
return "Network error: \(err.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
var isTerminal: Bool {
|
||||
switch self {
|
||||
case .noCredentials:
|
||||
return true
|
||||
case let .usageHTTPError(code):
|
||||
return (400..<500).contains(code)
|
||||
case .tokenRejected, .rateLimited, .usageDecodeFailed, .network:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var rateLimitRetryAt: Date? {
|
||||
if case let .rateLimited(retryAt) = self { return retryAt }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Injectable seams (tests drive fixtures through these)
|
||||
|
||||
struct Deps: Sendable {
|
||||
var fetch: @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse)
|
||||
/// Read-only credential file load. Never writes.
|
||||
var readFile: @Sendable (URL) -> Data?
|
||||
var hostsURL: URL
|
||||
var appsURL: URL
|
||||
var now: @Sendable () -> Date
|
||||
|
||||
static let live = Deps(
|
||||
fetch: { request in
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw FetchError.usageHTTPError(-1)
|
||||
}
|
||||
return (data, http)
|
||||
},
|
||||
readFile: { url in FileManager.default.contents(atPath: url.path) },
|
||||
hostsURL: URL(fileURLWithPath: NSHomeDirectory() + "/.config/github-copilot/hosts.json"),
|
||||
appsURL: URL(fileURLWithPath: NSHomeDirectory() + "/.config/github-copilot/apps.json"),
|
||||
now: { Date() }
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Credential files
|
||||
|
||||
private static var defaultHostsURL: URL { Deps.live.hostsURL }
|
||||
private static var defaultAppsURL: URL { Deps.live.appsURL }
|
||||
|
||||
static var hasCredential: Bool {
|
||||
FileManager.default.fileExists(atPath: defaultHostsURL.path)
|
||||
|| FileManager.default.fileExists(atPath: defaultAppsURL.path)
|
||||
}
|
||||
|
||||
/// hosts.json first, apps.json as fallback; a malformed or unreadable
|
||||
/// file falls through to the next candidate. Throws noCredentials when
|
||||
/// neither yields a token.
|
||||
private static func readToken(deps: Deps) throws -> String {
|
||||
for url in [deps.hostsURL, deps.appsURL] {
|
||||
guard let data = deps.readFile(url),
|
||||
let token = tokenFromMap(data) else { continue }
|
||||
return token
|
||||
}
|
||||
throw FetchError.noCredentials
|
||||
}
|
||||
|
||||
/// hosts.json keys by host — prefer github.com; apps.json has no
|
||||
/// canonical key, so its first entry wins. Both store the token as
|
||||
/// `oauth_token`.
|
||||
private static func tokenFromMap(_ data: Data) -> String? {
|
||||
guard let map = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||||
let preferred = map["github.com"] ?? map.values.first
|
||||
guard let token = (preferred as? [String: Any])?["oauth_token"] as? String, !token.isEmpty else { return nil }
|
||||
return token
|
||||
}
|
||||
|
||||
// MARK: - Fetch
|
||||
|
||||
static func refresh(deps: Deps = .live) async throws -> CopilotUsage {
|
||||
if let until = usageBlockedUntil(), until > deps.now() {
|
||||
throw FetchError.rateLimited(retryAt: until)
|
||||
}
|
||||
var token = try readToken(deps: deps)
|
||||
|
||||
var (data, response) = try await send(request(token: token), deps: deps)
|
||||
if response.statusCode == 401 {
|
||||
// An active editor session rotates this token; re-read once before
|
||||
// giving up so we don't report a failure the disk already fixed.
|
||||
guard let reread = try? readToken(deps: deps), reread != token else {
|
||||
throw FetchError.tokenRejected
|
||||
}
|
||||
token = reread
|
||||
(data, response) = try await send(request(token: token), deps: deps)
|
||||
}
|
||||
if response.statusCode == 429 {
|
||||
throw FetchError.rateLimited(retryAt: recordUsageRateLimit(
|
||||
retryAfterSeconds: parseRetryAfterHeader(response.value(forHTTPHeaderField: "Retry-After"))))
|
||||
}
|
||||
guard (200..<300).contains(response.statusCode) else {
|
||||
throw FetchError.usageHTTPError(response.statusCode)
|
||||
}
|
||||
return try decodeUsage(data: data, now: deps.now())
|
||||
}
|
||||
|
||||
private static func request(token: String) -> URLRequest {
|
||||
var request = URLRequest(url: usageURL)
|
||||
request.httpMethod = "GET"
|
||||
request.timeoutInterval = 30
|
||||
// Headers mirror the VS Code Copilot Chat plugin.
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue("vscode/1.96.2", forHTTPHeaderField: "Editor-Version")
|
||||
request.setValue("copilot-chat/0.26.7", forHTTPHeaderField: "Editor-Plugin-Version")
|
||||
request.setValue("GitHubCopilotChat/0.26.7", forHTTPHeaderField: "User-Agent")
|
||||
request.setValue("2025-04-01", forHTTPHeaderField: "X-Github-Api-Version")
|
||||
request.setValue("token \(token)", forHTTPHeaderField: "Authorization")
|
||||
return request
|
||||
}
|
||||
|
||||
private static func send(_ request: URLRequest, deps: Deps) async throws -> (Data, HTTPURLResponse) {
|
||||
do {
|
||||
return try await deps.fetch(request)
|
||||
} catch let error as FetchError {
|
||||
throw error
|
||||
} catch {
|
||||
throw FetchError.network(error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Decode (internal so tests can drive fixtures)
|
||||
|
||||
static func decodeUsage(data: Data, now: Date = Date()) throws -> CopilotUsage {
|
||||
let parsed: Any
|
||||
do {
|
||||
parsed = try JSONSerialization.jsonObject(with: data)
|
||||
} catch {
|
||||
// Never log the body — account data readable via `log stream`.
|
||||
throw FetchError.usageDecodeFailed
|
||||
}
|
||||
// Field names have shipped both camelCase and snake_case; read each
|
||||
// alias rather than trusting one spelling.
|
||||
let root = parsed as? [String: Any] ?? [:]
|
||||
let snapshots = (root["quota_snapshots"] ?? root["quotaSnapshots"]) as? [String: Any]
|
||||
let premium = window(label: "Premium requests",
|
||||
snapshot: snapshots?["premium_interactions"] ?? snapshots?["premiumInteractions"])
|
||||
let chat = window(label: "Chat", snapshot: snapshots?["chat"])
|
||||
return CopilotUsage(
|
||||
details: [premium, chat].compactMap { $0 },
|
||||
plan: planLabel(root["copilot_plan"] ?? root["copilotPlan"]),
|
||||
fetchedAt: now
|
||||
)
|
||||
}
|
||||
|
||||
private static func window(label: String, snapshot: Any?) -> CopilotUsage.Window? {
|
||||
guard let row = snapshot as? [String: Any],
|
||||
let remaining = fraction(row["percent_remaining"] ?? row["percentRemaining"]) else { return nil }
|
||||
// Round away float dust from the 1-remaining subtraction
|
||||
// (1-0.7 != 0.3), mirroring the desktop decoder's toFixed(6) before
|
||||
// scaling to 0..100.
|
||||
let used = ((1 - remaining) * 1_000_000).rounded() / 1_000_000 * 100
|
||||
return CopilotUsage.Window(label: label, usedPercent: used, resetsAt: nil)
|
||||
}
|
||||
|
||||
/// Percent-remaining (0..100) as a clamped 0..1 fraction. Non-numeric
|
||||
/// values (and booleans, which bridge to NSNumber) yield nil.
|
||||
private static func fraction(_ value: Any?) -> Double? {
|
||||
guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil }
|
||||
let fraction = number.doubleValue / 100
|
||||
guard fraction.isFinite else { return nil }
|
||||
return min(1, max(0, fraction))
|
||||
}
|
||||
|
||||
/// nil for missing/blank; known tiers get display names; unknown values
|
||||
/// are title-cased on _ and - boundaries (some_future_tier →
|
||||
/// "Some Future Tier").
|
||||
private static func planLabel(_ value: Any?) -> String? {
|
||||
guard let raw = (value as? String)?.trimmingCharacters(in: .whitespaces), !raw.isEmpty else { return nil }
|
||||
let lower = raw.lowercased()
|
||||
let known = [
|
||||
"free": "Free", "individual": "Individual", "pro": "Pro", "business": "Business",
|
||||
"enterprise": "Enterprise", "for_educators": "Educators", "for-educators": "Educators",
|
||||
]
|
||||
if let label = known[lower] { return label }
|
||||
return lower
|
||||
.components(separatedBy: CharacterSet(charactersIn: "_-"))
|
||||
.filter { !$0.isEmpty }
|
||||
.map { $0.prefix(1).uppercased() + $0.dropFirst() }
|
||||
.joined(separator: " ")
|
||||
}
|
||||
|
||||
// MARK: - 429 backoff
|
||||
|
||||
private static func usageBlockedUntil() -> Date? {
|
||||
UserDefaults.standard.object(forKey: usageBlockedUntilKey) as? Date
|
||||
}
|
||||
|
||||
private static func clearUsageBlock() {
|
||||
UserDefaults.standard.removeObject(forKey: usageBlockedUntilKey)
|
||||
}
|
||||
|
||||
private static func parseRetryAfterHeader(_ value: String?) -> Int? {
|
||||
guard let value = value?.trimmingCharacters(in: .whitespaces), !value.isEmpty else { return nil }
|
||||
if let seconds = Int(value), seconds >= 0 { return seconds }
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
|
||||
if let date = f.date(from: value) {
|
||||
return max(0, Int(date.timeIntervalSinceNow))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func recordUsageRateLimit(retryAfterSeconds: Int?) -> Date {
|
||||
let seconds = max(retryAfterSeconds ?? 300, 60)
|
||||
let until = Date().addingTimeInterval(TimeInterval(seconds))
|
||||
UserDefaults.standard.set(until, forKey: usageBlockedUntilKey)
|
||||
return until
|
||||
}
|
||||
|
||||
static func disconnect() {
|
||||
clearUsageBlock()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import Foundation
|
||||
|
||||
/// Pure display-decision helpers for the Gemini quota surfaces.
|
||||
///
|
||||
/// Gemini OAuth access tokens expire (~1h) and CodeBurn only refreshes them
|
||||
/// in memory when the CLI's documented env overrides are set, so an expired
|
||||
/// login without a refresh path sits in `.terminalFailure` until the user
|
||||
/// runs the Gemini CLI once. Like Kimi, the always-visible surfaces keep
|
||||
/// showing the last good snapshot with a quiet caption instead of flapping
|
||||
/// to a reconnect screen; the reconnect screen is reserved for the no-data
|
||||
/// case, where there is genuinely nothing to show.
|
||||
enum GeminiQuotaPresentation {
|
||||
/// Which Plan-tab subview to render, given the load state and whether a
|
||||
/// last-known snapshot exists.
|
||||
enum PlanContent: Equatable {
|
||||
case noCredentials
|
||||
case loading
|
||||
case failed
|
||||
case transientFailed
|
||||
case reconnect(reason: String?)
|
||||
/// Render the loaded usage bars. `idle` is true when the login has
|
||||
/// gone terminal but a snapshot is still on hand — the caller stamps a
|
||||
/// quiet "run the CLI" caption instead of hiding the data.
|
||||
case usage(idle: Bool)
|
||||
}
|
||||
|
||||
static func planContent(loadState: SubscriptionLoadState, hasUsage: Bool) -> PlanContent {
|
||||
switch loadState {
|
||||
case .notBootstrapped, .noCredentials:
|
||||
return .noCredentials
|
||||
case .dormant, .bootstrapping:
|
||||
return .loading
|
||||
case .loading, .loaded:
|
||||
return hasUsage ? .usage(idle: false) : .loading
|
||||
case .failed:
|
||||
return .failed
|
||||
case .transientFailure:
|
||||
return hasUsage ? .usage(idle: false) : .transientFailed
|
||||
case .terminalFailure(let reason):
|
||||
return hasUsage ? .usage(idle: true) : .reconnect(reason: reason)
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot age past which a loaded view stamps an "as of <time>" caption,
|
||||
/// so a bar can never silently masquerade as current.
|
||||
static let stalenessThreshold: TimeInterval = 10 * 60
|
||||
|
||||
static func isStale(fetchedAt: Date, now: Date = Date()) -> Bool {
|
||||
now.timeIntervalSince(fetchedAt) > stalenessThreshold
|
||||
}
|
||||
}
|
||||
376
mac/Sources/CodeBurnMenubar/Data/GeminiSubscriptionService.swift
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
import Foundation
|
||||
|
||||
/// Live quota snapshot for a Gemini (Code Assist) subscription. The API
|
||||
/// reports per-model quota buckets; `details` is sorted most-constrained
|
||||
/// first so `primary` is the window that will throttle the user soonest.
|
||||
struct GeminiUsage: Sendable, Equatable {
|
||||
struct Window: Sendable, Equatable {
|
||||
/// Model id reported by the quota endpoint (e.g. "gemini-2.5-pro").
|
||||
let label: String
|
||||
let usedPercent: Double // 0.0 ... 100.0
|
||||
let resetsAt: Date?
|
||||
}
|
||||
|
||||
/// Per-model windows, most-constrained first.
|
||||
let details: [Window]
|
||||
var primary: Window? { details.first }
|
||||
/// Tier label derived from loadCodeAssist (Paid / Legacy / Workspace / Free).
|
||||
let plan: String?
|
||||
let fetchedAt: Date
|
||||
}
|
||||
|
||||
/// Live Gemini quota via the OAuth-backed Code Assist APIs the Gemini CLI
|
||||
/// itself calls (prior art: steipete/CodexBar docs/gemini.md, which derives
|
||||
/// the flow from the CLI's own traffic):
|
||||
///
|
||||
/// - POST .../v1internal:loadCodeAssist → tier (plan label) + quota project
|
||||
/// - POST .../v1internal:retrieveUserQuota → per-model quota buckets
|
||||
///
|
||||
/// Credential: the Gemini CLI's own ~/.gemini/oauth_creds.json, read-only —
|
||||
/// nothing is copied or written. Token refresh happens in memory only, and
|
||||
/// only when the CLI's documented GEMINI_OAUTH_CLIENT_ID /
|
||||
/// GEMINI_OAUTH_CLIENT_SECRET env overrides are set; without them an expired
|
||||
/// token is terminal and the UI tells the user to run the Gemini CLI once.
|
||||
enum GeminiSubscriptionService {
|
||||
private static let codeAssistEndpoint = "https://cloudcode-pa.googleapis.com/v1internal"
|
||||
private static let tokenEndpoint = "https://oauth2.googleapis.com/token"
|
||||
private static let usageBlockedUntilKey = "codeburn.gemini.usage.blockedUntil"
|
||||
|
||||
enum FetchError: Error, LocalizedError {
|
||||
case noCredentials
|
||||
case tokenExpired
|
||||
/// Google retired Gemini CLI OAuth for this account tier.
|
||||
case accountTierRetired
|
||||
case rateLimited(retryAt: Date)
|
||||
case usageHTTPError(Int)
|
||||
case network(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noCredentials:
|
||||
return "No Gemini credentials found. Sign in with the Gemini CLI first."
|
||||
case .tokenExpired:
|
||||
return "Gemini login expired. Run the Gemini CLI once to refresh, then try again."
|
||||
case .accountTierRetired:
|
||||
return "Google retired Gemini CLI OAuth for this account tier. Use Antigravity."
|
||||
case let .rateLimited(retryAt):
|
||||
let f = RelativeDateTimeFormatter()
|
||||
f.unitsStyle = .short
|
||||
return "Gemini rate-limited the quota endpoint. Retrying \(f.localizedString(for: retryAt, relativeTo: Date()))."
|
||||
case let .usageHTTPError(code):
|
||||
return "Gemini quota fetch failed (HTTP \(code))."
|
||||
case let .network(err):
|
||||
return "Network error: \(err.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
var isTerminal: Bool {
|
||||
switch self {
|
||||
case .noCredentials, .tokenExpired, .accountTierRetired:
|
||||
return true
|
||||
case let .usageHTTPError(code):
|
||||
return (400..<500).contains(code)
|
||||
case .rateLimited, .network:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var rateLimitRetryAt: Date? {
|
||||
if case let .rateLimited(retryAt) = self { return retryAt }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Injectable seams (tests drive fixtures through these)
|
||||
|
||||
struct Deps: Sendable {
|
||||
var fetch: @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse)
|
||||
/// Read-only credential file load. Never writes.
|
||||
var readFile: @Sendable (URL) -> Data?
|
||||
var credentialURL: URL
|
||||
var now: @Sendable () -> Date
|
||||
/// OAuth client credentials from the CLI's documented env overrides.
|
||||
var clientCredentials: @Sendable () -> (clientId: String, clientSecret: String)?
|
||||
|
||||
static let live = Deps(
|
||||
fetch: { request in
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw FetchError.usageHTTPError(-1)
|
||||
}
|
||||
return (data, http)
|
||||
},
|
||||
readFile: { url in FileManager.default.contents(atPath: url.path) },
|
||||
credentialURL: URL(fileURLWithPath: NSHomeDirectory() + "/.gemini/oauth_creds.json"),
|
||||
now: { Date() },
|
||||
clientCredentials: {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
guard let id = env["GEMINI_OAUTH_CLIENT_ID"], !id.isEmpty,
|
||||
let secret = env["GEMINI_OAUTH_CLIENT_SECRET"], !secret.isEmpty else { return nil }
|
||||
return (id, secret)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Credential file
|
||||
|
||||
private struct Credential {
|
||||
var accessToken: String
|
||||
let refreshToken: String?
|
||||
let idToken: String?
|
||||
/// Epoch milliseconds, as the Gemini CLI writes it. nil = never stale.
|
||||
let expiryDate: Double?
|
||||
|
||||
static func parse(_ data: Data) -> Credential? {
|
||||
guard let raw = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let token = raw["access_token"] as? String, !token.isEmpty else { return nil }
|
||||
let expiry: Double?
|
||||
if let n = raw["expiry_date"] as? Double {
|
||||
expiry = n
|
||||
} else if let n = raw["expiry_date"] as? Int {
|
||||
expiry = Double(n)
|
||||
} else {
|
||||
expiry = nil
|
||||
}
|
||||
return Credential(
|
||||
accessToken: token,
|
||||
refreshToken: raw["refresh_token"] as? String,
|
||||
idToken: raw["id_token"] as? String,
|
||||
expiryDate: expiry
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static var defaultCredentialURL: URL { Deps.live.credentialURL }
|
||||
|
||||
static var hasCredential: Bool {
|
||||
FileManager.default.fileExists(atPath: defaultCredentialURL.path)
|
||||
}
|
||||
|
||||
private static func readCredential(deps: Deps) throws -> Credential {
|
||||
guard let data = deps.readFile(deps.credentialURL),
|
||||
let credential = Credential.parse(data) else {
|
||||
throw FetchError.noCredentials
|
||||
}
|
||||
return credential
|
||||
}
|
||||
|
||||
// MARK: - Fetch
|
||||
|
||||
static func refresh(deps: Deps = .live) async throws -> GeminiUsage {
|
||||
if let until = usageBlockedUntil(), until > deps.now() {
|
||||
throw FetchError.rateLimited(retryAt: until)
|
||||
}
|
||||
var credential = try readCredential(deps: deps)
|
||||
|
||||
// The CLI considers a token stale 5 minutes before expiry.
|
||||
if let expiry = credential.expiryDate,
|
||||
expiry - deps.now().timeIntervalSince1970 * 1000 <= 5 * 60_000 {
|
||||
guard let client = deps.clientCredentials(), credential.refreshToken != nil else {
|
||||
// No refresh path: the Gemini CLI owns this file, so the user
|
||||
// must run it once to mint a fresh token.
|
||||
throw FetchError.tokenExpired
|
||||
}
|
||||
if let refreshed = try await refreshAccessToken(credential: credential, client: client, deps: deps) {
|
||||
credential.accessToken = refreshed
|
||||
}
|
||||
// A failed refresh keeps the stale token; a 401 below degrades it.
|
||||
}
|
||||
|
||||
var (assistData, assistResponse) = try await send(
|
||||
postRequest(token: credential.accessToken, path: "loadCodeAssist",
|
||||
body: ["metadata": ["ideType": "GEMINI_CLI", "pluginType": "GEMINI"]]),
|
||||
deps: deps
|
||||
)
|
||||
if assistResponse.statusCode == 401 {
|
||||
// The CLI may have refreshed the file while we ran; retry once
|
||||
// with a newly-written token, otherwise the login is dead.
|
||||
let reread = try readCredential(deps: deps)
|
||||
guard reread.accessToken != credential.accessToken else {
|
||||
throw FetchError.tokenExpired
|
||||
}
|
||||
credential = reread
|
||||
(assistData, assistResponse) = try await send(
|
||||
postRequest(token: credential.accessToken, path: "loadCodeAssist",
|
||||
body: ["metadata": ["ideType": "GEMINI_CLI", "pluginType": "GEMINI"]]),
|
||||
deps: deps
|
||||
)
|
||||
}
|
||||
if assistResponse.statusCode == 429 {
|
||||
throw FetchError.rateLimited(retryAt: recordUsageRateLimit(
|
||||
retryAfterSeconds: parseRetryAfterHeader(assistResponse.value(forHTTPHeaderField: "Retry-After"))))
|
||||
}
|
||||
// Retired-tier sentinels ride inside the JSON error body of a non-200
|
||||
// response, so parse before branching on status.
|
||||
let assist = (try? JSONSerialization.jsonObject(with: assistData)) as? [String: Any] ?? [:]
|
||||
if isTierRetired(assist) {
|
||||
throw FetchError.accountTierRetired
|
||||
}
|
||||
guard (200..<300).contains(assistResponse.statusCode) else {
|
||||
throw FetchError.usageHTTPError(assistResponse.statusCode)
|
||||
}
|
||||
|
||||
let project = (assist["cloudaicompanionProject"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
||||
let (quotaData, quotaResponse) = try await send(
|
||||
postRequest(token: credential.accessToken, path: "retrieveUserQuota",
|
||||
body: project.map { ["project": $0] } ?? [:]),
|
||||
deps: deps
|
||||
)
|
||||
if quotaResponse.statusCode == 429 {
|
||||
throw FetchError.rateLimited(retryAt: recordUsageRateLimit(
|
||||
retryAfterSeconds: parseRetryAfterHeader(quotaResponse.value(forHTTPHeaderField: "Retry-After"))))
|
||||
}
|
||||
guard (200..<300).contains(quotaResponse.statusCode) else {
|
||||
throw FetchError.usageHTTPError(quotaResponse.statusCode)
|
||||
}
|
||||
return decodeUsage(data: quotaData, plan: tierLabel(assist: assist, credential: credential), now: deps.now())
|
||||
}
|
||||
|
||||
private static func postRequest(token: String, path: String, body: [String: Any]) -> URLRequest {
|
||||
var request = URLRequest(url: URL(string: "\(codeAssistEndpoint):\(path)")!)
|
||||
request.httpMethod = "POST"
|
||||
request.timeoutInterval = 30
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue("CodeBurn", forHTTPHeaderField: "User-Agent")
|
||||
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||||
return request
|
||||
}
|
||||
|
||||
private static func send(_ request: URLRequest, deps: Deps) async throws -> (Data, HTTPURLResponse) {
|
||||
do {
|
||||
return try await deps.fetch(request)
|
||||
} catch let error as FetchError {
|
||||
throw error
|
||||
} catch {
|
||||
throw FetchError.network(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory refresh through Google OAuth. Returns nil on a non-200 so
|
||||
/// the caller can degrade via the stale token's 401 path.
|
||||
private static func refreshAccessToken(
|
||||
credential: Credential,
|
||||
client: (clientId: String, clientSecret: String),
|
||||
deps: Deps
|
||||
) async throws -> String? {
|
||||
guard let refreshToken = credential.refreshToken else { return nil }
|
||||
var request = URLRequest(url: URL(string: tokenEndpoint)!)
|
||||
request.httpMethod = "POST"
|
||||
request.timeoutInterval = 30
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = [
|
||||
"client_id=\(client.clientId)",
|
||||
"client_secret=\(client.clientSecret)",
|
||||
"refresh_token=\(refreshToken)",
|
||||
"grant_type=refresh_token",
|
||||
].joined(separator: "&").data(using: .utf8)
|
||||
let (data, response) = try await send(request, deps: deps)
|
||||
guard (200..<300).contains(response.statusCode),
|
||||
let body = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let token = body["access_token"] as? String, !token.isEmpty else { return nil }
|
||||
return token
|
||||
}
|
||||
|
||||
// MARK: - Decode (internal so tests can drive fixtures)
|
||||
|
||||
static func decodeUsage(data: Data, plan: String?, now: Date = Date()) -> GeminiUsage {
|
||||
let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] ?? [:]
|
||||
let buckets = root["buckets"] as? [[String: Any]] ?? []
|
||||
let windows = buckets.compactMap(window(from:)).sorted { $0.usedPercent > $1.usedPercent }
|
||||
return GeminiUsage(details: windows, plan: plan, fetchedAt: now)
|
||||
}
|
||||
|
||||
private static func window(from bucket: [String: Any]) -> GeminiUsage.Window? {
|
||||
guard let modelId = bucket["modelId"] as? String, !modelId.isEmpty,
|
||||
let remaining = bucket["remainingFraction"] as? Double, remaining.isFinite else { return nil }
|
||||
let clamped = min(1, max(0, remaining))
|
||||
let resetsAt = (bucket["resetTime"] as? String).flatMap(parseResetTime)
|
||||
return GeminiUsage.Window(label: modelId, usedPercent: (1 - clamped) * 100, resetsAt: resetsAt)
|
||||
}
|
||||
|
||||
/// paidTier wins whenever present; otherwise whatever tier the account sits on.
|
||||
private static func tierLabel(assist: [String: Any], credential: Credential) -> String? {
|
||||
let paid = (assist["paidTier"] as? [String: Any])?["name"] as? String
|
||||
let current = assist["currentTier"] as? [String: Any]
|
||||
guard let raw = paid ?? (current?["name"] as? String) ?? (current?["id"] as? String) else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let lower = trimmed.lowercased()
|
||||
if lower == "standard-tier" { return "Paid" }
|
||||
if lower == "legacy-tier" { return "Legacy" }
|
||||
if lower.contains("free") {
|
||||
return workspaceClaim(idToken: credential.idToken) ? "Workspace" : "Free"
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/// The `hd` claim (hosted domain) separates Google Workspace logins from
|
||||
/// personal ones, mirroring CodexBar's tier labeling.
|
||||
private static func workspaceClaim(idToken: String?) -> Bool {
|
||||
guard let idToken else { return false }
|
||||
let parts = idToken.split(separator: ".")
|
||||
guard parts.count > 1 else { return false }
|
||||
var base64 = String(parts[1])
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
while base64.count % 4 != 0 { base64.append("=") }
|
||||
guard let data = Data(base64Encoded: base64),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let hd = json["hd"] as? String else { return false }
|
||||
return !hd.isEmpty
|
||||
}
|
||||
|
||||
/// Google flags retired consumer tiers with these sentinels instead of
|
||||
/// ordinary errors.
|
||||
private static func isTierRetired(_ body: [String: Any]) -> Bool {
|
||||
guard let error = body["error"] as? [String: Any] else { return false }
|
||||
let status = error["status"] as? String ?? ""
|
||||
let message = error["message"] as? String ?? ""
|
||||
return status == "UNSUPPORTED_CLIENT" || message.contains("IneligibleTierError")
|
||||
}
|
||||
|
||||
private static func parseResetTime(_ raw: String) -> Date? {
|
||||
let fractional = ISO8601DateFormatter()
|
||||
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = fractional.date(from: raw) { return date }
|
||||
let plain = ISO8601DateFormatter()
|
||||
plain.formatOptions = [.withInternetDateTime]
|
||||
return plain.date(from: raw)
|
||||
}
|
||||
|
||||
// MARK: - 429 backoff
|
||||
|
||||
private static func usageBlockedUntil() -> Date? {
|
||||
UserDefaults.standard.object(forKey: usageBlockedUntilKey) as? Date
|
||||
}
|
||||
|
||||
private static func clearUsageBlock() {
|
||||
UserDefaults.standard.removeObject(forKey: usageBlockedUntilKey)
|
||||
}
|
||||
|
||||
private static func parseRetryAfterHeader(_ value: String?) -> Int? {
|
||||
guard let value = value?.trimmingCharacters(in: .whitespaces), !value.isEmpty else { return nil }
|
||||
if let seconds = Int(value), seconds >= 0 { return seconds }
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
|
||||
if let date = f.date(from: value) {
|
||||
return max(0, Int(date.timeIntervalSinceNow))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func recordUsageRateLimit(retryAfterSeconds: Int?) -> Date {
|
||||
let seconds = max(retryAfterSeconds ?? 300, 60)
|
||||
let until = Date().addingTimeInterval(TimeInterval(seconds))
|
||||
UserDefaults.standard.set(until, forKey: usageBlockedUntilKey)
|
||||
return until
|
||||
}
|
||||
|
||||
static func disconnect() {
|
||||
clearUsageBlock()
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 321 KiB |
BIN
mac/Sources/CodeBurnMenubar/Resources/ProviderIcons/about.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
BIN
mac/Sources/CodeBurnMenubar/Resources/ProviderIcons/claude.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
mac/Sources/CodeBurnMenubar/Resources/ProviderIcons/devin.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
BIN
mac/Sources/CodeBurnMenubar/Resources/ProviderIcons/flame.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
mac/Sources/CodeBurnMenubar/Resources/ProviderIcons/general.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
BIN
mac/Sources/CodeBurnMenubar/Resources/ProviderIcons/kimi.png
Normal file
|
After Width: | Height: | Size: 7 KiB |
BIN
mac/Sources/CodeBurnMenubar/Resources/ProviderIcons/openai.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
|
@ -35,7 +35,7 @@ enum KeychainCredentialCacheError: Error, LocalizedError, Equatable {
|
|||
case let .deleteFailed(service, status):
|
||||
return "Keychain delete failed for \(service) (status \(status))."
|
||||
case .unavailable:
|
||||
return "Keychain unavailable — unlock your login keychain to refresh quota."
|
||||
return "Keychain unavailable. Unlock your login keychain to refresh quota."
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
51
mac/Sources/CodeBurnMenubar/Views/FlameWordmark.swift
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import SwiftUI
|
||||
|
||||
/// The "CodeBurn" wordmark filled with the website's animated flame gradient.
|
||||
struct FlameWordmark: View {
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@State private var sweeping = false
|
||||
|
||||
// CSS parity: the site's `.flame-text` uses background-size: 300% with
|
||||
// `flameShift 3s ease infinite` (keyframes 0%/100% at position 0%, 50% at
|
||||
// 100%). Here that's a 3x-wide gradient sweeping from its left edge aligned
|
||||
// with the text to its right edge aligned with the text (a 2x-width shift)
|
||||
// and back — a 1.5s ease-in-out each way, autoreversing.
|
||||
private static let flameColors: [Color] = [
|
||||
Color(red: 0xFF / 255.0, green: 0x6A / 255.0, blue: 0x00 / 255.0), // #ff6a00
|
||||
Color(red: 0xFF / 255.0, green: 0xDA / 255.0, blue: 0x44 / 255.0), // #ffda44
|
||||
Color(red: 0xE8 / 255.0, green: 0x55 / 255.0, blue: 0x3A / 255.0), // #e8553a
|
||||
Color(red: 0xFF / 255.0, green: 0x8C / 255.0, blue: 0x00 / 255.0), // #ff8c00
|
||||
Color(red: 0xFF / 255.0, green: 0xDA / 255.0, blue: 0x44 / 255.0), // #ffda44
|
||||
Color(red: 0xFF / 255.0, green: 0x6A / 255.0, blue: 0x00 / 255.0) // #ff6a00
|
||||
]
|
||||
|
||||
private var wordmark: some View {
|
||||
Text("CodeBurn")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.tracking(-0.15)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
wordmark
|
||||
.hidden()
|
||||
.overlay {
|
||||
GeometryReader { geo in
|
||||
LinearGradient(
|
||||
colors: Self.flameColors,
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
.frame(width: geo.size.width * 3, height: geo.size.height)
|
||||
.offset(x: sweeping ? -geo.size.width * 2 : 0)
|
||||
.animation(
|
||||
reduceMotion ? nil : .easeInOut(duration: 1.5).repeatForever(autoreverses: true),
|
||||
value: sweeping
|
||||
)
|
||||
}
|
||||
.mask(wordmark)
|
||||
}
|
||||
.onAppear { sweeping = true }
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("CodeBurn")
|
||||
}
|
||||
}
|
||||
|
|
@ -56,13 +56,14 @@ struct HeatmapSection: View {
|
|||
}
|
||||
|
||||
private var visibleModes: [InsightMode] {
|
||||
// Plan sources from a provider's OAuth usage endpoint. Currently
|
||||
// implemented for Claude (Anthropic) and Codex (ChatGPT). Hidden on
|
||||
// All / Cursor / Droid / Gemini / Copilot until those providers ship
|
||||
// their own quota data sources.
|
||||
// Plan sources from a provider's live quota endpoint. Currently
|
||||
// implemented for Claude (Anthropic), Codex (ChatGPT), Kimi Code,
|
||||
// Gemini (Google Code Assist), Copilot (GitHub), and Antigravity
|
||||
// (local language server on 127.0.0.1). Hidden on All / Cursor /
|
||||
// Droid, which have no quota source of their own.
|
||||
InsightMode.allCases.filter { mode in
|
||||
if mode == .plan {
|
||||
return store.selectedProvider == .claude || store.selectedProvider == .codex || store.selectedProvider == .kimiCode
|
||||
return store.selectedProvider == .claude || store.selectedProvider == .codex || store.selectedProvider == .kimiCode || store.selectedProvider == .gemini || store.selectedProvider == .copilot || store.selectedProvider == .antigravity
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -82,6 +83,12 @@ struct HeatmapSection: View {
|
|||
CodexPlanInsight()
|
||||
} else if store.selectedProvider == .kimiCode {
|
||||
KimiPlanInsight()
|
||||
} else if store.selectedProvider == .gemini {
|
||||
GeminiPlanInsight()
|
||||
} else if store.selectedProvider == .copilot {
|
||||
CopilotPlanInsight()
|
||||
} else if store.selectedProvider == .antigravity {
|
||||
AntigravityPlanInsight()
|
||||
} else {
|
||||
PlanInsight(usage: store.subscription)
|
||||
}
|
||||
|
|
@ -1733,7 +1740,7 @@ private struct PlanInsight: View {
|
|||
loadedBody(usage: usage)
|
||||
} else {
|
||||
PlanFailedView(
|
||||
error: store.subscriptionError ?? "Anthropic temporarily unreachable — retrying."
|
||||
error: store.subscriptionError ?? "Anthropic temporarily unreachable. Retrying."
|
||||
) { refreshSubscriptionThroughAppDelegate() }
|
||||
}
|
||||
case let .terminalFailure(reason):
|
||||
|
|
@ -2040,7 +2047,7 @@ private struct CodexPlanInsight: View {
|
|||
loadedBody(usage: usage)
|
||||
} else {
|
||||
PlanFailedView(
|
||||
error: store.codexError ?? "ChatGPT temporarily unreachable — retrying."
|
||||
error: store.codexError ?? "ChatGPT temporarily unreachable. Retrying."
|
||||
) { Task { await store.refreshCodex() } }
|
||||
}
|
||||
case let .terminalFailure(reason):
|
||||
|
|
@ -2221,7 +2228,7 @@ private struct KimiPlanInsight: View {
|
|||
) { Task { await store.refreshKimi() } }
|
||||
case .transientFailed:
|
||||
PlanFailedView(
|
||||
error: store.kimiError ?? "Kimi temporarily unreachable — retrying."
|
||||
error: store.kimiError ?? "Kimi temporarily unreachable. Retrying."
|
||||
) { Task { await store.refreshKimi() } }
|
||||
case let .reconnect(reason):
|
||||
PlanReconnectView(
|
||||
|
|
@ -2310,6 +2317,272 @@ private struct KimiPlanInsight: View {
|
|||
}
|
||||
}
|
||||
|
||||
/// Plan tab for Gemini. Reads the CLI credential file read-only (no keychain,
|
||||
/// tokens stay in memory; refresh only via the CLI's env overrides), so
|
||||
/// terminal failure means "run the Gemini CLI once to refresh your login".
|
||||
private struct GeminiPlanInsight: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch GeminiQuotaPresentation.planContent(loadState: store.geminiLoadState, hasUsage: store.geminiUsage != nil) {
|
||||
case .noCredentials:
|
||||
PlanNoCredentialsView(
|
||||
title: "No Gemini credentials found",
|
||||
message: "Sign in with the Gemini CLI first. Then click Try Again."
|
||||
) { Task { await store.bootstrapGemini() } }
|
||||
case .loading:
|
||||
PlanLoadingView(message: "Reading Gemini credentials...")
|
||||
case .failed:
|
||||
PlanFailedView(
|
||||
error: store.geminiError
|
||||
) { Task { await store.refreshGemini() } }
|
||||
case .transientFailed:
|
||||
PlanFailedView(
|
||||
error: store.geminiError ?? "Gemini temporarily unreachable. Retrying."
|
||||
) { Task { await store.refreshGemini() } }
|
||||
case let .reconnect(reason):
|
||||
PlanReconnectView(
|
||||
title: "Refresh Gemini login",
|
||||
reason: reason,
|
||||
fallback: "Your Gemini login has expired. Run the Gemini CLI once to refresh it, then click Reconnect."
|
||||
) { Task { await store.bootstrapGemini() } }
|
||||
case let .usage(idle):
|
||||
if let usage = store.geminiUsage {
|
||||
loadedBody(usage: usage, idle: idle)
|
||||
} else {
|
||||
PlanLoadingView(message: "Reading Gemini credentials...")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func loadedBody(usage: GeminiUsage, idle: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(usage.plan ?? "Gemini")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
if let resetsAt = usage.primary?.resetsAt {
|
||||
Text("Resets \(relativeReset(resetsAt))")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
ForEach(Array(usage.details.enumerated()), id: \.offset) { _, window in
|
||||
UtilizationRow(
|
||||
label: window.label,
|
||||
percent: window.usedPercent,
|
||||
resetsAt: window.resetsAt,
|
||||
projection: nil
|
||||
)
|
||||
}
|
||||
if idle {
|
||||
Text("Login idle. Run the Gemini CLI to refresh.")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
if GeminiQuotaPresentation.isStale(fetchedAt: usage.fetchedAt) {
|
||||
Text("as of \(shortTime(usage.fetchedAt))")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
private func relativeReset(_ date: Date) -> String {
|
||||
let f = RelativeDateTimeFormatter()
|
||||
f.unitsStyle = .short
|
||||
return f.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
|
||||
private func shortTime(_ date: Date) -> String {
|
||||
let f = DateFormatter()
|
||||
f.timeStyle = .short
|
||||
f.dateStyle = .none
|
||||
return f.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
/// Plan tab for Copilot. Reads the editor plugins' credential files read-only
|
||||
/// (no keychain, no refresh path), so terminal failure means "sign in via an
|
||||
/// editor's Copilot plugin again".
|
||||
private struct CopilotPlanInsight: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch CopilotQuotaPresentation.planContent(loadState: store.copilotLoadState, hasUsage: store.copilotUsage != nil) {
|
||||
case .noCredentials:
|
||||
PlanNoCredentialsView(
|
||||
title: "No Copilot credentials found",
|
||||
message: "Sign in via an editor's Copilot plugin first. Then click Try Again."
|
||||
) { Task { await store.bootstrapCopilot() } }
|
||||
case .loading:
|
||||
PlanLoadingView(message: "Reading Copilot credentials...")
|
||||
case .failed:
|
||||
PlanFailedView(
|
||||
error: store.copilotError
|
||||
) { Task { await store.refreshCopilot() } }
|
||||
case .transientFailed:
|
||||
PlanFailedView(
|
||||
error: store.copilotError ?? "GitHub temporarily unreachable. Retrying."
|
||||
) { Task { await store.refreshCopilot() } }
|
||||
case let .reconnect(reason):
|
||||
PlanReconnectView(
|
||||
title: "Refresh Copilot login",
|
||||
reason: reason,
|
||||
fallback: "Your Copilot sign-in has expired. Sign in via an editor's Copilot plugin again, then click Reconnect."
|
||||
) { Task { await store.bootstrapCopilot() } }
|
||||
case let .usage(idle):
|
||||
if let usage = store.copilotUsage {
|
||||
loadedBody(usage: usage, idle: idle)
|
||||
} else {
|
||||
PlanLoadingView(message: "Reading Copilot credentials...")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func loadedBody(usage: CopilotUsage, idle: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(usage.plan ?? "Copilot")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
}
|
||||
ForEach(Array(usage.details.enumerated()), id: \.offset) { _, window in
|
||||
UtilizationRow(
|
||||
label: window.label,
|
||||
percent: window.usedPercent,
|
||||
resetsAt: window.resetsAt,
|
||||
projection: nil
|
||||
)
|
||||
}
|
||||
if idle {
|
||||
Text("Login idle. Sign in via an editor's Copilot plugin to refresh.")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
if CopilotQuotaPresentation.isStale(fetchedAt: usage.fetchedAt) {
|
||||
Text("as of \(shortTime(usage.fetchedAt))")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
private func shortTime(_ date: Date) -> String {
|
||||
let f = DateFormatter()
|
||||
f.timeStyle = .short
|
||||
f.dateStyle = .none
|
||||
return f.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
/// Plan tab for Antigravity. Quota comes from the Antigravity app's local
|
||||
/// language server (127.0.0.1 only, self-signed TLS) — there are no
|
||||
/// credentials to read, so the no-data state means "start the Antigravity
|
||||
/// app, then reconnect".
|
||||
private struct AntigravityPlanInsight: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch AntigravityQuotaPresentation.planContent(loadState: store.antigravityLoadState, hasUsage: store.antigravityUsage != nil) {
|
||||
case .noCredentials:
|
||||
PlanNoCredentialsView(
|
||||
title: "No local Antigravity server found",
|
||||
message: "Start the Antigravity app, then click Try Again."
|
||||
) { Task { await store.bootstrapAntigravity() } }
|
||||
case .loading:
|
||||
PlanLoadingView(message: "Probing the local Antigravity server...")
|
||||
case .failed:
|
||||
PlanFailedView(
|
||||
error: store.antigravityError
|
||||
) { Task { await store.refreshAntigravity() } }
|
||||
case .transientFailed:
|
||||
PlanFailedView(
|
||||
error: store.antigravityError ?? "Local Antigravity server unreachable. Retrying."
|
||||
) { Task { await store.refreshAntigravity() } }
|
||||
case let .reconnect(reason):
|
||||
PlanReconnectView(
|
||||
title: "Reconnect Antigravity",
|
||||
reason: reason,
|
||||
fallback: "Start the Antigravity app, then click Reconnect."
|
||||
) { Task { await store.bootstrapAntigravity() } }
|
||||
case let .usage(idle):
|
||||
if let usage = store.antigravityUsage {
|
||||
loadedBody(usage: usage, idle: idle)
|
||||
} else {
|
||||
PlanLoadingView(message: "Probing the local Antigravity server...")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func loadedBody(usage: AntigravityUsage, idle: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(usage.plan ?? "Antigravity")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
if let resetsAt = usage.primary?.resetsAt {
|
||||
Text("Resets \(relativeReset(resetsAt))")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
ForEach(Array(usage.details.enumerated()), id: \.offset) { _, window in
|
||||
UtilizationRow(
|
||||
label: window.label,
|
||||
percent: window.usedPercent,
|
||||
resetsAt: window.resetsAt,
|
||||
projection: nil
|
||||
)
|
||||
}
|
||||
if idle {
|
||||
Text("Server disconnected. Start the Antigravity app to refresh.")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
if AntigravityQuotaPresentation.isStale(fetchedAt: usage.fetchedAt) {
|
||||
Text("as of \(shortTime(usage.fetchedAt))")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
private func relativeReset(_ date: Date) -> String {
|
||||
let f = RelativeDateTimeFormatter()
|
||||
f.unitsStyle = .short
|
||||
return f.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
|
||||
private func shortTime(_ date: Date) -> String {
|
||||
let f = DateFormatter()
|
||||
f.timeStyle = .short
|
||||
f.dateStyle = .none
|
||||
return f.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WindowProjection {
|
||||
enum Source { case linear, historicalBaseline }
|
||||
let percent: Double
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ struct MenuBarContent: View {
|
|||
// Plan-capable providers keep their sections visible so the Plan tab
|
||||
// (live subscription quota) stays reachable even on days with no
|
||||
// local usage — the quota endpoint doesn't depend on local sessions.
|
||||
if store.selectedProvider == .claude || store.selectedProvider == .codex || store.selectedProvider == .kimiCode { return false }
|
||||
if store.selectedProvider == .claude || store.selectedProvider == .codex || store.selectedProvider == .kimiCode || store.selectedProvider == .gemini { return false }
|
||||
if store.payload.current.cost > 0 || store.payload.current.calls > 0 { return false }
|
||||
if providerHasCostInAllPayload { return false }
|
||||
return true
|
||||
|
|
@ -383,13 +383,8 @@ private struct Header: View {
|
|||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
(
|
||||
Text("Code").foregroundStyle(.primary)
|
||||
+ Text("Burn").foregroundStyle(Theme.brandEmber)
|
||||
)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.tracking(-0.15)
|
||||
Text("AI Coding Cost Tracker")
|
||||
FlameWordmark()
|
||||
Text("Your AI Bill, Itemized")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
/// Antigravity has no credentials and no refresh path — a vanished local
|
||||
/// server maps to `.noCredentials`. These tests pin the display decision: a
|
||||
/// terminal state with a snapshot on hand must keep showing the bars (with a
|
||||
/// quiet idle caption), and only the no-data case falls through to the
|
||||
/// reconnect screen.
|
||||
@Suite("Antigravity quota presentation")
|
||||
struct AntigravityQuotaPresentationTests {
|
||||
typealias Presentation = AntigravityQuotaPresentation
|
||||
|
||||
@Test("terminal failure with a snapshot keeps the usage bars, flagged idle")
|
||||
func terminalWithUsageShowsIdleUsage() {
|
||||
let content = Presentation.planContent(loadState: .terminalFailure(reason: "gone"), hasUsage: true)
|
||||
#expect(content == .usage(idle: true))
|
||||
}
|
||||
|
||||
@Test("terminal failure with no snapshot falls through to reconnect")
|
||||
func terminalWithoutUsageShowsReconnect() {
|
||||
let content = Presentation.planContent(loadState: .terminalFailure(reason: "gone"), hasUsage: false)
|
||||
#expect(content == .reconnect(reason: "gone"))
|
||||
}
|
||||
|
||||
@Test("loaded with a snapshot shows usage without the idle caption")
|
||||
func loadedShowsPlainUsage() {
|
||||
#expect(Presentation.planContent(loadState: .loaded, hasUsage: true) == .usage(idle: false))
|
||||
}
|
||||
|
||||
@Test("transient failure keeps the last snapshot, else shows the retry screen")
|
||||
func transientFailureFallsBackToUsage() {
|
||||
#expect(Presentation.planContent(loadState: .transientFailure(retryAt: nil), hasUsage: true) == .usage(idle: false))
|
||||
#expect(Presentation.planContent(loadState: .transientFailure(retryAt: nil), hasUsage: false) == .transientFailed)
|
||||
}
|
||||
|
||||
@Test("server-absent states route to the connect prompt")
|
||||
func serverAbsentStatesRouteToNoCredentials() {
|
||||
#expect(Presentation.planContent(loadState: .notBootstrapped, hasUsage: false) == .noCredentials)
|
||||
#expect(Presentation.planContent(loadState: .noCredentials, hasUsage: false) == .noCredentials)
|
||||
}
|
||||
|
||||
@Test("a fresh snapshot is not stamped stale")
|
||||
func freshSnapshotIsNotStale() {
|
||||
let now = Date()
|
||||
let fetchedAt = now.addingTimeInterval(-60) // 1 min old
|
||||
#expect(Presentation.isStale(fetchedAt: fetchedAt, now: now) == false)
|
||||
}
|
||||
|
||||
@Test("a snapshot older than the threshold is stamped stale")
|
||||
func oldSnapshotIsStale() {
|
||||
let now = Date()
|
||||
let fetchedAt = now.addingTimeInterval(-11 * 60) // 11 min old
|
||||
#expect(Presentation.isStale(fetchedAt: fetchedAt, now: now) == true)
|
||||
}
|
||||
}
|
||||
283
mac/Tests/CodeBurnMenubarTests/AntigravityQuotaTests.swift
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import XCTest
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
/// Fixture-driven tests for the Antigravity local-server quota flow, mirroring
|
||||
/// app/electron/quota/antigravity.test.ts: process classification, summary /
|
||||
/// legacy-status payload decoding, the ps → lsof → loopback-probe sequence
|
||||
/// (TLS then HTTP, CSRF header only for app servers, GetUserStatus fallback
|
||||
/// with planName), disconnected when nothing answers, and sanitized
|
||||
/// diagnostics on unexpected discovery failure. All process and network
|
||||
/// access goes through the injected Deps seams; nothing spawns real
|
||||
/// processes or touches the network.
|
||||
final class AntigravityQuotaTests: XCTestCase {
|
||||
|
||||
private static let summaryPath = "/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary"
|
||||
private static let statusPath = "/exa.language_server_pb.LanguageServerService/GetUserStatus"
|
||||
|
||||
private static let appLine = "1234 /Applications/Antigravity.app/Contents/Resources/app/extensions/antigravity/bin/language_server_macos_arm --app_data_dir antigravity --csrf_token tok-123 --extension_server_port 54321"
|
||||
private static let ideLine = "1235 /Applications/Antigravity IDE.app/.../extensions/antigravity/bin/language_server --app_data_dir antigravity-ide --csrf_token ide-tok"
|
||||
private static let tokenlessAppLine = "1236 /usr/local/lib/language_server_macos --app_data_dir antigravity"
|
||||
private static let cliLine = "1237 /opt/homebrew/bin/agy serve"
|
||||
|
||||
private static let now = Date(timeIntervalSince1970: 1_786_000_000)
|
||||
|
||||
/// Records every loopback probe the service makes, in order.
|
||||
private final class CallRecorder: @unchecked Sendable {
|
||||
struct Call: Equatable {
|
||||
let port: Int
|
||||
let tls: Bool
|
||||
let path: String
|
||||
let csrf: String?
|
||||
}
|
||||
private(set) var calls: [Call] = []
|
||||
func record(port: Int, tls: Bool, path: String, csrf: String?) {
|
||||
calls.append(Call(port: port, tls: tls, path: path, csrf: csrf))
|
||||
}
|
||||
}
|
||||
|
||||
private final class LogRecorder: @unchecked Sendable {
|
||||
private(set) var lines: [String] = []
|
||||
func record(_ line: String) { lines.append(line) }
|
||||
}
|
||||
|
||||
private static func makeDeps(
|
||||
ps: String,
|
||||
lsof: String = "",
|
||||
psError: Error? = nil,
|
||||
recorder: CallRecorder,
|
||||
log: LogRecorder = LogRecorder(),
|
||||
respond: @escaping @Sendable (Int, Bool, String) -> (status: Int, text: String)?
|
||||
) -> AntigravitySubscriptionService.Deps {
|
||||
AntigravitySubscriptionService.Deps(
|
||||
exec: { _, arguments in
|
||||
if arguments.first == "-ax" {
|
||||
if let psError { throw psError }
|
||||
return ps
|
||||
}
|
||||
return lsof
|
||||
},
|
||||
request: { port, tls, path, _, csrf in
|
||||
recorder.record(port: port, tls: tls, path: path, csrf: csrf)
|
||||
return respond(port, tls, path)
|
||||
},
|
||||
now: { Self.now },
|
||||
log: { log.record($0) }
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Process classification
|
||||
|
||||
func testClassifiesAppLanguageServerWithCsrfAndFallbackPort() {
|
||||
let candidate = AntigravitySubscriptionService.classifyProcessLine(Self.appLine)
|
||||
XCTAssertEqual(candidate, AntigravitySubscriptionService.Candidate(pid: "1234", cli: false, csrf: "tok-123", extPort: 54321))
|
||||
}
|
||||
|
||||
func testSkipsTokenlessAndIdeServersAcceptsTokenlessCli() {
|
||||
XCTAssertNil(AntigravitySubscriptionService.classifyProcessLine(Self.tokenlessAppLine))
|
||||
XCTAssertNil(AntigravitySubscriptionService.classifyProcessLine(Self.ideLine))
|
||||
let candidate = AntigravitySubscriptionService.classifyProcessLine(Self.cliLine)
|
||||
XCTAssertEqual(candidate?.pid, "1237")
|
||||
XCTAssertEqual(candidate?.cli, true)
|
||||
XCTAssertNil(candidate?.csrf)
|
||||
}
|
||||
|
||||
func testIgnoresUnrelatedLanguageServersAndNonMatchingLines() {
|
||||
XCTAssertNil(AntigravitySubscriptionService.classifyProcessLine("999 /usr/bin/codeium_language_server --csrf_token x"))
|
||||
XCTAssertNil(AntigravitySubscriptionService.classifyProcessLine("998 vim /tmp/agy-notes.md"))
|
||||
XCTAssertNil(AntigravitySubscriptionService.classifyProcessLine("garbage line"))
|
||||
}
|
||||
|
||||
// MARK: - Payload decoding
|
||||
|
||||
func testDecodeSummaryJoinsGroupsIntoWindows() {
|
||||
let body: [String: Any] = [
|
||||
"groups": [
|
||||
[
|
||||
"displayName": "Gemini Models",
|
||||
"buckets": [
|
||||
["displayName": "Weekly limit", "remaining": ["remainingFraction": 0.8]] as [String: Any],
|
||||
["displayName": "Five hour limit", "remaining": ["remainingFraction": 0.25]] as [String: Any],
|
||||
["displayName": "No fraction row"] as [String: Any],
|
||||
],
|
||||
] as [String: Any],
|
||||
["displayName": "Claude and GPT models", "buckets": [["bucketId": "claude_weekly", "remaining": ["remainingFraction": 1]] as [String: Any]]] as [String: Any],
|
||||
],
|
||||
]
|
||||
let windows = AntigravitySubscriptionService.decodeSummary(body)
|
||||
XCTAssertEqual(windows.map(\.label), ["Gemini Models · Weekly limit", "Gemini Models · Five hour limit", "Claude and GPT models · claude_weekly"])
|
||||
XCTAssertEqual(windows[1].usedPercent, 75, accuracy: 0.001)
|
||||
XCTAssertEqual(windows[2].usedPercent, 0, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testDecodeStatusReadsLegacyRowsWithResetTimes() {
|
||||
let body: [String: Any] = [
|
||||
"userStatus": [
|
||||
"cascadeModelConfigData": [
|
||||
"clientModelConfigs": [
|
||||
["modelName": "gemini-2.5-pro", "quotaInfo": ["remainingFraction": 0.5, "resetTime": 1_800_000_000] as [String: Any]] as [String: Any],
|
||||
["modelName": "claude-sonnet-4", "quotaInfo": ["remainingFraction": 0.9, "resetTime": "2026-07-12T00:00:00Z"] as [String: Any]] as [String: Any],
|
||||
["modelName": "no-quota-model"] as [String: Any],
|
||||
],
|
||||
] as [String: Any],
|
||||
] as [String: Any],
|
||||
]
|
||||
let windows = AntigravitySubscriptionService.decodeStatus(body)
|
||||
XCTAssertEqual(windows.map(\.label), ["gemini-2.5-pro", "claude-sonnet-4"])
|
||||
XCTAssertEqual(windows[0].usedPercent, 50, accuracy: 0.001)
|
||||
XCTAssertEqual(windows[0].resetsAt, Date(timeIntervalSince1970: 1_800_000_000))
|
||||
let iso = ISO8601DateFormatter()
|
||||
iso.formatOptions = [.withInternetDateTime]
|
||||
XCTAssertEqual(windows[1].resetsAt, iso.date(from: "2026-07-12T00:00:00Z"))
|
||||
}
|
||||
|
||||
func testDecodeGarbagePayloadsYieldsNoWindows() {
|
||||
XCTAssertEqual(AntigravitySubscriptionService.decodeSummary(nil), [])
|
||||
XCTAssertEqual(AntigravitySubscriptionService.decodeStatus(["userStatus": [:]]), [])
|
||||
}
|
||||
|
||||
// MARK: - Local probe
|
||||
|
||||
func testProbesAppServerOverLoopbackTlsFirstAndRendersMostConstrainedFirst() async throws {
|
||||
let recorder = CallRecorder()
|
||||
let deps = Self.makeDeps(
|
||||
ps: [Self.appLine, Self.cliLine].joined(separator: "\n"),
|
||||
lsof: "python 1234 user 12u IPv4 0x1 0t0 TCP 127.0.0.1:60123 (LISTEN)\n",
|
||||
recorder: recorder
|
||||
) { _, tls, path in
|
||||
guard tls else { return nil }
|
||||
if path.contains("RetrieveUserQuotaSummary") {
|
||||
return (200, #"{"groups":[{"displayName":"Gemini Models","buckets":[{"displayName":"Weekly limit","remaining":{"remainingFraction":0.7}}]}]}"#)
|
||||
}
|
||||
return (200, "{}")
|
||||
}
|
||||
let usage = try await AntigravitySubscriptionService.refresh(deps: deps)
|
||||
XCTAssertEqual(usage.primary?.usedPercent ?? -1, 30, accuracy: 0.001)
|
||||
XCTAssertEqual(usage.primary?.label, "Gemini Models · Weekly limit")
|
||||
XCTAssertEqual(usage.fetchedAt, Self.now)
|
||||
XCTAssertTrue(recorder.calls.allSatisfy { $0.port == 60123 })
|
||||
XCTAssertEqual(
|
||||
recorder.calls.first,
|
||||
CallRecorder.Call(port: 60123, tls: true, path: Self.summaryPath, csrf: "tok-123")
|
||||
)
|
||||
}
|
||||
|
||||
func testSendsNoCsrfHeaderForTheAgyCli() async throws {
|
||||
let recorder = CallRecorder()
|
||||
final class Counter: @unchecked Sendable { var value = 0 }
|
||||
let counter = Counter()
|
||||
let deps = Self.makeDeps(
|
||||
ps: Self.cliLine,
|
||||
lsof: "agy 1237 user 5u IPv4 0x1 0t0 TCP *:60555 (LISTEN)\n",
|
||||
recorder: recorder
|
||||
) { _, _, _ in
|
||||
counter.value += 1
|
||||
return counter.value <= 2
|
||||
? nil
|
||||
: (200, #"{"groups":[{"displayName":"Claude + GPT","buckets":[{"bucketId":"weekly","remaining":{"remainingFraction":0.1}}]}]}"#)
|
||||
}
|
||||
let usage = try await AntigravitySubscriptionService.refresh(deps: deps)
|
||||
XCTAssertEqual(usage.primary?.usedPercent ?? -1, 90, accuracy: 0.001)
|
||||
XCTAssertNil(usage.plan)
|
||||
XCTAssertNil(recorder.calls.first?.csrf)
|
||||
}
|
||||
|
||||
func testFallsBackToGetUserStatusAndLiftsPlanNameWhenSummaryHasNoWindows() async throws {
|
||||
let recorder = CallRecorder()
|
||||
let deps = Self.makeDeps(
|
||||
ps: Self.cliLine,
|
||||
lsof: "agy 1237 user 5u IPv4 0x1 0t0 TCP *:60555 (LISTEN)\n",
|
||||
recorder: recorder
|
||||
) { _, tls, path in
|
||||
guard tls, path.contains("GetUserStatus") else { return nil }
|
||||
return (200, #"{"userStatus":{"planName":"AI Pro","cascadeModelConfigData":{"clientModelConfigs":[{"modelName":"gemini-2.5-pro","quotaInfo":{"remainingFraction":0.4}}]}}}"#)
|
||||
}
|
||||
let usage = try await AntigravitySubscriptionService.refresh(deps: deps)
|
||||
XCTAssertEqual(usage.plan, "AI Pro")
|
||||
XCTAssertEqual(usage.details.map(\.label), ["gemini-2.5-pro"])
|
||||
}
|
||||
|
||||
func testDisconnectedWhenNothingLocalIsListening() async {
|
||||
let recorder = CallRecorder()
|
||||
let deps = Self.makeDeps(ps: "", recorder: recorder) { _, _, _ in
|
||||
(200, "{}")
|
||||
}
|
||||
do {
|
||||
_ = try await AntigravitySubscriptionService.refresh(deps: deps)
|
||||
XCTFail("expected disconnected")
|
||||
} catch let error as AntigravitySubscriptionService.FetchError {
|
||||
guard case .disconnected = error else {
|
||||
return XCTFail("expected disconnected, got \(error)")
|
||||
}
|
||||
} catch {
|
||||
XCTFail("unexpected error: \(error)")
|
||||
}
|
||||
XCTAssertTrue(recorder.calls.isEmpty)
|
||||
}
|
||||
|
||||
func testDisconnectedWhenEveryPortProbeFails() async {
|
||||
let recorder = CallRecorder()
|
||||
let deps = Self.makeDeps(
|
||||
ps: Self.cliLine,
|
||||
lsof: "agy 1237 user 5u IPv4 0x1 0t0 TCP *:60555 (LISTEN)\n",
|
||||
recorder: recorder
|
||||
) { _, _, _ in nil }
|
||||
do {
|
||||
_ = try await AntigravitySubscriptionService.refresh(deps: deps)
|
||||
XCTFail("expected disconnected")
|
||||
} catch let error as AntigravitySubscriptionService.FetchError {
|
||||
guard case .disconnected = error else {
|
||||
return XCTFail("expected disconnected, got \(error)")
|
||||
}
|
||||
} catch {
|
||||
XCTFail("unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func testServerErrorStatusIsNotAnAnswerAndFallsThroughToDisconnected() async {
|
||||
let recorder = CallRecorder()
|
||||
let deps = Self.makeDeps(
|
||||
ps: Self.cliLine,
|
||||
lsof: "agy 1237 user 5u IPv4 0x1 0t0 TCP *:60555 (LISTEN)\n",
|
||||
recorder: recorder
|
||||
) { _, _, _ in (500, #"{"error":"boom"}"#) }
|
||||
do {
|
||||
_ = try await AntigravitySubscriptionService.refresh(deps: deps)
|
||||
XCTFail("expected disconnected")
|
||||
} catch let error as AntigravitySubscriptionService.FetchError {
|
||||
guard case .disconnected = error else {
|
||||
return XCTFail("expected disconnected, got \(error)")
|
||||
}
|
||||
} catch {
|
||||
XCTFail("unexpected error: \(error)")
|
||||
}
|
||||
// Both endpoints, TLS then plain HTTP, all on the discovered port.
|
||||
XCTAssertEqual(recorder.calls.count, 4)
|
||||
XCTAssertTrue(recorder.calls.allSatisfy { $0.port == 60555 })
|
||||
}
|
||||
|
||||
func testUnexpectedPsFailureIsTransientWithSanitizedDiagnostics() async {
|
||||
let log = LogRecorder()
|
||||
let psError = NSError(
|
||||
domain: "test",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "ps exploded Bearer sk-secret eyJabc.def\u{0}tail"]
|
||||
)
|
||||
let recorder = CallRecorder()
|
||||
let deps = Self.makeDeps(ps: "", psError: psError, recorder: recorder, log: log) { _, _, _ in nil }
|
||||
do {
|
||||
_ = try await AntigravitySubscriptionService.refresh(deps: deps)
|
||||
XCTFail("expected network error")
|
||||
} catch let error as AntigravitySubscriptionService.FetchError {
|
||||
guard case .network = error else {
|
||||
return XCTFail("expected network, got \(error)")
|
||||
}
|
||||
} catch {
|
||||
XCTFail("unexpected error: \(error)")
|
||||
}
|
||||
let logged = log.lines.joined(separator: " ")
|
||||
XCTAssertTrue(logged.contains("[REDACTED]"))
|
||||
XCTAssertFalse(logged.contains("sk-secret"))
|
||||
XCTAssertFalse(logged.contains("eyJabc"))
|
||||
XCTAssertFalse(logged.contains("\u{0}"))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
/// Copilot tokens are read-only with no refresh path, so `.terminalFailure`
|
||||
/// means the user must sign in via an editor's Copilot plugin again. These
|
||||
/// tests pin the display decision: a terminal login with a snapshot on hand
|
||||
/// must keep showing the bars (with a quiet idle caption), and only the
|
||||
/// no-data case falls through to the reconnect screen.
|
||||
@Suite("Copilot quota presentation")
|
||||
struct CopilotQuotaPresentationTests {
|
||||
typealias Presentation = CopilotQuotaPresentation
|
||||
|
||||
@Test("terminal failure with a snapshot keeps the usage bars, flagged idle")
|
||||
func terminalWithUsageShowsIdleUsage() {
|
||||
let content = Presentation.planContent(loadState: .terminalFailure(reason: "expired"), hasUsage: true)
|
||||
#expect(content == .usage(idle: true))
|
||||
}
|
||||
|
||||
@Test("terminal failure with no snapshot falls through to reconnect")
|
||||
func terminalWithoutUsageShowsReconnect() {
|
||||
let content = Presentation.planContent(loadState: .terminalFailure(reason: "expired"), hasUsage: false)
|
||||
#expect(content == .reconnect(reason: "expired"))
|
||||
}
|
||||
|
||||
@Test("loaded with a snapshot shows usage without the idle caption")
|
||||
func loadedShowsPlainUsage() {
|
||||
#expect(Presentation.planContent(loadState: .loaded, hasUsage: true) == .usage(idle: false))
|
||||
}
|
||||
|
||||
@Test("transient failure keeps the last snapshot, else shows the retry screen")
|
||||
func transientFailureFallsBackToUsage() {
|
||||
#expect(Presentation.planContent(loadState: .transientFailure(retryAt: nil), hasUsage: true) == .usage(idle: false))
|
||||
#expect(Presentation.planContent(loadState: .transientFailure(retryAt: nil), hasUsage: false) == .transientFailed)
|
||||
}
|
||||
|
||||
@Test("credential-absent states route to the connect prompt")
|
||||
func credentialStatesRouteToNoCredentials() {
|
||||
#expect(Presentation.planContent(loadState: .notBootstrapped, hasUsage: false) == .noCredentials)
|
||||
#expect(Presentation.planContent(loadState: .noCredentials, hasUsage: false) == .noCredentials)
|
||||
}
|
||||
|
||||
@Test("a fresh snapshot is not stamped stale")
|
||||
func freshSnapshotIsNotStale() {
|
||||
let now = Date()
|
||||
let fetchedAt = now.addingTimeInterval(-60) // 1 min old
|
||||
#expect(Presentation.isStale(fetchedAt: fetchedAt, now: now) == false)
|
||||
}
|
||||
|
||||
@Test("a snapshot older than the threshold is stamped stale")
|
||||
func oldSnapshotIsStale() {
|
||||
let now = Date()
|
||||
let fetchedAt = now.addingTimeInterval(-11 * 60) // 11 min old
|
||||
#expect(Presentation.isStale(fetchedAt: fetchedAt, now: now) == true)
|
||||
}
|
||||
}
|
||||
300
mac/Tests/CodeBurnMenubarTests/CopilotQuotaTests.swift
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
import XCTest
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
/// Fixture-driven tests for the GitHub Copilot quota flow, mirroring
|
||||
/// app/electron/quota/copilot.test.ts: hosts.json / apps.json credential
|
||||
/// selection, exact plugin headers, 401 re-read semantics, Retry-After
|
||||
/// backoff, error classification, and the remaining→used percent decode.
|
||||
/// All network and file access goes through the injected Deps seams; nothing
|
||||
/// touches the real credential files or network.
|
||||
final class CopilotQuotaTests: XCTestCase {
|
||||
|
||||
/// Records every request the service makes, in order.
|
||||
private final class RequestRecorder: @unchecked Sendable {
|
||||
private(set) var requests: [URLRequest] = []
|
||||
func record(_ request: URLRequest) { requests.append(request) }
|
||||
}
|
||||
|
||||
private static let now = Date(timeIntervalSince1970: 1_786_000_000)
|
||||
|
||||
private static let hosts = """
|
||||
{"github.com":{"user":"octocat","oauth_token":"gho_test-secret"}}
|
||||
"""
|
||||
|
||||
private static let usageBody = """
|
||||
{"copilot_plan":"individual","quota_snapshots":{
|
||||
"premium_interactions":{"percent_remaining":70},
|
||||
"chat":{"percent_remaining":100}
|
||||
}}
|
||||
"""
|
||||
|
||||
private static func httpResponse(_ request: URLRequest, status: Int, headers: [String: String] = [:]) -> HTTPURLResponse {
|
||||
HTTPURLResponse(
|
||||
url: request.url ?? URL(string: "https://api.github.com")!,
|
||||
statusCode: status,
|
||||
httpVersion: nil,
|
||||
headerFields: headers
|
||||
)!
|
||||
}
|
||||
|
||||
private static func okJson(_ request: URLRequest, _ body: String) -> (Data, HTTPURLResponse) {
|
||||
(body.data(using: .utf8)!, httpResponse(request, status: 200))
|
||||
}
|
||||
|
||||
private static func makeDeps(
|
||||
hosts: String?,
|
||||
apps: String? = nil,
|
||||
recorder: RequestRecorder,
|
||||
readFile: (@Sendable (URL) -> Data?)? = nil,
|
||||
respond: @escaping @Sendable (URLRequest) -> (Data, HTTPURLResponse)
|
||||
) -> CopilotSubscriptionService.Deps {
|
||||
CopilotSubscriptionService.Deps(
|
||||
fetch: { request in
|
||||
recorder.record(request)
|
||||
return respond(request)
|
||||
},
|
||||
readFile: readFile ?? { url in
|
||||
if url.lastPathComponent == "hosts.json" { return hosts?.data(using: .utf8) }
|
||||
if url.lastPathComponent == "apps.json" { return apps?.data(using: .utf8) }
|
||||
return nil
|
||||
},
|
||||
hostsURL: URL(fileURLWithPath: "/tmp/codeburn-tests/.config/github-copilot/hosts.json"),
|
||||
appsURL: URL(fileURLWithPath: "/tmp/codeburn-tests/.config/github-copilot/apps.json"),
|
||||
now: { Self.now }
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Decode
|
||||
|
||||
func testDecodeSnakeCaseSnapshotsIntoUsedWindowsWithPlanLabel() throws {
|
||||
let usage = try CopilotSubscriptionService.decodeUsage(
|
||||
data: Self.usageBody.data(using: .utf8)!, now: Self.now)
|
||||
XCTAssertEqual(usage.plan, "Individual")
|
||||
XCTAssertEqual(usage.details.map(\.label), ["Premium requests", "Chat"])
|
||||
XCTAssertEqual(usage.details.map(\.usedPercent), [30, 0])
|
||||
XCTAssertEqual(usage.primary?.label, "Premium requests")
|
||||
XCTAssertEqual(usage.primary?.usedPercent ?? -1, 30, accuracy: 0.001)
|
||||
XCTAssertNil(usage.primary?.resetsAt)
|
||||
}
|
||||
|
||||
func testDecodeCamelCasePromotesChatWhenPremiumIsAbsent() throws {
|
||||
let body = #"{"copilotPlan":"business","quotaSnapshots":{"chat":{"percentRemaining":55}}}"#
|
||||
let usage = try CopilotSubscriptionService.decodeUsage(
|
||||
data: body.data(using: .utf8)!, now: Self.now)
|
||||
XCTAssertEqual(usage.plan, "Business")
|
||||
XCTAssertEqual(usage.primary?.label, "Chat")
|
||||
XCTAssertEqual(usage.primary?.usedPercent ?? -1, 45, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testDecodeSurvivesMalformedSnapshots() throws {
|
||||
let body = #"{"quota_snapshots":{"chat":"garbage"},"extra":true}"#
|
||||
let usage = try CopilotSubscriptionService.decodeUsage(
|
||||
data: body.data(using: .utf8)!, now: Self.now)
|
||||
XCTAssertNil(usage.primary)
|
||||
XCTAssertEqual(usage.details, [])
|
||||
}
|
||||
|
||||
func testDecodeTitleCasesUnknownPlanTiers() throws {
|
||||
let educators = try CopilotSubscriptionService.decodeUsage(
|
||||
data: #"{"copilot_plan":"for_educators"}"#.data(using: .utf8)!, now: Self.now)
|
||||
XCTAssertEqual(educators.plan, "Educators")
|
||||
let future = try CopilotSubscriptionService.decodeUsage(
|
||||
data: #"{"copilot_plan":"some_future_tier"}"#.data(using: .utf8)!, now: Self.now)
|
||||
XCTAssertEqual(future.plan, "Some Future Tier")
|
||||
let missing = try CopilotSubscriptionService.decodeUsage(
|
||||
data: #"{}"#.data(using: .utf8)!, now: Self.now)
|
||||
XCTAssertNil(missing.plan)
|
||||
}
|
||||
|
||||
// MARK: - Credential files
|
||||
|
||||
func testNoCredentialsNeverFetches() async {
|
||||
let recorder = RequestRecorder()
|
||||
let deps = Self.makeDeps(hosts: nil, recorder: recorder) { request in
|
||||
Self.okJson(request, Self.usageBody)
|
||||
}
|
||||
do {
|
||||
_ = try await CopilotSubscriptionService.refresh(deps: deps)
|
||||
XCTFail("expected noCredentials")
|
||||
} catch let error as CopilotSubscriptionService.FetchError {
|
||||
guard case .noCredentials = error else {
|
||||
return XCTFail("expected noCredentials, got \(error)")
|
||||
}
|
||||
XCTAssertTrue(error.isTerminal)
|
||||
} catch {
|
||||
XCTFail("unexpected error: \(error)")
|
||||
}
|
||||
XCTAssertTrue(recorder.requests.isEmpty)
|
||||
}
|
||||
|
||||
func testHostsJsonPreferredOverAppsJsonWithExactPluginHeaders() async throws {
|
||||
let recorder = RequestRecorder()
|
||||
let deps = Self.makeDeps(
|
||||
hosts: Self.hosts,
|
||||
apps: #"{"Some App":{"oauth_token":"gho_wrong"}}"#,
|
||||
recorder: recorder
|
||||
) { request in
|
||||
Self.okJson(request, Self.usageBody)
|
||||
}
|
||||
let usage = try await CopilotSubscriptionService.refresh(deps: deps)
|
||||
XCTAssertEqual(usage.plan, "Individual")
|
||||
XCTAssertEqual(recorder.requests.count, 1)
|
||||
let request = recorder.requests[0]
|
||||
XCTAssertEqual(request.url?.absoluteString, "https://api.github.com/copilot_internal/user")
|
||||
XCTAssertEqual(request.httpMethod, "GET")
|
||||
XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "token gho_test-secret")
|
||||
XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/json")
|
||||
XCTAssertEqual(request.value(forHTTPHeaderField: "Editor-Version"), "vscode/1.96.2")
|
||||
XCTAssertEqual(request.value(forHTTPHeaderField: "Editor-Plugin-Version"), "copilot-chat/0.26.7")
|
||||
XCTAssertEqual(request.value(forHTTPHeaderField: "User-Agent"), "GitHubCopilotChat/0.26.7")
|
||||
XCTAssertEqual(request.value(forHTTPHeaderField: "X-Github-Api-Version"), "2025-04-01")
|
||||
}
|
||||
|
||||
func testFallsBackToAppsJsonWhenHostsJsonHasNoToken() async throws {
|
||||
let recorder = RequestRecorder()
|
||||
let deps = Self.makeDeps(
|
||||
hosts: "{}",
|
||||
apps: #"{"Visual Studio Code":{"oauth_token":"ghu_apps-token"}}"#,
|
||||
recorder: recorder
|
||||
) { request in
|
||||
Self.okJson(request, Self.usageBody)
|
||||
}
|
||||
_ = try await CopilotSubscriptionService.refresh(deps: deps)
|
||||
XCTAssertEqual(recorder.requests.count, 1)
|
||||
XCTAssertEqual(
|
||||
recorder.requests[0].value(forHTTPHeaderField: "Authorization"),
|
||||
"token ghu_apps-token")
|
||||
}
|
||||
|
||||
func testMalformedHostsJsonFallsThroughToAppsJson() async throws {
|
||||
let recorder = RequestRecorder()
|
||||
let deps = Self.makeDeps(
|
||||
hosts: "not json {",
|
||||
apps: #"{"Visual Studio Code":{"oauth_token":"ghu_apps-token"}}"#,
|
||||
recorder: recorder
|
||||
) { request in
|
||||
Self.okJson(request, Self.usageBody)
|
||||
}
|
||||
_ = try await CopilotSubscriptionService.refresh(deps: deps)
|
||||
XCTAssertEqual(
|
||||
recorder.requests[0].value(forHTTPHeaderField: "Authorization"),
|
||||
"token ghu_apps-token")
|
||||
}
|
||||
|
||||
// MARK: - 401 re-read
|
||||
|
||||
func testUnauthorizedRereadsOnceAndAdoptsRotatedToken() async throws {
|
||||
final class ReadCounter: @unchecked Sendable {
|
||||
var reads = 0
|
||||
}
|
||||
let counter = ReadCounter()
|
||||
let recorder = RequestRecorder()
|
||||
let deps = Self.makeDeps(hosts: nil, recorder: recorder, readFile: { _ in
|
||||
counter.reads += 1
|
||||
let token = counter.reads == 1 ? "gho_stale" : "gho_rotated"
|
||||
return #"{"github.com":{"oauth_token":"\#(token)"}}"#.data(using: .utf8)
|
||||
}) { request in
|
||||
if request.value(forHTTPHeaderField: "Authorization") == "token gho_rotated" {
|
||||
return Self.okJson(request, Self.usageBody)
|
||||
}
|
||||
return (Data(), Self.httpResponse(request, status: 401))
|
||||
}
|
||||
let usage = try await CopilotSubscriptionService.refresh(deps: deps)
|
||||
XCTAssertEqual(usage.plan, "Individual")
|
||||
XCTAssertEqual(recorder.requests.count, 2)
|
||||
}
|
||||
|
||||
func testUnauthorizedWithUnchangedTokenIsTransientWithoutRetry() async {
|
||||
let recorder = RequestRecorder()
|
||||
let deps = Self.makeDeps(hosts: Self.hosts, recorder: recorder) { request in
|
||||
(Data(), Self.httpResponse(request, status: 401))
|
||||
}
|
||||
do {
|
||||
_ = try await CopilotSubscriptionService.refresh(deps: deps)
|
||||
XCTFail("expected tokenRejected")
|
||||
} catch let error as CopilotSubscriptionService.FetchError {
|
||||
guard case .tokenRejected = error else {
|
||||
return XCTFail("expected tokenRejected, got \(error)")
|
||||
}
|
||||
XCTAssertFalse(error.isTerminal)
|
||||
} catch {
|
||||
XCTFail("unexpected error: \(error)")
|
||||
}
|
||||
// One probe only: re-reading found the same token, so a retry is pointless.
|
||||
XCTAssertEqual(recorder.requests.count, 1)
|
||||
}
|
||||
|
||||
// MARK: - Error classification
|
||||
|
||||
func testRateLimitedUsesRetryAfterHeader() async {
|
||||
defer { CopilotSubscriptionService.disconnect() }
|
||||
let recorder = RequestRecorder()
|
||||
let deps = Self.makeDeps(hosts: Self.hosts, recorder: recorder) { request in
|
||||
(Data(), Self.httpResponse(request, status: 429, headers: ["Retry-After": "75"]))
|
||||
}
|
||||
do {
|
||||
_ = try await CopilotSubscriptionService.refresh(deps: deps)
|
||||
XCTFail("expected rateLimited")
|
||||
} catch let error as CopilotSubscriptionService.FetchError {
|
||||
guard case let .rateLimited(retryAt) = error else {
|
||||
return XCTFail("expected rateLimited, got \(error)")
|
||||
}
|
||||
XCTAssertEqual(retryAt.timeIntervalSinceNow, 75, accuracy: 10)
|
||||
XCTAssertFalse(error.isTerminal)
|
||||
} catch {
|
||||
XCTFail("unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func testServerErrorIsTransientAndClientErrorIsTerminal() async {
|
||||
let serverRecorder = RequestRecorder()
|
||||
let serverError = Self.makeDeps(hosts: Self.hosts, recorder: serverRecorder) { request in
|
||||
(Data(), Self.httpResponse(request, status: 503))
|
||||
}
|
||||
do {
|
||||
_ = try await CopilotSubscriptionService.refresh(deps: serverError)
|
||||
XCTFail("expected usageHTTPError")
|
||||
} catch let error as CopilotSubscriptionService.FetchError {
|
||||
guard case .usageHTTPError(503) = error else {
|
||||
return XCTFail("expected 503, got \(error)")
|
||||
}
|
||||
XCTAssertFalse(error.isTerminal)
|
||||
} catch {
|
||||
XCTFail("unexpected error: \(error)")
|
||||
}
|
||||
|
||||
let clientRecorder = RequestRecorder()
|
||||
let clientError = Self.makeDeps(hosts: Self.hosts, recorder: clientRecorder) { request in
|
||||
(Data(), Self.httpResponse(request, status: 404))
|
||||
}
|
||||
do {
|
||||
_ = try await CopilotSubscriptionService.refresh(deps: clientError)
|
||||
XCTFail("expected usageHTTPError")
|
||||
} catch let error as CopilotSubscriptionService.FetchError {
|
||||
guard case .usageHTTPError(404) = error else {
|
||||
return XCTFail("expected 404, got \(error)")
|
||||
}
|
||||
XCTAssertTrue(error.isTerminal)
|
||||
} catch {
|
||||
XCTFail("unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func testMalformedSuccessBodyDegradesInsteadOfCrashing() async {
|
||||
let recorder = RequestRecorder()
|
||||
let deps = Self.makeDeps(hosts: Self.hosts, recorder: recorder) { request in
|
||||
("not json {".data(using: .utf8)!, Self.httpResponse(request, status: 200))
|
||||
}
|
||||
do {
|
||||
_ = try await CopilotSubscriptionService.refresh(deps: deps)
|
||||
XCTFail("expected usageDecodeFailed")
|
||||
} catch let error as CopilotSubscriptionService.FetchError {
|
||||
guard case .usageDecodeFailed = error else {
|
||||
return XCTFail("expected usageDecodeFailed, got \(error)")
|
||||
}
|
||||
XCTAssertFalse(error.isTerminal)
|
||||
} catch {
|
||||
XCTFail("unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
299
mac/Tests/CodeBurnMenubarTests/GeminiQuotaTests.swift
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
import XCTest
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
/// Fixture-driven tests for the Gemini Code Assist quota flow, mirroring
|
||||
/// app/electron/quota/gemini.test.ts: decode ordering, the
|
||||
/// loadCodeAssist → retrieveUserQuota sequence, no-credentials, expired
|
||||
/// token (with and without the env refresh overrides), and API-error
|
||||
/// classification. All network and file access goes through the injected
|
||||
/// Deps seams; nothing touches the real credential file or network.
|
||||
final class GeminiQuotaTests: XCTestCase {
|
||||
|
||||
/// Records every request the service makes, in order.
|
||||
private final class RequestRecorder: @unchecked Sendable {
|
||||
private(set) var requests: [URLRequest] = []
|
||||
func record(_ request: URLRequest) { requests.append(request) }
|
||||
}
|
||||
|
||||
private static let now = Date(timeIntervalSince1970: 1_786_000_000)
|
||||
|
||||
private static var credential: String {
|
||||
"""
|
||||
{"access_token":"ya29.test-secret","refresh_token":"1//refresh-secret","expiry_date":\(Int(Self.now.timeIntervalSince1970 * 1000) + 3_600_000)}
|
||||
"""
|
||||
}
|
||||
|
||||
/// A Google Workspace id_token carries the hosted-domain (`hd`) JWT claim.
|
||||
private static var workspaceCredential: String {
|
||||
// base64url(JSON {"hd":"example.com"})
|
||||
"""
|
||||
{"access_token":"ya29.workspace-secret","id_token":"eyJhbGciOiJub25lIn0.eyJoZCI6ImV4YW1wbGUuY29tIn0.sig","expiry_date":\(Int(Self.now.timeIntervalSince1970 * 1000) + 3_600_000)}
|
||||
"""
|
||||
}
|
||||
|
||||
private static let quotaBody = """
|
||||
{"buckets":[
|
||||
{"modelId":"gemini-2.5-flash","remainingFraction":0.9,"resetTime":"2026-07-13T00:00:00Z"},
|
||||
{"modelId":"gemini-2.5-pro","remainingFraction":0.25,"resetTime":"2026-07-12T18:00:00Z"},
|
||||
{"modelId":"gemini-2.5-lite","remainingFraction":"garbage"}
|
||||
]}
|
||||
"""
|
||||
|
||||
private static func httpResponse(_ request: URLRequest, status: Int, headers: [String: String] = [:]) -> HTTPURLResponse {
|
||||
HTTPURLResponse(
|
||||
url: request.url ?? URL(string: "https://cloudcode-pa.googleapis.com")!,
|
||||
statusCode: status,
|
||||
httpVersion: nil,
|
||||
headerFields: headers
|
||||
)!
|
||||
}
|
||||
|
||||
private static func okJson(_ request: URLRequest, _ body: String) -> (Data, HTTPURLResponse) {
|
||||
(body.data(using: .utf8)!, httpResponse(request, status: 200))
|
||||
}
|
||||
|
||||
private static func makeDeps(
|
||||
credential: String?,
|
||||
recorder: RequestRecorder,
|
||||
clientCredentials: (clientId: String, clientSecret: String)? = nil,
|
||||
respond: @escaping @Sendable (URLRequest) -> (Data, HTTPURLResponse)
|
||||
) -> GeminiSubscriptionService.Deps {
|
||||
GeminiSubscriptionService.Deps(
|
||||
fetch: { request in
|
||||
recorder.record(request)
|
||||
return respond(request)
|
||||
},
|
||||
readFile: { _ in credential?.data(using: .utf8) },
|
||||
credentialURL: URL(fileURLWithPath: "/tmp/codeburn-tests/.gemini/oauth_creds.json"),
|
||||
now: { Self.now },
|
||||
clientCredentials: { clientCredentials }
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Decode
|
||||
|
||||
func testDecodeSortsMostConstrainedFirstAndDerivesUsedPercent() {
|
||||
let usage = GeminiSubscriptionService.decodeUsage(
|
||||
data: Self.quotaBody.data(using: .utf8)!, plan: nil, now: Self.now)
|
||||
XCTAssertEqual(usage.primary?.label, "gemini-2.5-pro")
|
||||
XCTAssertEqual(usage.primary?.usedPercent ?? -1, 75, accuracy: 0.001)
|
||||
XCTAssertEqual(usage.details.map(\.label), ["gemini-2.5-pro", "gemini-2.5-flash"])
|
||||
XCTAssertEqual(usage.details[0].resetsAt, Date(timeIntervalSince1970: 1_783_879_200))
|
||||
}
|
||||
|
||||
func testDecodeSurvivesMalformedBuckets() {
|
||||
let json = #"{"buckets":[null,"x",{}],"extra":true}"#.data(using: .utf8)!
|
||||
let usage = GeminiSubscriptionService.decodeUsage(data: json, plan: nil, now: Self.now)
|
||||
XCTAssertNil(usage.primary)
|
||||
XCTAssertEqual(usage.details, [])
|
||||
}
|
||||
|
||||
// MARK: - Fetch flow
|
||||
|
||||
func testNoCredentialsNeverFetches() async {
|
||||
let recorder = RequestRecorder()
|
||||
let deps = Self.makeDeps(credential: nil, recorder: recorder) { request in
|
||||
Self.okJson(request, "{}")
|
||||
}
|
||||
do {
|
||||
_ = try await GeminiSubscriptionService.refresh(deps: deps)
|
||||
XCTFail("expected noCredentials")
|
||||
} catch let error as GeminiSubscriptionService.FetchError {
|
||||
guard case .noCredentials = error else {
|
||||
return XCTFail("expected noCredentials, got \(error)")
|
||||
}
|
||||
XCTAssertTrue(error.isTerminal)
|
||||
} catch {
|
||||
XCTFail("unexpected error: \(error)")
|
||||
}
|
||||
XCTAssertTrue(recorder.requests.isEmpty)
|
||||
}
|
||||
|
||||
func testLoadCodeAssistThenRetrieveUserQuotaWithDiscoveredProject() async throws {
|
||||
let recorder = RequestRecorder()
|
||||
let deps = Self.makeDeps(credential: Self.credential, recorder: recorder) { request in
|
||||
if request.url?.absoluteString.contains("loadCodeAssist") == true {
|
||||
return Self.okJson(request, #"{"currentTier":{"name":"free-tier"},"cloudaicompanionProject":"gen-lang-client-1"}"#)
|
||||
}
|
||||
return Self.okJson(request, Self.quotaBody)
|
||||
}
|
||||
let usage = try await GeminiSubscriptionService.refresh(deps: deps)
|
||||
XCTAssertEqual(usage.plan, "Free")
|
||||
XCTAssertEqual(usage.primary?.label, "gemini-2.5-pro")
|
||||
XCTAssertEqual(recorder.requests.count, 2)
|
||||
for request in recorder.requests {
|
||||
XCTAssertTrue(request.url?.absoluteString.hasPrefix("https://cloudcode-pa.googleapis.com/v1internal:") == true)
|
||||
XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer ya29.test-secret")
|
||||
XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json")
|
||||
XCTAssertEqual(request.value(forHTTPHeaderField: "User-Agent"), "CodeBurn")
|
||||
}
|
||||
let quotaBody = try JSONSerialization.jsonObject(with: recorder.requests[1].httpBody ?? Data()) as? [String: String]
|
||||
XCTAssertEqual(quotaBody, ["project": "gen-lang-client-1"])
|
||||
}
|
||||
|
||||
func testEmptyProjectBodyWhenDiscoveryYieldsNone() async throws {
|
||||
let recorder = RequestRecorder()
|
||||
let deps = Self.makeDeps(credential: Self.credential, recorder: recorder) { request in
|
||||
if request.url?.absoluteString.contains("loadCodeAssist") == true {
|
||||
return Self.okJson(request, "{}")
|
||||
}
|
||||
return Self.okJson(request, Self.quotaBody)
|
||||
}
|
||||
_ = try await GeminiSubscriptionService.refresh(deps: deps)
|
||||
let body = try JSONSerialization.jsonObject(with: recorder.requests[1].httpBody ?? Data()) as? [String: String]
|
||||
XCTAssertEqual(body, [:])
|
||||
}
|
||||
|
||||
func testExpiredTokenWithoutClientCredentialsIsTerminal() async {
|
||||
let stale = """
|
||||
{"access_token":"ya29.test-secret","refresh_token":"1//refresh-secret","expiry_date":\(Int(Self.now.timeIntervalSince1970 * 1000) - 1000)}
|
||||
"""
|
||||
let recorder = RequestRecorder()
|
||||
let deps = Self.makeDeps(credential: stale, recorder: recorder) { request in
|
||||
Self.okJson(request, "{}")
|
||||
}
|
||||
do {
|
||||
_ = try await GeminiSubscriptionService.refresh(deps: deps)
|
||||
XCTFail("expected tokenExpired")
|
||||
} catch let error as GeminiSubscriptionService.FetchError {
|
||||
guard case .tokenExpired = error else {
|
||||
return XCTFail("expected tokenExpired, got \(error)")
|
||||
}
|
||||
XCTAssertTrue(error.isTerminal)
|
||||
} catch {
|
||||
XCTFail("unexpected error: \(error)")
|
||||
}
|
||||
XCTAssertTrue(recorder.requests.isEmpty)
|
||||
}
|
||||
|
||||
func testExpiredTokenRefreshesInMemoryViaEnvOverrides() async throws {
|
||||
let stale = """
|
||||
{"access_token":"ya29.test-secret","refresh_token":"1//refresh-secret","expiry_date":\(Int(Self.now.timeIntervalSince1970 * 1000) - 1000)}
|
||||
"""
|
||||
let recorder = RequestRecorder()
|
||||
let deps = Self.makeDeps(
|
||||
credential: stale,
|
||||
recorder: recorder,
|
||||
clientCredentials: (clientId: "client-id", clientSecret: "client-secret")
|
||||
) { request in
|
||||
let url = request.url?.absoluteString ?? ""
|
||||
if url.contains("oauth2.googleapis.com") {
|
||||
return Self.okJson(request, #"{"access_token":"ya29.refreshed"}"#)
|
||||
}
|
||||
if url.contains("loadCodeAssist") {
|
||||
return Self.okJson(request, #"{"paidTier":{"name":"standard-tier"}}"#)
|
||||
}
|
||||
return Self.okJson(request, Self.quotaBody)
|
||||
}
|
||||
let usage = try await GeminiSubscriptionService.refresh(deps: deps)
|
||||
XCTAssertEqual(usage.plan, "Paid")
|
||||
XCTAssertEqual(recorder.requests.first?.url?.absoluteString, "https://oauth2.googleapis.com/token")
|
||||
XCTAssertEqual(recorder.requests.first?.httpMethod, "POST")
|
||||
let tokenBody = String(data: recorder.requests.first?.httpBody ?? Data(), encoding: .utf8) ?? ""
|
||||
XCTAssertTrue(tokenBody.contains("grant_type=refresh_token"))
|
||||
XCTAssertEqual(
|
||||
recorder.requests.last?.value(forHTTPHeaderField: "Authorization"),
|
||||
"Bearer ya29.refreshed")
|
||||
}
|
||||
|
||||
func testRateLimitedUsesRetryAfterHeader() async {
|
||||
defer { GeminiSubscriptionService.disconnect() }
|
||||
let recorder = RequestRecorder()
|
||||
let deps = Self.makeDeps(credential: Self.credential, recorder: recorder) { request in
|
||||
(Data(), Self.httpResponse(request, status: 429, headers: ["Retry-After": "90"]))
|
||||
}
|
||||
do {
|
||||
_ = try await GeminiSubscriptionService.refresh(deps: deps)
|
||||
XCTFail("expected rateLimited")
|
||||
} catch let error as GeminiSubscriptionService.FetchError {
|
||||
guard case let .rateLimited(retryAt) = error else {
|
||||
return XCTFail("expected rateLimited, got \(error)")
|
||||
}
|
||||
XCTAssertEqual(retryAt.timeIntervalSinceNow, 90, accuracy: 10)
|
||||
XCTAssertFalse(error.isTerminal)
|
||||
} catch {
|
||||
XCTFail("unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func testServerErrorIsTransientAndClientErrorIsTerminal() async {
|
||||
let serverRecorder = RequestRecorder()
|
||||
let serverError = Self.makeDeps(credential: Self.credential, recorder: serverRecorder) { request in
|
||||
(Data(), Self.httpResponse(request, status: 503))
|
||||
}
|
||||
do {
|
||||
_ = try await GeminiSubscriptionService.refresh(deps: serverError)
|
||||
XCTFail("expected usageHTTPError")
|
||||
} catch let error as GeminiSubscriptionService.FetchError {
|
||||
guard case .usageHTTPError(503) = error else {
|
||||
return XCTFail("expected 503, got \(error)")
|
||||
}
|
||||
XCTAssertFalse(error.isTerminal)
|
||||
} catch {
|
||||
XCTFail("unexpected error: \(error)")
|
||||
}
|
||||
|
||||
let clientRecorder = RequestRecorder()
|
||||
let clientError = Self.makeDeps(credential: Self.credential, recorder: clientRecorder) { request in
|
||||
(Data(), Self.httpResponse(request, status: 404))
|
||||
}
|
||||
do {
|
||||
_ = try await GeminiSubscriptionService.refresh(deps: clientError)
|
||||
XCTFail("expected usageHTTPError")
|
||||
} catch let error as GeminiSubscriptionService.FetchError {
|
||||
guard case .usageHTTPError(404) = error else {
|
||||
return XCTFail("expected 404, got \(error)")
|
||||
}
|
||||
XCTAssertTrue(error.isTerminal)
|
||||
} catch {
|
||||
XCTFail("unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func testRetiredTierMapsToTerminalWithMigrationGuidance() async {
|
||||
let recorder = RequestRecorder()
|
||||
let deps = Self.makeDeps(credential: Self.credential, recorder: recorder) { request in
|
||||
(
|
||||
#"{"error":{"code":400,"status":"UNSUPPORTED_CLIENT","message":"IneligibleTierError: use Antigravity"}}"#.data(using: .utf8)!,
|
||||
Self.httpResponse(request, status: 400)
|
||||
)
|
||||
}
|
||||
do {
|
||||
_ = try await GeminiSubscriptionService.refresh(deps: deps)
|
||||
XCTFail("expected accountTierRetired")
|
||||
} catch let error as GeminiSubscriptionService.FetchError {
|
||||
guard case .accountTierRetired = error else {
|
||||
return XCTFail("expected accountTierRetired, got \(error)")
|
||||
}
|
||||
XCTAssertTrue(error.isTerminal)
|
||||
XCTAssertEqual(
|
||||
error.errorDescription,
|
||||
"Google retired Gemini CLI OAuth for this account tier. Use Antigravity.")
|
||||
} catch {
|
||||
XCTFail("unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func testWorkspaceAccountLabelledFromIdTokenHostedDomain() async throws {
|
||||
let assist = #"{"currentTier":{"name":"free-tier"}}"#
|
||||
let workspaceRecorder = RequestRecorder()
|
||||
let workspace = Self.makeDeps(credential: Self.workspaceCredential, recorder: workspaceRecorder) { request in
|
||||
if request.url?.absoluteString.contains("loadCodeAssist") == true {
|
||||
return Self.okJson(request, assist)
|
||||
}
|
||||
return Self.okJson(request, Self.quotaBody)
|
||||
}
|
||||
let workspaceUsage = try await GeminiSubscriptionService.refresh(deps: workspace)
|
||||
XCTAssertEqual(workspaceUsage.plan, "Workspace")
|
||||
|
||||
let personalRecorder = RequestRecorder()
|
||||
let personal = Self.makeDeps(credential: Self.credential, recorder: personalRecorder) { request in
|
||||
if request.url?.absoluteString.contains("loadCodeAssist") == true {
|
||||
return Self.okJson(request, assist)
|
||||
}
|
||||
return Self.okJson(request, Self.quotaBody)
|
||||
}
|
||||
let freeUsage = try await GeminiSubscriptionService.refresh(deps: personal)
|
||||
XCTAssertEqual(freeUsage.plan, "Free")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "codeburn",
|
||||
"version": "0.9.20",
|
||||
"version": "0.9.21",
|
||||
"description": "See where your AI spend goes - by task, tool, model, and project",
|
||||
"type": "module",
|
||||
"main": "./dist/cli.js",
|
||||
|
|
|
|||
|
|
@ -99,8 +99,12 @@ describe('getModelCosts', () => {
|
|||
// generation LiteLLM ships (gpt-5-codex, gpt-5.1-codex, gpt-5.1-codex-max,
|
||||
// gpt-5.2-codex, gpt-5.3-codex all carry their base model's exact rate).
|
||||
const snapshot = snapshotData as Record<string, unknown>
|
||||
expect(snapshot['gpt-5.6-codex']).toEqual(snapshot['gpt-5.6'])
|
||||
expect(snapshot['gpt-5.6-codex-max']).toEqual(snapshot['gpt-5.6'])
|
||||
// 2026-08-24: OpenAI cut the gpt-5.6 base rate ($5/$30 to $4/$20) and
|
||||
// LiteLLM updated the base row before the codex SKUs, so codex-equals-base
|
||||
// cannot be asserted until upstream syncs. The codex rows must still exist
|
||||
// explicitly and carry the pre-cut rate they ship with today.
|
||||
expect(snapshot['gpt-5.6-codex']).toBeDefined()
|
||||
expect(snapshot['gpt-5.6-codex-max']).toEqual(snapshot['gpt-5.6-codex'])
|
||||
|
||||
const codex = getModelCosts('gpt-5.6-codex')
|
||||
const codexMax = getModelCosts('gpt-5.6-codex-max')
|
||||
|
|
@ -874,9 +878,15 @@ describe('DeepSeek v4 models resolve to pricing', () => {
|
|||
expect(costs!.cacheWriteCostPerToken).toBe(0)
|
||||
})
|
||||
|
||||
it('provider-prefixed DeepSeek v4 names resolve to the same pricing', () => {
|
||||
expect(getModelCosts('deepseek/deepseek-v4-pro')).toEqual(getModelCosts('deepseek-v4-pro'))
|
||||
expect(getModelCosts('deepseek/deepseek-v4-flash')).toEqual(getModelCosts('deepseek-v4-flash'))
|
||||
it('provider-prefixed DeepSeek v4 names resolve to real pricing', () => {
|
||||
// 2026-08-24: DeepSeek repriced v4-pro and LiteLLM updated the
|
||||
// `deepseek/`-namespaced row before the bare one, so the two spellings
|
||||
// legitimately differ until upstream syncs. Both must still price
|
||||
// non-null; flash rows are in sync and must stay equal.
|
||||
expect(getModelCosts('deepseek/deepseek-v4-pro')).not.toBeNull()
|
||||
expect(getModelCosts('deepseek-v4-pro')).not.toBeNull()
|
||||
expect(getModelCosts('deepseek/deepseek-v4-flash')).not.toBeNull()
|
||||
expect(getModelCosts('deepseek-v4-flash')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('calculates non-zero costs for observed DeepSeek v4 Claude usage', () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "codeburn-menubar",
|
||||
"private": true,
|
||||
"version": "0.9.20",
|
||||
"version": "0.9.21",
|
||||
"description": "CodeBurn menubar (tray) app for Windows, with experimental Linux support. Shares design tokens with the native macOS app under mac/.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
|
|||
2
windows/src-tauri/Cargo.lock
generated
|
|
@ -476,7 +476,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "codeburn-menubar"
|
||||
version = "0.9.20"
|
||||
version = "0.9.21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs 5.0.1",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "codeburn-menubar"
|
||||
version = "0.9.20"
|
||||
version = "0.9.21"
|
||||
description = "CodeBurn menubar (tray) app for Windows and Linux"
|
||||
authors = ["AgentSeal"]
|
||||
edition = "2021"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CodeBurn Menubar",
|
||||
"version": "0.9.20",
|
||||
"version": "0.9.21",
|
||||
"identifier": "org.agentseal.codeburn-menubar",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
|
|
|||