diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..9af748a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,18 @@ +## Summary + + + +## Testing + +- [ ] I have tested this locally against real data (not just unit tests) +- [ ] `npm test` passes +- [ ] `npm run build` succeeds + +### For new providers only: + +- [ ] I installed the tool and generated real sessions by using it +- [ ] `npm run dev -- today` shows correct costs and session counts for this provider +- [ ] `npm run dev -- models --provider ` shows correct model names and pricing +- [ ] Screenshot or terminal output attached below proving it works with real data + + diff --git a/.github/workflows/release-menubar.yml b/.github/workflows/release-menubar.yml index b3902d3..db41334 100644 --- a/.github/workflows/release-menubar.yml +++ b/.github/workflows/release-menubar.yml @@ -2,8 +2,8 @@ name: Release macOS Menubar # Triggers on a `mac-v*` tag push (e.g. `git tag mac-v0.8.0 && git push origin mac-v0.8.0`), # or manually via the Actions tab. Builds a universal arm64+x86_64 bundle, ad-hoc signs it, -# zips via `ditto`, and uploads the zip to the GitHub Release. `npx codeburn menubar` clears -# the download quarantine flag on install so Gatekeeper stays quiet. +# zips via `ditto`, and uploads the zip to the GitHub Release. The installer verifies +# the checksum and bundle identity before replacing the local app. on: push: tags: @@ -45,7 +45,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: CodeBurnMenubar-${{ steps.version.outputs.value }} - path: mac/.build/dist/CodeBurnMenubar-*.zip + path: | + mac/.build/dist/CodeBurnMenubar-${{ steps.version.outputs.value }}.zip + mac/.build/dist/CodeBurnMenubar-${{ steps.version.outputs.value }}.zip.sha256 if-no-files-found: error - name: Create / update GitHub Release @@ -58,12 +60,16 @@ jobs: Install with: ``` - npx codeburn menubar + npm install -g codeburn + codeburn menubar ``` - That command drops the app into `~/Applications`, clears the download - quarantine, and launches it. If you download the zip from this page directly - and macOS shows "cannot verify developer", right-click the app in Finder and - pick Open to whitelist it once. - files: mac/.build/dist/CodeBurnMenubar-*.zip + That command drops the app into `~/Applications`, records the persistent + `codeburn` CLI path used by the menubar, verifies the downloaded checksum, + clears quarantine after bundle verification, and launches it. If you download + the zip from this page directly and macOS shows "cannot verify developer", + right-click the app in Finder and pick Open to whitelist it once. + files: | + mac/.build/dist/CodeBurnMenubar-${{ steps.version.outputs.value }}.zip + mac/.build/dist/CodeBurnMenubar-${{ steps.version.outputs.value }}.zip.sha256 fail_on_unmatched_files: true diff --git a/.gitignore b/.gitignore index 1fc45e8..431bdc2 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,7 @@ Thumbs.db # Planning artifacts (internal, not shipped) docs/superpowers/ .claude/ -CLAUDE.md +/CLAUDE.md # Config / secrets .env @@ -40,3 +40,6 @@ assets/discord-*.png # Desktop app experiments desktop/ + +# WIP / not ready +src/summit.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4820b56..78f8da8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,272 @@ # Changelog +## Unreleased + +### Added (CLI) +- **Mistral Vibe provider.** CodeBurn now reads Mistral Vibe session folders + from `$VIBE_HOME/logs/session/` or `~/.vibe/logs/session/`, using + `meta.json` for cumulative prompt/completion tokens, model pricing, and + timestamps, and `messages.jsonl` for user prompts and tool calls. Subagent + sessions under a parent session's `agents/` folder are tracked separately. + Closes #283. +- **Kimi Code CLI provider.** CodeBurn now reads Kimi session usage from + `$KIMI_SHARE_DIR/sessions/` or `~/.kimi/sessions/`, including subagent + `wire.jsonl` files. The parser consumes Kimi's official `StatusUpdate` + token usage fields (`input_other`, `input_cache_read`, + `input_cache_creation`, `output`), normalizes Kimi tool names such as + `Shell`, `ReadFile`, and `WriteFile`, and maps hidden managed Kimi Code + model aliases to priced Kimi K2 entries. + +## 0.9.9 - 2026-05-15 + +### Added (CLI) +- **IBM Bob provider.** Discovers IBM Bob IDE task history, reuses the + Cline-family parser for token/cost records, extracts model tags and + workspace-based project names from session data. Closes #248. + +### Fixed (CLI) +- **Reduced Claude parser OOM risk.** Large Claude JSONL sessions retained + full entry objects (text, thinking blocks, tool results) in memory during + parsing, causing V8 heap exhaustion on heavy usage months. Entries are now + compacted immediately after JSON.parse, keeping only the fields needed for + cost/token aggregation. This is a mitigation - very heavy users may still + need the streaming parser refactor planned next. +- **Eager daily-cache hydration caused OOM on most CLI commands.** Eight + commands (report, today, month, export, optimize, compare, models, yield) + called `hydrateCache()` which parses a 365-day backfill, even though only + `status --format menubar-json` consumes the daily cache. Removed from all + paths that parse their own date ranges via `parseAllSessions`. +- **Session cache retained between status parses.** The `status --format json` + path parsed today and month ranges without clearing the in-process session + cache between them, keeping both result sets pinned. Cache is now cleared + after each period is consumed. +- **Claude 1-hour cache write pricing.** 1-hour cache writes are now priced + at 2x base input (previously used the 5-minute 1.25x rate for all writes). + Daily cache bumped to v6 so stale totals are recomputed. Closes #276. +- **OpenCode MCP usage now counted.** OpenCode stores MCP tool calls as + `_` names, which the shared MCP pipeline did not recognize. + The provider now normalizes these to the canonical `mcp____` + form so MCP breakdowns and `optimize` work correctly. Closes #308. +- **Antigravity Windows language-server discovery.** Antigravity detection now + supports Windows process discovery, `--extension_server_port`, + `--extension_server_csrf_token`, `--flag=value` syntax, and both wrapped and + unwrapped Connect-RPC response shapes. Closes #249. +- **Mangled project names in dashboard.** The By Project and Top Sessions + panels decoded slugs by splitting on `-`, which broke directory names + containing dashes or dots (e.g. `my-project` rendered as `my/project`). + Now uses the real project path instead. Closes #320. +- **Cursor undated bubble rows misattributed to Today.** Bubble rows without + a `createdAt` timestamp were defaulting to the current date, inflating + Today's spend. Now skipped at both the SQL and application level. +- **Node version guard.** Running on Node < 22.13.0 now prints a clear + upgrade message instead of crashing with a cryptic `node:sqlite` parse + error. Closes #319. + +### Fixed (macOS menubar) +- **All-provider refresh OOM.** Refreshing with provider set to "All" could + exhaust the V8 heap on accounts with heavy session history. +- **Tab refresh recovery.** Switching tabs during a refresh no longer leaves + the panel in a stale loading state. +- **Stale cache recovery.** The menubar now detects and discards a corrupt or + outdated on-disk cache instead of rendering zeroes until the next restart. +- **Refresh timer hardening.** The 30-second auto-refresh timer is now + cancelled on sleep/wake and restarted cleanly, preventing overlapping + refreshes after lid-open. +- **Version display.** The settings panel now shows the version without the + `v` prefix for consistency with `codeburn --version`. + +## 0.9.8 - 2026-05-10 + +### Added (CLI) +- **Cline provider support.** CodeBurn now reads Cline task usage from both + VS Code globalStorage (`saoudrizwan.claude-dev`) and Cline's + `~/.cline/data` task root. It reuses the existing Cline-family parser for + `ui_messages.json` usage entries, deduplicates migrated tasks by the newest + `ui_messages.json`, and exposes Cline in CLI provider filters, docs, and the + macOS menubar provider tabs. Closes #130. +- **Multiple Claude config directories.** Set `CLAUDE_CONFIG_DIRS` to an + OS-delimited list of paths (`:`-separated on POSIX, `;`-separated on + Windows) to scan more than one Claude data directory in a single run. + Sessions across every configured directory roll up into one project row + per project, so a user with `~/.claude-work` and `~/.claude-personal` + who works on the same repo from both accounts sees one combined row + rather than two split rows. `~` is expanded; missing or unreadable + directories in the list are skipped instead of aborting the scan; if + every listed entry is unreadable a one-line hint is written to stderr + so a misplaced delimiter does not silently produce zero rows. + Precedence: `CLAUDE_CONFIG_DIRS` > `CLAUDE_CONFIG_DIR` > `~/.claude`. + As part of this change `~` and `~/foo` are now also expanded in + `CLAUDE_CONFIG_DIR` (previously the value was passed through verbatim, + which only worked when the shell expanded `~` before exporting). + Closes #208. +- **`codeburn models` command.** Per-model breakdown across all providers, + one row per (provider, model), sorted by cost. Each row carries Input, + Output, Cache Write, Cache Read, Total, and Cost columns plus a Top Task + cell showing the dominant task category and its cost share (e.g. + `Coding (42%)`). Pass `--by-task` to explode each model into one row per + task type, with provider/model cells blanked on subsequent rows of the + same group and a horizontal divider between groups. Filters: `--period` + (default `30days`), `--from/--to`, `--provider`, `--task`, `--top`, + `--min-cost`, `--no-totals`. Output formats: `table` (Unicode box-drawn, + default), `markdown` (GitHub-flavored, copy-paste friendly), `json`, + `csv`. The table renderer auto-sizes every column to its content and + drops cache columns first, then input/output, then top-task when the + terminal is too narrow to fit the full set. Headers are cyan, totals row + is yellow, provider name is dim. Inspired by tokscale's per-model table + and ccusage's responsive cli-table3 layout, ported to plain Node with + no new runtime dependency. +- **Per-day one-shot data in `--format json`.** Each entry of `daily[]` now + carries `turns`, `editTurns`, `oneShotTurns`, and `oneShotRate` (0-100, + one decimal, `null` when no edit turns). Counts match the existing + period-level `activities[]` rollup so a consumer can sum across days and + reconcile. Closes #279. + +### Fixed (CLI) +- **Cursor sessions break down by project, not one row called "cursor".** + Cursor's chat history sat under a single dashboard row labeled `cursor` + because the provider had no way to attribute bubbles to a workspace. + The fix walks `~/Library/Application Support/Cursor/User/workspaceStorage/*` + for each workspace's `workspace.json` (folder URI) and + `composer.composerData` (the composer ids opened in that workspace), + then joins those composer ids against the global bubbles. Each + workspace becomes its own project row, sanitized into the same slug + shape Claude uses (e.g. `-Users-you-myproject`); composers that have + no workspace mapping (multi-root workspaces, "no folder open" + sessions, deleted workspaces) remain under a catch-all `cursor` row. + As part of this the cursor parser now derives `sessionId` from the + bubble row key (`bubbleId::`) instead of the + empty `conversationId` JSON field, which was always falling back to + `'unknown'`. Cursor result cache version bumped to 3 to invalidate + prior caches that recorded the old session id. Closes the per-project + half of #196. +- **Cursor cost shown for every model, not just Auto.** Cursor emits model + names in a `claude--` shape (`claude-4.6-sonnet`, + `claude-4.5-opus`, `claude-4.5-opus-high-thinking`, etc.) plus its own + `composer-1` house model, none of which match the canonical LiteLLM + pricing keys (`claude-sonnet-4-6`, `claude-opus-4-5`). The alias map in + `src/models.ts` filled some of these in v0.9.4 but missed the plain + no-suffix forms (`claude-4.5-opus`, `claude-4.5-sonnet`, + `claude-4.6-opus`), the haiku tier, the forward-looking 4.7 variant, + and `composer-1`. The dashboard rendered $0 for sessions that used any + unaliased model. Visible to users in #159 even after the v0.9.4 fix. + Every Cursor variant in `src/providers/cursor.ts:modelDisplayNames` + now has an alias and a regression test asserting non-zero pricing + resolution. Closes #159. +- **Activity classifier no longer mislabels feature work as debugging.** + Messages like "add error handling", "create an issue tracker", or + "implement the 404 page" used to land in the Debugging bucket because + the classifier checked the debug-keyword regex (which matches `error`, + `issue`, `404`) before the feature regex. Now the keyword that appears + earliest in the user message wins, so "add" beats "error", "create" + beats "issue", etc. A real bug report ("login is broken, traceback + below") still classifies as debugging because the debug word leads. + Fixes the activity-misattribution half of #196. + +### Changed (CLI) +- **`optimize` suggestions now declare their destination.** Every paste-style + fix carries an explicit destination — `claude-md` (permanent project rule), + `session-opener` (one-time paste at the start of a future session), + `prompt` (one-time ask in the current chat), or `shell-config` (append to + `~/.zshrc` / `~/.bashrc`). Output renders a clearly-labeled section header + per destination so users no longer accidentally bake one-time session + openers into their CLAUDE.md as permanent rules. Closes #277. + +## 0.9.7 - 2026-05-07 + +### Added (CLI) +- **MCP tool coverage detector.** New `optimize` finding flags MCP servers + whose tool inventory is largely unused. Inventory is observed from the + Claude `deferred_tools_delta` JSONL attachments (exact tool names per + session) instead of guessed at five tools per server. Token-savings + estimates are cache-aware: schema bytes pay full input price on the first + cache-creation turn of a session, then carry at the cache-read discount + on subsequent turns, capped per call so we never claim more overhead + than the call's own cache buckets could contain. Threshold: + >10 tools available, <20% coverage, observed in ≥2 sessions. Closes #2. +- **Session cost outlier detector.** New `optimize` finding flags sessions costing more than 2x their peer-session average within the same project. Ignores sub-$1 outliers to avoid noise. Requires at least 3 sessions per project for a baseline. +- **Context bloat detector.** New `optimize` finding flags sessions where + effective input/cache tokens are large and disproportionate to output. + Cache reads are discounted in the estimate to avoid overstating cheap cached + context. The report highlights top sessions by imbalance, notes sharp + growth from the previous project session (within a 7-day baseline window), + and suggests starting fresh with only the current goal, relevant files, + failing output, and constraints. Sessions flagged here are excluded from + the cost-outlier finding so the same session is not listed twice. +- **Worth-it score detector.** New `optimize` finding flags expensive sessions + with weak delivery signals: no edit turns, repeated retries, or edit work + that never landed in one shot, when no `git`/`gh` delivery command is + observed. Framed as a conservative review candidate, not proof of waste. + Sessions flagged here take priority and are excluded from both the + context-bloat and cost-outlier findings so the same session is not listed + more than once. +- **Per-model efficiency metrics.** JSON report includes edit turns, one-shot rate, retries per edit, and cost per edit for each model. +- **Custom date range export.** `codeburn export --from --to` exports a single custom period. +- **Live Claude quota bar.** Menubar shows real-time quota usage inside the agent tab strip with OAuth refresh gate. + +### Fixed (CLI) +- **Invalid `--format` silently accepted.** All commands now reject unknown format values with a clear error and exit 1 instead of silently falling back to the default. +- **Invalid `--period` silently accepted.** `getDateRange()` no longer falls back to "week" on unknown periods. All period-accepting commands reject invalid values. +- **`status` help text.** Description said "today + week + month" but only today and month were shown. Fixed to match actual output. +- **Windows Claude project paths.** Claude Code project rollups now prefer + the canonical `cwd` stored in session JSONL files instead of reconstructing + paths from lossy directory slugs, and group case/slash variants together. + Closes #217. +- **`all` period semantics unified between CLI and dashboard.** The dashboard treated `--period all` as all-time (epoch start) while the CLI bounded it to the last 6 months. Both now consistently mean "Last 6 months". Period helpers (`Period`, `PERIODS`, `PERIOD_LABELS`, `toPeriod`, `getDateRange`) consolidated into `cli-date.ts`. Use `--from` / `--to` for unbounded historical ranges. +- **Popover anchor, tab strip flicker, and stale-data refresh.** Batch of UI regressions from the menubar hardening round. +- **Validator hardenings.** Batch of edge-case fixes from the multi-agent bug hunt. +- **Command injection in yield.** `yield` now uses `execFileSync` instead of `execSync` to prevent shell injection via crafted branch names. +- **SHA-256 checksum verification.** Menubar installer verifies download integrity before replacing the running app. + +### Fixed (macOS menubar) +- **Stuck loading spinner.** The menubar ran `--optimize` on every 30-second background refresh. As sessions accumulated, optimize exceeded the 45-second timeout, and the loading overlay stayed forever with no fallback. Optimize is now stripped from all menubar fetches (use `codeburn optimize` in the CLI instead). On fetch failure with empty cache, the app retries without optimize so the spinner always clears. +- **Stale data after overnight sleep.** Cache keys used the period enum (`.today`) not a calendar date, so data from yesterday persisted after midnight. Cache now tracks the current date and clears itself on day rollover. Wake-from-sleep additionally clears all cached entries before fetching fresh data. +- **Refresh button appeared to do nothing.** Clicking refresh with stale cached data never showed the loading overlay because loading state only triggered on empty cache. Manual refresh and wake-from-sleep now explicitly request loading feedback. +- **Update button stuck spinning forever.** `performUpdate()` only reset `isUpdating` on failure. On success the installer kills and relaunches the app, but if the process survives (pkill fails silently), the button stayed on "Updating..." permanently. Now always resets on termination and clears the update badge on success. + +## 0.9.6 - 2026-05-03 + +### Added (CLI) +- **Goose provider.** New provider for Block's Goose AI coding assistant. +- **Antigravity provider.** New provider for Antigravity IDE sessions. +- **Antigravity model aliases.** gemini-3-pro, flash-image, flash-lite, and community-contributed Gemini model IDs. +- **GPT-5.5 display name** for Codex. +- **Deno support.** `deno dx` added as a run method. + +### Fixed (CLI) +- **Streaming dedup.** Claude Code streams each `message.id` multiple times (start, intermediate, stop). The old keep-first strategy lost tool_use blocks and understated output tokens by ~6.3%. Now keeps last occurrence content with first occurrence timestamp for correct date bucketing. +- **`$0.0000` display.** Near-zero costs showed four decimal places instead of `$0.00`. Fixes #205. +- **ANSI escape stripping.** Shell commands containing ANSI color codes now cleaned across all providers. +- **Antigravity dedup collision.** Fixed key collision in session dedup. Added Codex ChatGPT Plus token estimation. +- **Codex large session validation.** Reads full first line for session meta validation; caps read size and handles torn writes. +- **Codex fork dedup.** Deduplicates forked Codex sessions to avoid double-counting. +- **Windows dashboard hang.** Fixed `ExperimentalWarning` and dashboard freeze on Windows. +- **Hardcoded `$` in forecast.** Forecast comparison text now uses the configured currency symbol. + +### Fixed (macOS menubar) +- **Provider tabs showing $0.00 after idle.** CLI timeout increased from 20s to 45s for cold file-cache latency. Loading overlay now appears when the all-provider payload confirms a provider has spend but its dedicated data hasn't loaded yet. +- **Refresh button blocked by in-flight requests.** Manual refresh now bypasses the in-flight guard so users can always re-fetch. +- **Tab strip vs hero cost mismatch.** Tab strip prefers the provider-specific payload cost when available, staying in sync with the hero section. +- **Ghost status item on macOS Tahoe.** + +## 0.9.5 - 2026-05-01 + +### Added (CLI) +- **Homebrew tap.** `brew tap getagentseal/codeburn && brew install codeburn`. +- **GPT-5.3 and DeepSeek display names.** GPT-5.3, DeepSeek Coder, DeepSeek Coder Max, DeepSeek R1. + +### Fixed (macOS menubar) +- **Menubar refresh loop.** Was a single-fire Task that never repeated; now a proper while loop with 30s interval and `force: true`. +- **Loading overlay flicker.** Counter-based `isLoading` so concurrent fetches don't toggle the overlay. +- **Rapid tab switching race.** Previous fetch is cancelled when switching tabs; stale results are discarded via `Task.isCancelled`. +- **Tab strip vs hero cost desync.** Provider-specific and all-provider data now fetched in parallel so costs arrive from the same snapshot. +- **Stale menubar icon after wake.** `forceRefresh` now fetches today/all in parallel alongside the current selection. +- **Accent color propagation.** `ThemeState` is now `@Observable`; removes `.id()` view hierarchy teardown hack. +- **Currency flash on first switch.** Symbol and rate now apply atomically — no more wrong-symbol-with-old-rate flash. +- **Export UI freeze.** Uses `terminationHandler` instead of `waitUntilExit`; HHmmss in filename prevents overwrite on double-export. +- **CurrencyState concurrency.** Proper `@MainActor` isolation with `Sendable` conformance; `nonisolated` on pure static functions. +- **Streak count.** Iterates calendar days instead of sparse history entries so gaps correctly break streaks. +- **TrendBar chart flicker.** Stable date-based identity instead of UUID. + ## 0.9.4 - 2026-04-29 ### Added (CLI) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..aebe0f2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,127 @@ +# Contributing to CodeBurn + +Thanks for your interest. This document covers what you need to know to send a working pull request. + +## Prerequisites + +- Node.js 22.20 or newer (`engines.node` in `package.json`). +- npm 10 or newer (ships with recent Node). +- macOS or Linux for full provider coverage. Windows works for most providers but Cursor / Antigravity development is easier on macOS. +- Optional: Swift 6 toolchain if you are touching the macOS menubar (`mac/`). +- Optional: GNOME 45 or newer if you are touching the GNOME extension (`gnome/`). + +## Setup + +```bash +git clone https://github.com/getagentseal/codeburn +cd codeburn +npm install +``` + +There is no separate build step required to run the dev CLI. `npm run dev` runs `tsx` against `src/cli.ts` directly. + +## Common Commands + +| Command | What it does | +|---|---| +| `npm test` | Runs the vitest suite (42 test files, 568 tests). | +| `npm run dev -- status` | Runs the CLI in dev mode against your real data. | +| `npm run build` | Bundles the litellm pricing snapshot, then runs `tsup` to produce `dist/cli.js`. | +| `npm run bundle-litellm` | Refreshes `src/data/litellm-snapshot.json` from the upstream litellm repo. | + +To test a specific suite, pass a path: + +```bash +npm test -- tests/providers/codex.test.ts +``` + +## What to Read Before Editing + +- `docs/architecture.md` for the high-level codebase map. +- `docs/providers/.md` for the provider you intend to change. +- `RELEASING.md` if you are touching version bumps or the release pipeline. +- `SECURITY.md` for the disclosure policy. + +## Project Layout + +``` +src/ CLI, parsers, optimize detectors, cache layers +src/providers/ One file per AI tool integration +src/data/ Bundled litellm pricing snapshot +tests/ vitest specs +mac/ Swift menubar app +gnome/ GNOME shell extension +scripts/ Build helpers (litellm bundle) +``` + +See `docs/architecture.md` for a fuller map. + +## Coding Conventions + +- TypeScript strict mode is on. Do not introduce `any` without a comment explaining why. +- Avoid bracket-assign (`obj[key] = value`) on parsed user input in hot paths inside `src/providers/` and `src/parser.ts`. There is a Semgrep rule (`.semgrep/rules/no-bracket-assign-hot-paths.yml`) enforced in CI that will fail your PR if you do. Use a `Map` or an explicit allowlist instead. +- Provider parsers must be deterministic given the same input. If you read the system clock or the filesystem outside the documented session paths, add a fixture-based test. +- New providers go through `src/providers/index.ts`. Lazy-load anything that pulls a heavy native dependency (sqlite, protobuf) so users without that provider are not slowed down. + +## Tests + +- Each new provider should ship with a fixture-based test under `tests/providers/`. The five providers without test files today (claude, gemini, goose, qwen, antigravity) are a known gap; new code should not add to that list. +- Each new optimize detector in `src/optimize.ts` needs at least one positive and one negative case in `tests/optimize.test.ts`. +- If your change affects the menubar JSON contract, update `tests/menubar-json.test.ts`. + +## Commit Message Format + +Short imperative subject, optional body. Examples from `git log`: + +``` +Enhance GNOME extension with scrollable UI, dark mode, charts, and performance fixes +Add table column headers, oneshot placeholder, currency picker dropdown +``` + +### No AI Co-Author Trailers + +The `.github/workflows/block-claude-coauthor.yml` workflow rejects any PR whose commits contain a `Co-authored-by: ... claude ...` or `... anthropic ...` trailer. You may use AI tools to help write code, but strip the co-author line before pushing. + +If a flagged PR rejects on this check, the workflow prints the exact rebase command to fix it. + +## Before You Start + +**Comment on the issue first.** Before writing code for a feature or new provider, leave a comment on the relevant issue saying what you plan to do. Wait for a maintainer to confirm the approach. Unsolicited PRs that duplicate work already in progress or take an incompatible approach will be closed. + +**One PR at a time.** We will not review a second PR from you until the first is merged or closed. This keeps the review queue manageable and ensures each contribution gets proper attention. + +## Adding a New Provider + +New providers have the highest bar because broken parsing silently produces wrong data for users. Before opening a PR: + +1. **Install the tool and use it.** Generate real sessions by actually coding with the provider. We do this ourselves for every provider we ship. +2. **Test against real data.** Run `npm run dev -- today` and `npm run dev -- models` with your real sessions and confirm the output looks correct — costs are non-zero, model names resolve, session counts match what you see in the tool. +3. **Include proof in the PR.** Attach a screenshot or terminal output showing codeburn correctly parsing your real sessions. PRs for new providers without evidence of local testing will not be reviewed. +4. **Do not rely on AI-generated guesses about storage paths or schemas.** Tools change their data formats between versions. The only way to know the current schema is to install the tool and inspect the actual files on disk. + +PRs that add a provider based solely on online documentation or AI-generated code, without evidence of testing against real data, will be closed. + +## Pull Requests + +1. Fork or branch from `main`. +2. Push your branch and open a PR against `main`. +3. The `firstlook` workflow will auto-assess the PR. The `semgrep` CI workflow runs the hot-path bracket-assign guard. The `block-claude-coauthor` workflow scans commits. +4. A maintainer reviews. For non-trivial changes, expect requests for tests. +5. Squash-merge is the default. Keep the PR title short and accurate; the description carries the context. + +## Reporting Bugs + +File issues at https://github.com/getagentseal/codeburn/issues. Useful details: + +- Output of `codeburn --version`. +- Provider involved and rough size of your session history (`du -sh ~/.codex/sessions`, etc.). +- Output of the failing command with `DEBUG=1` if applicable. +- For parsing bugs: a redacted JSONL or SQLite snippet that reproduces the issue. + +## Security Issues + +Do not file security issues in the public tracker. See `SECURITY.md` for the disclosure process. + +## License + +CodeBurn is MIT-licensed. By contributing, you agree your contributions are licensed under the same terms. diff --git a/README.md b/README.md index f45c02b..abad118 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Sponsor

-CodeBurn tracks token usage, cost, and performance across **16 AI coding tools**. It breaks down spending by task type, model, tool, project, and provider so you can see exactly where your budget goes. +CodeBurn tracks token usage, cost, and performance across **19 AI coding tools**. It breaks down spending by task type, model, tool, project, and provider so you can see exactly where your budget goes. Everything runs locally. No wrapper, no proxy, no API keys. CodeBurn reads session data directly from disk and prices every call using [LiteLLM](https://github.com/BerriAI/litellm). @@ -48,11 +48,19 @@ Everything runs locally. No wrapper, no proxy, no API keys. CodeBurn reads sessi npm install -g codeburn ``` +Or with Homebrew: + +```bash +brew tap getagentseal/codeburn +brew install codeburn +``` + Or run directly without installing: ```bash npx codeburn bunx codeburn +dx codeburn ``` ## Usage @@ -75,32 +83,47 @@ codeburn optimize -p week # scope the scan to last 7 days codeburn compare # side-by-side model comparison codeburn yield # track productive vs reverted/abandoned spend codeburn yield -p 30days # yield analysis for last 30 days +codeburn models # per-model token + cost table (last 30 days) +codeburn models --by-task # explode each model into per-task-type rows +codeburn models --top 10 # only the top 10 by cost +codeburn models --format markdown # paste-friendly markdown table +codeburn models --task feature # filter to feature-development work +codeburn models --provider claude # filter to one provider ``` -Arrow keys switch between Today, 7 Days, 30 Days, Month, and All Time. Press `q` to quit, `1` `2` `3` `4` `5` as shortcuts, `c` to open model comparison, `o` to open optimize. The dashboard auto-refreshes every 30 seconds by default (`--refresh 0` to disable). It also shows average cost per session and the five most expensive sessions across all projects. +Arrow keys switch between Today, 7 Days, 30 Days, Month, and 6 Months (use `--from` / `--to` for an exact historical window). Press `q` to quit, `1` `2` `3` `4` `5` as shortcuts, `c` to open model comparison, `o` to open optimize. The dashboard auto-refreshes every 30 seconds by default (`--refresh 0` to disable). It also shows average cost per session and the five most expensive sessions across all projects. ## Supported Providers -| Provider | Data Location | Supported | -|----------|--------------|-----------| -| Claude Code | `~/.claude/projects/` | Yes | -| Claude Desktop | `~/Library/Application Support/Claude/local-agent-mode-sessions/` | Yes | -| Codex (OpenAI) | `~/.codex/sessions/` | Yes | -| Cursor | `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` | Yes | -| cursor-agent | `~/.cursor/projects/` | Yes | -| Gemini CLI | `~/.gemini/tmp//chats/` | Yes | -| GitHub Copilot | `~/.copilot/session-state/` + VS Code `workspaceStorage/` | Yes | -| Kiro | `~/Library/Application Support/Kiro/User/globalStorage/kiro.kiroagent/` | Yes | -| OpenCode | `~/.local/share/opencode/` (SQLite) | Yes | -| OpenClaw | `~/.openclaw/agents/` (+ legacy `.clawdbot`, `.moltbot`, `.moldbot`) | Yes | -| Pi | `~/.pi/agent/sessions/` | Yes | -| OMP (Oh My Pi) | `~/.omp/agent/sessions/` | Yes | -| Droid | `~/.factory/projects/` | Yes | -| Roo Code | VS Code `globalStorage/rooveterinaryinc.roo-cline/tasks/` | Yes | -| KiloCode | VS Code `globalStorage/kilocode.kilo-code/tasks/` | Yes | -| Qwen | `~/.qwen/projects//chats/` | Yes | +| | Provider | Supported | Doc | +|---|----------|-----------|-----| +| | Claude Code | Yes | [claude.md](docs/providers/claude.md) | +| | Claude Desktop | Yes | [claude.md](docs/providers/claude.md) | +| | Cline | Yes | [cline.md](docs/providers/cline.md) | +| | Codex (OpenAI) | Yes | [codex.md](docs/providers/codex.md) | +| | Cursor | Yes | [cursor.md](docs/providers/cursor.md) | +| | cursor-agent | Yes | [cursor-agent.md](docs/providers/cursor-agent.md) | +| | Gemini CLI | Yes | [gemini.md](docs/providers/gemini.md) | +| | Mistral Vibe | Yes | [mistral-vibe.md](docs/providers/mistral-vibe.md) | +| | GitHub Copilot | Yes | [copilot.md](docs/providers/copilot.md) | +| | IBM Bob | Yes | [ibm-bob.md](docs/providers/ibm-bob.md) | +| | Kiro | Yes | [kiro.md](docs/providers/kiro.md) | +| | OpenCode | Yes | [opencode.md](docs/providers/opencode.md) | +| | OpenClaw | Yes | [openclaw.md](docs/providers/openclaw.md) | +| | Pi | Yes | [pi.md](docs/providers/pi.md) | +| | OMP (Oh My Pi) | Yes | [omp.md](docs/providers/omp.md) | +| | Droid | Yes | [droid.md](docs/providers/droid.md) | +| | Roo Code | Yes | [roo-code.md](docs/providers/roo-code.md) | +| | KiloCode | Yes | [kilo-code.md](docs/providers/kilo-code.md) | +| | Qwen | Yes | [qwen.md](docs/providers/qwen.md) | +| | Kimi Code CLI | Yes | [kimi.md](docs/providers/kimi.md) | +| | Goose | Yes | [goose.md](docs/providers/goose.md) | +| | Antigravity | Yes | [antigravity.md](docs/providers/antigravity.md) | +| | Crush | Yes | [crush.md](docs/providers/crush.md) | -Paths shown are for macOS. Linux and Windows equivalents are detected automatically. If a path has changed or is wrong, please [open an issue](https://github.com/getagentseal/codeburn/issues). +Each provider doc lists the exact data location, storage format, and known quirks. Linux and Windows paths are detected automatically. If a path has changed or is wrong, please [open an issue](https://github.com/getagentseal/codeburn/issues). + +Provider logos are trademarks of their respective owners. The icon set was sourced from [tokscale](https://github.com/junhoyeo/tokscale) (MIT), official vendor assets, and simple provider identifiers, used under nominative fair use for the purpose of identifying supported tools. CodeBurn auto-detects which AI coding tools you use. If multiple providers have session data on disk, press `p` in the dashboard to toggle between them. @@ -112,6 +135,8 @@ The `--provider` flag filters any command to a single provider: `codeburn report **Gemini CLI** stores sessions as single JSON files. Each session embeds real token counts (input, output, cached, thoughts) per message, so no estimation is needed. Gemini reports input tokens inclusive of cached; CodeBurn subtracts cached from input before pricing to avoid double charging. +**Mistral Vibe** stores sessions as folders under `~/.vibe/logs/session/` (or `$VIBE_HOME/logs/session/`). CodeBurn reads cumulative prompt/completion totals and model pricing from `meta.json`, then reads `messages.jsonl` for the first user prompt and assistant tool calls. Subagent sessions under `agents/` are counted as separate Vibe sessions. + **Kiro** stores conversations as `.chat` JSON files. Token counts are estimated from content length. The underlying model is not exposed, so sessions are labeled `kiro-auto` and costed at Sonnet rates. **GitHub Copilot** reads from both `~/.copilot/session-state/` (legacy CLI) and VS Code's `workspaceStorage/*/GitHub.copilot-chat/transcripts/`. The VS Code format has no explicit token counts; tokens are estimated from content length and the model is inferred from tool call ID prefixes. @@ -120,6 +145,8 @@ The `--provider` flag filters any command to a single provider: `codeburn report **Roo Code and KiloCode** are Cline-family VS Code extensions. CodeBurn reads `ui_messages.json` from each task directory and extracts token usage from `api_req_started` entries. +**Claude with multiple config directories.** If you run Claude Code under more than one account or profile (e.g. `~/.claude-work` and `~/.claude-personal`), point `CLAUDE_CONFIG_DIRS` at all of them at once: `CLAUDE_CONFIG_DIRS=~/.claude-work:~/.claude-personal codeburn`. Sessions across every directory are merged into one row per project so the totals reflect all your Claude usage in one place. Use `:` on POSIX, `;` on Windows. Missing or unreadable directories in the list are skipped. + Adding a new provider is a single file. See `src/providers/codex.ts` for an example. ## Features @@ -178,6 +205,9 @@ Scans your sessions and your `~/.claude/` setup for waste patterns: - Ghost agents, skills, and slash commands defined in `~/.claude/` but never invoked - Bloated `CLAUDE.md` files (with `@-import` expansion counted) - Cache creation overhead and junk directory reads +- Context-heavy sessions where effective input/cache tokens swamp output +- Possibly low-worth expensive sessions with no edit turns or repeated retries + when no `git`/`gh` delivery command is observed Each finding shows the estimated token and dollar savings plus a ready-to-paste fix: a `CLAUDE.md` line, an environment variable, or a `mv` command to archive unused items. Findings are ranked by urgency (impact weighted against observed waste) and rolled up into an A to F setup health grade. Repeat runs classify each finding as new, improving, or resolved against a 48-hour recent window. @@ -186,7 +216,7 @@ You can also open it inline from the dashboard: press `o` when a finding count a ### Compare ```bash -codeburn compare # interactive model picker (default: all time) +codeburn compare # interactive model picker (default: last 6 months) codeburn compare -p week # last 7 days codeburn compare -p today # today only codeburn compare --provider claude # Claude Code sessions only @@ -354,60 +384,39 @@ These are starting points, not verdicts. A 60% cache hit on a single experimenta **Gemini CLI** stores sessions as single JSON files at `~/.gemini/tmp//chats/session-*.json`. Each session embeds real token counts (input, output, cached, thoughts) per message. Gemini reports input tokens inclusive of cached; CodeBurn subtracts cached from input before pricing to avoid double charging. +**Mistral Vibe** stores session folders at `~/.vibe/logs/session/`. Each folder contains `meta.json` with cumulative prompt/completion token totals, model pricing, timestamps, and working directory, plus `messages.jsonl` with user prompts and assistant tool calls. CodeBurn emits one record per Vibe session because the source data is cumulative, not per assistant turn. + **OpenClaw** stores agent sessions as JSONL at `~/.openclaw/agents/*.jsonl`. Also checks legacy paths `.clawdbot`, `.moltbot`, `.moldbot`. Token usage comes from assistant message `usage` blocks; model from `modelId` or `message.model` fields. -**Roo Code / KiloCode** are Cline-family VS Code extensions. CodeBurn reads `ui_messages.json` from each task directory in VS Code's `globalStorage`, filtering `type: "say"` entries with `say: "api_req_started"` to extract token counts. +**Cline / Roo Code / KiloCode** are Cline-family coding agents. CodeBurn reads `ui_messages.json` from each task directory, filtering `type: "say"` entries with `say: "api_req_started"` to extract token counts. Cline scans both VS Code's `globalStorage/saoudrizwan.claude-dev` and `~/.cline/data`. -CodeBurn deduplicates messages (by API message ID for Claude, by cumulative token cross-check for Codex, by conversation/timestamp for Cursor, by session ID for Gemini, by session+message ID for OpenCode, by responseId for Pi/OMP, by chat folder + message ID for Codebuff), filters by date range per entry, and classifies each turn. +**IBM Bob** stores IDE task history in `User/globalStorage/ibm.bob-code/tasks//` under the IBM Bob application data directory. CodeBurn reads `ui_messages.json` for API request token/cost records and `api_conversation_history.json` for the selected model, with support for both GA (`IBM Bob`) and preview (`Bob-IDE`) app data folders. + +**Kimi Code CLI** stores session logs under `$KIMI_SHARE_DIR/sessions///` or `~/.kimi/sessions///`. CodeBurn reads `wire.jsonl` `StatusUpdate.token_usage` records, maps `input_other`, `input_cache_read`, `input_cache_creation`, and `output` into the standard token columns, and includes subagent sessions under each session's `subagents/` folder. + +CodeBurn deduplicates messages (by API message ID for Claude, by cumulative token cross-check for Codex, by conversation/timestamp for Cursor, by session ID for Gemini, by session+message ID for OpenCode, by responseId for Pi/OMP, by chat folder + message ID for Codebuff, by session+message ID for Kimi), filters by date range per entry, and classifies each turn. ## Environment Variables | Variable | Description | |----------|-------------| | `CLAUDE_CONFIG_DIR` | Override Claude Code data directory (default: `~/.claude`) | +| `CLAUDE_CONFIG_DIRS` | OS-delimited list of Claude data directories to scan together (e.g. `~/.claude-work:~/.claude-personal`). Sessions merge into one row per project. Overrides `CLAUDE_CONFIG_DIR` when set. | | `CODEX_HOME` | Override Codex data directory (default: `~/.codex`) | | `CODEBUFF_DATA_DIR` | Override Codebuff data directory (default: `~/.config/manicode`) | | `FACTORY_DIR` | Override Droid data directory (default: `~/.factory`) | +| `KIMI_SHARE_DIR` | Override Kimi Code CLI share directory (default: `~/.kimi`) | +| `KIMI_MODEL_NAME` | Override Kimi model name when Kimi sessions do not record the model | | `QWEN_DATA_DIR` | Override Qwen data directory (default: `~/.qwen/projects`) | +| `VIBE_HOME` | Override Mistral Vibe home directory (default: `~/.vibe`) | -## Project Structure +## Sponsoring CodeBurn -``` -src/ - cli.ts Commander.js entry point - dashboard.tsx Ink TUI (React for terminals) - parser.ts JSONL reader, dedup, date filter, provider orchestration - models.ts LiteLLM pricing, cost calculation - classifier.ts 13-category task classifier - compare-stats.ts Model comparison engine - daily-cache.ts Persistent daily cache with migration - day-aggregator.ts Daily aggregation from session data - types.ts Type definitions - format.ts Text rendering (status bar) - menubar-json.ts Payload builder for the macOS menubar app - export.ts CSV/JSON multi-period export - config.ts Config file management (~/.config/codeburn/) - currency.ts Currency conversion, exchange rates - sqlite.ts SQLite adapter (lazy-loads better-sqlite3) - optimize.ts Waste pattern detection engine - providers/ - types.ts Provider interface definitions - index.ts Provider registry - claude.ts Claude Code session discovery - codex.ts Codex session discovery and JSONL parsing - copilot.ts GitHub Copilot session parsing - cursor.ts Cursor SQLite parsing, language extraction - cursor-agent.ts cursor-agent CLI session parsing - droid.ts Droid session discovery - gemini.ts Gemini CLI session JSON parsing - kilo-code.ts KiloCode VS Code extension parsing - kiro.ts Kiro .chat JSON session parsing - openclaw.ts OpenClaw agent JSONL parsing - opencode.ts OpenCode SQLite session parsing - pi.ts Pi/OMP agent JSONL session parsing - qwen.ts Qwen CLI JSONL session parsing - roo-code.ts Roo Code VS Code extension parsing -``` +If CodeBurn is useful to you or your team, consider sponsoring development. + +Sponsorship helps support the time spent building and maintaining the project, the providers we add, and the bug-fix turnaround on issues like Cursor schema drift and Claude config-dir support. + +[Sponsor on GitHub](https://github.com/sponsors/iamtoruk) ## Star History diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..b42d7d8 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,252 @@ +# Releasing CodeBurn + +This document describes the actual steps a maintainer takes to cut a CLI or macOS menubar release. CLI releases are run by hand with `npm publish`; macOS menubar releases are automated by `.github/workflows/release-menubar.yml` when a `mac-v*` tag is pushed. + +## Versioning + +CodeBurn uses semantic versioning (major.minor.patch). The CLI and macOS menubar share the same version number for clarity. + +## Before Every Release + +Run the test suite to catch any regressions: + +```bash +npm test +``` + +Verify that the build completes without errors: + +```bash +npm run build +``` + +## CLI Release Process + +### 1. Update the Version + +Edit `package.json` to bump the version number. Update both the `version` field at the top and the `package-lock.json` lockfile to match (npm handles this automatically): + +```bash +npm version +``` + +For example, `npm version 0.9.8` updates both files and creates a commit. + +Alternatively, edit `package.json` by hand and run `npm install` to regenerate the lockfile with the new version. + +### 2. Update the Changelog + +Edit `CHANGELOG.md`. Move all changes from the "Unreleased" section into a new section with the version number and today's date: + +```markdown +## Unreleased + +### ... + +## 0.9.8 - 2026-05-10 + +### Added +- Feature X + +### Fixed +- Bug Y +``` + +Commit these changes: + +```bash +git add CHANGELOG.md package.json package-lock.json +git commit -m "chore: bump to 0.9.8" +``` + +### 3. Publish to npm + +There is no GitHub Actions workflow for the CLI; the maintainer runs `npm publish` from a clean working tree: + +```bash +npm publish +``` + +The `prepublishOnly` script in `package.json` runs `npm run build` first, which bundles the litellm pricing snapshot and then runs `tsup` to emit `dist/cli.js`. + +If publishing for the first time on a new machine, run `npm login` first. + +### 4. Tag the Release + +After npm accepts the publish, tag the commit and push: + +```bash +git tag v0.9.8 +git push origin v0.9.8 +``` + +The tag is for human reference and to anchor the GitHub Release. No workflow runs on `v*` tags for the CLI today. + +### 5. Verify npm Publication + +```bash +npm view codeburn version +``` + +### 6. Create a GitHub Release + +Use the GitHub CLI to create a release with notes from the changelog: + +```bash +gh release create v0.9.8 --title v0.9.8 --notes "$(sed -n '/^## 0.9.8/,/^## /p' CHANGELOG.md | head -n -1)" +``` + +Or use the web interface to draft a release and copy the changelog section into the body. + +## macOS Menubar Release Process + +The macOS menubar is released separately with its own GitHub Release, but shares the same version number as the CLI. + +### 1. Same Version Bump + +Follow the same version bumping process as the CLI. Both `package.json` and `CHANGELOG.md` reflect the shared version. + +### 2. Tag the macOS Release + +After the CLI tag is published, create a separate tag for the menubar: + +```bash +git tag mac-v0.9.8 +git push origin mac-v0.9.8 +``` + +### 3. GitHub Actions Builds the Bundle + +The `.github/workflows/release-menubar.yml` workflow automatically detects the `mac-v*` tag and: + +1. Checks out the repo +2. Runs `mac/Scripts/package-app.sh v0.9.8` +3. Signs the app bundle (ad-hoc signing) +4. Creates a zip file: `CodeBurnMenubar-v0.9.8.zip` +5. Computes a SHA-256 checksum: `CodeBurnMenubar-v0.9.8.zip.sha256` +6. Uploads both to a GitHub Release named "Menubar v0.9.8" + +The script output on the build machine shows: + +``` +✓ Built /path/mac/.build/dist/CodeBurnMenubar-v0.9.8.zip +✓ Checksum /path/mac/.build/dist/CodeBurnMenubar-v0.9.8.zip.sha256 + CodeBurnMenubar-v0.9.8.zip +``` + +No manual action is needed; the workflow handles everything. + +### 4. Verify the Release + +After the workflow completes, the GitHub Release page shows the zip and sha256 files. The installed CLI command `codeburn menubar --force` fetches the newest `mac-v*` menubar release that includes both assets, verifies the checksum and bundle identity, and installs it into `~/Applications`. + +## Homebrew Tap Update + +The Homebrew tap lives at `https://github.com/getagentseal/homebrew-codeburn`. A maintainer with access to that repository must manually update the formula. + +### 1. Fetch the npm Tarball + +When the CLI is published to npm, get its download URL and SHA-256 hash: + +```bash +npm view codeburn@0.9.8 dist.tarball +npm view codeburn@0.9.8 dist.shasum +``` + +This returns a URL like `https://registry.npmjs.org/codeburn/-/codeburn-0.9.8.tgz` and a SHA-256 hash. + +Alternatively, compute the hash yourself: + +```bash +curl -sL https://registry.npmjs.org/codeburn/-/codeburn-0.9.8.tgz | shasum -a 256 +``` + +### 2. Update the Formula + +Edit `Formula/codeburn.rb` in the homebrew-codeburn tap: + +```ruby +class Codeburn < Formula + desc "See where your AI coding tokens go" + homepage "https://github.com/getagentseal/codeburn" + url "https://registry.npmjs.org/codeburn/-/codeburn-0.9.8.tgz" + sha256 "" + license "MIT" + + depends_on "node" + + def install + system "npm", "install", *Language::Node.std_npm_install_args(libexec) + bin.install_symlink Dir[libexec/"bin/*"] + end + + test do + system "#{bin}/codeburn", "--version" + end +end +``` + +Update the `url` and `sha256` fields with the new version's values. + +### 3. Test Locally + +Before pushing, test the formula locally: + +```bash +brew install --build-from-source Formula/codeburn.rb +codeburn --version +``` + +### 4. Commit and Push + +Commit the formula change: + +```bash +git add Formula/codeburn.rb +git commit -m "codeburn: bump to 0.9.8" +git push origin main +``` + +Users can now install with: + +```bash +brew tap getagentseal/codeburn +brew install codeburn +``` + +Or upgrade an existing installation: + +```bash +brew upgrade codeburn +``` + +## Replacing Assets on an Existing Release + +If a release is published with broken assets (e.g., a menubar zip with a build error), re-run the build and upload the fixed assets without creating a new tag. + +Use `gh release upload` with the `--clobber` flag to overwrite existing files: + +```bash +# After re-running mac/Scripts/package-app.sh v0.9.8 to regenerate the zip and sha256 +gh release upload mac-v0.9.8 mac/.build/dist/CodeBurnMenubar-v0.9.8.zip --clobber +gh release upload mac-v0.9.8 mac/.build/dist/CodeBurnMenubar-v0.9.8.zip.sha256 --clobber +``` + +The GitHub Release page will now serve the fixed assets. The menubar installer selects the newest `mac-v*` release with `CodeBurnMenubar-v*.zip` plus its checksum, so users who run `codeburn menubar --force` after the replacement get the fixed version automatically. + +## Rollback + +If a released version has a critical bug, the fastest path is to fix the bug and cut a new patch release (e.g., 0.9.8 -> 0.9.9). Delete the broken tag locally and on GitHub if it has not yet been widely distributed: + +```bash +git tag -d v0.9.8 +git push origin --delete v0.9.8 +``` + +npm does not allow republishing to the same version. If you must unpublish from npm, use `npm unpublish codeburn@0.9.8 --force` (requires Owner role), but this is discouraged and all users who installed that version retain it. + +For the menubar, tag a new mac-v0.9.9 and let the workflow build and upload it. Users will see the update pill in the menubar settings and upgrade automatically (or manually via `codeburn menubar --force`). + +## Summary + +The CLI release is manual: bump the version, update `CHANGELOG.md`, commit, run `npm publish`, then tag and create a GitHub Release. The macOS menubar release is automated: pushing a `mac-v*` tag fires `.github/workflows/release-menubar.yml`, which builds, signs, zips, and publishes the bundle. The Homebrew formula at `getagentseal/homebrew-codeburn` is updated by hand after each CLI publish. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c94d7f9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Reporting a Vulnerability + +Please report security vulnerabilities via [GitHub's private vulnerability reporting](https://github.com/getagentseal/codeburn/security/advisories/new). + +Do not open a public issue for security vulnerabilities. + +## Scope + +Security reports are welcome for: + +- The CLI (`src/`) +- The menubar installer (`src/menubar-installer.ts`) +- The macOS menubar app (`mac/`) +- The desktop app (`desktop/`) +- CI/CD workflows (`.github/workflows/`) + +## Release Integrity + +Menubar release assets include a `.sha256` checksum file. The installer verifies the checksum before extracting and launching the downloaded bundle. diff --git a/assets/providers/antigravity.png b/assets/providers/antigravity.png new file mode 100644 index 0000000..9a0e29f Binary files /dev/null and b/assets/providers/antigravity.png differ diff --git a/assets/providers/claude.jpg b/assets/providers/claude.jpg new file mode 100644 index 0000000..93d3a98 Binary files /dev/null and b/assets/providers/claude.jpg differ diff --git a/assets/providers/cline.svg b/assets/providers/cline.svg new file mode 100644 index 0000000..d00094b --- /dev/null +++ b/assets/providers/cline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/providers/codex.png b/assets/providers/codex.png new file mode 100644 index 0000000..5c8532a Binary files /dev/null and b/assets/providers/codex.png differ diff --git a/assets/providers/copilot.jpg b/assets/providers/copilot.jpg new file mode 100644 index 0000000..3f28afe Binary files /dev/null and b/assets/providers/copilot.jpg differ diff --git a/assets/providers/crush.png b/assets/providers/crush.png new file mode 100644 index 0000000..138a7ab Binary files /dev/null and b/assets/providers/crush.png differ diff --git a/assets/providers/cursor-agent.jpg b/assets/providers/cursor-agent.jpg new file mode 100644 index 0000000..447a9ed Binary files /dev/null and b/assets/providers/cursor-agent.jpg differ diff --git a/assets/providers/cursor.jpg b/assets/providers/cursor.jpg new file mode 100644 index 0000000..447a9ed Binary files /dev/null and b/assets/providers/cursor.jpg differ diff --git a/assets/providers/droid.png b/assets/providers/droid.png new file mode 100644 index 0000000..0aab780 Binary files /dev/null and b/assets/providers/droid.png differ diff --git a/assets/providers/gemini.png b/assets/providers/gemini.png new file mode 100644 index 0000000..6c98e13 Binary files /dev/null and b/assets/providers/gemini.png differ diff --git a/assets/providers/goose.png b/assets/providers/goose.png new file mode 100644 index 0000000..757649e Binary files /dev/null and b/assets/providers/goose.png differ diff --git a/assets/providers/ibm-bob.svg b/assets/providers/ibm-bob.svg new file mode 100644 index 0000000..ab76047 --- /dev/null +++ b/assets/providers/ibm-bob.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/providers/kilo-code.png b/assets/providers/kilo-code.png new file mode 100644 index 0000000..2be6018 Binary files /dev/null and b/assets/providers/kilo-code.png differ diff --git a/assets/providers/kimi.svg b/assets/providers/kimi.svg new file mode 100644 index 0000000..c09b36f --- /dev/null +++ b/assets/providers/kimi.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/providers/kiro.png b/assets/providers/kiro.png new file mode 100644 index 0000000..f997c66 Binary files /dev/null and b/assets/providers/kiro.png differ diff --git a/assets/providers/mistral-vibe.svg b/assets/providers/mistral-vibe.svg new file mode 100644 index 0000000..f70841a --- /dev/null +++ b/assets/providers/mistral-vibe.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/providers/omp.svg b/assets/providers/omp.svg new file mode 100644 index 0000000..f1ccf2a --- /dev/null +++ b/assets/providers/omp.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/assets/providers/openclaw.jpg b/assets/providers/openclaw.jpg new file mode 100644 index 0000000..0394c6f Binary files /dev/null and b/assets/providers/openclaw.jpg differ diff --git a/assets/providers/opencode.png b/assets/providers/opencode.png new file mode 100644 index 0000000..605c441 Binary files /dev/null and b/assets/providers/opencode.png differ diff --git a/assets/providers/pi.png b/assets/providers/pi.png new file mode 100644 index 0000000..17bc550 Binary files /dev/null and b/assets/providers/pi.png differ diff --git a/assets/providers/qwen.png b/assets/providers/qwen.png new file mode 100644 index 0000000..a834c0f Binary files /dev/null and b/assets/providers/qwen.png differ diff --git a/assets/providers/roo-code.png b/assets/providers/roo-code.png new file mode 100644 index 0000000..776a103 Binary files /dev/null and b/assets/providers/roo-code.png differ diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..5f31c7c --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,189 @@ +# CodeBurn Architecture + +A map of the codebase. Read this once before opening a non-trivial PR. + +## Three Surfaces + +CodeBurn is one Node.js CLI plus two GUI clients that shell out to it. + +``` ++----------------------+ +-----------------+ +| mac/ (Swift) | ---> | | ++----------------------+ | src/cli.ts | +| gnome/ (JavaScript) | ---> | (the CLI) | ++----------------------+ | | + | status | + | --format | + | menubar-json | + +-----------------+ + | + v + +----------------------------+ + | session files on disk | + | (JSONL, SQLite, protobuf) | + +----------------------------+ +``` + +The macOS menubar (`mac/`) and the GNOME extension (`gnome/`) both invoke `codeburn status --format menubar-json --period

` and parse the JSON. They do not share code with the CLI; they only depend on its output contract. + +## CLI (`src/`) + +`src/cli.ts` is the Commander.js entry point. The bin field in `package.json` points at `dist/cli.js`. Twelve commands are registered: + +| Command | Line | Purpose | +|---|---|---| +| `report` | 274 | Default. Interactive Ink TUI dashboard. | +| `status` | 358 | Compact text status, plus `--format menubar-json` for clients. | +| `today` | 524 | Today-only view of `report`. | +| `month` | 542 | Month-only view of `report`. | +| `export` | 560 | CSV or JSON dump of usage data. | +| `menubar` | 621 | Downloads and launches the macOS menubar bundle. | +| `currency` | 636 | Sets display currency. | +| `model-alias` | 687 | Maps an unknown model name to a known one for pricing. | +| `plan` | 737 | Configures a subscription plan for overage tracking. | +| `optimize` | 857 | Runs all 14 waste detectors. | +| `compare` | 870 | Compares two models side by side. | +| `yield` | 882 | Tracks which sessions shipped to main vs. were reverted (experimental). | + +### Pipeline + +``` +provider.discoverSessions() + | + v +provider.createSessionParser(source, seenKeys) + | + v yields ParsedProviderCall (see src/providers/types.ts) + | + v +src/parser.ts: parseAllSessions() + | + v aggregates into ProjectSummary[] + | + v +src/daily-cache.ts: aggregate per day, persist + | + v +output formatter (Ink TUI, JSON, or menubar-json) +``` + +`src/parser.ts` is the central aggregator. Public exports: `parseAllSessions`, `filterProjectsByName`, `extractMcpInventory`. It owns the dedup `Set` (`seenKeys`) that is passed into every provider parser so a turn that surfaces in two providers (Claude logs vs. Cursor mirror, for instance) is counted once. + +### Cache Layers + +Three caches under `~/.cache/codeburn/` (override with `CODEBURN_CACHE_DIR`): + +| File | Owner | Invalidation | +|---|---|---| +| `codex-results.json` | `src/codex-cache.ts` | `mtimeMs + sizeBytes` per Codex `.jsonl`. | +| `cursor-results.json` | `src/cursor-cache.ts` | `mtimeMs + sizeBytes` of the Cursor SQLite db. | +| `daily-cache.json` | `src/daily-cache.ts` | Tracks `lastComputedDate`; new days are backfilled, old days are reused. | + +All three use atomic write (temp file + `rename`) and write with mode `0o600`. All three carry a numeric `version` field; bumping it forces a recompute next run. + +### Optimize Detectors + +`src/optimize.ts` exports 14 detectors. Each returns a `WasteFinding | null`. They are composed by `runOptimize()` which collects findings, ranks them by impact, and returns them with `WasteAction` objects (paste-to-CLAUDE.md, paste-to-session-opener, prompt-now, edit shell config). + +| Detector | Line | What it catches | +|---|---|---| +| `detectJunkReads` | 428 | Reads into `node_modules`, `.git`, `dist`, etc. | +| `detectDuplicateReads` | 477 | Re-reads of the same file in a session. | +| `detectMcpToolCoverage` | 795 | MCP servers with many tools but low usage. | +| `detectUnusedMcp` | 855 | MCP servers configured but never invoked. | +| `detectBloatedClaudeMd` | 944 | `CLAUDE.md` files past a healthy size. | +| `detectLowReadEditRatio` | 987 | Edit-heavy sessions with too few prior reads. | +| `detectCacheBloat` | 1048 | High `cache_creation_input_tokens`. | +| `detectGhostAgents` | 1124 | Defined but never-invoked Claude agents. | +| `detectGhostSkills` | 1154 | Defined but never-invoked skills. | +| `detectGhostCommands` | 1184 | Defined but never-invoked slash commands. | +| `detectBashBloat` | 1228 | Shell output limit set above the recommended 15K chars. | +| `detectLowWorthSessions` | 1405 | Sessions with cost but no edits or git delivery. | +| `detectContextBloat` | 1512 | Input:output token ratio above 25:1. | +| `detectSessionOutliers` | 1558 | Sessions costing more than 2x the project average. | + +### Output Formats + +| Command | `--format` choices | Default | +|---|---|---| +| `report`, `today`, `month` | `tui`, `json` | `tui` | +| `status` | `terminal`, `menubar-json`, `json` | `terminal` | +| `export` | `csv`, `json` | `csv` | +| `plan` | `text`, `json` | `text` | + +The macOS menubar and GNOME extension consume `menubar-json`. `src/menubar-json.ts` defines the contract; `tests/menubar-json.test.ts` pins it. + +## Providers (`src/providers/`) + +Every provider implements the `Provider` interface in `src/providers/types.ts`: + +```ts +type Provider = { + name: string + displayName: string + modelDisplayName(model: string): string + toolDisplayName(rawTool: string): string + discoverSessions(): Promise + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser +} +``` + +`src/providers/index.ts` registers twenty-one providers across two tiers: + +- **Eager**: `claude`, `cline`, `codex`, `copilot`, `droid`, `gemini`, `ibm-bob`, `kilo-code`, `kiro`, `kimi`, `openclaw`, `pi`, `omp`, `qwen`, `roo-code`. Imported at module load. +- **Lazy**: `antigravity`, `goose`, `cursor`, `opencode`, `cursor-agent`, `crush`. Imported via dynamic `import()` so the heavy dependencies (SQLite, protobuf) do not touch users who do not have those tools installed. + +Both lists hit the same `getAllProviders()` aggregator. A failed lazy import is silent and excludes that provider from the run. + +`src/providers/vscode-cline-parser.ts` is a shared helper consumed by `cline`, `ibm-bob`, `kilo-code`, and `roo-code`. It is not registered as a provider on its own. + +For the per-provider data location, storage format, parser quirks, and test coverage, see `docs/providers/`. + +## macOS Menubar (`mac/`) + +Swift package (`mac/Package.swift`), targets macOS 14, strict concurrency on. Layout under `mac/Sources/CodeBurnMenubar/`: + +- `CodeBurnApp.swift` boots the SwiftUI `App` and the `NSStatusItem`. +- `AppStore.swift` is the single source of truth for UI state. +- `Data/` holds models, the CLI client, credential stores, and subscription services. + - `DataClient.swift` spawns the CLI and decodes `MenubarPayload`. See file-level comment for why we never route through `/bin/zsh -c`. + - `MenubarPayload.swift` mirrors the JSON the CLI emits; keep it in sync with `src/menubar-json.ts`. +- `Security/CodeburnCLI.swift` resolves the CLI binary (env override `CODEBURN_BIN`, fallback `codeburn`), validates each argv entry against an allowlist regex, and augments PATH for Homebrew and npm-global installs. The Process is launched via `/usr/bin/env`, never via a shell. +- `Theme/` holds color and typography constants and the dark/light state. +- `Views/` are the SwiftUI components rendered inside `NSPopover`. + +Tests live in `mac/Tests/CodeBurnMenubarTests/` (currently `CapacityEstimatorTests.swift`). + +The build artifact is a zipped `.app` bundle produced by `mac/Scripts/package-app.sh`. See `RELEASING.md` for how the GitHub Actions workflow uses it. + +## GNOME Extension (`gnome/`) + +Plain JavaScript, no bundler. Targets GNOME Shell 45-50 (`metadata.json`). + +- `extension.js` is the entry point. On `enable()` it constructs a `CodeBurnIndicator` and adds it to the panel. +- `indicator.js` is the popover. It owns the period selector, the insight tabs, and the provider filter. +- `dataClient.js` wraps `Gio.Subprocess` to call the CLI. It validates argv against the same allowlist pattern as the macOS client and augments PATH with `~/.local/bin`, `~/.npm-global/bin`, `~/.volta/bin`, `~/.bun/bin`, `~/.cargo/bin`, `~/.asdf/shims`, and a few others. Results are cached for 300 seconds. +- `prefs.js` is the settings dialog backed by `schemas/org.gnome.shell.extensions.codeburn.gschema.xml`. +- `install.sh` copies the extension into `~/.local/share/gnome-shell/extensions/`. + +## Build (`scripts/`, `tsup.config.ts`) + +`npm run build` is two steps: + +1. `node scripts/bundle-litellm.mjs` fetches the latest litellm pricing JSON and writes `src/data/litellm-snapshot.json`. The bundle script keeps a manual override for MiniMax variants. Direct (un-prefixed) entries win over prefixed ones. The result is checked in so the build is reproducible. +2. `tsup` reads `tsup.config.ts` and emits a single ESM bundle at `dist/cli.js` with a Node shebang banner. No source maps in publish builds; sourcemaps on for development. + +The `prepublishOnly` hook in `package.json` runs `npm run build` so `npm publish` always ships fresh code. + +## Tests + +`npm test` runs vitest. Forty-two test files live under `tests/`: + +- `tests/` root (27 files) covers CLI, parser, optimize, cache, format, models, plans. +- `tests/security/` (1 file) covers prototype-pollution guards. +- `tests/providers/` (15 files) covers per-provider parsing. +- `tests/fixtures/` holds redacted real-world session data. + +Five providers ship without dedicated test files today: `antigravity`, `claude`, `gemini`, `goose`, `qwen`. Closing this gap is a standing good-first-issue. + +CI runs Semgrep against `.semgrep/rules/no-bracket-assign-hot-paths.yml` over `src/providers/` and `src/parser.ts` (`.github/workflows/ci.yml`). It does not run vitest in CI today; tests run locally before publish. diff --git a/docs/providers/README.md b/docs/providers/README.md new file mode 100644 index 0000000..414289e --- /dev/null +++ b/docs/providers/README.md @@ -0,0 +1,59 @@ +# Provider Docs + +One file per provider integration. If you are fixing a bug or adding a feature scoped to a single provider, read the file for that provider first; it tells you which file to edit, where on disk the source data lives, and what edge cases the test suite already covers. + +For the architectural picture, see `../architecture.md`. + +## Provider Index + +### Eager (always loaded) + +| Provider | Storage | Source | Test | +|---|---|---|---| +| [Claude](claude.md) | JSONL (no parser) | `src/providers/claude.ts` | none (covered indirectly) | +| [Cline](cline.md) | JSON | `src/providers/cline.ts` | `tests/providers/cline.test.ts` | +| [Codex](codex.md) | JSONL | `src/providers/codex.ts` | `tests/providers/codex.test.ts` | +| [Copilot](copilot.md) | JSONL | `src/providers/copilot.ts` | `tests/providers/copilot.test.ts` | +| [Droid](droid.md) | JSONL | `src/providers/droid.ts` | `tests/providers/droid.test.ts` | +| [Gemini](gemini.md) | JSON / JSONL | `src/providers/gemini.ts` | none | +| [IBM Bob](ibm-bob.md) | JSON | `src/providers/ibm-bob.ts` | `tests/providers/ibm-bob.test.ts` | +| [KiloCode](kilo-code.md) | JSON | `src/providers/kilo-code.ts` | `tests/providers/kilo-code.test.ts` | +| [Kiro](kiro.md) | JSON | `src/providers/kiro.ts` | `tests/providers/kiro.test.ts` | +| [Kimi](kimi.md) | JSONL | `src/providers/kimi.ts` | `tests/providers/kimi.test.ts` | +| [Mistral Vibe](mistral-vibe.md) | JSON / JSONL | `src/providers/mistral-vibe.ts` | `tests/providers/mistral-vibe.test.ts` | +| [OpenClaw](openclaw.md) | JSONL | `src/providers/openclaw.ts` | `tests/providers/openclaw.test.ts` | +| [Pi](pi.md) | JSONL | `src/providers/pi.ts` | `tests/providers/pi.test.ts` | +| [OMP](omp.md) | JSONL | `src/providers/pi.ts` | `tests/providers/omp.test.ts` | +| [Qwen](qwen.md) | JSONL | `src/providers/qwen.ts` | none | +| [Roo Code](roo-code.md) | JSON | `src/providers/roo-code.ts` | `tests/providers/roo-code.test.ts` | + +### Lazy (loaded on first call) + +| Provider | Storage | Source | Test | +|---|---|---|---| +| [Antigravity](antigravity.md) | protobuf over RPC | `src/providers/antigravity.ts` | none | +| [Crush](crush.md) | SQLite (per-project) | `src/providers/crush.ts` | `tests/providers/crush.test.ts` | +| [Cursor](cursor.md) | SQLite | `src/providers/cursor.ts` | `tests/providers/cursor.test.ts` | +| [Cursor Agent](cursor-agent.md) | text / JSONL | `src/providers/cursor-agent.ts` | `tests/providers/cursor-agent.test.ts` | +| [Goose](goose.md) | SQLite | `src/providers/goose.ts` | none | +| [OpenCode](opencode.md) | SQLite | `src/providers/opencode.ts` | `tests/providers/opencode.test.ts` | + +### Shared + +| Helper | Used by | Source | +|---|---|---| +| [vscode-cline-parser](vscode-cline-parser.md) | `cline`, `ibm-bob`, `kilo-code`, `roo-code` | `src/providers/vscode-cline-parser.ts` | + +## File Format + +Each provider doc has the same structure: + +1. **One-line summary** of what the provider integrates. +2. **Where it reads from** on disk (or over RPC). +3. **Storage format** and validation rules. +4. **Caching** (which cache layer, if any). +5. **Deduplication key** so you understand cross-provider dedup. +6. **Quirks** that have bitten us before. +7. **When fixing a bug here** as a checklist. + +If you add a new provider, copy `claude.md` as a template and fill in your provider's specifics. Update this index, and prefer adding a real test fixture under `tests/providers/`. diff --git a/docs/providers/antigravity.md b/docs/providers/antigravity.md new file mode 100644 index 0000000..ca6d23f --- /dev/null +++ b/docs/providers/antigravity.md @@ -0,0 +1,52 @@ +# Antigravity + +Google Antigravity. The only provider that does not read files off disk: it speaks to a local language-server RPC endpoint instead. + +- **Source:** `src/providers/antigravity.ts` +- **Loading:** lazy via `src/providers/index.ts`. Lazy because the protobuf dependency is heavy. +- **Test:** focused helper coverage in `tests/providers/antigravity.test.ts`. + +## Where it reads from + +A local HTTPS RPC endpoint exposed by Antigravity's language server. The parser: + +1. Locates the running language-server process via `ps` on POSIX or + `Get-CimInstance Win32_Process` on Windows. +2. Reads its port and CSRF token from process metadata. +3. Calls `GetCascadeTrajectoryGeneratorMetadata` over HTTPS. +4. Validates the response (capped at 16 MB). + +Antigravity exposes slightly different process flags across platforms: +POSIX builds have used `--https_server_port` and `--csrf_token`; Windows +builds can expose `--extension_server_port` and +`--extension_server_csrf_token`. Both space-separated and `--flag=value` +forms are supported. + +If the language server is not running, the parser falls back to the cached results file. + +## Storage format + +Protobuf. Cascade and response objects map to `ParsedProviderCall` directly. + +## Caching + +Custom file cache at `$CODEBURN_CACHE_DIR/antigravity-results.json` (defaults to `~/.cache/codeburn/`). The cache is also used as the data source when the RPC endpoint is unavailable, not just as an optimization. Bumping the cache version forces a recompute. + +## Deduplication + +Per `:`. + +## Quirks + +- **Antigravity is the only provider that requires a live process.** A user who closes Antigravity loses the most-recent data until next launch (the cache covers older runs). +- The 16 MB cap on RPC responses is necessary because individual cascades can balloon. Raising it risks OOM on the user's machine. +- Token types are split across `inputTokens`, `responseOutputTokens`, and `thinkingOutputTokens`. Thinking is billed at output rate. + +## When fixing a bug here + +1. Reproducing the full provider path requires Antigravity running locally. + The unit tests cover process flag parsing and wrapped/unwrapped RPC response + extraction, but they do not stand up a live Antigravity RPC endpoint. +2. Before any change, capture a sample protobuf response (anonymized) so future regressions can be tested against a recording. +3. If the bug is "no data after Antigravity update", the protobuf schema may have shifted. The parser's response handling is the place to look. +4. If the bug is "stale data", check whether the RPC is reachable; the cache fallback can mask connectivity issues. diff --git a/docs/providers/claude.md b/docs/providers/claude.md new file mode 100644 index 0000000..b5954c1 --- /dev/null +++ b/docs/providers/claude.md @@ -0,0 +1,54 @@ +# Claude + +Anthropic Claude Code CLI and Claude Desktop's local agent mode. + +- **Source:** `src/providers/claude.ts` +- **Loading:** eager (`src/providers/index.ts:1`) +- **Test:** none directly. Coverage comes from `tests/parser-claude-cwd.test.ts`, `tests/parser-filter.test.ts`, and `tests/parser-mcp-inventory.test.ts`, which exercise `src/parser.ts` end-to-end against fixture session files. + +## Where it reads from + +| Source | Path | +|---|---| +| Claude Code CLI | `$CLAUDE_CONFIG_DIR` if set, otherwise `~/.claude/projects/` | +| Claude Desktop (macOS) | `~/Library/Application Support/Claude/local-agent-mode-sessions/` | +| Claude Desktop (Windows) | `%APPDATA%/Claude/local-agent-mode-sessions/` | +| Claude Desktop (Linux) | `~/.config/Claude/local-agent-mode-sessions/` | + +For Desktop, `findDesktopProjectDirs` walks up to 8 levels deep looking for `projects/` subdirectories, skipping `node_modules` and `.git`. + +## Storage format + +JSONL, one event per line, per session file. Sessions live under `/.jsonl`. + +## Parser + +`createSessionParser` returns an empty async generator (`claude.ts:101-105`). Claude is a special case: `src/parser.ts` reads Claude JSONL files directly with full turn grouping, dedup of streaming message IDs, and MCP tool inventory extraction. The provider object exists only so `discoverSessions` can return Claude session sources alongside the others. + +## Pricing + +Claude Code reports total cache-write tokens in `usage.cache_creation_input_tokens`. +When available, it also splits those writes by duration in +`usage.cache_creation.ephemeral_5m_input_tokens` and +`usage.cache_creation.ephemeral_1h_input_tokens`. CodeBurn keeps the existing +aggregate cache-write token total for reports, but prices the 1-hour portion at +2x base input cost (1.6x the 5-minute cache-write rate exposed by LiteLLM). +If the split fields are missing, the parser falls back to the legacy behavior +and prices every cache write at the 5-minute rate. + +## Caching + +None at the provider level. The daily aggregation cache (`src/daily-cache.ts`) reuses prior computed days. + +## Quirks + +- The parser is in `src/parser.ts`, not in `src/providers/claude.ts`. Anything that changes Claude parsing belongs in `parser.ts`. +- Streaming responses produce duplicate message IDs across resumed sessions; `parser.ts` strips them via the global `seenMsgIds` Set. +- Model display names are mapped in `claude.ts:7-20`; add new versions there when Anthropic releases them. + +## When fixing a bug here + +1. Confirm whether the bug is in **discovery** (sessions not picked up) or **parsing** (sessions found but data wrong). +2. Discovery bugs live in `claude.ts:78-99`. Verify the directory layout you expect actually matches what Claude writes today. +3. Parsing bugs live in `src/parser.ts`. Look for `parseSessionFile`, `groupIntoTurns`, and `dedupeStreamingMessageIds`. +4. Add a fixture under `tests/fixtures/` and a test under `tests/parser-claude-cwd.test.ts` (or a new file). Do not mock the filesystem. diff --git a/docs/providers/cline.md b/docs/providers/cline.md new file mode 100644 index 0000000..65f27ea --- /dev/null +++ b/docs/providers/cline.md @@ -0,0 +1,50 @@ +# Cline + +Cline VS Code extension and Cline home-data task storage. + +- **Source:** `src/providers/cline.ts` +- **Loading:** eager (`src/providers/index.ts:2`) +- **Test:** `tests/providers/cline.test.ts` + +## Where it reads from + +Two task roots are scanned: + +1. VS Code extension globalStorage for `saoudrizwan.claude-dev`. +2. Cline's home-data root at `~/.cline/data`. + +Both roots are expected to contain a `tasks/` child directory. Discovery is delegated to `discoverClineTasks` in `src/providers/vscode-cline-parser.ts`, so a task is only included when it has a `ui_messages.json` file. + +## Storage format + +Per-task directories with: + +``` +tasks// + ui_messages.json + api_conversation_history.json + task_metadata.json +``` + +`ui_messages.json` provides the `api_req_started` usage entries. `api_conversation_history.json` is used for model extraction. See [`vscode-cline-parser`](vscode-cline-parser.md) for the full schema description. +`task_metadata.json` is part of Cline's task layout but is not read by CodeBurn today. + +## Caching + +None at the provider level; delegates to the shared helper and normal parser/cache layers. + +## Deduplication + +Discovery deduplicates by task id across the two Cline roots so a migrated task is not scanned twice. If the same task id exists in multiple roots, the one with the newest `ui_messages.json` wins. Parsing still uses the shared per-call key: `::`. + +## Quirks + +- This provider is intentionally a thin wrapper over the shared Cline-family parser. +- Cline can keep data in both VS Code globalStorage and `~/.cline/data`, depending on version and workflow. +- If Cline changes the JSON shape, fix `vscode-cline-parser.ts` only if Roo Code and KiloCode still pass. Branch provider-specific parsing rather than duplicating the whole parser. + +## When fixing a bug here + +1. Reproduce with a minimal task directory containing `ui_messages.json` and `api_conversation_history.json`. +2. Run `tests/providers/cline.test.ts`, plus `tests/providers/roo-code.test.ts` and `tests/providers/kilo-code.test.ts` if the shared parser changes. +3. Keep the provider name `cline`; downstream filters and dedup keys depend on it. diff --git a/docs/providers/codex.md b/docs/providers/codex.md new file mode 100644 index 0000000..268fd35 --- /dev/null +++ b/docs/providers/codex.md @@ -0,0 +1,50 @@ +# Codex + +OpenAI Codex CLI. + +- **Source:** `src/providers/codex.ts` +- **Loading:** eager (`src/providers/index.ts:2`) +- **Test:** `tests/providers/codex.test.ts` (374 lines) + +## Where it reads from + +`$CODEX_HOME` if set, otherwise `~/.codex`. Sessions are nested by date: + +``` +~/.codex/sessions///

/rollout-*.jsonl +``` + +The discovery walk uses strict regex (`^\d{4}$`, `^\d{2}$`) on each path component. + +## Storage format + +JSONL. The first line must be a `session_meta` entry with `payload.originator` starting with `codex` (case-insensitive). Files that fail this check are silently skipped. + +The first line read is capped at 1 MB (`FIRST_LINE_READ_CAP`). Codex CLI 0.128+ embeds the full system prompt in `session_meta`, which can run 20-27 KB; the cap leaves headroom while bounding memory if a corrupt file has no newline. + +## Caching + +`src/codex-cache.ts` writes `~/.cache/codeburn/codex-results.json` (or `$CODEBURN_CACHE_DIR/codex-results.json`). Each entry is keyed by absolute file path and validated against `mtimeMs + sizeBytes`. Cached entries are returned wholesale. + +A session that yielded zero parseable lines does **not** write to the cache (`codex.ts:419`); this prevents a transient read failure from pinning an empty result against a fingerprint. + +## Deduplication + +`codex:::` for accounted events, plus `codex:::est` for estimated events that fall back to char-counting. + +## Quirks + +- Codex CLI emits both `last_token_usage` (per turn) and `total_token_usage` (cumulative). The parser handles three modes: + 1. `last_token_usage` present: use it directly. + 2. Only cumulative: compute deltas against the prior turn. + 3. Neither: estimate from message text length (`CHARS_PER_TOKEN = 4`). +- `prevCumulativeTotal` is initialized to `null`, not `0`. A session whose first event reports `total = 0` would otherwise be dropped as a "duplicate" of the initial state. +- `prev*` token counters are advanced on **every** event, including ones that used `last_token_usage`. Earlier code only updated them on the fallback branch, which double-counted any session that mixed modes. +- OpenAI counts cached tokens **inside** `input_tokens`. The parser subtracts them so the rest of the codebase can assume Anthropic semantics (cached are separate). + +## When fixing a bug here + +1. Reproduce against a real `rollout-*.jsonl` if you can. Drop a redacted copy under `tests/fixtures/codex/` and reference it from `tests/providers/codex.test.ts`. +2. If the bug is "zero tokens reported", first check whether the file is being skipped by `isValidCodexSession`. +3. If the bug is "tokens counted twice", look at `prevCumulativeTotal` and the prev-counter advancement. +4. If you change the dedup key shape, run `tests/providers/codex.test.ts` and `tests/parser-filter.test.ts` together; cross-provider dedup happens via the global `seenKeys` Set. diff --git a/docs/providers/copilot.md b/docs/providers/copilot.md new file mode 100644 index 0000000..a02198e --- /dev/null +++ b/docs/providers/copilot.md @@ -0,0 +1,49 @@ +# Copilot + +GitHub Copilot Chat (CLI and VS Code extension transcripts). + +- **Source:** `src/providers/copilot.ts` +- **Loading:** eager (`src/providers/index.ts:3`) +- **Test:** `tests/providers/copilot.test.ts` (401 lines) + +## Where it reads from + +Two locations. Both are walked on every run; results merge. + +1. **Legacy CLI sessions:** `~/.copilot/session-state/` +2. **VS Code transcripts:** `~/Library/Application Support/Code/User/workspaceStorage//GitHub.copilot-chat/transcripts/` and equivalents on Windows / Linux + +## Storage format + +JSONL in both locations, but the schemas differ. The parser switches by detecting which schema the first event uses (`copilot.ts:83-159` for legacy, `copilot.ts:215-293` for transcripts). + +## Caching + +None at the provider level. + +## Deduplication + +Per `messageId` in both formats (`copilot.ts:118` for legacy, `copilot.ts:245` for transcripts). + +## Model inference + +Copilot does not always tag the model on each message. The parser infers it from the tool-call ID prefix: + +| Prefix | Inferred model family | +|---|---| +| `toolu_bdrk_`, `toolu_vrtx_`, `tooluse_`, `toolu_` | Anthropic | +| `call_` | OpenAI | + +See `copilot.ts:176-213`. + +## Quirks + +- `toolRequests` can be missing or non-array on older sessions; the parser guards against that (`copilot.ts:126`, `:260`). +- When `outputTokens` is missing the parser falls back to char-counting (`CHARS_PER_TOKEN = 4`, `copilot.ts:252-254`). +- A single chat may be mirrored across both legacy and transcript paths if the user upgraded; the dedup `messageId` collision handles this. + +## When fixing a bug here + +1. Determine which schema reproduces the bug. The two parsers share little code on purpose; do not unify them unless you understand both formats. +2. If the model is misidentified, look at the tool-call ID prefix list and consider whether a new prefix should be added. +3. New fixtures go under `tests/fixtures/copilot/` and are referenced from `tests/providers/copilot.test.ts`. diff --git a/docs/providers/crush.md b/docs/providers/crush.md new file mode 100644 index 0000000..b293002 --- /dev/null +++ b/docs/providers/crush.md @@ -0,0 +1,87 @@ +# Crush + +Charmbracelet's Crush TUI coding agent. + +- **Source:** `src/providers/crush.ts` +- **Loading:** lazy (`src/providers/index.ts`). Lazy because Crush ships per-project SQLite databases and we use `node:sqlite` to read them. +- **Test:** `tests/providers/crush.test.ts` (10 tests, fixture-based) + +## Where it reads from + +Crush keeps a global registry that lists every project it has touched, and a separate SQLite database **per project**. + +| File | Path | +|---|---| +| Registry (project list) | `$CRUSH_GLOBAL_DATA/projects.json`, otherwise `$XDG_DATA_HOME/crush/projects.json`, otherwise `~/.local/share/crush/projects.json` (Linux/macOS) or `%LOCALAPPDATA%/crush/projects.json` (Windows). | +| Per-project db | `//crush.db` where `data_dir` defaults to `.crush`. | + +The registry shape is an object keyed by project id (modern Crush) or an array (older builds and tokscale's sample fixtures). The parser accepts both. + +## Storage format + +SQLite. Schema verified against `charmbracelet/crush` v0.66.1 (`internal/db/migrations/20250424200609_initial.sql` plus subsequent additive migrations). + +Two tables matter for codeburn: + +```sql +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + parent_session_id TEXT, + title TEXT NOT NULL, + message_count INTEGER NOT NULL DEFAULT 0, + prompt_tokens INTEGER NOT NULL DEFAULT 0, + completion_tokens INTEGER NOT NULL DEFAULT 0, + cost REAL NOT NULL DEFAULT 0.0, + updated_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + ... +); + +CREATE TABLE messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + parts TEXT NOT NULL DEFAULT '[]', + model TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + ... +); +``` + +## Caching + +None at the provider level. + +## Deduplication + +Per `crush:` (`crush.ts`). + +## What we extract + +| codeburn field | Crush source | +|---|---| +| `inputTokens` | `sessions.prompt_tokens` | +| `outputTokens` | `sessions.completion_tokens` | +| `costUSD` | `sessions.cost` (already in dollars) | +| `model` | dominant value of `messages.model` for the session, picked by `GROUP BY model ORDER BY COUNT(*) DESC LIMIT 1`. Falls back to `unknown`. | +| `timestamp` | `sessions.updated_at` if set, otherwise `created_at` | + +Cache tokens, reasoning tokens, web-search counts, tools, and bash commands are all left as zero / empty. Crush does not record per-message token data, so per-turn attribution is not available. + +## Quirks worth knowing + +- **Timestamps are seconds, not milliseconds.** The Crush schema *comments* in the upstream migration claim millisecond timestamps, but every actual `INSERT`/`UPDATE` in `internal/db/sql/{sessions,messages}.sql` uses `strftime('%s', 'now')`, which returns Unix seconds. The parser multiplies by 1000 before constructing a `Date`. **Tokscale's parser (junhoyeo/tokscale#346) gets this wrong and is off by 1000x.** Confirmed against Crush v0.66.1. +- **Cost is stored in dollars as a `REAL`.** No conversion needed. +- **Child sessions are skipped.** Only rows with `parent_session_id IS NULL` are surfaced. Crush sub-agents inherit cost into the parent. +- **Zero-spend rows are filtered.** Discovery skips sessions with `cost = 0 AND prompt_tokens = 0 AND completion_tokens = 0`. +- **Optimize detectors that depend on tools (`detectJunkReads`, `detectDuplicateReads`, `detectLowReadEditRatio`) will not flag Crush sessions.** That is correct: Crush does not log per-tool calls in a way we can read today. +- **`detectLowWorthSessions` may flag Crush sessions** because it looks for cost without edits. That is a known false positive; if it becomes noisy, we can branch the detector on provider. + +## When fixing a bug here + +1. Confirm the issue against a real Crush install (`brew install charmbracelet/tap/crush`) before assuming the schema has changed. Migrations in the last six months have only added columns to `sessions`/`messages`, never removed any of the ones we read. +2. If the bug is "Crush sessions show timestamps from 1970-something", check whether someone "fixed" the seconds-vs-milliseconds handling by removing the `* 1000`. The schema comment is wrong; the data is in seconds. +3. If the bug is "Crush model column shows `unknown`", the session has no messages with a non-null `model`. Some early Crush builds did not record provider on every message; add `LIKE` matching against `provider` if you want a stronger fallback. +4. If the bug is "no sessions discovered", the registry path probably has not been verified for the user's setup. Print `getRegistryPath()` and have them confirm the file exists at that location. +5. New fixtures go under the inline schema in `tests/providers/crush.test.ts`; keep the `CREATE TABLE` literal and synchronized with the upstream migration. diff --git a/docs/providers/cursor-agent.md b/docs/providers/cursor-agent.md new file mode 100644 index 0000000..d77775b --- /dev/null +++ b/docs/providers/cursor-agent.md @@ -0,0 +1,41 @@ +# Cursor Agent + +Cursor's background agent transcripts (separate from the regular chat). + +- **Source:** `src/providers/cursor-agent.ts` +- **Loading:** lazy (`src/providers/index.ts:62-87`) +- **Test:** `tests/providers/cursor-agent.test.ts` (243 lines) + +## Where it reads from + +`~/.cursor/projects//agent-transcripts/`. Inside each project, two layouts coexist: + +1. **Legacy:** `*.txt` flat files. +2. **Composer 2:** UUID-named subdirectories, each containing JSONL. + +Subagents (delegated runs) live in `subagents/` subdirectories under the parent (`cursor-agent.ts:479-490`). They are picked up too. + +## Storage format + +- Legacy: free-form text transcripts. The parser does line-based heuristic parsing (`cursor-agent.ts:219-314`). +- Composer 2: JSONL (`cursor-agent.ts:167-217`). + +## Caching + +None at the provider level. Conversation metadata is read from the same Cursor SQLite db (`state.vscdb`), specifically the `conversation_summaries` table (`cursor-agent.ts:46-50`). If the summary is missing, file mtime is used as the timestamp. + +## Deduplication + +Per `::` (`cursor-agent.ts:379`). + +## Quirks + +- A file with a UUID-shaped name is treated as the conversation ID directly (`cursor-agent.ts:142-143`); other names are derived from the parent directory. +- Token counts are estimated from char count (`CHARS_PER_TOKEN = 4`, `cursor-agent.ts:35`, `:81-84`). The legacy text format never reports real tokens. +- The text parser is regex-driven and brittle. It is easier to fix a Composer 2 (JSONL) bug than a legacy (text) bug. + +## When fixing a bug here + +1. Check which format the failing transcript uses before opening a fix. +2. For text-format bugs, copy the redacted transcript verbatim into `tests/fixtures/cursor-agent/` so the regex change can be regression-tested. +3. If the bug is "wrong project", look at `cursor-agent.ts:46-50` and whether a `conversation_summaries` row exists for the conversation. diff --git a/docs/providers/cursor.md b/docs/providers/cursor.md new file mode 100644 index 0000000..8ccf6c4 --- /dev/null +++ b/docs/providers/cursor.md @@ -0,0 +1,50 @@ +# Cursor + +Cursor IDE chat history. + +- **Source:** `src/providers/cursor.ts` +- **Loading:** lazy (`src/providers/index.ts:44-57`). The `node:sqlite` import is the heavy dependency that justifies lazy loading. +- **Test:** `tests/providers/cursor.test.ts` (77 lines), `tests/providers/cursor-bubble-dedup.test.ts` (176 lines) + +## Where it reads from + +A single SQLite database per platform: + +| Platform | Path | +|---|---| +| macOS | `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` | +| Windows | `%APPDATA%/Cursor/User/globalStorage/state.vscdb` | +| Linux | `~/.config/Cursor/User/globalStorage/state.vscdb` | + +## Storage format + +SQLite. Two parallel sources within the same db: + +1. **Bubbles** (`cursor.ts:201-331`): per-message rows. The richer source. +2. **agentKv** (`cursor.ts:350-460`): per-conversation key-value blobs. The fallback for older sessions. + +The parser tries both and dedupes via `seenKeys`. + +## Caching + +`src/cursor-cache.ts` writes `~/.cache/codeburn/cursor-results.json` (override with `$CODEBURN_CACHE_DIR`). The fingerprint is `dbMtimeMs + dbSizeBytes` of `state.vscdb`. Atomic write via temp + rename. + +## Deduplication + +- Bubbles: per `bubbleId` (`cursor.ts:282`). +- agentKv: per `requestId` (`cursor.ts:429`). + +## Quirks + +- **180-day lookback.** The bubbles query bounds itself to the trailing 180 days (`cursor.ts:205`). Older history is ignored. If a user reports "Cursor data missing", confirm the date range first. +- **250 000 bubble cap.** Power users with massive history are capped to prevent unbounded memory. If you need to raise this, also raise the cache size budget. +- **Per-conversation user-message queue.** The parser caches the user-message stream per conversation to avoid an O(n) shift on every turn (`cursor.ts:171-191`). +- **agentKv has no per-message timestamp.** The DB file's mtime is used as the timestamp for every agentKv-derived call (`cursor.ts:358-363`). This is wrong but consistent. +- **Cursor v3 reports zero token counts.** The parser falls back to char-counting (`CHARS_PER_TOKEN = 4`) for those rows (`cursor.ts:265-272`). + +## When fixing a bug here + +1. **Always reproduce against a fixture, not a real db.** SQLite over the live db is racy; the user might be using Cursor while you read. +2. If the bug is "tokens are zero", check whether the row is a v3 zero-token bubble, in which case the char-fallback should kick in. +3. If the bug is "duplicate counts", check both `bubbleId` dedup and the cross-provider `seenKeys` dedup. +4. Cache poisoning is the most common failure mode after a Cursor schema change. Bump `CURSOR_CACHE_VERSION` in `src/cursor-cache.ts` so old caches are invalidated. diff --git a/docs/providers/droid.md b/docs/providers/droid.md new file mode 100644 index 0000000..b8288e5 --- /dev/null +++ b/docs/providers/droid.md @@ -0,0 +1,36 @@ +# Droid + +Factory's Droid CLI. + +- **Source:** `src/providers/droid.ts` +- **Loading:** eager (`src/providers/index.ts:4`) +- **Test:** `tests/providers/droid.test.ts` (148 lines) + +## Where it reads from + +`$FACTORY_DIR` if set, otherwise `~/.factory/sessions//*.jsonl`. + +The parser ignores the `.factory/` directory itself (`droid.ts:293-296`); some installs nest it accidentally. + +## Storage format + +JSONL. + +## Caching + +None. + +## Deduplication + +Per `messageId` within a session (`droid.ts:253`). + +## Quirks + +- **Token totals are session-level only.** Droid does not report per-message tokens. The parser reads `settings.tokenUsage` once per session and **splits it evenly** across all assistant calls, with the remainder added to the last call (`droid.ts:223-251`). This is approximate but consistent. +- Project name is derived from the session's `cwd`. If the cwd contains `projects/`, that name is preferred over the basename (`droid.ts:299-319`). + +## When fixing a bug here + +1. If the bug is "tokens unevenly attributed", that is by design. The session-level total is the only signal Droid emits. +2. If the bug is "no sessions found", confirm the user does not have `$FACTORY_DIR` pointing somewhere unexpected. +3. New fixtures go under `tests/fixtures/droid/`. diff --git a/docs/providers/gemini.md b/docs/providers/gemini.md new file mode 100644 index 0000000..b411d23 --- /dev/null +++ b/docs/providers/gemini.md @@ -0,0 +1,35 @@ +# Gemini + +Google Gemini CLI. + +- **Source:** `src/providers/gemini.ts` +- **Loading:** eager (`src/providers/index.ts:5`) +- **Test:** none. Adding a fixture-based test is a known good first issue. + +## Where it reads from + +`~/.gemini/tmp//chats/session-*.json` and `session-*.jsonl` (`gemini.ts:218-252`). + +## Storage format + +Either a single JSON document per session or JSONL, depending on Gemini CLI version. The parser sniffs the first non-whitespace character to decide (`gemini.ts:197-206`). + +## Caching + +None. + +## Deduplication + +Per `sessionId` (`gemini.ts:72`). Gemini sessions are aggregated to a single call per session. + +## Quirks + +- **Cached tokens are a subset of input.** Gemini reports cached tokens included inside `promptTokenCount`. The parser subtracts them so callers see Anthropic semantics (cached are separate). +- **Thoughts are billed at output rate** (`gemini.ts:125`). +- Each session collapses to one `ParsedProviderCall`. If you need per-turn data, the upstream format does not support it without re-parsing the prompt history. + +## When fixing a bug here + +1. The lack of a test file is a hazard. **Add a fixture and a test before changing parsing logic** so future regressions are caught. +2. If the bug involves a new Gemini version's schema, sniff with the same first-character heuristic; do not call `JSON.parse` on the whole file. +3. If the bug is "Gemini sessions report less than expected", check whether the cached-token subtraction is over-correcting. diff --git a/docs/providers/goose.md b/docs/providers/goose.md new file mode 100644 index 0000000..d203d55 --- /dev/null +++ b/docs/providers/goose.md @@ -0,0 +1,42 @@ +# Goose + +Block's Goose CLI. + +- **Source:** `src/providers/goose.ts` +- **Loading:** lazy (`src/providers/index.ts:29-42`) +- **Test:** none. Adding a fixture-based test is a known good first issue. + +## Where it reads from + +A SQLite database. Path resolution honors `XDG_DATA_HOME` and a `GOOSE_PATH_ROOT` override: + +| Platform | Default path | +|---|---| +| macOS / Linux | `~/.local/share/goose/sessions/sessions.db` | +| Windows | `%APPDATA%/Block/goose/sessions/sessions.db` | + +See `goose.ts:52-62`. + +## Storage format + +SQLite. + +## Caching + +None. + +## Deduplication + +Per `sessionId` (`goose.ts:174`). + +## Quirks + +- Source paths are encoded as `:` so a single db can yield many session sources. The discovery code splits on the last colon (`goose.ts:148-150`). +- Tool inventory comes from the `messages` table queried with `LIKE '%toolRequest%'` (`goose.ts:90`). This will miss tools whose payloads are encoded differently in a future Goose version. +- Tokens are read directly from `accumulated_input_tokens` and `accumulated_output_tokens`. No estimation. + +## When fixing a bug here + +1. Add a fixture-based test before changing logic. `tests/providers/goose.test.ts` does not exist yet; create it and use a small SQLite file under `tests/fixtures/goose/`. +2. If the bug is "no sessions", check `XDG_DATA_HOME` and `GOOSE_PATH_ROOT` first; users on non-default Linux setups will not match the default path. +3. The `LIKE '%toolRequest%'` query is fragile. If Goose changes the message envelope, this is where it will break. diff --git a/docs/providers/ibm-bob.md b/docs/providers/ibm-bob.md new file mode 100644 index 0000000..c9d4373 --- /dev/null +++ b/docs/providers/ibm-bob.md @@ -0,0 +1,55 @@ +# IBM Bob + +IBM Bob IDE task history. + +- **Source:** `src/providers/ibm-bob.ts` +- **Loading:** eager (`src/providers/index.ts`) +- **Test:** `tests/providers/ibm-bob.test.ts` + +## Where It Reads From + +IBM Bob stores IDE task history below `User/globalStorage/ibm.bob-code/tasks/` in the application data directory. + +Default paths checked: + +| Platform | Paths | +|---|---| +| macOS | `~/Library/Application Support/IBM Bob/User/globalStorage/ibm.bob-code/`, `~/Library/Application Support/Bob-IDE/User/globalStorage/ibm.bob-code/` | +| Windows | `%APPDATA%/IBM Bob/User/globalStorage/ibm.bob-code/`, `%APPDATA%/Bob-IDE/User/globalStorage/ibm.bob-code/` | +| Linux | `$XDG_CONFIG_HOME/IBM Bob/User/globalStorage/ibm.bob-code/`, `$XDG_CONFIG_HOME/Bob-IDE/User/globalStorage/ibm.bob-code/` with `~/.config` fallback | + +The `Bob-IDE` paths cover the preview-era app name that some installs used before the GA `IBM Bob` directory. + +## Storage Format + +Each task is a directory under `tasks//` and must contain `ui_messages.json`. + +CodeBurn parses the same Cline-family UI event format used by Roo Code and KiloCode: + +- `ui_messages.json` entries with `type: "say"` and `say: "api_req_started"` contain serialized token/cost metrics. +- `ui_messages.json` user text entries seed the turn's first user message. +- `api_conversation_history.json` is optional and is used to extract the selected model from `...` environment details when present. +- `task_metadata.json` may exist upstream, but CodeBurn does not need it for usage math today. + +If no model tag is present, the parser uses `ibm-bob-auto`, which is priced through the same conservative Sonnet fallback used for Cline-family auto modes. + +## Caching + +None at the provider level. + +## Deduplication + +Per `::` via `vscode-cline-parser.ts`. + +## Quirks + +- IBM Bob has shipped under both `IBM Bob` and `Bob-IDE` application data folder names. +- This provider intentionally covers the IDE task-history format. Bob Shell's `~/.bob` checkpoint data is a separate storage surface and is not parsed until we have a stable usage schema fixture. +- The shared Cline parser does not currently extract individual tool names from UI messages, so tool breakdowns are empty for IBM Bob just like Roo Code and KiloCode. + +## When Fixing A Bug Here + +1. Check whether the install uses `IBM Bob` or `Bob-IDE` as the application data directory. +2. Confirm the task folder still contains `ui_messages.json` and `api_conversation_history.json`. +3. If the UI message schema changed, add a focused fixture to `tests/providers/ibm-bob.test.ts`. +4. If the change also affects Roo Code or KiloCode, update `src/providers/vscode-cline-parser.ts` and run all three provider test files. diff --git a/docs/providers/kilo-code.md b/docs/providers/kilo-code.md new file mode 100644 index 0000000..51527ef --- /dev/null +++ b/docs/providers/kilo-code.md @@ -0,0 +1,34 @@ +# KiloCode + +KiloCode VS Code extension. + +- **Source:** `src/providers/kilo-code.ts` +- **Loading:** eager (`src/providers/index.ts:6`) +- **Test:** `tests/providers/kilo-code.test.ts` (62 lines) + +## Where it reads from + +VS Code extension globalStorage for `kilocode.kilo-code` (extension ID set at `kilo-code.ts:4`). The actual walk is delegated to `discoverClineTasks` in `src/providers/vscode-cline-parser.ts`. + +## Storage format + +Per-task directories with `ui_messages.json` and `api_conversation_history.json`. See [`vscode-cline-parser`](vscode-cline-parser.md) for the full schema description. + +## Caching + +None at the provider level; delegates to the shared helper. + +## Deduplication + +Delegated. Per `::` (handled in `vscode-cline-parser.ts:109`). + +## Quirks + +- This file is a thin wrapper. Almost every bug for KiloCode actually lives in `vscode-cline-parser.ts`. +- The VS Code extension wrappers using the Cline-family parser differ **only** by extension ID. + +## When fixing a bug here + +1. If the bug is "Cline, KiloCode, and Roo Code all broken in the same way", fix it in `vscode-cline-parser.ts`. +2. If the bug is "KiloCode broken, Roo Code fine", the difference is upstream (KiloCode's emitted JSON differs slightly). Reproduce with a fixture and consider whether the cline parser needs to branch on extension ID. +3. Read [`vscode-cline-parser.md`](vscode-cline-parser.md) before editing. diff --git a/docs/providers/kimi.md b/docs/providers/kimi.md new file mode 100644 index 0000000..19d6876 --- /dev/null +++ b/docs/providers/kimi.md @@ -0,0 +1,62 @@ +# Kimi + +Kimi Code CLI session parser. + +- **Source:** `src/providers/kimi.ts` +- **Loading:** eager (`src/providers/index.ts`) +- **Test:** `tests/providers/kimi.test.ts` + +## Where it reads from + +`$KIMI_SHARE_DIR/sessions/` if set, otherwise `~/.kimi/sessions/`. + +Kimi stores sessions by work-directory hash: + +```text +~/.kimi/ + kimi.json + config.toml + sessions/ + / + / + context.jsonl + wire.jsonl + state.json + subagents/ + / + context.jsonl + wire.jsonl +``` + +`kimi.json` maps each work-directory hash back to the original working path. CodeBurn uses that to display the project basename; if the metadata file is missing, the hash directory name is used. + +## Storage Format + +CodeBurn reads `wire.jsonl`. Each data line is a persisted wire record: + +```json +{"timestamp":1776162403,"message":{"type":"StatusUpdate","payload":{"message_id":"msg-1","token_usage":{"input_other":100,"input_cache_read":25,"input_cache_creation":10,"output":40}}}} +``` + +`TurnBegin` / `SteerInput` provide the user prompt, `ToolCall` / `ToolCallRequest` provide tool names and shell commands, and `StatusUpdate.token_usage` provides the billable token counts. + +## Caching + +None. + +## Deduplication + +Per `kimi::`, falling back to the status-update line index if the message id is absent. + +## Quirks + +- Kimi's official `TokenUsage` separates `input_other`, `input_cache_read`, `input_cache_creation`, and `output`. CodeBurn maps those directly into input, cache read, cache write, and output. +- The current Kimi wire schema does not persist the model on every usage update. CodeBurn uses `KIMI_MODEL_NAME` when set, then the active `~/.kimi/config.toml` default model, then `kimi-auto`. +- `kimi-auto`, `kimi-code`, and `kimi-for-coding` are priced as `kimi-k2-thinking` so managed Kimi Code sessions do not show as `$0` when the exact backend model is hidden. +- Subagent sessions are discovered from `subagents//wire.jsonl` and parsed as separate Kimi sessions under the same project. + +## When Fixing A Bug Here + +1. Reproduce with a tiny `wire.jsonl` fixture in `tests/providers/kimi.test.ts`. +2. If token totals look wrong, inspect `StatusUpdate.token_usage` first; `context.jsonl` only stores context checkpoints and cumulative counts, not per-step billing detail. +3. If tools are missing, check whether Kimi emitted `ToolCall`, `ToolCallRequest`, or nested `SubagentEvent`; CodeBurn intentionally counts subagent wire files separately to avoid double-counting parent mirrors. diff --git a/docs/providers/kiro.md b/docs/providers/kiro.md new file mode 100644 index 0000000..0c450fb --- /dev/null +++ b/docs/providers/kiro.md @@ -0,0 +1,44 @@ +# Kiro + +Kiro IDE chat history. + +- **Source:** `src/providers/kiro.ts` +- **Loading:** eager (`src/providers/index.ts:7`) +- **Test:** `tests/providers/kiro.test.ts` (328 lines) + +## Where it reads from + +VS Code-style globalStorage at `kiro.kiroagent`: + +| Platform | Path | +|---|---| +| macOS | `~/Library/Application Support/Kiro/User/globalStorage/kiro.kiroagent` | +| Windows | `%APPDATA%/Kiro/User/globalStorage/kiro.kiroagent` | +| Linux | `~/.config/Kiro/User/globalStorage/kiro.kiroagent` | + +Sessions are `.chat` files under hash-named subdirectories. Discovery is in `kiro.ts:215-247`; the path-resolution helpers it uses start at `kiro.ts:164`. + +## Storage format + +JSON `.chat` files (`kiro.ts:153`). + +## Caching + +None. + +## Deduplication + +Per `executionId` (`kiro.ts:104`). + +## Quirks + +- **Workspace hash resolution** is non-trivial. The parser tries `workspace.json` first; if that fails, it base64-decodes the directory name to recover the workspace path (`kiro.ts:198-213`). +- **Model ID normalization.** Kiro stores models like `claude-1.2`; the parser rewrites the dot to a hyphen so they match `claude-1-2` in the pricing snapshot (`kiro.ts:65-67`). Add new versions here when Kiro ships them. +- **Tool name extraction is regex-driven.** Kiro embeds tool calls inside the message text as `...` (`kiro.ts:69-78`). Brittle but unavoidable until Kiro emits structured tool data. +- Token counts are estimated via char count (`CHARS_PER_TOKEN = 4`, `kiro.ts:9`, `:108-109`). + +## When fixing a bug here + +1. If the bug is "wrong workspace", check the base64 fallback path. Some users name their workspaces with characters that are not valid base64. +2. If the bug is "missing model in pricing", add the model to the normalization map at `kiro.ts:65-67` and verify against `tests/providers/kiro.test.ts`. +3. If the bug is "tools missing", look at the regex at `kiro.ts:69-78`. Kiro changes its envelope occasionally. diff --git a/docs/providers/mistral-vibe.md b/docs/providers/mistral-vibe.md new file mode 100644 index 0000000..c7005f7 --- /dev/null +++ b/docs/providers/mistral-vibe.md @@ -0,0 +1,41 @@ +# Mistral Vibe + +Mistral Vibe CLI. + +- **Source:** `src/providers/mistral-vibe.ts` +- **Loading:** eager (`src/providers/index.ts`) +- **Test:** `tests/providers/mistral-vibe.test.ts` + +## Where it reads from + +`$VIBE_HOME/logs/session/` when `VIBE_HOME` is set, otherwise `~/.vibe/logs/session/`. + +## Storage format + +Vibe 2.x stores each session as a directory: + +- `meta.json` contains session metadata, cumulative token totals, active model config, model prices, timestamps, working directory, and available tools. +- `messages.jsonl` contains non-system messages and assistant `tool_calls`. + +Subagent traces are stored under a parent session's `agents/` folder with the same `meta.json` / `messages.jsonl` shape, so CodeBurn scans those one level down as separate sessions. + +## Caching + +None. + +## Deduplication + +Per `mistral-vibe:`. + +## Quirks + +- **Usage is cumulative per session.** Vibe does not write per-assistant-message token usage into `messages.jsonl`; token counts come from `meta.json.stats.session_prompt_tokens` and `session_completion_tokens`. CodeBurn emits one usage record per Vibe session. +- **Cost prefers Vibe's own model prices.** `meta.json.stats.input_price_per_million` and `output_price_per_million` are used first, with the active model config as a fallback. LiteLLM pricing is only used when Vibe provides no price data. +- **Project names come from metadata.** Discovery uses `meta.json.environment.working_directory` and falls back to the session directory name if that field is missing. +- **Tool calls come from messages.** Assistant `tool_calls[*].function.name` is normalized to the standard CodeBurn names (`bash` to `Bash`, `search_replace` to `Edit`, etc.). Bash commands are extracted from `function.arguments.command`. + +## When fixing a bug here + +1. Reproduce with a fixture that has both `meta.json` and `messages.jsonl`; both files are required for current Vibe sessions. +2. If the bug is "wrong total", check `meta.json.stats` first. `messages.jsonl` is only for prompts and tool calls. +3. If a future Vibe release adds per-turn usage, add tests before changing the one-record-per-session behavior so historical sessions continue to parse correctly. diff --git a/docs/providers/omp.md b/docs/providers/omp.md new file mode 100644 index 0000000..4546a2f --- /dev/null +++ b/docs/providers/omp.md @@ -0,0 +1,34 @@ +# OMP + +OMP CLI. Same parser as Pi, different data directory. + +- **Source:** `src/providers/pi.ts` (the `omp` export) +- **Loading:** eager (`src/providers/index.ts:9`) +- **Test:** `tests/providers/omp.test.ts` (225 lines) + +## Where it reads from + +`~/.omp/agent/sessions/` (`pi.ts:59-61`). + +## Storage format + +JSONL, identical schema to Pi. + +## Caching + +None. + +## Deduplication + +Identical to Pi: `::` with timestamp / line-index fallbacks (`pi.ts:164`). + +## Quirks + +- OMP and Pi share the **same** `createParser` function. The provider object differs only in name, displayName, and the discovery directory. +- If OMP and Pi diverge in a future release, do **not** copy-paste the parser. Add a discriminator to `createParser` and branch. + +## When fixing a bug here + +1. Check if the bug also reproduces against Pi. If yes, fix both with one change; the parser is shared. +2. If the bug is OMP-specific, the right fix is usually to pass an option into `createParser` rather than to fork the file. +3. Read [`pi.md`](pi.md) for the parser-level details. diff --git a/docs/providers/openclaw.md b/docs/providers/openclaw.md new file mode 100644 index 0000000..255b736 --- /dev/null +++ b/docs/providers/openclaw.md @@ -0,0 +1,41 @@ +# OpenClaw + +OpenClaw, plus the older Clawdbot / Moltbot / Moldbot lineage. + +- **Source:** `src/providers/openclaw.ts` +- **Loading:** eager (`src/providers/index.ts:8`) +- **Test:** `tests/providers/openclaw.test.ts` (192 lines) + +## Where it reads from + +Four directories, all checked on every run (`openclaw.ts:62-70`): + +- `~/.openclaw/agents` +- `~/.clawdbot/agents` +- `~/.moltbot/agents` +- `~/.moldbot/agents` + +The legacy directories are kept for users who upgraded from older builds. + +## Storage format + +JSONL (`openclaw.ts:242`). Each agents directory has a `sessions.json` index file plus per-session `.jsonl` files. The parser reads the index when present and falls back to a directory scan if it is missing or stale (`openclaw.ts:220-247`). + +## Caching + +None. + +## Deduplication + +Per `:` (`openclaw.ts:169`). + +## Quirks + +- **Cost is preferred from the provider when reported.** OpenClaw emits `costUSD` in `message.usage`; the parser uses it directly when present (`openclaw.ts:174-177`) and only computes from tokens when it is missing. +- Tokens are reported across `input`, `output`, `cacheRead`, and `cacheWrite`. Anthropic semantics throughout, no normalization needed. + +## When fixing a bug here + +1. If the bug is "session not found", check the four legacy dirs. A user might have a stray `~/.moltbot/` that the parser is reading instead of the real `~/.openclaw/`. +2. If the bug is "wrong cost", confirm whether `costUSD` is present in the source data; the parser trusts it over its own calculation. +3. The `sessions.json` index can drift when the user crashes mid-session. Make sure the directory-scan fallback triggers in those cases. diff --git a/docs/providers/opencode.md b/docs/providers/opencode.md new file mode 100644 index 0000000..0148cc9 --- /dev/null +++ b/docs/providers/opencode.md @@ -0,0 +1,40 @@ +# OpenCode + +OpenCode (sst/opencode). + +- **Source:** `src/providers/opencode.ts` +- **Loading:** lazy (`src/providers/index.ts:59-75`) +- **Test:** `tests/providers/opencode.test.ts` (676 lines, the largest provider test) + +## Where it reads from + +Default `~/.local/share/opencode/` or `$XDG_DATA_HOME/opencode/`. The discovery walk picks up `opencode*.db` files (`opencode.ts:71-88`). + +## Storage format + +SQLite. + +## Caching + +None. + +## Deduplication + +Per `:`. + +## Quirks + +- **Schema validation is loud.** When a required table is missing, the parser logs an actionable warning telling the user which table is gone and what version of OpenCode it expects. This is the right behavior; do not silently swallow these. +- Source paths are encoded as `:`. +- Each message's `parts` are indexed; preserving the order matters for reasoning-token correctness. +- Tokens are reported across `input`, `output`, `reasoning`, `cache.read`, and `cache.write`. Anthropic semantics. +- External MCP tools are stored as `_` names (for example + `clickup_clickup_get_task`). The provider normalizes those to CodeBurn's + canonical `mcp____` names before aggregation so shared MCP + panels and `optimize` findings count OpenCode usage. + +## When fixing a bug here + +1. The 558-line test suite catches a lot. Run `npm test -- tests/providers/opencode.test.ts` before and after any change. +2. If the bug is "missing table" warning, do not catch and silence it. Either upgrade the version expectation in the parser or document the breaking schema change. +3. If the bug is "reasoning tokens off by one", check the parts index ordering. diff --git a/docs/providers/pi.md b/docs/providers/pi.md new file mode 100644 index 0000000..9427226 --- /dev/null +++ b/docs/providers/pi.md @@ -0,0 +1,35 @@ +# Pi + +Pi agent CLI. + +- **Source:** `src/providers/pi.ts` +- **Loading:** eager (`src/providers/index.ts:9`) +- **Test:** `tests/providers/pi.test.ts` (336 lines) + +## Where it reads from + +`~/.pi/agent/sessions/` (`pi.ts:55-57`). + +## Storage format + +JSONL (`pi.ts:98`). + +## Caching + +None. + +## Deduplication + +Per `::` when a response ID is present, falling back to the entry timestamp, and finally to a line index (`pi.ts:164`). + +## Quirks + +- Undefined token fields in `message.usage` are coerced to `0` (`pi.ts:156-159`); never `undefined`. +- The provider name is taken from `source.provider` (`pi.ts:182`), not hard-coded. This matters because `pi.ts` is the parser for **both** Pi and OMP; see [`omp.md`](omp.md). +- Tool-call content type is extracted from the message envelope (`pi.ts:169-176`). + +## When fixing a bug here + +1. If you change parsing logic, also run `tests/providers/omp.test.ts` because OMP shares this code. +2. If the bug is "tokens are NaN", look at the coercion at `pi.ts:156-159`. A regression on this is silent and easy to miss. +3. If the bug is specific to the dedup behavior, decide which of the three fallback keys was used by adding a temporary log; the keys collide differently for old vs. new Pi versions. diff --git a/docs/providers/qwen.md b/docs/providers/qwen.md new file mode 100644 index 0000000..1970328 --- /dev/null +++ b/docs/providers/qwen.md @@ -0,0 +1,36 @@ +# Qwen + +Qwen Code CLI. + +- **Source:** `src/providers/qwen.ts` +- **Loading:** eager (`src/providers/index.ts:10`) +- **Test:** none. Adding a fixture-based test is a known good first issue. + +## Where it reads from + +`$QWEN_DATA_DIR` if set, otherwise `~/.qwen/projects//chats/*.jsonl` (`qwen.ts:52-54`). + +## Storage format + +JSONL. + +## Caching + +None. + +## Deduplication + +Per `:` (`qwen.ts:110`). + +## Quirks + +- **Project name comes from the last path component** (`qwen.ts:56-59`), not from any in-file field. If a user puts the same project under two different paths, they will appear as two projects. +- **Thought parts are filtered out** before token accounting (`qwen.ts:97`). Qwen reports `thoughtsTokenCount` separately from `candidatesTokenCount`; this parser counts both as output but does not double-count thoughts in the main message. +- **Tool calls** are extracted from a fixed envelope shape (`qwen.ts:61-76`). If Qwen restructures its tool-call format in a future release, this is where it will break first. +- Tokens come from `usageMetadata`: `promptTokenCount`, `candidatesTokenCount`, `thoughtsTokenCount`, `cachedContentTokenCount`. + +## When fixing a bug here + +1. Add a fixture and a test before changing logic. The lack of `tests/providers/qwen.test.ts` makes regressions invisible. +2. If the bug is "tools missing", look at the function-call extraction loop at `qwen.ts:61-76`. +3. If the bug is "duplicate counts", confirm `:` actually uniquely identifies a turn in your reproducer; some Qwen builds repeat UUIDs across resumed sessions. diff --git a/docs/providers/roo-code.md b/docs/providers/roo-code.md new file mode 100644 index 0000000..e829064 --- /dev/null +++ b/docs/providers/roo-code.md @@ -0,0 +1,34 @@ +# Roo Code + +Roo Code VS Code extension. + +- **Source:** `src/providers/roo-code.ts` +- **Loading:** eager (`src/providers/index.ts:11`) +- **Test:** `tests/providers/roo-code.test.ts` (247 lines) + +## Where it reads from + +VS Code extension globalStorage for `rooveterinaryinc.roo-cline` (extension ID set at `roo-code.ts:4`). The actual walk is delegated to `discoverClineTasks` in `src/providers/vscode-cline-parser.ts`. + +## Storage format + +Per-task directories with `ui_messages.json` and `api_conversation_history.json`. See [`vscode-cline-parser`](vscode-cline-parser.md) for the schema. + +## Caching + +None at the provider level; delegates to the shared helper. + +## Deduplication + +Delegated. Per `::` (in `vscode-cline-parser.ts:109`). + +## Quirks + +- Thin wrapper. Almost every Roo Code bug actually lives in `vscode-cline-parser.ts`. +- The VS Code extension wrappers using the Cline-family parser differ **only** by extension ID. + +## When fixing a bug here + +1. If the bug also reproduces against Cline or KiloCode, fix it in `vscode-cline-parser.ts`. +2. If the bug is Roo Code-specific, the difference is upstream JSON shape. Reproduce with a fixture and consider whether the cline parser needs to branch on extension ID. +3. Read [`vscode-cline-parser.md`](vscode-cline-parser.md) before editing. diff --git a/docs/providers/vscode-cline-parser.md b/docs/providers/vscode-cline-parser.md new file mode 100644 index 0000000..3535e63 --- /dev/null +++ b/docs/providers/vscode-cline-parser.md @@ -0,0 +1,50 @@ +# vscode-cline-parser (Shared Helper) + +Shared discovery and parsing for Cline and VS Code extensions descended from Cline. + +- **Source:** `src/providers/vscode-cline-parser.ts` +- **Loading:** not a provider; imported by `cline.ts`, `ibm-bob.ts`, `kilo-code.ts`, and `roo-code.ts`. +- **Test:** none directly. Coverage comes from `tests/providers/cline.test.ts`, `tests/providers/ibm-bob.test.ts`, `tests/providers/kilo-code.test.ts`, and `tests/providers/roo-code.test.ts`. + +## What it does + +Two responsibilities: + +1. `discoverClineTasks(extensionId)` walks a base directory's `tasks/` child and returns one source per task that has a `ui_messages.json` file (`vscode-cline-parser.ts:25-50`). Without an override directory it uses VS Code's `globalStorage//` path. +2. `discoverClineTasksInBaseDirs(baseDirs)` does the same for non-VS Code apps with compatible task storage, such as IBM Bob. +3. `createClineParser` reads each task's `ui_messages.json` and `api_conversation_history.json`, extracts model, tools, and token counts, and yields `ParsedProviderCall` objects. + +## Storage layout + +Per task directory: + +``` +/tasks// + ui_messages.json # event stream + api_conversation_history.json # full prompt history with model tags +``` + +## Model resolution + +The model is extracted from `api_conversation_history.json` by searching user message content blocks for a `...` tag. Falls back to the provider-supplied auto model (`cline-auto` by default) if no tag is found. + +## Token extraction + +From `api_req_started` entries inside `ui_messages.json`. Each such entry's `text` field is JSON-parsed; the parsed object holds `tokensIn`, `tokensOut`, `cacheReads`, `cacheWrites`, and (optionally) `cost`. + +If `cost` is present, it is used directly. If not, `calculateCost` from `src/models.ts` computes it from tokens. + +## Deduplication + +Per `::` where `index` is the position of the `api_req_started` entry within `ui_messages.json`. + +## Quirks + +- Only the **first** user message is emitted as `userMessage` in the `ParsedProviderCall`. Subsequent user turns are accounted but not surfaced. +- The model regex looks inside content blocks, not at top-level fields. Some Cline-derivative extensions emit the model elsewhere; if you add support for one, branch on extension ID rather than rewriting the regex. + +## When fixing a bug here + +1. A change here ripples to Cline, IBM Bob, KiloCode, and Roo Code. Run all four provider test files before opening a PR. +2. If you find that one of the extensions emits a different shape, branch on the extension ID parameter that the discovery function already takes; do not duplicate the parser. +3. If you add support for another Cline-family task store, register it as a thin wrapper file in the same shape as `cline.ts`, `ibm-bob.ts`, `kilo-code.ts`, and `roo-code.ts`. diff --git a/docs/superpowers/plans/2026-04-19-model-comparison.md b/docs/superpowers/plans/2026-04-19-model-comparison.md deleted file mode 100644 index 43d4ce3..0000000 --- a/docs/superpowers/plans/2026-04-19-model-comparison.md +++ /dev/null @@ -1,1169 +0,0 @@ -# Model Comparison Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Let users pick any two AI models and see a fair, normalized side-by-side comparison of cost efficiency, edit reliability, and self-correction rates. - -**Architecture:** Pure data module (`compare-stats.ts`) handles aggregation and comparison logic. Ink TUI module (`compare.tsx`) handles model selection and results display. Accessible via `codeburn compare` standalone command and `c` shortcut in the dashboard. - -**Tech Stack:** TypeScript, React 19, Ink 7, vitest - ---- - -## File Structure - -``` -src/compare-stats.ts -- ModelStats type, aggregateModelStats(), computeComparison(), - self-correction JSONL scanner. Pure data, no UI. -src/compare.tsx -- ModelSelector, ComparisonResults, CompareView components. - Exported renderCompare() for standalone command. -tests/compare-stats.test.ts -- Unit tests for aggregation, comparison, edge cases. -src/cli.ts -- Add `compare` command (modify ~line 650). -src/dashboard.tsx -- Add 'compare' to View type, 'c' keybinding, CompareView - render branch, StatusBar hint (modify ~5 locations). -``` - ---- - -### Task 1: ModelStats type and aggregateModelStats() - -**Files:** -- Create: `src/compare-stats.ts` -- Test: `tests/compare-stats.test.ts` - -- [ ] **Step 1: Write the failing test for aggregateModelStats** - -Create `tests/compare-stats.test.ts`: - -```ts -import { describe, it, expect } from 'vitest' -import { aggregateModelStats, type ModelStats } from '../src/compare-stats.js' -import type { ProjectSummary, SessionSummary, ClassifiedTurn } from '../src/types.js' - -function makeTurn(model: string, cost: number, opts: { hasEdits?: boolean; retries?: number; outputTokens?: number; inputTokens?: number; cacheRead?: number; cacheWrite?: number; timestamp?: string } = {}): ClassifiedTurn { - return { - timestamp: opts.timestamp ?? '2026-04-15T10:00:00Z', - category: 'coding', - retries: opts.retries ?? 0, - hasEdits: opts.hasEdits ?? false, - userMessage: '', - assistantCalls: [{ - provider: 'claude', - model, - usage: { - inputTokens: opts.inputTokens ?? 100, - outputTokens: opts.outputTokens ?? 200, - cacheCreationInputTokens: opts.cacheWrite ?? 500, - cacheReadInputTokens: opts.cacheRead ?? 5000, - cachedInputTokens: 0, - reasoningTokens: 0, - webSearchRequests: 0, - }, - costUSD: cost, - tools: opts.hasEdits ? ['Edit'] : ['Read'], - mcpTools: [], - hasAgentSpawn: false, - hasPlanMode: false, - speed: 'standard' as const, - timestamp: opts.timestamp ?? '2026-04-15T10:00:00Z', - bashCommands: [], - deduplicationKey: `key-${Math.random()}`, - }], - } -} - -function makeProject(turns: ClassifiedTurn[]): ProjectSummary { - const session: SessionSummary = { - sessionId: 'test-session', - project: 'test-project', - firstTimestamp: turns[0]?.timestamp ?? '', - lastTimestamp: turns[turns.length - 1]?.timestamp ?? '', - totalCostUSD: turns.reduce((s, t) => s + t.assistantCalls.reduce((s2, c) => s2 + c.costUSD, 0), 0), - totalInputTokens: 0, - totalOutputTokens: 0, - totalCacheReadTokens: 0, - totalCacheWriteTokens: 0, - apiCalls: turns.reduce((s, t) => s + t.assistantCalls.length, 0), - turns, - modelBreakdown: {}, - toolBreakdown: {}, - mcpBreakdown: {}, - bashBreakdown: {}, - categoryBreakdown: {} as SessionSummary['categoryBreakdown'], - } - return { - project: 'test-project', - projectPath: '/test', - sessions: [session], - totalCostUSD: session.totalCostUSD, - totalApiCalls: session.apiCalls, - } -} - -describe('aggregateModelStats', () => { - it('aggregates calls, cost, and tokens per model', () => { - const project = makeProject([ - makeTurn('opus-4-6', 0.10, { outputTokens: 200, inputTokens: 50, cacheRead: 5000, cacheWrite: 500 }), - makeTurn('opus-4-6', 0.15, { outputTokens: 300, inputTokens: 80, cacheRead: 6000, cacheWrite: 600 }), - makeTurn('opus-4-7', 0.25, { outputTokens: 800, inputTokens: 100, cacheRead: 7000, cacheWrite: 700 }), - ]) - const stats = aggregateModelStats([project]) - const m6 = stats.find(s => s.model === 'opus-4-6')! - const m7 = stats.find(s => s.model === 'opus-4-7')! - - expect(m6.calls).toBe(2) - expect(m6.cost).toBeCloseTo(0.25) - expect(m6.outputTokens).toBe(500) - expect(m7.calls).toBe(1) - expect(m7.cost).toBeCloseTo(0.25) - expect(m7.outputTokens).toBe(800) - }) - - it('attributes turn-level metrics to the primary model', () => { - const project = makeProject([ - makeTurn('opus-4-6', 0.10, { hasEdits: true, retries: 0 }), - makeTurn('opus-4-6', 0.10, { hasEdits: true, retries: 2 }), - makeTurn('opus-4-7', 0.20, { hasEdits: true, retries: 0 }), - makeTurn('opus-4-7', 0.20, { hasEdits: false }), - ]) - const stats = aggregateModelStats([project]) - const m6 = stats.find(s => s.model === 'opus-4-6')! - const m7 = stats.find(s => s.model === 'opus-4-7')! - - expect(m6.editTurns).toBe(2) - expect(m6.oneShotTurns).toBe(1) - expect(m6.retries).toBe(2) - expect(m7.editTurns).toBe(1) - expect(m7.oneShotTurns).toBe(1) - expect(m7.totalTurns).toBe(2) - }) - - it('tracks firstSeen and lastSeen timestamps', () => { - const project = makeProject([ - makeTurn('opus-4-6', 0.10, { timestamp: '2026-04-10T08:00:00Z' }), - makeTurn('opus-4-6', 0.10, { timestamp: '2026-04-15T20:00:00Z' }), - ]) - const stats = aggregateModelStats([project]) - const m = stats.find(s => s.model === 'opus-4-6')! - expect(m.firstSeen).toBe('2026-04-10T08:00:00Z') - expect(m.lastSeen).toBe('2026-04-15T20:00:00Z') - }) - - it('filters out model entries', () => { - const project = makeProject([ - makeTurn('', 0, {}), - makeTurn('opus-4-6', 0.10, {}), - ]) - const stats = aggregateModelStats([project]) - expect(stats.find(s => s.model === '')).toBeUndefined() - expect(stats).toHaveLength(1) - }) - - it('returns empty array for no projects', () => { - expect(aggregateModelStats([])).toEqual([]) - }) - - it('sorts by cost descending', () => { - const project = makeProject([ - makeTurn('cheap-model', 0.01), - makeTurn('expensive-model', 5.00), - ]) - const stats = aggregateModelStats([project]) - expect(stats[0].model).toBe('expensive-model') - expect(stats[1].model).toBe('cheap-model') - }) -}) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `npx vitest run tests/compare-stats.test.ts` -Expected: FAIL with "Cannot find module '../src/compare-stats.js'" - -- [ ] **Step 3: Write minimal implementation** - -Create `src/compare-stats.ts`: - -```ts -import type { ProjectSummary } from './types.js' - -export type ModelStats = { - model: string - calls: number - cost: number - outputTokens: number - inputTokens: number - cacheReadTokens: number - cacheWriteTokens: number - totalTurns: number - editTurns: number - oneShotTurns: number - retries: number - selfCorrections: number - firstSeen: string - lastSeen: string -} - -export function aggregateModelStats(projects: ProjectSummary[]): ModelStats[] { - const byModel = new Map() - - const ensure = (model: string): ModelStats => { - let s = byModel.get(model) - if (!s) { - s = { model, calls: 0, cost: 0, outputTokens: 0, inputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, totalTurns: 0, editTurns: 0, oneShotTurns: 0, retries: 0, selfCorrections: 0, firstSeen: '', lastSeen: '' } - byModel.set(model, s) - } - return s - } - - for (const project of projects) { - for (const session of project.sessions) { - for (const turn of session.turns) { - if (turn.assistantCalls.length === 0) continue - const primaryModel = turn.assistantCalls[0].model - if (primaryModel === '') continue - - const ms = ensure(primaryModel) - ms.totalTurns++ - if (turn.hasEdits) ms.editTurns++ - if (turn.hasEdits && turn.retries === 0) ms.oneShotTurns++ - ms.retries += turn.retries - - for (const call of turn.assistantCalls) { - if (call.model === '') continue - const cs = call.model === primaryModel ? ms : ensure(call.model) - cs.calls++ - cs.cost += call.costUSD - cs.outputTokens += call.usage.outputTokens - cs.inputTokens += call.usage.inputTokens - cs.cacheReadTokens += call.usage.cacheReadInputTokens - cs.cacheWriteTokens += call.usage.cacheCreationInputTokens - - if (!cs.firstSeen || call.timestamp < cs.firstSeen) cs.firstSeen = call.timestamp - if (!cs.lastSeen || call.timestamp > cs.lastSeen) cs.lastSeen = call.timestamp - } - } - } - } - - return [...byModel.values()].sort((a, b) => b.cost - a.cost) -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `npx vitest run tests/compare-stats.test.ts` -Expected: All 6 tests PASS - -- [ ] **Step 5: Commit** - -```bash -git add src/compare-stats.ts tests/compare-stats.test.ts -git commit --author="iamtoruk " -m "feat(compare): add ModelStats type and aggregateModelStats" -``` - ---- - -### Task 2: computeComparison() - -**Files:** -- Modify: `src/compare-stats.ts` -- Modify: `tests/compare-stats.test.ts` - -- [ ] **Step 1: Write the failing test for computeComparison** - -Add to `tests/compare-stats.test.ts`: - -```ts -import { computeComparison, type ComparisonRow } from '../src/compare-stats.js' - -function makeStats(model: string, overrides: Partial = {}): ModelStats { - return { - model, - calls: 1000, - cost: 100, - outputTokens: 200000, - inputTokens: 10000, - cacheReadTokens: 500000, - cacheWriteTokens: 50000, - totalTurns: 500, - editTurns: 100, - oneShotTurns: 80, - retries: 30, - selfCorrections: 5, - firstSeen: '2026-04-01T00:00:00Z', - lastSeen: '2026-04-15T00:00:00Z', - ...overrides, - } -} - -describe('computeComparison', () => { - it('computes normalized metrics and picks winners', () => { - const a = makeStats('opus-4-6', { calls: 1000, cost: 100, outputTokens: 200000 }) - const b = makeStats('opus-4-7', { calls: 500, cost: 100, outputTokens: 400000 }) - const rows = computeComparison(a, b) - - const costRow = rows.find(r => r.label === 'Cost / call')! - expect(costRow.valueA).toBeCloseTo(0.10) - expect(costRow.valueB).toBeCloseTo(0.20) - expect(costRow.winner).toBe('a') - - const outputRow = rows.find(r => r.label === 'Output tok / call')! - expect(outputRow.valueA).toBe(200) - expect(outputRow.valueB).toBe(800) - expect(outputRow.winner).toBe('a') - }) - - it('handles zero edit turns gracefully', () => { - const a = makeStats('opus-4-6', { editTurns: 0, oneShotTurns: 0, retries: 0 }) - const b = makeStats('opus-4-7', { editTurns: 50, oneShotTurns: 40, retries: 15 }) - const rows = computeComparison(a, b) - - const osRow = rows.find(r => r.label === 'One-shot rate')! - expect(osRow.valueA).toBeNull() - expect(osRow.valueB).not.toBeNull() - expect(osRow.winner).toBe('none') - }) - - it('returns tie when values are equal', () => { - const a = makeStats('opus-4-6') - const b = makeStats('opus-4-7') - const rows = computeComparison(a, b) - for (const row of rows) { - expect(row.winner).toBe('tie') - } - }) - - it('higher-is-better metrics pick the higher value', () => { - const a = makeStats('opus-4-6', { cacheReadTokens: 900000, inputTokens: 10000, cacheWriteTokens: 90000 }) - const b = makeStats('opus-4-7', { cacheReadTokens: 500000, inputTokens: 10000, cacheWriteTokens: 490000 }) - const rows = computeComparison(a, b) - const cacheRow = rows.find(r => r.label === 'Cache hit rate')! - expect(cacheRow.winner).toBe('a') - }) -}) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `npx vitest run tests/compare-stats.test.ts` -Expected: FAIL with "computeComparison is not a function" - -- [ ] **Step 3: Write minimal implementation** - -Add to `src/compare-stats.ts`: - -```ts -export type ComparisonRow = { - label: string - valueA: number | null - valueB: number | null - formatFn: 'cost' | 'number' | 'percent' | 'decimal' - winner: 'a' | 'b' | 'tie' | 'none' -} - -type MetricDef = { - label: string - extract: (s: ModelStats) => number | null - format: ComparisonRow['formatFn'] - higherIsBetter: boolean -} - -const METRICS: MetricDef[] = [ - { - label: 'Cost / call', - extract: s => s.calls > 0 ? s.cost / s.calls : null, - format: 'cost', - higherIsBetter: false, - }, - { - label: 'Output tok / call', - extract: s => s.calls > 0 ? Math.round(s.outputTokens / s.calls) : null, - format: 'number', - higherIsBetter: false, - }, - { - label: 'Cache hit rate', - extract: s => { - const total = s.inputTokens + s.cacheReadTokens + s.cacheWriteTokens - return total > 0 ? (s.cacheReadTokens / total) * 100 : null - }, - format: 'percent', - higherIsBetter: true, - }, - { - label: 'One-shot rate', - extract: s => s.editTurns > 0 ? (s.oneShotTurns / s.editTurns) * 100 : null, - format: 'percent', - higherIsBetter: true, - }, - { - label: 'Retry rate', - extract: s => s.editTurns > 0 ? s.retries / s.editTurns : null, - format: 'decimal', - higherIsBetter: false, - }, - { - label: 'Self-correction', - extract: s => s.totalTurns > 0 ? (s.selfCorrections / s.totalTurns) * 100 : null, - format: 'percent', - higherIsBetter: false, - }, -] - -function pickWinner(a: number | null, b: number | null, higherIsBetter: boolean): ComparisonRow['winner'] { - if (a === null || b === null) return 'none' - if (a === b) return 'tie' - if (higherIsBetter) return a > b ? 'a' : 'b' - return a < b ? 'a' : 'b' -} - -export function computeComparison(a: ModelStats, b: ModelStats): ComparisonRow[] { - return METRICS.map(m => { - const valueA = m.extract(a) - const valueB = m.extract(b) - return { - label: m.label, - valueA, - valueB, - formatFn: m.format, - winner: pickWinner(valueA, valueB, m.higherIsBetter), - } - }) -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `npx vitest run tests/compare-stats.test.ts` -Expected: All tests PASS - -- [ ] **Step 5: Commit** - -```bash -git add src/compare-stats.ts tests/compare-stats.test.ts -git commit --author="iamtoruk " -m "feat(compare): add computeComparison with normalized metrics" -``` - ---- - -### Task 3: Self-correction JSONL scanner - -**Files:** -- Modify: `src/compare-stats.ts` -- Modify: `tests/compare-stats.test.ts` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/compare-stats.test.ts`: - -```ts -import { scanSelfCorrections } from '../src/compare-stats.js' -import { writeFile, mkdir, rm } from 'fs/promises' -import { tmpdir } from 'os' -import { join } from 'path' -import { afterEach, beforeEach } from 'vitest' - -const TMP_DIR = join(tmpdir(), `codeburn-compare-test-${Date.now()}`) - -function jsonlLine(type: string, model: string, text: string, timestamp = '2026-04-15T10:00:00Z'): string { - if (type === 'assistant') { - return JSON.stringify({ - type: 'assistant', - timestamp, - message: { model, content: [{ type: 'text', text }], id: `msg-${Math.random()}`, usage: { input_tokens: 0, output_tokens: 0 } }, - }) - } - return JSON.stringify({ type: 'user', timestamp, message: { role: 'user', content: text } }) -} - -describe('scanSelfCorrections', () => { - beforeEach(async () => { - await mkdir(TMP_DIR, { recursive: true }) - }) - - afterEach(async () => { - await rm(TMP_DIR, { recursive: true, force: true }) - }) - - it('counts apology patterns per model', async () => { - const lines = [ - jsonlLine('user', '', 'fix this'), - jsonlLine('assistant', 'opus-4-6', 'Sure, let me fix that.'), - jsonlLine('assistant', 'opus-4-6', "I'm sorry, I made a mistake in the previous edit."), - jsonlLine('assistant', 'opus-4-7', 'My bad, that was incorrect.'), - jsonlLine('assistant', 'opus-4-7', 'Here is the correct version.'), - ] - await writeFile(join(TMP_DIR, 'session1.jsonl'), lines.join('\n'), 'utf-8') - - const counts = await scanSelfCorrections([TMP_DIR]) - expect(counts.get('opus-4-6')).toBe(1) - expect(counts.get('opus-4-7')).toBe(1) - }) - - it('does not count non-apology text', async () => { - const lines = [ - jsonlLine('assistant', 'opus-4-6', 'Everything looks good. The tests pass.'), - jsonlLine('assistant', 'opus-4-6', 'I have fixed the bug successfully.'), - ] - await writeFile(join(TMP_DIR, 'session1.jsonl'), lines.join('\n'), 'utf-8') - - const counts = await scanSelfCorrections([TMP_DIR]) - expect(counts.get('opus-4-6') ?? 0).toBe(0) - }) - - it('handles missing or empty directories', async () => { - const counts = await scanSelfCorrections(['/nonexistent/path']) - expect(counts.size).toBe(0) - }) - - it('scans subagent directories', async () => { - const subDir = join(TMP_DIR, 'abc123', 'subagents') - await mkdir(subDir, { recursive: true }) - const lines = [ - jsonlLine('assistant', 'opus-4-7', "I apologize for the confusion."), - ] - await writeFile(join(subDir, 'sub1.jsonl'), lines.join('\n'), 'utf-8') - - const counts = await scanSelfCorrections([TMP_DIR]) - expect(counts.get('opus-4-7')).toBe(1) - }) -}) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `npx vitest run tests/compare-stats.test.ts` -Expected: FAIL with "scanSelfCorrections is not exported" - -- [ ] **Step 3: Write the implementation** - -Add to `src/compare-stats.ts`: - -```ts -import { readdir, readFile } from 'fs/promises' -import { join } from 'path' - -const SELF_CORRECTION_PATTERNS = [ - /\bI('m| am) sorry\b/i, - /\bmy mistake\b/i, - /\bmy apolog/i, - /\bI made (a |an )?(error|mistake)\b/i, - /\bI was wrong\b/i, - /\bmy bad\b/i, - /\bI apologize\b/i, - /\bsorry about that\b/i, - /\bsorry for (the|that|this)\b/i, - /\bI should have\b/i, - /\bI shouldn't have\b/i, - /\bI incorrectly\b/i, - /\bI mistakenly\b/i, -] - -function hasSelfCorrection(text: string): boolean { - return SELF_CORRECTION_PATTERNS.some(p => p.test(text)) -} - -function extractAssistantText(entry: { message?: { content?: unknown } }): string { - const content = entry.message?.content - if (typeof content === 'string') return content - if (Array.isArray(content)) { - return content - .filter((b: { type?: string; text?: string }) => b.type === 'text' && typeof b.text === 'string') - .map((b: { text: string }) => b.text) - .join(' ') - } - return '' -} - -async function collectJsonlPaths(dirPath: string): Promise { - const paths: string[] = [] - const files = await readdir(dirPath).catch(() => []) - for (const f of files) { - if (f.endsWith('.jsonl')) { - paths.push(join(dirPath, f)) - } else { - const subagents = join(dirPath, f, 'subagents') - const subs = await readdir(subagents).catch(() => []) - for (const sf of subs) { - if (sf.endsWith('.jsonl')) paths.push(join(subagents, sf)) - } - } - } - return paths -} - -export async function scanSelfCorrections(sessionDirs: string[]): Promise> { - const counts = new Map() - - for (const dir of sessionDirs) { - const jsonlPaths = await collectJsonlPaths(dir) - for (const filePath of jsonlPaths) { - const content = await readFile(filePath, 'utf-8').catch(() => null) - if (!content) continue - for (const line of content.split('\n')) { - if (!line.trim()) continue - try { - const entry = JSON.parse(line) - if (entry.type !== 'assistant') continue - const model = entry.message?.model - if (!model || model === '') continue - const text = extractAssistantText(entry) - if (text && hasSelfCorrection(text)) { - counts.set(model, (counts.get(model) ?? 0) + 1) - } - } catch {} - } - } - } - - return counts -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `npx vitest run tests/compare-stats.test.ts` -Expected: All tests PASS - -- [ ] **Step 5: Commit** - -```bash -git add src/compare-stats.ts tests/compare-stats.test.ts -git commit --author="iamtoruk " -m "feat(compare): add self-correction JSONL scanner" -``` - ---- - -### Task 4: ModelSelector Ink component - -**Files:** -- Create: `src/compare.tsx` - -- [ ] **Step 1: Create the ModelSelector component** - -Create `src/compare.tsx`: - -```tsx -import React, { useState } from 'react' -import { Box, Text, useInput } from 'ink' - -import type { ModelStats, ComparisonRow } from './compare-stats.js' -import { formatCost } from './format.js' - -const ORANGE = '#FF8C42' -const GREEN = '#5BF5A0' -const DIM = '#555555' -const GOLD = '#FFD700' - -const LOW_DATA_THRESHOLD = 20 - -type ModelSelectorProps = { - models: ModelStats[] - onSelect: (a: ModelStats, b: ModelStats) => void - onBack: () => void -} - -export function ModelSelector({ models, onSelect, onBack }: ModelSelectorProps) { - const [cursor, setCursor] = useState(0) - const [selected, setSelected] = useState>(new Set()) - - useInput((input, key) => { - if (input === 'q') { process.exit(0) } - if (key.escape) { onBack(); return } - - if (key.upArrow) { - setCursor(c => (c - 1 + models.length) % models.length) - } else if (key.downArrow) { - setCursor(c => (c + 1) % models.length) - } else if (input === ' ') { - setSelected(prev => { - const next = new Set(prev) - const model = models[cursor].model - if (next.has(model)) { - next.delete(model) - } else if (next.size < 2) { - next.add(model) - } - return next - }) - } else if (key.return && selected.size === 2) { - const picks = models.filter(m => selected.has(m.model)) - onSelect(picks[0], picks[1]) - } - }) - - return ( - - Model Comparison - {''} - Select two models to compare: - {''} - {models.map((m, i) => { - const isCursor = i === cursor - const isSelected = selected.has(m.model) - const isLowData = m.calls < LOW_DATA_THRESHOLD - const prefix = isCursor ? '> ' : ' ' - const marker = isSelected ? ' [selected]' : '' - const lowLabel = isLowData ? ' low data' : '' - return ( - - - {prefix}{m.model.padEnd(28)} - - {String(m.calls.toLocaleString()).padStart(10)} calls - {formatCost(m.cost).padStart(10)} - {marker} - {lowLabel} - - ) - })} - {''} - - [space] select {selected.size === 2 ? [enter] compare : [enter] compare} [esc] back [q] quit - - - ) -} -``` - -- [ ] **Step 2: Verify it compiles** - -Run: `npx tsx --eval "import './src/compare.js'" 2>&1 | head -5` -Expected: No import errors (may warn about unused exports, that's fine) - -- [ ] **Step 3: Commit** - -```bash -git add src/compare.tsx -git commit --author="iamtoruk " -m "feat(compare): add ModelSelector component" -``` - ---- - -### Task 5: ComparisonResults Ink component - -**Files:** -- Modify: `src/compare.tsx` - -- [ ] **Step 1: Add the ComparisonResults component** - -Add to `src/compare.tsx`: - -```tsx -type ComparisonResultsProps = { - modelA: ModelStats - modelB: ModelStats - rows: ComparisonRow[] - onBack: () => void -} - -function formatValue(value: number | null, fmt: ComparisonRow['formatFn']): string { - if (value === null) return '-' - switch (fmt) { - case 'cost': return '$' + value.toFixed(4) - case 'number': return value.toLocaleString() - case 'percent': return value.toFixed(1) + '%' - case 'decimal': return value.toFixed(2) - } -} - -function shortName(model: string): string { - return model.replace(/^claude-/, '') -} - -function daysOfData(first: string, last: string): number { - if (!first || !last) return 0 - const ms = new Date(last).getTime() - new Date(first).getTime() - return Math.max(1, Math.ceil(ms / 86400000)) -} - -const LABEL_WIDTH = 20 -const VALUE_WIDTH = 14 -const WINNER_WIDTH = 12 - -export function ComparisonResults({ modelA, modelB, rows, onBack }: ComparisonResultsProps) { - const nameA = shortName(modelA.model) - const nameB = shortName(modelB.model) - - useInput((input, key) => { - if (input === 'q') process.exit(0) - if (key.escape) onBack() - }) - - return ( - - {modelA.model} vs {modelB.model} - {''} - - - {''.padEnd(LABEL_WIDTH)}{nameA.padStart(VALUE_WIDTH)}{nameB.padStart(VALUE_WIDTH)} - - - {rows.map(row => ( - - {' ' + row.label.padEnd(LABEL_WIDTH - 2)} - - {formatValue(row.valueA, row.formatFn).padStart(VALUE_WIDTH)} - - - {formatValue(row.valueB, row.formatFn).padStart(VALUE_WIDTH)} - - - {(row.winner === 'a' ? `${nameA} wins` : row.winner === 'b' ? `${nameB} wins` : row.winner === 'tie' ? 'tie' : '').padStart(WINNER_WIDTH)} - - - ))} - - {''} - {' ' + '\u2500'.repeat(LABEL_WIDTH + VALUE_WIDTH * 2 + WINNER_WIDTH - 4) + ' Context'} - - - {' ' + 'Calls'.padEnd(LABEL_WIDTH - 2)} - {modelA.calls.toLocaleString().padStart(VALUE_WIDTH)} - {modelB.calls.toLocaleString().padStart(VALUE_WIDTH)} - - - {' ' + 'Cost'.padEnd(LABEL_WIDTH - 2)} - {formatCost(modelA.cost).padStart(VALUE_WIDTH)} - {formatCost(modelB.cost).padStart(VALUE_WIDTH)} - - - {' ' + 'Days of data'.padEnd(LABEL_WIDTH - 2)} - {String(daysOfData(modelA.firstSeen, modelA.lastSeen)).padStart(VALUE_WIDTH)} - {String(daysOfData(modelB.firstSeen, modelB.lastSeen)).padStart(VALUE_WIDTH)} - - - {' ' + 'Edit turns'.padEnd(LABEL_WIDTH - 2)} - {modelA.editTurns.toLocaleString().padStart(VALUE_WIDTH)} - {modelB.editTurns.toLocaleString().padStart(VALUE_WIDTH)} - - - {(modelA.calls < LOW_DATA_THRESHOLD || modelB.calls < LOW_DATA_THRESHOLD) && ( - <> - {''} - Note: {modelA.calls < LOW_DATA_THRESHOLD ? nameA : nameB} has limited data ({Math.min(modelA.calls, modelB.calls)} calls). Results may not be representative. - - )} - - {''} - [esc] back [q] quit - - ) -} -``` - -- [ ] **Step 2: Verify it compiles** - -Run: `npx tsx --eval "import './src/compare.js'" 2>&1 | head -5` -Expected: No import errors - -- [ ] **Step 3: Commit** - -```bash -git add src/compare.tsx -git commit --author="iamtoruk " -m "feat(compare): add ComparisonResults component" -``` - ---- - -### Task 6: CompareView orchestrator and renderCompare() - -**Files:** -- Modify: `src/compare.tsx` - -- [ ] **Step 1: Add CompareView and renderCompare** - -Add to `src/compare.tsx`: - -```tsx -import { render } from 'ink' - -import { aggregateModelStats, computeComparison, scanSelfCorrections } from './compare-stats.js' -import { parseAllSessions } from './parser.js' -import { getAllProviders } from './providers/index.js' -import type { ProjectSummary, DateRange } from './types.js' - -type ComparePhase = 'select' | 'loading' | 'results' - -type CompareViewProps = { - projects: ProjectSummary[] - onBack: () => void -} - -export function CompareView({ projects, onBack }: CompareViewProps) { - const [phase, setPhase] = useState('select') - const [models] = useState(() => aggregateModelStats(projects)) - const [pickedA, setPickedA] = useState(null) - const [pickedB, setPickedB] = useState(null) - const [rows, setRows] = useState([]) - - if (models.length < 2) { - return ( - - Model Comparison - {''} - Need at least 2 models to compare. Found: {models.map(m => m.model).join(', ') || 'none'} - {''} - [esc] back [q] quit - - ) - } - - const handleSelect = async (a: ModelStats, b: ModelStats) => { - setPickedA(a) - setPickedB(b) - setPhase('loading') - - const providers = await getAllProviders() - const dirs: string[] = [] - for (const p of providers) { - const sources = await p.discoverSessions() - for (const s of sources) dirs.push(s.path) - } - const corrections = await scanSelfCorrections(dirs) - a.selfCorrections = corrections.get(a.model) ?? 0 - b.selfCorrections = corrections.get(b.model) ?? 0 - - setRows(computeComparison(a, b)) - setPhase('results') - } - - if (phase === 'select') { - return - } - - if (phase === 'loading') { - return ( - - Comparing {pickedA?.model} vs {pickedB?.model}... - - ) - } - - return ( - setPhase('select')} - /> - ) -} - -export async function renderCompare( - range: DateRange, - provider: string, -): Promise { - const projects = await parseAllSessions(range, provider) - if (projects.length === 0) { - console.log('\n No usage data found.\n') - return - } - - const isTTY = process.stdin.isTTY && process.stdout.isTTY - if (!isTTY) { - console.log('\n Model comparison requires an interactive terminal.\n') - return - } - - const { waitUntilExit } = render( - process.exit(0)} /> - ) - await waitUntilExit() -} -``` - -- [ ] **Step 2: Verify it compiles** - -Run: `npx tsx --eval "import './src/compare.js'" 2>&1 | head -5` -Expected: No import errors - -- [ ] **Step 3: Commit** - -```bash -git add src/compare.tsx -git commit --author="iamtoruk " -m "feat(compare): add CompareView orchestrator and renderCompare" -``` - ---- - -### Task 7: CLI compare command - -**Files:** -- Modify: `src/cli.ts` (add command at ~line 650, before `program.parse()`) - -- [ ] **Step 1: Add the compare command** - -Add before the `program.parse()` line in `src/cli.ts`: - -```ts -import { renderCompare } from './compare.js' -``` - -Add at the top with other imports. Then add the command before `program.parse()`: - -```ts -program - .command('compare') - .description('Compare two AI models side-by-side') - .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', 'all') - .option('--provider ', 'Filter by provider: all, claude, codex, cursor', 'all') - .action(async (opts) => { - await loadPricing() - const { range } = getDateRange(opts.period) - await renderCompare(range, opts.provider) - }) -``` - -- [ ] **Step 2: Test the standalone command** - -Run: `npx tsx src/cli.ts compare` -Expected: Model selection screen appears with arrow-key navigation. Press `q` to quit. - -- [ ] **Step 3: Commit** - -```bash -git add src/cli.ts -git commit --author="iamtoruk " -m "feat(compare): add codeburn compare command" -``` - ---- - -### Task 8: Dashboard integration - -**Files:** -- Modify: `src/dashboard.tsx` (~5 changes) - -- [ ] **Step 1: Add 'compare' to View type** - -Change line 16 in `src/dashboard.tsx`: - -```ts -// Before: -type View = 'dashboard' | 'optimize' - -// After: -type View = 'dashboard' | 'optimize' | 'compare' -``` - -- [ ] **Step 2: Add import** - -Add to imports at the top of `src/dashboard.tsx`: - -```ts -import { CompareView } from './compare.js' -``` - -- [ ] **Step 3: Add modelCount state and 'c' keybinding** - -In the `InteractiveDashboard` component, add state tracking after `optimizeAvailable`: - -```ts -const modelCount = new Set( - projects.flatMap(p => p.sessions.flatMap(s => Object.keys(s.modelBreakdown))) -).size -const compareAvailable = modelCount >= 2 -``` - -In the `useInput` handler, add after the optimize toggle: - -```ts -if (input === 'c' && compareAvailable && view === 'dashboard') { setView('compare'); return } -if (key.escape && view === 'compare') { setView('dashboard'); return } -``` - -Update the existing escape handler for optimize to also check compare: - -```ts -// Before: -if ((input === 'b' || key.escape) && view === 'optimize') { setView('dashboard'); return } - -// After: -if ((input === 'b' || key.escape) && (view === 'optimize' || view === 'compare')) { setView('dashboard'); return } -``` - -- [ ] **Step 4: Add CompareView to render** - -In the return JSX, extend the conditional render (around line 704): - -```tsx -// Before: -{view === 'optimize' && optimizeResult - ? - : } - -// After: -{view === 'compare' - ? setView('dashboard')} /> - : view === 'optimize' && optimizeResult - ? - : } -``` - -- [ ] **Step 5: Update StatusBar** - -Add `compareAvailable` prop to StatusBar and render the hint. In the StatusBar component, add after the optimize hint: - -```tsx -{!isOptimize && view !== 'compare' && compareAvailable && ( - <> c compare -)} -``` - -Update StatusBar props: - -```ts -function StatusBar({ width, showProvider, view, findingCount, optimizeAvailable, compareAvailable }: { - width: number; showProvider?: boolean; view?: View; findingCount?: number; optimizeAvailable?: boolean; compareAvailable?: boolean -}) -``` - -Pass `compareAvailable` at both StatusBar call sites. - -- [ ] **Step 6: Test the dashboard integration** - -Run: `npx tsx src/cli.ts report` -Expected: Status bar shows `c compare`. Press `c` to open model selection. Press `Esc` to go back. Press `q` to quit. - -- [ ] **Step 7: Commit** - -```bash -git add src/dashboard.tsx -git commit --author="iamtoruk " -m "feat(compare): integrate into dashboard with c shortcut" -``` - ---- - -### Task 9: End-to-end verification - -**Files:** None (testing only) - -- [ ] **Step 1: Run the full test suite** - -Run: `npx vitest run` -Expected: All tests pass (including new compare-stats tests) - -- [ ] **Step 2: Test standalone compare** - -Run: `npx tsx src/cli.ts compare` -Expected: Model selection screen. Select two models with spacebar. Press Enter. See comparison table with color-coded winners. Press Esc to go back. Press q to quit. - -- [ ] **Step 3: Test dashboard integration** - -Run: `npx tsx src/cli.ts report` -Expected: Press `c` to open compare. Select models. See results. Press Esc twice to return to dashboard. Verify `o` for optimize still works. - -- [ ] **Step 4: Verify edge cases** - -Run: `npx tsx src/cli.ts compare --provider codex` -Expected: If Codex has < 2 models, shows "Need at least 2 models" message. - -- [ ] **Step 5: Final commit on branch** - -```bash -git add -A -git status # verify no unrelated files -# Only if there are unstaged fixes: -git commit --author="iamtoruk " -m "fix(compare): polish from end-to-end testing" -``` diff --git a/docs/superpowers/specs/2026-04-19-model-comparison-design.md b/docs/superpowers/specs/2026-04-19-model-comparison-design.md deleted file mode 100644 index 1dc5cc2..0000000 --- a/docs/superpowers/specs/2026-04-19-model-comparison-design.md +++ /dev/null @@ -1,266 +0,0 @@ -# Model Comparison Design - -Compare two AI models side-by-side using normalized metrics derived from real -usage data. Answers "is Opus 4.7 actually better than 4.6 for my workflow?" -with hard numbers instead of vibes. - -## Goals - -1. Let users pick any two models and see a fair, normalized comparison -2. Surface efficiency metrics that raw cost/token dashboards don't show - (one-shot rate, retry rate, self-correction rate) -3. Accessible from both the dashboard (press `c`) and standalone (`codeburn compare`) -4. Screenshot-friendly terminal output - -## Non-Goals - -- Multi-model comparison (3+) -- v2 -- Time-frame filtering (`--period`) -- v2 -- Charts/graphs in the comparison view -- v2 -- Exporting comparison results to JSON/CSV -- v2 -- Statistical significance testing (show sample sizes, let the user judge) - ---- - -## 1. Entry Points - -### Standalone command - -``` -codeburn compare [--provider ] [--period ] -``` - -Period defaults to `all` (6 months). Provider defaults to `all`. Both flags -are accepted but optional. Launches the full-screen Ink TUI directly into the -model selection screen. - -### Dashboard shortcut - -Press `c` in the dashboard to switch to the compare view. Same component, same -flow. `Escape` returns to the dashboard (mirrors how `o` toggles optimize). - -The status bar gains a `[c]ompare` hint next to the existing `[o]ptimize`. - ---- - -## 2. Data Pipeline - -### Aggregation - -Reuse `parseAllSessions` to get `ProjectSummary[]` for the selected -period/provider. Then build per-model stats by iterating turns and calls: - -```ts -type ModelStats = { - model: string - calls: number - cost: number - outputTokens: number - inputTokens: number - cacheReadTokens: number - cacheWriteTokens: number - totalTurns: number - editTurns: number - oneShotTurns: number // edit turns with 0 retries - retries: number // total retry count - selfCorrections: number // turns matching apology/mistake patterns - firstSeen: string // earliest timestamp (ISO) - lastSeen: string // latest timestamp (ISO) -} -``` - -Turn-level metrics are attributed to the primary model (first call in the -turn). This matches how the dashboard already attributes turns. - -### Self-correction detection - -Scan assistant message text for patterns that indicate the model acknowledged -an error. These patterns were validated against real session data: - -```ts -const SELF_CORRECTION_PATTERNS = [ - /\bI('m| am) sorry\b/i, - /\bmy mistake\b/i, - /\bmy apolog/i, - /\bI made (a |an )?(error|mistake)\b/i, - /\bI was wrong\b/i, - /\bmy bad\b/i, - /\bI apologize\b/i, - /\bsorry about that\b/i, - /\bsorry for (the|that|this)\b/i, - /\bI should have\b/i, - /\bI shouldn't have\b/i, - /\bI incorrectly\b/i, - /\bI mistakenly\b/i, -] -``` - -This requires reading assistant message content from session JSONL files, -which the current parser does not expose. The aggregation function will need -to read raw JSONL entries for sessions that contain the selected models. - -### Normalization - -All comparison metrics are rates or per-call averages. Raw totals (cost, calls, -days) are shown as context, never as comparison metrics. - ---- - -## 3. Comparison Metrics - -| Metric | Formula | Better | -|---|---|---| -| Cost / call | `cost / calls` | Lower | -| Output tokens / call | `outputTokens / calls` | Lower | -| Cache hit rate | `cacheRead / (input + cacheRead + cacheWrite) * 100` | Higher | -| One-shot rate | `oneShotTurns / editTurns * 100` | Higher | -| Retry rate | `retries / editTurns` | Lower | -| Self-correction rate | `selfCorrections / totalTurns * 100` | Lower | - -### Context row (not compared) - -Displayed below the table to give sample-size context: - -- Total calls -- Total cost -- Days of data (lastSeen - firstSeen) -- Edit turns (denominator for one-shot/retry metrics) - ---- - -## 4. UI Screens - -### Model Selection Screen - -``` - Model Comparison - - Select two models to compare: - - claude-opus-4-6 56,031 calls $5,272 - > claude-opus-4-7 3,592 calls $664 [selected] - claude-sonnet-4-6 1,142 calls $25 - claude-haiku-4-5 323 calls $4 - gpt-5 113 calls $3 low data - - [space] select [enter] compare [esc] back [q] quit -``` - -- Arrow keys navigate, spacebar toggles selection (max 2) -- Models sorted by cost descending (most-used first) -- Models with < 20 calls show "low data" dim label -- Enter is disabled until exactly 2 models are selected -- Filter out `` model entries - -### Loading Screen - -``` - Comparing claude-opus-4-6 vs claude-opus-4-7... -``` - -Simple spinner while aggregation runs. Should be fast (< 2 seconds) since -session data is already parsed. - -### Comparison Results Screen - -``` - claude-opus-4-6 vs claude-opus-4-7 - - 4.6 4.7 - Cost / call $0.094 $0.185 4.6 wins - Output tok / call 227 800 4.6 wins - Cache hit rate 98.4% 98.8% 4.7 wins - One-shot rate 88.8% 74.5% 4.6 wins - Retry rate 0.18 0.46 4.6 wins - Self-correction 0.18% 0.25% 4.6 wins - - ── Context ────────────────────────────────── - Calls 56,031 3,592 - Cost $5,272.13 $664.32 - Days of data 60 3 - Edit turns 1,577 102 - - [esc] back [q] quit -``` - -- Winner column uses green text for the better model on each metric -- Model names in the header are shortened for display (drop `claude-` prefix) -- Context section is dimmed to visually separate it from the comparison -- If a metric can't be computed (e.g., 0 edit turns), show `-` instead - ---- - -## 5. File Structure - -``` -src/compare.tsx -- Ink components: ModelSelector, ComparisonResults, - CompareView (top-level), loading state -src/compare-stats.ts -- aggregateModelStats(), computeComparison(), - self-correction pattern matching, ModelStats type -src/cli.ts -- new `compare` command registration -src/dashboard.tsx -- add 'c' keybinding, CompareView integration -``` - -### compare-stats.ts - -Pure data module, no UI. Exports: - -```ts -function aggregateModelStats(projects: ProjectSummary[]): ModelStats[] -function computeComparison(a: ModelStats, b: ModelStats): ComparisonRow[] -``` - -The self-correction scanner needs raw assistant message text from JSONL files. -Two options: (a) extend the parser to expose message text on turns during -initial parse, or (b) have `compare-stats.ts` re-read JSONL files via provider -session discovery (same mechanism the parser uses). Option (a) is cleaner but -increases memory for all commands; option (b) is isolated to compare. The -implementation plan should decide. - -### compare.tsx - -Three Ink components: - -- `ModelSelector` -- arrow navigation, spacebar toggle, enter to confirm -- `ComparisonResults` -- the formatted table with color-coded winners -- `CompareView` -- orchestrates the flow (selection -> loading -> results) - -Exported `renderCompare()` function for the standalone command, and -`CompareView` component for embedding in the dashboard. - ---- - -## 6. Dashboard Integration - -### Status bar - -Add `[c]ompare` to the status bar, after `[o]ptimize`: - -``` - 1-5 period arrows switch p provider o optimize c compare q quit -``` - -### View state - -Extend the existing `View` type: - -```ts -type View = 'dashboard' | 'optimize' | 'compare' -``` - -Press `c` sets view to `'compare'`. Escape from compare returns to -`'dashboard'`. The compare view receives the already-parsed `projects` -from the dashboard state -- no re-parsing needed. - ---- - -## 7. Edge Cases - -- **Only one model in data**: Show message "Need at least 2 models to compare. - Only found: claude-opus-4-6" -- **Model with 0 edit turns**: Show `-` for one-shot rate and retry rate -- **Model with < 20 calls**: Show "low data" warning on selection screen; - allow selection but display a note on the results screen -- **Self-correction scanner fails to read JSONL**: Gracefully degrade -- - show `-` for self-correction rate, don't block the rest of the comparison -- **Both models have identical metrics**: Show "tie" instead of a winner diff --git a/gnome/README.md b/gnome/README.md new file mode 100644 index 0000000..3b76632 --- /dev/null +++ b/gnome/README.md @@ -0,0 +1,70 @@ +# CodeBurn GNOME Extension + +Monitor AI coding assistant token usage and costs from your GNOME desktop panel. + +## Requirements + +- GNOME Shell 45 or later +- CodeBurn CLI installed (`npm i -g codeburn`) +- `glib-compile-schemas` (usually part of `glib2-devel` or `libglib2.0-dev`) + +## Install + +```bash +cd gnome +chmod +x install.sh +./install.sh +``` + +Then restart GNOME Shell: +- **Wayland:** Log out and back in +- **X11:** Press `Alt+F2`, type `r`, press Enter + +Enable the extension: + +```bash +gnome-extensions enable codeburn@codeburn.dev +``` + +## Configure + +Open preferences: + +```bash +gnome-extensions prefs codeburn@codeburn.dev +``` + +Or use the GNOME Extensions app. + +### Settings + +| Setting | Default | Description | +|---------|---------|-------------| +| Refresh Interval | 30s | How often to poll CodeBurn CLI | +| Default Period | Today | Period shown on open | +| Compact Mode | Off | Hide cost label, show icon only | +| Budget Threshold | $0 | Daily budget alert (0 = disabled) | +| Budget Alerts | Off | Show warning when budget exceeded | +| CLI Path | (auto) | Custom path to `codeburn` binary | + +## Uninstall + +```bash +gnome-extensions disable codeburn@codeburn.dev +rm -r ~/.local/share/gnome-shell/extensions/codeburn@codeburn.dev +``` + +## Development + +Test changes without installing: + +```bash +# Compile schemas locally +glib-compile-schemas schemas/ + +# Symlink for development +ln -sf "$(pwd)" ~/.local/share/gnome-shell/extensions/codeburn@codeburn.dev + +# Watch logs +journalctl -f -o cat /usr/bin/gnome-shell +``` diff --git a/gnome/dataClient.js b/gnome/dataClient.js new file mode 100644 index 0000000..4d0056b --- /dev/null +++ b/gnome/dataClient.js @@ -0,0 +1,161 @@ +import GLib from 'gi://GLib'; +import Gio from 'gi://Gio'; + +const TIMEOUT_SECONDS = 15; +const SAFE_ARG_RE = /^[A-Za-z0-9 ._/\-]+$/; + +function buildAdditionalPaths() { + const home = GLib.get_home_dir(); + return [ + '/usr/local/bin', + `${home}/.local/bin`, + `${home}/.npm-global/bin`, + `${home}/.volta/bin`, + `${home}/.bun/bin`, + `${home}/.cargo/bin`, + `${home}/.asdf/shims`, + `${home}/.local/share/fnm/aliases/default/bin`, + `${home}/.local/share/pnpm`, + ]; +} + +export class DataClient { + _cache = new Map(); + _inFlight = null; + _codeburnPath; + _augmentedPath; + + constructor(codeburnPath) { + this._codeburnPath = codeburnPath || ''; + this._augmentedPath = this._buildAugmentedPath(); + } + + setCodeburnPath(path) { + this._codeburnPath = path || ''; + } + + cancelInFlight() { + if (this._inFlight) { + this._inFlight.cancellable.cancel(); + this._inFlight = null; + } + } + + getCached(period, provider) { + const key = `${period}:${provider}`; + return this._cache.get(key) ?? null; + } + + async fetch(period, provider) { + this.cancelInFlight(); + + const cancellable = new Gio.Cancellable(); + this._inFlight = { cancellable }; + + try { + const payload = await this._spawn(period, provider, cancellable); + const key = `${period}:${provider}`; + this._cache.set(key, payload); + return payload; + } finally { + if (this._inFlight?.cancellable === cancellable) + this._inFlight = null; + } + } + + _buildArgv(period, provider) { + let base; + if (this._codeburnPath && SAFE_ARG_RE.test(this._codeburnPath)) { + base = this._codeburnPath.split(' ').filter(s => s.length > 0); + } else { + base = ['codeburn']; + } + + const args = [ + ...base, + 'status', + '--format', 'menubar-json', + '--period', period, + '--no-optimize', + ]; + + if (provider && provider !== 'all') + args.push('--provider', provider); + + return args; + } + + _buildAugmentedPath() { + const currentPath = GLib.getenv('PATH') || '/usr/bin:/bin'; + const parts = currentPath.split(':'); + for (const extra of buildAdditionalPaths()) { + if (!parts.includes(extra)) + parts.push(extra); + } + return parts.join(':'); + } + + _spawn(period, provider, cancellable) { + return new Promise((resolve, reject) => { + const argv = this._buildArgv(period, provider); + let settled = false; + + const settle = (fn, value) => { + if (settled) return; + settled = true; + fn(value); + }; + + let proc; + try { + const launcher = Gio.SubprocessLauncher.new( + Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE + ); + launcher.setenv('PATH', this._augmentedPath, true); + proc = launcher.spawnv(argv); + } catch (e) { + settle(reject, new Error(`CLI not found: ${e.message}`)); + return; + } + + let timeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, TIMEOUT_SECONDS, () => { + timeoutId = 0; + proc.force_exit(); + settle(reject, new Error('CLI timeout')); + return GLib.SOURCE_REMOVE; + }); + + proc.communicate_utf8_async(null, cancellable, (_proc, res) => { + if (timeoutId) { + GLib.Source.remove(timeoutId); + timeoutId = 0; + } + + try { + const [, stdout, stderr] = _proc.communicate_utf8_finish(res); + + if (!_proc.get_successful()) { + const msg = stderr?.trim() || 'CLI exited with error'; + settle(reject, new Error(msg)); + return; + } + + if (!stdout || stdout.trim().length === 0) { + settle(reject, new Error('CLI returned empty output')); + return; + } + + const payload = JSON.parse(stdout); + settle(resolve, payload); + } catch (e) { + settle(reject, e); + } + }); + }); + } + + destroy() { + this.cancelInFlight(); + this._cache.clear(); + } +} diff --git a/gnome/extension.js b/gnome/extension.js new file mode 100644 index 0000000..fba94fd --- /dev/null +++ b/gnome/extension.js @@ -0,0 +1,17 @@ +import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import { CodeBurnIndicator } from './indicator.js'; + +export default class CodeBurnExtension extends Extension { + _indicator = null; + + enable() { + this._indicator = new CodeBurnIndicator(this); + Main.panel.addToStatusArea('codeburn-indicator', this._indicator); + } + + disable() { + this._indicator?.destroy(); + this._indicator = null; + } +} diff --git a/gnome/icons/codeburn-symbolic.svg b/gnome/icons/codeburn-symbolic.svg new file mode 100644 index 0000000..3a4ee85 --- /dev/null +++ b/gnome/icons/codeburn-symbolic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/gnome/indicator.js b/gnome/indicator.js new file mode 100644 index 0000000..533f644 --- /dev/null +++ b/gnome/indicator.js @@ -0,0 +1,1004 @@ +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import Clutter from 'gi://Clutter'; +import Soup from 'gi://Soup?version=3.0'; +import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; +import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; +import { DataClient } from './dataClient.js'; + +const CACHE_TTL_MS = 300_000; +const TOP_ACTIVITIES = 10; +const CHART_HEIGHT = 52; +const BAR_TRACK_WIDTH = 240; + +const PERIODS = [ + { id: 'today', label: 'Today' }, + { id: 'week', label: '7 Days' }, + { id: '30days', label: '30 Days' }, + { id: 'month', label: 'Month' }, + { id: 'all', label: '6 Months' }, +]; + +const INSIGHTS = [ + { id: 'activity', label: 'Activity' }, + { id: 'trend', label: 'Trend' }, + { id: 'forecast', label: 'Forecast' }, + { id: 'pulse', label: 'Pulse' }, + { id: 'stats', label: 'Stats' }, +]; + +const PROVIDERS = [ + { id: 'all', label: 'All' }, + { id: 'claude', label: 'Claude' }, + { id: 'codex', label: 'Codex' }, + { id: 'cursor', label: 'Cursor' }, + { id: 'copilot', label: 'Copilot' }, + { id: 'opencode', label: 'OpenCode' }, + { id: 'pi', label: 'Pi' }, + { id: 'droid', label: 'Droid' }, + { id: 'gemini', label: 'Gemini' }, + { id: 'kilo-code', label: 'Kilo Code' }, + { id: 'kiro', label: 'Kiro' }, + { id: 'kimi', label: 'Kimi' }, + { id: 'roo-code', label: 'Roo Code' }, +]; + +const CURRENCIES = [ + { code: 'USD', symbol: '$' }, + { code: 'EUR', symbol: '€' }, + { code: 'GBP', symbol: '£' }, + { code: 'CAD', symbol: 'C$' }, + { code: 'AUD', symbol: 'A$' }, + { code: 'JPY', symbol: '¥' }, + { code: 'INR', symbol: '₹' }, + { code: 'BRL', symbol: 'R$' }, + { code: 'CHF', symbol: 'CHF ' }, + { code: 'SEK', symbol: 'kr ' }, + { code: 'SGD', symbol: 'S$' }, + { code: 'HKD', symbol: 'HK$' }, + { code: 'KRW', symbol: '₩' }, + { code: 'MXN', symbol: 'MX$' }, + { code: 'ZAR', symbol: 'R ' }, + { code: 'DKK', symbol: 'kr ' }, + { code: 'CNY', symbol: '¥' }, +]; + +const PROVIDER_PATHS = { + claude: '.claude/projects', + codex: '.codex/sessions', + cursor: '.config/Cursor/User/globalStorage/state.vscdb', + copilot: '.copilot/session-state', + kimi: '.kimi/sessions', + pi: '.pi/agent/sessions', +}; + +function formatCost(value, currency, rate = 1, exact = false) { + const n = (Number(value) || 0) * (Number(rate) || 1); + const abs = Math.abs(n); + const symbol = currency?.symbol || '$'; + if (!exact && abs >= 1000) return `${symbol}${(n / 1000).toFixed(abs >= 10000 ? 0 : 1)}k`; + const parts = n.toFixed(2).split('.'); + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); + return `${symbol}${parts.join('.')}`; +} + +function formatTokensCompact(n) { + const v = Number(n) || 0; + if (v >= 1_000_000_000) return `${(v / 1_000_000_000).toFixed(1)}B`; + if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`; + if (v >= 1000) return `${(v / 1000).toFixed(1)}k`; + return String(v); +} + +function formatTime(date) { + if (!date || Number.isNaN(date.getTime())) return ''; + const now = new Date(); + const diffSec = Math.floor((now.getTime() - date.getTime()) / 1000); + if (diffSec < 60) return 'just now'; + if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`; + if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`; + return date.toLocaleDateString(); +} + +export const CodeBurnIndicator = GObject.registerClass( +class CodeBurnIndicator extends PanelMenu.Button { + _init(extension) { + super._init(0.0, 'CodeBurn'); + + this._extension = extension; + this._settings = extension.getSettings(); + this._dataClient = new DataClient(this._settings.get_string('codeburn-path')); + this._settingsChangedIds = []; + + this._period = this._settings.get_string('default-period') || 'today'; + this._insight = 'activity'; + this._availableProviders = this._detectProviders(); + this._provider = this._availableProviders.length === 1 ? this._availableProviders[0] : 'all'; + + this._currency = this._loadCurrency(); + this._exactCosts = this._settings.get_boolean('show-exact-costs'); + this._fxRate = 1; + this._fxCache = { USD: 1 }; + this._soupSession = new Soup.Session(); + this._payload = null; + this._payloadCache = new Map(); + this._inFlightKeys = new Set(); + this._refreshGen = 0; + this._refreshSourceId = 0; + this._chartSummaryText = ''; + this._destroyed = false; + + this._themeSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' }); + this._themeSignal = this._themeSettings.connect('changed::color-scheme', () => this._applyThemeClass()); + this._applyThemeClass(); + this._updateFxRate(); + + this._buildPanelButton(); + this._buildPopup(); + this._connectSettings(); + this._startRefreshLoop(); + this._refresh(); + } + + // -- Panel button -- + + _buildPanelButton() { + const box = new St.BoxLayout({ style_class: 'panel-status-menu-box codeburn-panel' }); + this._panelIcon = new St.Label({ + text: '🔥', + y_align: Clutter.ActorAlign.CENTER, + style_class: 'codeburn-flame', + }); + this._panelLabel = new St.Label({ + text: '...', + y_align: Clutter.ActorAlign.CENTER, + style_class: 'codeburn-label', + }); + box.add_child(this._panelIcon); + box.add_child(this._panelLabel); + this._panelLabel.visible = !this._settings.get_boolean('compact-mode'); + this.add_child(box); + } + + // -- Popup -- + + _buildPopup() { + try { + this.menu.box.add_style_class_name('codeburn-menu'); + this._popupHost = new PopupMenu.PopupBaseMenuItem({ reactive: false, can_focus: false }); + this._popupHost.add_style_class_name('codeburn-host'); + this.menu.addMenuItem(this._popupHost); + + this._root = new St.BoxLayout({ vertical: true, style_class: 'codeburn-root', x_expand: true }); + this._popupHost.add_child(this._root); + + this._buildBrandHeader(); + + this._scrollView = new St.ScrollView({ + style_class: 'codeburn-scroll', + hscrollbar_policy: St.PolicyType.NEVER, + vscrollbar_policy: St.PolicyType.AUTOMATIC, + y_expand: true, + }); + this._scrollContent = new St.BoxLayout({ vertical: true, x_expand: true }); + this._scrollView.set_child(this._scrollContent); + this._root.add_child(this._scrollView); + + this._buildAgentTabs(); + this._buildHero(); + this._buildPeriodTabs(); + this._buildInsightPills(); + this._buildTokenChart(); + this._buildLoadingIndicator(); + this._buildContentArea(); + this._buildBudgetAlert(); + this._buildFindingsSection(); + this._buildFooter(); + } catch (e) { + log(`CodeBurn: popup build error: ${e.message}\n${e.stack}`); + } + } + + _buildBrandHeader() { + const header = new St.BoxLayout({ vertical: true, style_class: 'codeburn-brand-header' }); + const title = new St.BoxLayout({ style_class: 'codeburn-brand-row' }); + title.add_child(new St.Label({ text: 'Code', style_class: 'codeburn-brand-primary' })); + title.add_child(new St.Label({ text: 'Burn', style_class: 'codeburn-brand-accent' })); + header.add_child(title); + header.add_child(new St.Label({ text: 'AI Coding Cost Tracker', style_class: 'codeburn-brand-subhead' })); + this._root.add_child(header); + } + + _buildAgentTabs() { + const detected = this._availableProviders; + this._agentTabs = new Map(); + this._agentTabRow = null; + if (detected.length === 0) return; + + const disabled = this._getDisabledProviders(); + const tabs = detected.length === 1 + ? PROVIDERS.filter(p => p.id === detected[0]) + : [PROVIDERS[0], ...PROVIDERS.slice(1).filter(p => detected.includes(p.id) && !disabled.has(p.id))]; + + if (tabs.length === 1) { + const badge = new St.Label({ text: tabs[0].label, style_class: 'codeburn-agent-badge' }); + const row = new St.BoxLayout({ style_class: 'codeburn-tab-row' }); + row.add_child(badge); + this._scrollContent.add_child(row); + return; + } + + const useScroll = tabs.length > 5; + this._agentTabRow = new St.BoxLayout({ style_class: 'codeburn-tab-row' }); + for (const p of tabs) { + const btn = new St.Button({ label: p.label, style_class: 'codeburn-tab', can_focus: true, x_expand: !useScroll }); + btn.connect('clicked', () => { + this._provider = p.id; + this._updateAgentTabStyle(); + this._refresh(); + }); + this._agentTabRow.add_child(btn); + this._agentTabs.set(p.id, btn); + } + if (useScroll) { + const agentScroll = new St.ScrollView({ + style_class: 'codeburn-agent-scroll', + hscrollbar_policy: St.PolicyType.AUTOMATIC, + vscrollbar_policy: St.PolicyType.NEVER, + }); + agentScroll.set_child(this._agentTabRow); + this._scrollContent.add_child(agentScroll); + } else { + this._scrollContent.add_child(this._agentTabRow); + } + this._updateAgentTabStyle(); + } + + _updateAgentTabStyle() { + for (const [id, btn] of this._agentTabs) { + if (id === this._provider) btn.add_style_class_name('codeburn-tab-active'); + else btn.remove_style_class_name('codeburn-tab-active'); + } + } + + _buildHero() { + const hero = new St.BoxLayout({ vertical: true, style_class: 'codeburn-hero' }); + const topLine = new St.BoxLayout({ style_class: 'codeburn-hero-top' }); + this._heroDot = new St.Widget({ style_class: 'codeburn-hero-dot' }); + this._heroLabel = new St.Label({ text: 'Loading...', style_class: 'codeburn-hero-label' }); + topLine.add_child(this._heroDot); + topLine.add_child(this._heroLabel); + this._heroAmount = new St.Label({ text: '$0.00', style_class: 'codeburn-hero-amount' }); + this._heroMeta = new St.Label({ text: '', style_class: 'codeburn-hero-meta' }); + hero.add_child(topLine); + hero.add_child(this._heroAmount); + hero.add_child(this._heroMeta); + this._scrollContent.add_child(hero); + } + + _buildPeriodTabs() { + const row = new St.BoxLayout({ style_class: 'codeburn-tab-row codeburn-period-row' }); + this._periodTabs = new Map(); + for (const p of PERIODS) { + const btn = new St.Button({ label: p.label, style_class: 'codeburn-period', can_focus: true, x_expand: true }); + btn.connect('clicked', () => { + this._period = p.id; + this._updatePeriodTabStyle(); + this._refresh(); + }); + row.add_child(btn); + this._periodTabs.set(p.id, btn); + } + this._scrollContent.add_child(row); + this._updatePeriodTabStyle(); + } + + _updatePeriodTabStyle() { + for (const [id, btn] of this._periodTabs) { + if (id === this._period) btn.add_style_class_name('codeburn-period-active'); + else btn.remove_style_class_name('codeburn-period-active'); + } + } + + _buildInsightPills() { + const row = new St.BoxLayout({ style_class: 'codeburn-insight-row' }); + this._insightPills = new Map(); + for (const i of INSIGHTS) { + const btn = new St.Button({ label: i.label, style_class: 'codeburn-insight-pill', can_focus: true, x_expand: true }); + btn.connect('clicked', () => { + this._insight = i.id; + this._updateInsightPillStyle(); + this._renderContent(); + }); + row.add_child(btn); + this._insightPills.set(i.id, btn); + } + this._scrollContent.add_child(row); + this._updateInsightPillStyle(); + } + + _updateInsightPillStyle() { + for (const [id, btn] of this._insightPills) { + if (id === this._insight) btn.add_style_class_name('codeburn-insight-pill-active'); + else btn.remove_style_class_name('codeburn-insight-pill-active'); + } + } + + _buildTokenChart() { + this._chartContainer = new St.BoxLayout({ vertical: true, style_class: 'codeburn-chart' }); + const header = new St.BoxLayout({ style_class: 'codeburn-chart-header' }); + this._chartLabel = new St.Label({ text: 'Tokens', style_class: 'codeburn-chart-label', x_expand: true }); + this._chartTotal = new St.Label({ text: '', style_class: 'codeburn-chart-total' }); + header.add_child(this._chartLabel); + header.add_child(this._chartTotal); + this._chartContainer.add_child(header); + this._chartBars = new St.BoxLayout({ style_class: 'codeburn-chart-bars' }); + this._chartContainer.add_child(this._chartBars); + this._scrollContent.add_child(this._chartContainer); + } + + _buildContentArea() { + this._scrollContent.add_child(new St.Widget({ style_class: 'codeburn-divider' })); + this._contentArea = new St.BoxLayout({ vertical: true, style_class: 'codeburn-content' }); + this._scrollContent.add_child(this._contentArea); + } + + _buildBudgetAlert() { + this._budgetLabel = new St.Label({ text: '', style_class: 'codeburn-budget-warning', visible: false }); + this._scrollContent.add_child(this._budgetLabel); + } + + _buildFindingsSection() { + this._findingsBtn = new St.Button({ style_class: 'codeburn-findings', visible: false }); + const box = new St.BoxLayout({ style_class: 'codeburn-findings-inner' }); + this._findingsCount = new St.Label({ text: '', style_class: 'codeburn-findings-count' }); + this._findingsSavings = new St.Label({ text: '', style_class: 'codeburn-findings-savings' }); + box.add_child(this._findingsCount); + box.add_child(this._findingsSavings); + this._findingsBtn.set_child(box); + this._findingsBtn.connect('clicked', () => this._spawnTerminal(['codeburn', 'optimize'])); + this._scrollContent.add_child(this._findingsBtn); + } + + _buildLoadingIndicator() { + this._loadingBox = new St.BoxLayout({ vertical: true, style_class: 'codeburn-loading', visible: false, x_expand: true }); + const widths = [0.85, 0.6, 0.92, 0.5, 0.75, 0.45]; + for (const w of widths) { + const bar = new St.Widget({ style_class: 'codeburn-skeleton-bar', x_expand: false }); + bar.set_width(Math.round(308 * w)); + bar.set_height(10); + this._loadingBox.add_child(bar); + } + this._scrollContent.add_child(this._loadingBox); + } + + _showLoading() { + if (!this._loadingBox) return; + this._loadingBox.visible = true; + this._loadingBox.get_children().forEach((bar, i) => { + bar.opacity = 255; + bar.ease({ + opacity: 60, + duration: 900, + delay: i * 120, + mode: Clutter.AnimationMode.EASE_IN_OUT_SINE, + repeatCount: -1, + autoReverse: true, + }); + }); + } + + _hideLoading() { + if (!this._loadingBox) return; + this._loadingBox.visible = false; + this._loadingBox.get_children().forEach(bar => { + bar.remove_all_transitions(); + bar.opacity = 255; + }); + } + + _buildFooter() { + this._currencyPicker = new St.ScrollView({ + style_class: 'codeburn-currency-picker', + visible: false, + hscrollbar_policy: St.PolicyType.NEVER, + vscrollbar_policy: St.PolicyType.AUTOMATIC, + }); + const pickerList = new St.BoxLayout({ vertical: true, style_class: 'codeburn-currency-list' }); + for (const c of CURRENCIES) { + const item = new St.Button({ label: `${c.symbol} ${c.code}`, style_class: 'codeburn-currency-item', can_focus: true }); + if (c.code === this._currency.code) item.add_style_class_name('codeburn-currency-item-active'); + item.connect('clicked', () => { + this._setCurrency(c.code); + this._currencyPicker.hide(); + pickerList.get_children().forEach(ch => ch.remove_style_class_name('codeburn-currency-item-active')); + item.add_style_class_name('codeburn-currency-item-active'); + }); + pickerList.add_child(item); + } + this._currencyPicker.set_child(pickerList); + this._root.add_child(this._currencyPicker); + + const footer = new St.BoxLayout({ style_class: 'codeburn-footer' }); + + this._currencyBtn = new St.Button({ + label: `${this._currency.code} ⌄`, + style_class: 'codeburn-footer-btn codeburn-currency-btn', + can_focus: true, + }); + this._currencyBtn.connect('clicked', () => this._toggleCurrencyPicker()); + footer.add_child(this._currencyBtn); + + const refreshBtn = new St.Button({ label: 'Refresh', style_class: 'codeburn-footer-btn', can_focus: true, x_expand: true }); + refreshBtn.connect('clicked', () => this._refresh(true)); + footer.add_child(refreshBtn); + + const reportBtn = new St.Button({ label: 'Full Report', style_class: 'codeburn-footer-btn codeburn-footer-cta', can_focus: true, x_expand: true }); + reportBtn.connect('clicked', () => this._spawnTerminal(['codeburn', 'report', '--period', this._period, '--provider', this._provider])); + footer.add_child(reportBtn); + + const prefsBtn = new St.Button({ label: '⚙', style_class: 'codeburn-footer-btn codeburn-prefs-btn', can_focus: true }); + prefsBtn.connect('clicked', () => { + this._extension.openPreferences(); + this.menu.close(); + }); + footer.add_child(prefsBtn); + + this._root.add_child(footer); + this._updatedLabel = new St.Label({ text: '', style_class: 'codeburn-updated' }); + this._root.add_child(this._updatedLabel); + } + + // -- Settings -- + + _connectSettings() { + const watch = (key, cb) => { + const id = this._settings.connect(`changed::${key}`, cb); + this._settingsChangedIds.push(id); + }; + watch('refresh-interval', () => this._restartRefreshLoop()); + watch('compact-mode', () => { this._panelLabel.visible = !this._settings.get_boolean('compact-mode'); }); + watch('codeburn-path', () => { + this._dataClient.setCodeburnPath(this._settings.get_string('codeburn-path')); + this._refresh(true); + }); + watch('default-period', () => { + this._period = this._settings.get_string('default-period'); + this._updatePeriodTabStyle(); + this._refresh(); + }); + watch('budget-threshold', () => this._updateBudget()); + watch('budget-alert-enabled', () => this._updateBudget()); + watch('force-dark-mode', () => this._applyThemeClass()); + watch('show-exact-costs', () => { + this._exactCosts = this._settings.get_boolean('show-exact-costs'); + if (this._payload) this._render(this._payload); + }); + watch('disabled-providers', () => { + if (this._payload) this._render(this._payload); + }); + } + + _getDisabledProviders() { + return new Set(this._settings.get_strv('disabled-providers')); + } + + // -- Provider detection -- + + _detectProviders() { + const home = GLib.get_home_dir(); + const xdgData = GLib.getenv('XDG_DATA_HOME') || `${home}/.local/share`; + const checks = Object.fromEntries( + Object.entries(PROVIDER_PATHS).map(([id, rel]) => [id, `${home}/${rel}`]) + ); + checks.opencode = `${xdgData}/opencode`; + const out = []; + for (const [id, path] of Object.entries(checks)) { + if (Gio.File.new_for_path(path).query_exists(null)) out.push(id); + } + return out; + } + + // -- Refresh loop -- + + _startRefreshLoop() { + const interval = this._settings.get_uint('refresh-interval') || 30; + this._refreshSourceId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, interval, () => { + this._refresh(); + return GLib.SOURCE_CONTINUE; + }); + } + + _restartRefreshLoop() { + if (this._refreshSourceId) { + GLib.Source.remove(this._refreshSourceId); + this._refreshSourceId = 0; + } + this._startRefreshLoop(); + } + + // -- Data fetching with cache -- + + _cacheKey() { + return `${this._period}|${this._provider}`; + } + + async _refresh(force = false) { + const key = this._cacheKey(); + const cached = this._payloadCache.get(key); + const cacheAge = cached ? Date.now() - cached.fetchedAt : Infinity; + + if (!force && cached && cacheAge < CACHE_TTL_MS) { + this._payload = cached.payload; + this._render(this._payload); + return; + } + + if (this._inFlightKeys.has(key)) return; + this._inFlightKeys.add(key); + const gen = ++this._refreshGen; + + if (cached) { + this._payload = cached.payload; + this._render(this._payload); + } else { + this._showLoading(); + if (this._contentArea) this._contentArea.opacity = 120; + } + + try { + const payload = await this._dataClient.fetch(this._period, this._provider); + this._inFlightKeys.delete(key); + if (this._destroyed || gen !== this._refreshGen) return; + this._payloadCache.set(key, { payload, fetchedAt: Date.now() }); + if (this._cacheKey() === key) { + this._payload = payload; + this._hideLoading(); + if (this._contentArea) this._contentArea.opacity = 255; + this._render(this._payload); + } + } catch (e) { + this._inFlightKeys.delete(key); + if (this._destroyed) return; + this._hideLoading(); + if (this._contentArea) this._contentArea.opacity = 255; + if (gen !== this._refreshGen) return; + if (e.message?.includes('cancelled')) return; + log(`CodeBurn: refresh error: ${e.message}`); + if (!this._payload) this._renderError(e.message); + } + } + + // -- Rendering -- + + _render(payload) { + const current = payload?.current ?? {}; + const cost = Number(current.cost ?? 0); + + this._panelLabel.set_text(this._fmt(cost)); + this._heroLabel.set_text(current.label || ''); + this._heroAmount.set_text(this._fmt(cost)); + + const calls = Number(current.calls ?? 0); + const sessions = Number(current.sessions ?? 0); + this._heroMeta.set_text(`${calls.toLocaleString()} calls ${sessions} sessions`); + + this._renderChart(payload?.history?.daily ?? []); + this._renderContent(); + this._renderFindings(payload?.optimize ?? {}); + this._updateBudget(); + + const updated = payload?.generated ? formatTime(new Date(payload.generated)) : ''; + this._updatedLabel.set_text(updated ? `Updated ${updated}` : ''); + } + + _renderChart(daily) { + this._chartBars.destroy_all_children(); + const days = Array.isArray(daily) ? daily.slice(-19) : []; + if (days.length === 0) { + this._chartContainer.visible = false; + return; + } + const inTotals = days.map(d => Number(d?.inputTokens) || 0); + const outTotals = days.map(d => Number(d?.outputTokens) || 0); + const totals = inTotals.map((v, i) => v + outTotals[i]); + let maxTotal = 1; + let totalIn = 0; + let totalOut = 0; + let hasAnyTokens = false; + for (let i = 0; i < days.length; i++) { + if (totals[i] > maxTotal) maxTotal = totals[i]; + if (totals[i] > 0) hasAnyTokens = true; + totalIn += inTotals[i]; + totalOut += outTotals[i]; + } + if (!hasAnyTokens) { + this._chartContainer.visible = false; + return; + } + this._chartContainer.visible = true; + const summaryText = `In: ${formatTokensCompact(totalIn)} Out: ${formatTokensCompact(totalOut)}`; + this._chartTotal.set_text(summaryText); + this._chartSummaryText = summaryText; + + const chartWidth = 308; + const gap = 2; + const barW = Math.max(4, Math.floor((chartWidth - gap * (days.length - 1)) / days.length)); + + for (let i = 0; i < days.length; i++) { + const h = Math.max(2, Math.round((totals[i] / maxTotal) * CHART_HEIGHT)); + const col = new St.BoxLayout({ vertical: true, style_class: 'codeburn-chart-col', reactive: true }); + col.set_width(barW); + col.set_height(CHART_HEIGHT); + const spacer = new St.Widget({ style_class: 'codeburn-chart-spacer' }); + spacer.set_height(CHART_HEIGHT - h); + const bar = new St.Widget({ style_class: 'codeburn-chart-bar' }); + bar.set_width(barW); + bar.set_height(h); + col.add_child(spacer); + col.add_child(bar); + + const date = days[i]?.date || ''; + const inTok = formatTokensCompact(inTotals[i]); + const outTok = formatTokensCompact(outTotals[i]); + const cost = days[i]?.cost != null ? this._fmt(days[i].cost) : ''; + col.connect('enter-event', () => { + this._chartTotal.set_text(`${date} ${inTok}/${outTok} ${cost}`); + this._chartTotal.add_style_class_name('codeburn-chart-total-hover'); + bar.add_style_class_name('codeburn-chart-bar-hover'); + return Clutter.EVENT_PROPAGATE; + }); + col.connect('leave-event', () => { + this._chartTotal.set_text(this._chartSummaryText); + this._chartTotal.remove_style_class_name('codeburn-chart-total-hover'); + bar.remove_style_class_name('codeburn-chart-bar-hover'); + return Clutter.EVENT_PROPAGATE; + }); + + this._chartBars.add_child(col); + } + } + + _renderContent() { + this._contentArea.destroy_all_children(); + switch (this._insight) { + case 'trend': return this._renderTrendView(); + case 'forecast': return this._renderForecastView(); + case 'pulse': return this._renderPulseView(); + case 'stats': return this._renderStatsView(); + default: return this._renderActivityView(); + } + } + + _renderActivityView() { + const current = this._payload?.current ?? {}; + this._contentArea.add_child(this._sectionTitle('Activity')); + const actHeader = new St.BoxLayout({ style_class: 'codeburn-table-header' }); + actHeader.add_child(new St.Label({ text: 'Name', style_class: 'codeburn-th', x_expand: true })); + actHeader.add_child(new St.Label({ text: 'Cost', style_class: 'codeburn-th codeburn-th-right codeburn-th-cost' })); + actHeader.add_child(new St.Label({ text: 'Turns', style_class: 'codeburn-th codeburn-th-right codeburn-th-turns' })); + actHeader.add_child(new St.Label({ text: '1-shot', style_class: 'codeburn-th codeburn-th-right codeburn-th-turns' })); + this._contentArea.add_child(actHeader); + const rows = new St.BoxLayout({ vertical: true, style_class: 'codeburn-activity-rows' }); + const activities = Array.isArray(current.topActivities) ? current.topActivities : []; + if (!activities.length) { + rows.add_child(new St.Label({ text: 'No activity for this period', style_class: 'codeburn-empty' })); + } else { + const maxCost = activities.reduce((m, a) => Math.max(m, Number(a.cost) || 0), 0) || 1; + for (const a of activities.slice(0, TOP_ACTIVITIES)) { + rows.add_child(this._buildActivityRow(a, maxCost)); + } + } + this._contentArea.add_child(rows); + + const models = Array.isArray(current.topModels) ? current.topModels : []; + if (models.length) { + this._contentArea.add_child(this._sectionTitle('Models')); + const modHeader = new St.BoxLayout({ style_class: 'codeburn-table-header' }); + modHeader.add_child(new St.Label({ text: 'Model', style_class: 'codeburn-th', x_expand: true })); + modHeader.add_child(new St.Label({ text: 'Cost', style_class: 'codeburn-th codeburn-th-right codeburn-th-cost' })); + modHeader.add_child(new St.Label({ text: 'Calls', style_class: 'codeburn-th codeburn-th-right codeburn-th-calls' })); + this._contentArea.add_child(modHeader); + const mrows = new St.BoxLayout({ vertical: true, style_class: 'codeburn-models-rows' }); + for (const m of models.slice(0, 3)) mrows.add_child(this._buildModelRow(m)); + this._contentArea.add_child(mrows); + } + } + + _renderTrendView() { + const daily = this._payload?.history?.daily ?? []; + if (!daily.length) { + this._contentArea.add_child(new St.Label({ text: 'Not enough history yet', style_class: 'codeburn-empty' })); + return; + } + for (const d of daily.slice(-7).reverse()) { + const row = new St.BoxLayout({ style_class: 'codeburn-trend-row' }); + row.add_child(new St.Label({ text: d.date, style_class: 'codeburn-trend-date', x_expand: true })); + const costLabel = new St.Label({ text: this._fmt(d.cost), style_class: 'codeburn-trend-cost' }); + costLabel.clutter_text.x_align = Clutter.ActorAlign.END; + row.add_child(costLabel); + const callsLabel = new St.Label({ text: `${Number(d.calls).toLocaleString()} calls`, style_class: 'codeburn-trend-calls' }); + callsLabel.clutter_text.x_align = Clutter.ActorAlign.END; + row.add_child(callsLabel); + this._contentArea.add_child(row); + } + } + + _renderForecastView() { + const daily = this._payload?.history?.daily ?? []; + if (daily.length < 3) { + this._contentArea.add_child(new St.Label({ text: 'Need at least 3 days of history', style_class: 'codeburn-empty' })); + return; + } + const last7 = daily.slice(-7); + const avg = last7.reduce((s, d) => s + Number(d.cost || 0), 0) / last7.length; + const yesterday = daily.at(-2); + const yestCost = Number(yesterday?.cost || 0); + const todCost = Number(daily.at(-1)?.cost || 0); + const dod = yestCost > 0 ? ((todCost - yestCost) / yestCost) * 100 : 0; + const now = new Date(); + const dayOfMonth = now.getUTCDate(); + const daysInMonth = new Date(now.getUTCFullYear(), now.getUTCMonth() + 1, 0).getUTCDate(); + + this._contentArea.add_child(this._kvRow('7-day avg', this._fmt(avg))); + this._contentArea.add_child(this._kvRow('Yesterday', yesterday ? this._fmt(yestCost) : '-')); + this._contentArea.add_child(this._kvRow('Day-over-day', `${dod > 0 ? '+' : ''}${dod.toFixed(1)}%`)); + this._contentArea.add_child(this._kvRow('Month projection', this._fmt(avg * daysInMonth))); + this._contentArea.add_child(this._kvRow('Days elapsed', `${dayOfMonth} of ${daysInMonth}`)); + } + + _renderPulseView() { + const current = this._payload?.current ?? {}; + const daily = this._payload?.history?.daily ?? []; + this._contentArea.add_child(this._sectionTitle('Pulse')); + const row = new St.BoxLayout({ style_class: 'codeburn-pulse-row' }); + row.add_child(this._pulseTile(this._fmt(current.cost), 'cost')); + row.add_child(this._pulseTile(Number(current.calls || 0).toLocaleString(), 'calls')); + row.add_child(this._pulseTile(`${Number(current.cacheHitPercent || 0).toFixed(0)}%`, 'cache hit')); + this._contentArea.add_child(row); + + if (daily.length) { + this._contentArea.add_child(this._sectionTitle('Last 7 days')); + const last7 = daily.slice(-7); + const sumCost = last7.reduce((s, d) => s + Number(d.cost || 0), 0); + const sumCalls = last7.reduce((s, d) => s + Number(d.calls || 0), 0); + const peakDay = last7.reduce((best, d) => Number(d.cost || 0) > Number(best.cost || 0) ? d : best, last7[0]); + this._contentArea.add_child(this._kvRow('Total spend', this._fmt(sumCost))); + this._contentArea.add_child(this._kvRow('Total calls', Number(sumCalls).toLocaleString())); + this._contentArea.add_child(this._kvRow('Peak day', `${peakDay?.date || '-'} ${this._fmt(peakDay?.cost)}`)); + } + } + + _renderStatsView() { + const current = this._payload?.current ?? {}; + const daily = this._payload?.history?.daily ?? []; + this._contentArea.add_child(this._sectionTitle('Stats')); + const models = Array.isArray(current.topModels) ? current.topModels : []; + const favModel = models[0]?.name ?? '-'; + const activeDays = daily.filter(d => Number(d.cost || 0) > 0).length; + const peakDay = daily.reduce((best, d) => Number(d.cost || 0) > Number((best || {}).cost || 0) ? d : best, null); + let streak = 0; + for (let i = daily.length - 1; i >= 0; i--) { + if (Number(daily[i].cost || 0) > 0) streak++; + else break; + } + this._contentArea.add_child(this._kvRow('Favorite model', favModel)); + this._contentArea.add_child(this._kvRow('Active days', `${activeDays}`)); + this._contentArea.add_child(this._kvRow('Current streak', `${streak} days`)); + if (peakDay) this._contentArea.add_child(this._kvRow('Peak day', `${peakDay.date} ${this._fmt(peakDay.cost)}`)); + } + + _renderFindings(optimize) { + const count = Number(optimize?.findingCount ?? 0); + if (count === 0) { + this._findingsBtn.hide(); + return; + } + const savings = Number(optimize?.savingsUSD ?? 0); + this._findingsCount.set_text(`${count} optimize findings`); + this._findingsSavings.set_text(`save ~${this._fmt(savings)}`); + this._findingsBtn.show(); + } + + _renderError(message) { + this._panelLabel.set_text('!'); + if (message?.includes('not found') || message?.includes('No such file')) { + this._heroLabel.set_text('CodeBurn CLI not found'); + this._heroMeta.set_text('Install: npm i -g codeburn'); + } else { + this._heroLabel.set_text('Error loading data'); + this._heroMeta.set_text(message?.substring(0, 80) || 'Unknown error'); + } + this._heroAmount.set_text(''); + this._findingsBtn.hide(); + } + + // -- Budget -- + + _updateBudget() { + const enabled = this._settings.get_boolean('budget-alert-enabled'); + const threshold = this._settings.get_double('budget-threshold'); + if (!enabled || threshold <= 0 || !this._payload?.current) { + this._budgetLabel.visible = false; + return; + } + const cost = Number(this._payload.current.cost ?? 0) * this._fxRate; + const thresholdConverted = threshold * this._fxRate; + if (cost >= thresholdConverted) { + this._budgetLabel.set_text(`Budget exceeded: ${this._fmt(cost)} / ${this._fmt(thresholdConverted)}`); + this._budgetLabel.visible = true; + } else { + this._budgetLabel.visible = false; + } + } + + // -- Currency -- + + _loadCurrency() { + const configPath = GLib.build_filenamev([GLib.get_home_dir(), '.config', 'codeburn', 'config.json']); + try { + const [ok, contents] = GLib.file_get_contents(configPath); + if (ok) { + const config = JSON.parse(new TextDecoder().decode(contents)); + if (config.currency?.code) { + const known = CURRENCIES.find(c => c.code === config.currency.code); + if (known) return known; + return { code: config.currency.code, symbol: config.currency.symbol || `${config.currency.code} ` }; + } + } + } catch (_) { /* default */ } + return CURRENCIES[0]; + } + + _toggleCurrencyPicker() { + this._currencyPicker.visible = !this._currencyPicker.visible; + } + + _setCurrency(code) { + try { + Gio.Subprocess.new(['codeburn', 'currency', code], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE); + } catch (_) { /* CLI missing */ } + const known = CURRENCIES.find(c => c.code === code); + this._currency = known || { code, symbol: `${code} ` }; + this._currencyBtn.set_label(`${this._currency.code} ⌄`); + this._updateFxRate(); + } + + _updateFxRate() { + const code = this._currency?.code || 'USD'; + if (this._fxCache[code] !== undefined) { + this._fxRate = this._fxCache[code]; + if (this._payload) this._render(this._payload); + return; + } + const url = `https://api.frankfurter.app/latest?from=USD&to=${code}`; + const msg = Soup.Message.new('GET', url); + this._soupSession.send_and_read_async(msg, GLib.PRIORITY_DEFAULT, null, (session, result) => { + if (this._destroyed) return; + try { + const bytes = session.send_and_read_finish(result); + if (!bytes) return; + const json = JSON.parse(new TextDecoder().decode(bytes.get_data())); + const rate = json?.rates?.[code]; + if (typeof rate === 'number' && rate > 0) { + this._fxCache[code] = rate; + this._fxRate = rate; + if (this._payload) this._render(this._payload); + } + } catch (_) { /* FX fetch failed */ } + }); + } + + _fmt(value) { + return formatCost(value, this._currency, this._fxRate, this._exactCosts); + } + + // -- UI helpers -- + + _sectionTitle(text) { + return new St.Label({ text, style_class: 'codeburn-section-title' }); + } + + _kvRow(label, value) { + const row = new St.BoxLayout({ style_class: 'codeburn-kv-row' }); + row.add_child(new St.Label({ text: label, style_class: 'codeburn-kv-label', x_expand: true })); + row.add_child(new St.Label({ text: String(value ?? '-'), style_class: 'codeburn-kv-value' })); + return row; + } + + _pulseTile(value, label) { + const tile = new St.BoxLayout({ vertical: true, style_class: 'codeburn-pulse-tile', x_expand: true }); + tile.add_child(new St.Label({ text: value, style_class: 'codeburn-pulse-value' })); + tile.add_child(new St.Label({ text: label, style_class: 'codeburn-pulse-label' })); + return tile; + } + + _buildActivityRow(activity, maxCost) { + const row = new St.BoxLayout({ vertical: true, style_class: 'codeburn-activity-row' }); + const topLine = new St.BoxLayout({ style_class: 'codeburn-activity-top' }); + topLine.add_child(new St.Label({ text: activity.name, style_class: 'codeburn-activity-name', x_expand: true })); + const costLabel = new St.Label({ text: this._fmt(activity.cost), style_class: 'codeburn-activity-cost' }); + costLabel.clutter_text.x_align = Clutter.ActorAlign.END; + topLine.add_child(costLabel); + const turnsLabel = new St.Label({ text: `${Number(activity.turns) || 0}`, style_class: 'codeburn-activity-turns' }); + turnsLabel.clutter_text.x_align = Clutter.ActorAlign.END; + topLine.add_child(turnsLabel); + const osText = activity.oneShotRate != null ? `${Math.round(Number(activity.oneShotRate) * 100)}%` : '--'; + const osLabel = new St.Label({ text: osText, style_class: 'codeburn-activity-oneshot' }); + osLabel.clutter_text.x_align = Clutter.ActorAlign.END; + topLine.add_child(osLabel); + row.add_child(topLine); + + const track = new St.BoxLayout({ style_class: 'codeburn-bar-track' }); + const pct = Math.max(0.02, Math.min(1, Number(activity.cost) / maxCost)); + const fill = new St.Widget({ style_class: 'codeburn-bar-fill' }); + fill.set_width(Math.round(BAR_TRACK_WIDTH * pct)); + track.add_child(fill); + row.add_child(track); + return row; + } + + _buildModelRow(model) { + const row = new St.BoxLayout({ style_class: 'codeburn-model-row' }); + row.add_child(new St.Label({ text: model.name, style_class: 'codeburn-model-name', x_expand: true })); + const mc = new St.Label({ text: this._fmt(model.cost), style_class: 'codeburn-model-cost' }); + mc.clutter_text.x_align = Clutter.ActorAlign.END; + row.add_child(mc); + const mcalls = new St.Label({ text: `${Number(model.calls || 0).toLocaleString()}`, style_class: 'codeburn-model-calls' }); + mcalls.clutter_text.x_align = Clutter.ActorAlign.END; + row.add_child(mcalls); + return row; + } + + // -- Theme -- + + _applyThemeClass() { + const forceDark = this._settings.get_boolean('force-dark-mode'); + const scheme = this._themeSettings.get_string('color-scheme'); + const isDark = forceDark || scheme === 'prefer-dark'; + if (isDark) { + this._root?.add_style_class_name('codeburn-dark'); + this._root?.remove_style_class_name('codeburn-light'); + } else { + this._root?.add_style_class_name('codeburn-light'); + this._root?.remove_style_class_name('codeburn-dark'); + } + } + + // -- Terminal spawning -- + + _spawnTerminal(argv) { + const command = `${argv.join(' ')}; echo; read -n 1 -s -r -p 'Press any key to close...'`; + try { + Gio.Subprocess.new(['gnome-terminal', '--', 'bash', '-lc', command], Gio.SubprocessFlags.NONE); + } catch (e) { + log(`CodeBurn: terminal spawn error: ${e.message}`); + } + this.menu.close(); + } + + // -- Cleanup -- + + destroy() { + this._destroyed = true; + if (this._refreshSourceId) { + GLib.Source.remove(this._refreshSourceId); + this._refreshSourceId = 0; + } + if (this._themeSettings && this._themeSignal) { + this._themeSettings.disconnect(this._themeSignal); + this._themeSignal = null; + this._themeSettings = null; + } + for (const id of this._settingsChangedIds) this._settings.disconnect(id); + this._settingsChangedIds = []; + this._dataClient?.destroy(); + if (this._soupSession) { + this._soupSession.abort(); + this._soupSession = null; + } + super.destroy(); + } +}); diff --git a/gnome/install.sh b/gnome/install.sh new file mode 100755 index 0000000..df03881 --- /dev/null +++ b/gnome/install.sh @@ -0,0 +1,38 @@ +#!/bin/bash +set -euo pipefail + +UUID="codeburn@codeburn.dev" +INSTALL_DIR="${HOME}/.local/share/gnome-shell/extensions/${UUID}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "Installing CodeBurn GNOME extension..." + +# Compile GSettings schema +echo "Compiling schemas..." +glib-compile-schemas "${SCRIPT_DIR}/schemas/" + +# Create install directory +mkdir -p "${INSTALL_DIR}" + +# Copy extension files +cp "${SCRIPT_DIR}/metadata.json" "${INSTALL_DIR}/" +cp "${SCRIPT_DIR}/extension.js" "${INSTALL_DIR}/" +cp "${SCRIPT_DIR}/indicator.js" "${INSTALL_DIR}/" +cp "${SCRIPT_DIR}/dataClient.js" "${INSTALL_DIR}/" +cp "${SCRIPT_DIR}/prefs.js" "${INSTALL_DIR}/" +cp "${SCRIPT_DIR}/stylesheet.css" "${INSTALL_DIR}/" + +# Copy schemas +mkdir -p "${INSTALL_DIR}/schemas" +cp "${SCRIPT_DIR}/schemas/"* "${INSTALL_DIR}/schemas/" + +# Copy icons +mkdir -p "${INSTALL_DIR}/icons" +cp "${SCRIPT_DIR}/icons/"* "${INSTALL_DIR}/icons/" + +echo "Extension installed to ${INSTALL_DIR}" +echo "" +echo "Next steps:" +echo " 1. Restart GNOME Shell (log out and back in on Wayland)" +echo " 2. Enable: gnome-extensions enable ${UUID}" +echo " 3. Configure: gnome-extensions prefs ${UUID}" diff --git a/gnome/metadata.json b/gnome/metadata.json new file mode 100644 index 0000000..be8d2c0 --- /dev/null +++ b/gnome/metadata.json @@ -0,0 +1,8 @@ +{ + "name": "CodeBurn Monitor", + "description": "Monitor AI coding assistant token usage and costs", + "uuid": "codeburn@codeburn.dev", + "shell-version": ["45", "46", "47", "48", "49", "50"], + "url": "https://github.com/getagentseal/codeburn", + "settings-schema": "org.gnome.shell.extensions.codeburn" +} diff --git a/gnome/prefs.js b/gnome/prefs.js new file mode 100644 index 0000000..08d4b82 --- /dev/null +++ b/gnome/prefs.js @@ -0,0 +1,170 @@ +import Adw from 'gi://Adw'; +import Gtk from 'gi://Gtk'; +import Gio from 'gi://Gio'; +import { ExtensionPreferences } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; + +const PROVIDERS = [ + { id: 'claude', label: 'Claude' }, + { id: 'codex', label: 'Codex' }, + { id: 'copilot', label: 'Copilot' }, + { id: 'cursor', label: 'Cursor' }, + { id: 'droid', label: 'Droid' }, + { id: 'gemini', label: 'Gemini' }, + { id: 'goose', label: 'Goose' }, + { id: 'kilo-code', label: 'Kilo Code' }, + { id: 'kiro', label: 'Kiro' }, + { id: 'kimi', label: 'Kimi' }, + { id: 'openclaw', label: 'OpenClaw' }, + { id: 'opencode', label: 'OpenCode' }, + { id: 'pi', label: 'Pi' }, + { id: 'qwen', label: 'Qwen' }, + { id: 'roo-code', label: 'Roo Code' }, + { id: 'antigravity', label: 'Antigravity' }, +]; + +const PERIODS = [ + { id: 'today', label: 'Today' }, + { id: 'week', label: '7 Days' }, + { id: '30days', label: '30 Days' }, + { id: 'month', label: 'Month' }, + { id: 'all', label: '6 Months' }, +]; + +export default class CodeBurnPreferences extends ExtensionPreferences { + fillPreferencesWindow(window) { + const settings = this.getSettings(); + + const displayPage = new Adw.PreferencesPage({ + title: 'Display', + icon_name: 'preferences-desktop-display-symbolic', + }); + window.add(displayPage); + + const displayGroup = new Adw.PreferencesGroup({ + title: 'Display', + description: 'Configure how CodeBurn appears in the panel', + }); + displayPage.add(displayGroup); + + const refreshRow = new Adw.SpinRow({ + title: 'Refresh Interval', + subtitle: 'Seconds between data refreshes', + adjustment: new Gtk.Adjustment({ + lower: 5, + upper: 300, + step_increment: 5, + page_increment: 30, + value: settings.get_uint('refresh-interval'), + }), + }); + settings.bind('refresh-interval', refreshRow, 'value', Gio.SettingsBindFlags.DEFAULT); + displayGroup.add(refreshRow); + + const compactRow = new Adw.SwitchRow({ + title: 'Compact Mode', + subtitle: 'Show only the icon, hide the cost label', + }); + settings.bind('compact-mode', compactRow, 'active', Gio.SettingsBindFlags.DEFAULT); + displayGroup.add(compactRow); + + const darkModeRow = new Adw.SwitchRow({ + title: 'Force Dark Mode', + subtitle: 'Always use dark theme for the popup', + }); + settings.bind('force-dark-mode', darkModeRow, 'active', Gio.SettingsBindFlags.DEFAULT); + displayGroup.add(darkModeRow); + + const exactCostsRow = new Adw.SwitchRow({ + title: 'Show Exact Costs', + subtitle: 'Show full values like $2,655.23 instead of $2.7k', + }); + settings.bind('show-exact-costs', exactCostsRow, 'active', Gio.SettingsBindFlags.DEFAULT); + displayGroup.add(exactCostsRow); + + const periodModel = new Gtk.StringList(); + for (const p of PERIODS) + periodModel.append(p.label); + + const periodRow = new Adw.ComboRow({ + title: 'Default Period', + subtitle: 'Time period shown when extension opens', + model: periodModel, + }); + const currentPeriod = settings.get_string('default-period'); + const periodIndex = PERIODS.findIndex(p => p.id === currentPeriod); + periodRow.set_selected(periodIndex >= 0 ? periodIndex : 0); + periodRow.connect('notify::selected', () => { + const idx = periodRow.get_selected(); + if (idx >= 0 && idx < PERIODS.length) + settings.set_string('default-period', PERIODS[idx].id); + }); + displayGroup.add(periodRow); + + const alertsGroup = new Adw.PreferencesGroup({ + title: 'Budget Alerts', + description: 'Get warned when spending exceeds a threshold', + }); + displayPage.add(alertsGroup); + + const budgetEnabledRow = new Adw.SwitchRow({ + title: 'Enable Budget Alerts', + subtitle: 'Show a warning when daily spending exceeds the threshold', + }); + settings.bind('budget-alert-enabled', budgetEnabledRow, 'active', Gio.SettingsBindFlags.DEFAULT); + alertsGroup.add(budgetEnabledRow); + + const budgetRow = new Adw.SpinRow({ + title: 'Daily Budget (USD)', + subtitle: 'Set to 0 to disable', + adjustment: new Gtk.Adjustment({ + lower: 0, + upper: 1000, + step_increment: 1, + page_increment: 10, + value: settings.get_double('budget-threshold'), + }), + digits: 2, + }); + settings.bind('budget-threshold', budgetRow, 'value', Gio.SettingsBindFlags.DEFAULT); + alertsGroup.add(budgetRow); + + const providersGroup = new Adw.PreferencesGroup({ + title: 'Providers', + description: 'Toggle providers on/off for cost accounting', + }); + displayPage.add(providersGroup); + + const disabledProviders = settings.get_strv('disabled-providers'); + + for (const provider of PROVIDERS) { + const row = new Adw.SwitchRow({ + title: provider.label, + active: !disabledProviders.includes(provider.id), + }); + row.connect('notify::active', () => { + const current = settings.get_strv('disabled-providers'); + if (row.get_active()) { + settings.set_strv('disabled-providers', current.filter(p => p !== provider.id)); + } else { + if (!current.includes(provider.id)) + settings.set_strv('disabled-providers', [...current, provider.id]); + } + }); + providersGroup.add(row); + } + + const advancedGroup = new Adw.PreferencesGroup({ + title: 'Advanced', + }); + displayPage.add(advancedGroup); + + const pathRow = new Adw.EntryRow({ + title: 'CodeBurn CLI Path', + text: settings.get_string('codeburn-path'), + }); + pathRow.connect('changed', () => { + settings.set_string('codeburn-path', pathRow.get_text()); + }); + advancedGroup.add(pathRow); + } +} diff --git a/gnome/schemas/org.gnome.shell.extensions.codeburn.gschema.xml b/gnome/schemas/org.gnome.shell.extensions.codeburn.gschema.xml new file mode 100644 index 0000000..e122cd8 --- /dev/null +++ b/gnome/schemas/org.gnome.shell.extensions.codeburn.gschema.xml @@ -0,0 +1,68 @@ + + + + + + 30 + Refresh interval + Seconds between automatic data refreshes + + + + + 'today' + Default time period + Period shown when extension opens (today, week, 30days, month, all) + + + + 0.0 + Budget threshold + Daily budget threshold in USD. Set to 0 to disable. + + + + false + Enable budget alerts + Show warning when spending exceeds budget threshold + + + + false + Compact mode + Show only icon in panel, hide cost label + + + + false + Force dark mode + Always use dark theme for the popup, regardless of system theme + + + + false + Show exact costs + Show full decimal values instead of compact notation (e.g. $2,655.23 instead of $2.7k) + + + + '' + CodeBurn CLI path + Custom path to the codeburn executable. Leave empty to use PATH. + + + + 'all' + Default provider filter + Default provider to filter by (all shows everything) + + + + [] + Disabled providers + Providers excluded from cost accounting and display + + + + diff --git a/gnome/stylesheet.css b/gnome/stylesheet.css new file mode 100644 index 0000000..74bf896 --- /dev/null +++ b/gnome/stylesheet.css @@ -0,0 +1,610 @@ +/* ---- panel button ---- */ +.codeburn-panel { + spacing: 4px; +} +.codeburn-flame { + font-size: 14px; +} +.codeburn-label { + font-weight: 500; + padding-left: 2px; + padding-right: 2px; +} + +/* ---- popup host ---- */ +.codeburn-menu { + padding: 0; +} +.codeburn-host { + padding: 0; + margin: 0; + background: transparent; + border: none; +} +.codeburn-host:hover, +.codeburn-host:focus, +.codeburn-host:active, +.codeburn-host:selected { + background: transparent; +} +.codeburn-root { + width: 340px; + height: 540px; + padding: 0; + spacing: 0; +} +.codeburn-scroll { + padding: 0; +} + +/* ---- brand header ---- */ +.codeburn-brand-header { + padding: 14px 16px 10px 16px; + spacing: 2px; +} +.codeburn-brand-row { + spacing: 0; +} +.codeburn-brand-primary { + font-weight: 700; + font-size: 18px; +} +.codeburn-brand-accent { + font-weight: 700; + font-size: 18px; + color: #ff8c42; +} +.codeburn-brand-subhead { + font-size: 10.5px; + opacity: 0.55; +} + +/* ---- tab rows ---- */ +.codeburn-tab-row { + padding: 4px 10px 8px 10px; + spacing: 4px; +} +.codeburn-period-row { + padding-top: 0; + padding-bottom: 10px; +} +.codeburn-tab, +.codeburn-period { + padding: 5px 6px; + border-radius: 6px; + font-size: 11px; + font-weight: 500; + background: transparent; + border: none; + opacity: 0.7; + transition-duration: 80ms; +} +.codeburn-tab:hover, +.codeburn-period:hover { + background: rgba(255, 140, 66, 0.08); + opacity: 1; +} +.codeburn-tab-active, +.codeburn-period-active { + background: rgba(255, 140, 66, 0.18); + color: #ff8c42; + opacity: 1; + font-weight: 600; +} +.codeburn-agent-scroll { + padding: 0; +} +.codeburn-agent-badge { + padding: 3px 10px; + border-radius: 10px; + background: rgba(255, 140, 66, 0.12); + color: #ff8c42; + font-size: 10.5px; + font-weight: 500; +} + +/* ---- hero ---- */ +.codeburn-hero { + padding: 4px 16px 10px 16px; + spacing: 2px; +} +.codeburn-hero-top { + spacing: 6px; +} +.codeburn-hero-dot { + width: 6px; + height: 6px; + border-radius: 3px; + background-color: #ff8c42; + margin-top: 7px; +} +.codeburn-hero-label { + font-size: 11px; + opacity: 0.65; + font-weight: 500; +} +.codeburn-hero-amount { + font-size: 28px; + font-weight: 700; + color: #ffd700; +} +.codeburn-hero-meta { + font-size: 11px; + opacity: 0.6; +} + +/* ---- activity section ---- */ +.codeburn-section-title { + font-weight: 600; + font-size: 11px; + opacity: 0.6; + padding-bottom: 2px; +} +/* ---- table headers ---- */ +.codeburn-table-header { + spacing: 6px; + padding: 2px 0 4px 0; +} +.codeburn-th { + font-size: 10px; + font-weight: 600; + opacity: 0.45; +} +.codeburn-th-cost { + min-width: 64px; +} +.codeburn-th-turns { + min-width: 40px; +} +.codeburn-th-calls { + min-width: 50px; +} + +.codeburn-activity-rows { + spacing: 0; +} +.codeburn-activity-row { + spacing: 3px; + padding: 6px 0; +} +.codeburn-activity-top { + spacing: 6px; +} +.codeburn-activity-name { + font-size: 11.5px; + font-weight: 500; + min-width: 120px; +} +.codeburn-activity-cost { + font-size: 11.5px; + font-family: monospace; + font-weight: 600; + color: #ffd700; + min-width: 64px; +} +.codeburn-activity-turns { + font-size: 10.5px; + font-family: monospace; + opacity: 0.6; + min-width: 40px; +} +.codeburn-activity-oneshot { + font-size: 10.5px; + font-family: monospace; + color: #4ec972; + min-width: 40px; +} +.codeburn-bar-track { + height: 4px; + border-radius: 2px; + background-color: rgba(255, 255, 255, 0.08); + width: 240px; +} +.codeburn-bar-fill { + height: 4px; + border-radius: 2px; + background-color: #ff8c42; +} +.codeburn-empty { + font-style: italic; + opacity: 0.55; + padding: 6px 0; +} + +/* ---- loading skeleton ---- */ +.codeburn-loading { + padding: 10px 16px; + spacing: 10px; +} +.codeburn-skeleton-bar { + background-color: rgba(255, 140, 66, 0.15); + border-radius: 4px; +} +.codeburn-light .codeburn-skeleton-bar { + background-color: rgba(200, 80, 30, 0.12); +} + +/* ---- findings CTA ---- */ +.codeburn-findings { + margin: 2px 16px 10px 16px; + padding: 9px 11px; + border-radius: 8px; + background: rgba(255, 140, 66, 0.12); + border: none; + transition-duration: 120ms; +} +.codeburn-findings:hover { + background: rgba(255, 140, 66, 0.2); +} +.codeburn-findings-inner { + spacing: 8px; +} +.codeburn-findings-count { + font-size: 11.5px; + font-weight: 600; + color: #ff8c42; +} +.codeburn-findings-savings { + font-size: 11.5px; + font-weight: 500; + color: #ff8c42; + opacity: 0.8; +} + +/* ---- footer ---- */ +.codeburn-footer { + padding: 10px 12px; + spacing: 6px; +} +.codeburn-footer-btn { + padding: 6px 10px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.05); + border: none; + font-size: 11px; + font-weight: 500; + transition-duration: 80ms; +} +.codeburn-footer-btn:hover { + background: rgba(255, 255, 255, 0.1); +} +.codeburn-currency-box { + spacing: 2px; +} +.codeburn-currency-btn { + font-family: monospace; + min-width: 62px; +} +.codeburn-currency-picker { + background: rgba(30, 30, 30, 0.95); + border-radius: 8px; + padding: 4px; + height: 180px; +} +.codeburn-currency-list { + spacing: 1px; +} +.codeburn-currency-item { + padding: 4px 10px; + border-radius: 4px; + font-size: 11px; + font-family: monospace; + background: transparent; + border: none; +} +.codeburn-currency-item:hover { + background: rgba(255, 140, 66, 0.12); +} +.codeburn-currency-item-active { + background: rgba(255, 140, 66, 0.2); + color: #ff8c42; + font-weight: 600; +} +.codeburn-footer-cta { + background: #c9521d; + color: #ffffff; +} +.codeburn-footer-cta:hover { + background: #ff8c42; +} +.codeburn-updated { + font-size: 10px; + opacity: 0.45; + padding: 0 16px 10px 16px; +} + +/* ---- insight pills row ---- */ +.codeburn-insight-row { + padding: 4px 10px 8px 10px; + spacing: 4px; +} +.codeburn-insight-pill { + padding: 4px 4px; + border-radius: 6px; + font-size: 10.5px; + font-weight: 500; + background: transparent; + border: none; + opacity: 0.65; + transition-duration: 80ms; +} +.codeburn-insight-pill:hover { + background: rgba(255, 140, 66, 0.08); + opacity: 1; +} +.codeburn-insight-pill-active { + background: rgba(255, 140, 66, 0.18); + color: #ff8c42; + opacity: 1; + font-weight: 600; +} + +/* ---- token histogram chart ---- */ +.codeburn-chart { + padding: 0 16px 10px 16px; + spacing: 4px; +} +.codeburn-chart-header { + spacing: 6px; +} +.codeburn-chart-label { + font-weight: 600; + font-size: 11px; + opacity: 0.6; +} +.codeburn-chart-total { + font-family: monospace; + font-size: 11px; + opacity: 0.7; + color: #ff8c42; +} +.codeburn-chart-bars { + spacing: 2px; + height: 52px; +} +.codeburn-chart-col { + height: 52px; +} +.codeburn-chart-spacer { + background: transparent; +} +.codeburn-chart-bar { + background-color: #ff8c42; + border-radius: 2px 2px 0 0; +} +.codeburn-chart-bar-hover { + background-color: #ffa94d; +} +.codeburn-chart-total-hover { + font-weight: 600; +} +.codeburn-divider { + height: 1px; + background-color: rgba(255, 255, 255, 0.08); + margin: 4px 16px; +} + +/* ---- trend, pulse, stats, kv rows ---- */ +.codeburn-content { + padding: 6px 16px 10px 16px; + spacing: 6px; +} +.codeburn-trend-row, +.codeburn-kv-row { + padding: 4px 0; + spacing: 8px; +} +.codeburn-trend-date, +.codeburn-kv-label { + font-size: 11.5px; + font-weight: 500; +} +.codeburn-trend-cost, +.codeburn-kv-value { + font-family: monospace; + font-size: 11.5px; + font-weight: 600; + color: #ffd700; +} +.codeburn-trend-calls { + font-size: 10.5px; + opacity: 0.6; + min-width: 62px; +} + +/* ---- pulse tiles ---- */ +.codeburn-pulse-row { + spacing: 6px; + padding: 4px 0; +} +.codeburn-pulse-tile { + padding: 10px 8px; + border-radius: 8px; + background: rgba(255, 140, 66, 0.08); + spacing: 2px; +} +.codeburn-pulse-value { + font-size: 16px; + font-weight: 700; + color: #ff8c42; + font-family: monospace; +} +.codeburn-pulse-label { + font-size: 10px; + opacity: 0.6; +} + +/* ---- models rows ---- */ +.codeburn-models-rows { + spacing: 0; + padding-top: 4px; +} +.codeburn-model-row { + spacing: 8px; + padding: 6px 0; +} +.codeburn-model-name { + font-size: 11.5px; + min-width: 120px; +} +.codeburn-model-cost { + font-family: monospace; + font-size: 11.5px; + color: #ffd700; + min-width: 64px; +} +.codeburn-model-calls { + font-family: monospace; + font-size: 10.5px; + opacity: 0.6; + min-width: 50px; +} + +/* ---- settings gear button ---- */ +.codeburn-prefs-btn { + padding: 6px 8px; + font-size: 14px; +} + +/* ---- budget warning ---- */ +.codeburn-budget-warning { + color: #e5a50a; + font-weight: bold; + font-size: 11.5px; + padding: 6px 16px; +} + +/* ---- dark theme ---- */ +.codeburn-dark { + background-color: rgba(30, 30, 30, 0.98); + color: #e0e0e0; + border-radius: 12px; +} +.codeburn-dark .codeburn-brand-primary { + color: #ffffff; +} +.codeburn-dark .codeburn-brand-subhead { + color: rgba(255, 255, 255, 0.55); +} +.codeburn-dark .codeburn-hero-label, +.codeburn-dark .codeburn-hero-meta { + color: rgba(255, 255, 255, 0.65); +} +.codeburn-dark .codeburn-section-title, +.codeburn-dark .codeburn-th, +.codeburn-dark .codeburn-chart-label { + color: rgba(255, 255, 255, 0.5); +} +.codeburn-dark .codeburn-activity-name, +.codeburn-dark .codeburn-model-name, +.codeburn-dark .codeburn-trend-date, +.codeburn-dark .codeburn-kv-label { + color: #e0e0e0; +} +.codeburn-dark .codeburn-activity-turns, +.codeburn-dark .codeburn-model-calls, +.codeburn-dark .codeburn-trend-calls { + color: rgba(255, 255, 255, 0.5); +} +.codeburn-dark .codeburn-footer-btn { + background: rgba(255, 255, 255, 0.08); + color: #e0e0e0; +} +.codeburn-dark .codeburn-footer-btn:hover { + background: rgba(255, 255, 255, 0.14); +} +.codeburn-dark .codeburn-currency-picker { + background: rgba(20, 20, 20, 0.98); +} +.codeburn-dark .codeburn-currency-item { + color: #e0e0e0; +} +.codeburn-dark .codeburn-tab, +.codeburn-dark .codeburn-period, +.codeburn-dark .codeburn-insight-pill { + color: rgba(255, 255, 255, 0.7); +} +.codeburn-dark .codeburn-updated { + color: rgba(255, 255, 255, 0.45); +} + +/* ---- light theme ---- */ +.codeburn-light { + background-color: rgba(255, 255, 255, 0.98); + color: #1a1a1a; + border-radius: 12px; +} +.codeburn-light .codeburn-brand-primary { + color: #1a1a1a; +} +.codeburn-light .codeburn-brand-subhead { + color: rgba(0, 0, 0, 0.5); +} +.codeburn-light .codeburn-hero-label, +.codeburn-light .codeburn-hero-meta { + color: rgba(0, 0, 0, 0.6); +} +.codeburn-light .codeburn-hero-amount { + color: #c9521d; +} +.codeburn-light .codeburn-section-title, +.codeburn-light .codeburn-th, +.codeburn-light .codeburn-chart-label { + color: rgba(0, 0, 0, 0.45); +} +.codeburn-light .codeburn-activity-name, +.codeburn-light .codeburn-model-name, +.codeburn-light .codeburn-trend-date, +.codeburn-light .codeburn-kv-label { + color: #1a1a1a; +} +.codeburn-light .codeburn-activity-cost, +.codeburn-light .codeburn-model-cost, +.codeburn-light .codeburn-trend-cost, +.codeburn-light .codeburn-kv-value { + color: #c9521d; +} +.codeburn-light .codeburn-activity-turns, +.codeburn-light .codeburn-model-calls, +.codeburn-light .codeburn-trend-calls { + color: rgba(0, 0, 0, 0.5); +} +.codeburn-light .codeburn-activity-oneshot { + color: #1b7a35; +} +.codeburn-light .codeburn-bar-track { + background-color: rgba(0, 0, 0, 0.08); +} +.codeburn-light .codeburn-bar-fill { + background-color: #c9521d; +} +.codeburn-light .codeburn-chart-bar { + background-color: #c9521d; +} +.codeburn-light .codeburn-footer-btn { + background: rgba(0, 0, 0, 0.06); + color: #1a1a1a; +} +.codeburn-light .codeburn-footer-btn:hover { + background: rgba(0, 0, 0, 0.1); +} +.codeburn-light .codeburn-currency-picker { + background: rgba(245, 245, 245, 0.98); +} +.codeburn-light .codeburn-currency-item { + color: #1a1a1a; +} +.codeburn-light .codeburn-tab, +.codeburn-light .codeburn-period, +.codeburn-light .codeburn-insight-pill { + color: rgba(0, 0, 0, 0.65); +} +.codeburn-light .codeburn-pulse-tile { + background: rgba(255, 140, 66, 0.1); +} +.codeburn-light .codeburn-updated { + color: rgba(0, 0, 0, 0.4); +} +.codeburn-light .codeburn-divider { + background-color: rgba(0, 0, 0, 0.1); +} diff --git a/mac/README.md b/mac/README.md index 3a7f1d7..b12b836 100644 --- a/mac/README.md +++ b/mac/README.md @@ -6,19 +6,17 @@ Native Swift + SwiftUI menubar app. The codeburn menubar surface. - macOS 14+ (Sonoma) - Swift 6.0+ toolchain (bundled with Xcode 16 or standalone) -- `codeburn` CLI installed globally (`npm install -g codeburn`) or available at a path you pass via `CODEBURN_BIN` +- `codeburn` CLI installed globally (`npm install -g codeburn`) ## Install (end users) One command: ```bash -npx codeburn menubar +codeburn menubar ``` -That's it. The command downloads the latest `.app` from GitHub Releases, drops it into `~/Applications`, clears Gatekeeper quarantine, and launches it. Re-running it upgrades in place with `--force`, or just launches the existing copy otherwise. - -If you already have the CLI installed globally (`npm install -g codeburn`), `codeburn menubar` works the same way. +That's it. The command records the persistent `codeburn` CLI path, downloads the latest `.app` from the newest `mac-v*` GitHub Release with a matching checksum, verifies it, drops it into `~/Applications`, clears Gatekeeper quarantine, and launches it. Re-running it upgrades in place with `--force`, or just launches the existing copy otherwise. ### Build from source @@ -39,7 +37,7 @@ cd mac swift build # Point the app at your dev CLI build instead of the globally installed `codeburn`: npm --prefix .. run build -CODEBURN_BIN="node $(pwd)/../dist/cli.js" swift run +CODEBURN_ALLOW_DEV_BIN=1 CODEBURN_BIN="node $(pwd)/../dist/cli.js" swift run ``` The app registers itself as a menubar accessory (`LSUIElement = true` at runtime). No Dock icon. @@ -48,7 +46,7 @@ The app registers itself as a menubar accessory (`LSUIElement = true` at runtime On launch and every 60 seconds thereafter, the app spawns `codeburn status --format menubar-json --no-optimize` directly (argv, no shell) via `CodeburnCLI.makeProcess` and decodes the JSON into `MenubarPayload`. The manual refresh button in the footer invokes the same command without `--no-optimize`, which includes optimize findings but takes longer. -Override the binary via the `CODEBURN_BIN` environment variable (default: `codeburn` on PATH). The value is validated against a strict allowlist (alphanumerics plus `._/-` space) before use, so a malicious env var can't inject shell commands. +Release installs record a persistent absolute CLI path in `~/Library/Application Support/CodeBurn/codeburn-cli-path.v1`, then fall back to Homebrew's common `codeburn` locations. For development only, set `CODEBURN_ALLOW_DEV_BIN=1` with `CODEBURN_BIN`; the value is validated against a strict allowlist before use, so a malicious env var can't inject shell commands. ## Project layout diff --git a/mac/Scripts/package-app.sh b/mac/Scripts/package-app.sh index 5672b5e..c9982a7 100755 --- a/mac/Scripts/package-app.sh +++ b/mac/Scripts/package-app.sh @@ -9,6 +9,8 @@ set -euo pipefail VERSION="${1:-dev}" +ASSET_VERSION="${VERSION#mac-}" +BUNDLE_VERSION="${ASSET_VERSION#v}" BUNDLE_NAME="CodeBurnMenubar.app" BUNDLE_ID="org.agentseal.codeburn-menubar" EXECUTABLE_NAME="CodeBurnMenubar" @@ -66,9 +68,9 @@ cat > "${BUNDLE}/Contents/Info.plist" <CFBundlePackageType APPL CFBundleShortVersionString - ${VERSION} + ${BUNDLE_VERSION} CFBundleVersion - ${VERSION} + ${BUNDLE_VERSION} LSMinimumSystemVersion ${MIN_MACOS} LSUIElement @@ -85,19 +87,25 @@ cat > "${BUNDLE}/Contents/PkgInfo" <<'PKG' APPL???? PKG -# Ad-hoc sign so macOS treats the bundle as internally consistent. This satisfies the -# minimum bundle-validity checks on macOS 14+ and prevents a class of Gatekeeper edge -# cases on managed Macs. A Developer ID signature (separate setup) would additionally -# surface the publisher name in Finder; not required here. +# Ad-hoc sign so macOS treats the bundle as internally consistent. Release +# notarization can layer a Developer ID signature on top, but this local step +# must still fail closed if signing or verification breaks. echo "▸ Ad-hoc signing..." -codesign --force --sign - --timestamp=none --deep "${BUNDLE}" 2>/dev/null || true -codesign --verify --deep --strict "${BUNDLE}" 2>/dev/null || echo " (signature verify skipped)" +codesign --force --sign - --timestamp=none --deep "${BUNDLE}" +codesign --verify --deep --strict "${BUNDLE}" -ZIP_NAME="CodeBurnMenubar-${VERSION}.zip" +ZIP_NAME="CodeBurnMenubar-${ASSET_VERSION}.zip" ZIP_PATH="${DIST_DIR}/${ZIP_NAME}" echo "▸ Packaging ${ZIP_NAME}..." -(cd "${DIST_DIR}" && /usr/bin/ditto -c -k --keepParent "${BUNDLE_NAME}" "${ZIP_NAME}") +(cd "${DIST_DIR}" && COPYFILE_DISABLE=1 /usr/bin/ditto -c -k --norsrc --keepParent "${BUNDLE_NAME}" "${ZIP_NAME}") + +CHECKSUM_NAME="${ZIP_NAME}.sha256" +CHECKSUM_PATH="${DIST_DIR}/${CHECKSUM_NAME}" +echo "▸ Computing SHA-256 checksum..." +(cd "${DIST_DIR}" && shasum -a 256 "${ZIP_NAME}" > "${CHECKSUM_NAME}") echo "" echo "✓ Built ${ZIP_PATH}" +echo "✓ Checksum ${CHECKSUM_PATH}" +cat "${CHECKSUM_PATH}" ls -la "${DIST_DIR}" diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 4ac3948..c901362 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -2,6 +2,7 @@ import Foundation import Observation private let cacheTTLSeconds: TimeInterval = 30 +private let interactiveRefreshResetSeconds: TimeInterval = 120 struct CachedPayload { let payload: MenubarPayload @@ -25,14 +26,48 @@ final class AppStore { } var showingAccentPicker: Bool = false var currency: String = "USD" - var isLoading: Bool = false - var lastError: String? + var isLoading: Bool { loadingCountsByKey.values.contains { $0 > 0 } } + var isCurrentKeyLoading: Bool { loadingCountsByKey[currentKey, default: 0] > 0 } + var hasAttemptedCurrentKeyLoad: Bool { attemptedKeys.contains(currentKey) } + var lastError: String? { lastErrorByKey[currentKey] } + private var loadingCountsByKey: [PayloadCacheKey: Int] = [:] + private var loadingStartedAtByKey: [PayloadCacheKey: Date] = [:] + private var attemptedKeys: Set = [] + private var lastErrorByKey: [PayloadCacheKey: String] = [:] var subscription: SubscriptionUsage? var subscriptionError: String? - var subscriptionLoadState: SubscriptionLoadState = .idle + var subscriptionLoadState: SubscriptionLoadState = ClaudeCredentialStore.isBootstrapCompleted ? .loading : .notBootstrapped var capacityEstimates: [String: CapacityEstimate] = [:] + var codexUsage: CodexUsage? + var codexError: String? + var codexLoadState: SubscriptionLoadState = CodexCredentialStore.isBootstrapCompleted ? .loading : .notBootstrapped + + /// 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 cache: [PayloadCacheKey: CachedPayload] = [:] + private var cacheDate: String = "" + private var switchTask: Task? + private var payloadRefreshGeneration: UInt64 = 0 + /// Tracks the last successful fetch timestamp per key for stuck-loading + /// diagnostics. NOT used for cache-freshness logic — `CachedPayload.fetchedAt` + /// is authoritative there. This map persists across cache wipes (day + /// rollover, etc.) so we can distinguish "fresh install, never fetched" + /// from "cache was wiped 10 minutes ago and we still haven't refilled". + private var lastSuccessByKey: [PayloadCacheKey: Date] = [:] + + private func staleSecondsForKey(_ key: PayloadCacheKey) -> TimeInterval { + guard let last = lastSuccessByKey[key] else { return .infinity } + return Date().timeIntervalSince(last) + } + + private var todayAllKey: PayloadCacheKey { + PayloadCacheKey(period: .today, provider: .all) + } private var currentKey: PayloadCacheKey { PayloadCacheKey(period: selectedPeriod, provider: selectedProvider) @@ -45,7 +80,16 @@ final class AppStore { /// Today (across all providers) is pinned for the always-visible menubar icon, independent of /// the popover's selected period or provider. var todayPayload: MenubarPayload? { - cache[PayloadCacheKey(period: .today, provider: .all)]?.payload + cache[todayAllKey]?.payload + } + + var todayPayloadAgeSeconds: Int? { + guard let cached = cache[todayAllKey] else { return nil } + return Int(Date().timeIntervalSince(cached.fetchedAt)) + } + + var needsStatusPayloadRefresh: Bool { + cache[todayAllKey]?.isFresh != true } /// All-provider payload for the selected period. Used by the tab strip to show @@ -58,47 +102,262 @@ final class AppStore { cache[currentKey] != nil } + var hasStaleLoading: Bool { + let now = Date() + return loadingStartedAtByKey.values.contains { + now.timeIntervalSince($0) > loadingWatchdogSeconds + } + } + + var hasStaleInteractivePayload: Bool { + staleInteractivePayloadAgeSeconds != nil + } + + var hasMissingInteractivePayloadWithoutAttempt: Bool { + cache[currentKey] == nil && !isCurrentKeyLoading && !hasAttemptedCurrentKeyLoad + } + + var shouldResetInteractiveRefreshPipeline: Bool { + hasStaleLoading || hasStaleInteractivePayload || hasMissingInteractivePayloadWithoutAttempt + } + + var staleInteractivePayloadAgeSeconds: Int? { + let keys = Set([ + currentKey, + todayAllKey, + PayloadCacheKey(period: selectedPeriod, provider: .all), + ]) + let staleAges = keys.compactMap { key -> TimeInterval? in + guard let cached = cache[key] else { return nil } + let age = Date().timeIntervalSince(cached.fetchedAt) + return age > interactiveRefreshResetSeconds ? age : nil + } + return staleAges.max().map(Int.init) + } + + var needsInteractivePayloadRefresh: Bool { + let periodAllKey = PayloadCacheKey(period: selectedPeriod, provider: .all) + return cache[currentKey]?.isFresh != true || + cache[todayAllKey]?.isFresh != true || + cache[periodAllKey]?.isFresh != true || + hasStaleLoading + } + + /// True if any cached payload reports at least one provider. Used to keep the + /// AgentTabStrip visible across period/provider switches even when the current + /// key's payload is briefly empty (e.g. immediately after a `switchTo` and + /// before the new fetch lands). + var hasAnyProvidersInCache: Bool { + cache.values.contains { !$0.payload.current.providers.isEmpty } + } + +#if DEBUG + func setCachedPayloadForTesting(_ payload: MenubarPayload, period: Period, provider: ProviderFilter, fetchedAt: Date) { + cache[PayloadCacheKey(period: period, provider: provider)] = CachedPayload(payload: payload, fetchedAt: fetchedAt) + } +#endif + var findingsCount: Int { payload.optimize.findingCount } - /// Switch to a period. Always fetches fresh data so the user never sees stale numbers. - func switchTo(period: Period) async { + /// Switch to a period. Cancels any in-flight switch and fetches provider-specific + + /// all-provider data in parallel so tab strip costs stay in sync with the hero. + func switchTo(period: Period) { selectedPeriod = period - await refresh(includeOptimize: true, force: true) + startInteractiveSelectionRefresh() } - /// Switch to a provider filter. Always fetches fresh data so the user never sees stale numbers. - func switchTo(provider: ProviderFilter) async { + /// Switch to a provider filter. Cancels any in-flight switch so rapid tab tapping only + /// runs the CLI for the final selection. Fetches provider-specific and all-provider data + /// in parallel so the tab strip costs stay in sync with the hero. + func switchTo(provider: ProviderFilter) { selectedProvider = provider - await refresh(includeOptimize: true, force: true) + startInteractiveSelectionRefresh() + } + + private func startInteractiveSelectionRefresh() { + switchTask?.cancel() + resetLoadingState() + let period = selectedPeriod + let provider = selectedProvider + lastErrorByKey[PayloadCacheKey(period: period, provider: provider)] = nil + switchTask = Task { + if provider == .all { + await refresh(includeOptimize: false, force: true, showLoading: true) + } else { + async let main: Void = refresh(includeOptimize: false, force: true, showLoading: true) + async let all: Void = refreshQuietly(period: period) + _ = await (main, all) + } + } } private var inFlightKeys: Set = [] - /// Refresh the currently selected (period, provider) combination. Guards against concurrent - /// fetches for the same key so a slow initial request can't overwrite a newer one that - /// finished first (which would show stale numbers the user has already moved past). - /// When `force` is false (background timer), skips the CLI call if the cache is still fresh. - func refresh(includeOptimize: Bool, force: Bool = false) async { + func resetLoadingState() { + payloadRefreshGeneration &+= 1 + loadingCountsByKey.removeAll() + loadingStartedAtByKey.removeAll() + inFlightKeys.removeAll() + } + + func resetRefreshState(clearCache: Bool = false) { + switchTask?.cancel() + switchTask = nil + resetLoadingState() + attemptedKeys.removeAll() + lastErrorByKey.removeAll() + if clearCache { + cache.removeAll() + } + } + + private let loadingWatchdogSeconds: TimeInterval = 60 + + @discardableResult + func clearStaleLoadingIfNeeded() -> Bool { + let now = Date() + let staleEntries = loadingStartedAtByKey.filter { + now.timeIntervalSince($0.value) > loadingWatchdogSeconds + } + guard !staleEntries.isEmpty else { return false } + + payloadRefreshGeneration &+= 1 + for (key, started) in staleEntries { + NSLog("CodeBurn: loading stuck for %ds on %@/%@ — auto-clearing", + Int(now.timeIntervalSince(started)), key.period.rawValue, key.provider.rawValue) + loadingCountsByKey[key] = nil + loadingStartedAtByKey[key] = nil + inFlightKeys.remove(key) + if cache[key] == nil { + lastErrorByKey[key] = "Refresh took longer than expected. CodeBurn will keep retrying in the background." + } + } + return true + } + + private func beginLoading(for key: PayloadCacheKey) { + if loadingCountsByKey[key, default: 0] == 0 { + loadingStartedAtByKey[key] = Date() + } + loadingCountsByKey[key, default: 0] += 1 + } + + private func finishLoading(for key: PayloadCacheKey) { + guard let count = loadingCountsByKey[key], count > 0 else { return } + if count == 1 { + loadingCountsByKey[key] = nil + loadingStartedAtByKey[key] = nil + } else { + loadingCountsByKey[key] = count - 1 + } + } + + private func currentCacheDate() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: Date()) + } + + private func invalidateStaleDayCache() { + let today = currentCacheDate() + if cacheDate != today { + payloadRefreshGeneration &+= 1 + cache.removeAll() + loadingCountsByKey.removeAll() + loadingStartedAtByKey.removeAll() + inFlightKeys.removeAll() + attemptedKeys.removeAll() + lastErrorByKey.removeAll() + cacheDate = today + NSLog("CodeBurn: reset menubar payload cache for new day %@", today) + } + } + + func invalidateCache() { + cache.removeAll() + } + + func refresh(includeOptimize: Bool, force: Bool = false, showLoading: Bool = false) async { + invalidateStaleDayCache() let key = currentKey + let cacheDateAtStart = cacheDate + let generationAtStart = payloadRefreshGeneration if !force, cache[key]?.isFresh == true { return } - guard !inFlightKeys.contains(key) else { return } + if inFlightKeys.contains(key) { return } inFlightKeys.insert(key) - if cache[key] == nil { - isLoading = true + attemptedKeys.insert(key) + lastErrorByKey[key] = nil + let didShowLoading = showLoading || cache[key] == nil + if didShowLoading { + beginLoading(for: key) + } + // Diagnostic anchor: if this key has been empty for a long time (the + // popover would currently be showing "Loading..."), log how stale the + // miss is so the next time a user reports a stuck-loading bug we have + // a concrete data point — "no successful fetch for (today, claude) + // in 14 minutes" beats squinting at unified-log noise. We deliberately + // skip the first-attempt case (no prior success ever, finite check + // below filters .infinity) — that's just the cold path, not a bug. + let staleSeconds = staleSecondsForKey(key) + if staleSeconds.isFinite, staleSeconds > 120 { + NSLog("CodeBurn: refresh attempt for stale key \(key.period.rawValue)/\(key.provider.rawValue) — last success was \(Int(staleSeconds))s ago") } defer { inFlightKeys.remove(key) - isLoading = false + if didShowLoading { + finishLoading(for: key) + } } do { let fresh = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: includeOptimize) + if generationAtStart != payloadRefreshGeneration { + NSLog("CodeBurn: dropping fetch result for \(key.period.rawValue)/\(key.provider.rawValue) — refresh pipeline reset mid-fetch") + return + } + if Task.isCancelled { + // Distinguish cancellation (user switched tabs mid-fetch) from + // the silent-no-result path. Without this log, a cancelled + // fetch leaves cache empty + lastError nil and the user sees + // perpetual loading with nothing in the diagnostics. + NSLog("CodeBurn: fetch for \(key.period.rawValue)/\(key.provider.rawValue) cancelled before result was applied") + return + } + // Day-rollover race guard: if the calendar date changed during the + // fetch, this payload was computed against yesterday's date and + // would pollute today's freshly-cleared cache. Drop it; the next + // tick will refetch with today's data. + if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() { + invalidateStaleDayCache() + NSLog("CodeBurn: dropping fetch result for \(key.period.rawValue)/\(key.provider.rawValue) — calendar rolled mid-fetch") + return + } cache[key] = CachedPayload(payload: fresh, fetchedAt: Date()) - lastError = nil + lastSuccessByKey[key] = Date() + lastErrorByKey[key] = nil } catch { - lastError = String(describing: error) + if Task.isCancelled { return } NSLog("CodeBurn: fetch failed for \(key.period.rawValue)/\(key.provider.rawValue): \(error)") + if includeOptimize, cache[key] == nil { + do { + let fallback = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: false) + guard !Task.isCancelled else { return } + if generationAtStart != payloadRefreshGeneration { return } + if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() { + invalidateStaleDayCache() + return + } + cache[key] = CachedPayload(payload: fallback, fetchedAt: Date()) + lastSuccessByKey[key] = Date() + lastErrorByKey[key] = nil + return + } catch { + if Task.isCancelled { return } + NSLog("CodeBurn: fallback fetch also failed: \(error)") + } + } + lastErrorByKey[key] = String(describing: error) } let allKey = PayloadCacheKey(period: selectedPeriod, provider: .all) @@ -110,37 +369,390 @@ final class AppStore { /// Background refresh for a period other than the visible one (e.g. keeping today fresh for the menubar badge). /// Does not toggle isLoading, so the popover's loading overlay is unaffected. /// Always uses the .all provider since the menubar badge shows total spend. - func refreshQuietly(period: Period) async { + func refreshQuietly(period: Period, force: Bool = false) async { + invalidateStaleDayCache() + let key = PayloadCacheKey(period: period, provider: .all) + if !force, cache[key]?.isFresh == true { return } + if inFlightKeys.contains(key) { return } + inFlightKeys.insert(key) + attemptedKeys.insert(key) + let cacheDateAtStart = cacheDate + let generationAtStart = payloadRefreshGeneration + if period == .today, let age = todayPayloadAgeSeconds, age > 120 { + NSLog("CodeBurn: refreshing stale today status payload after %ds", age) + } + defer { + inFlightKeys.remove(key) + } do { - let fresh = try await DataClient.fetch(period: period, provider: .all, includeOptimize: true) - cache[PayloadCacheKey(period: period, provider: .all)] = CachedPayload(payload: fresh, fetchedAt: Date()) + let fresh = try await DataClient.fetch(period: period, provider: .all, includeOptimize: false) + if generationAtStart != payloadRefreshGeneration { + NSLog("CodeBurn: dropping quiet fetch result for \(period.rawValue) — refresh pipeline reset mid-fetch") + return + } + // Same day-rollover guard as refresh(): drop yesterday's payload if + // the calendar rolled over during the fetch. + if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() { + invalidateStaleDayCache() + return + } + cache[key] = CachedPayload(payload: fresh, fetchedAt: Date()) + lastSuccessByKey[key] = Date() + lastErrorByKey[key] = nil } catch { NSLog("CodeBurn: quiet refresh failed for \(period.rawValue): \(error)") } } - /// Fetch Claude subscription usage. Sets subscription = nil on missing creds (API users / unauthenticated). - /// Triggered lazily when the user opens the Plan pill, so the Keychain prompt only fires on intent. - func refreshSubscription() async { - subscriptionLoadState = .loading + /// User-initiated. Reads Claude's source (this is what triggers the macOS keychain + /// prompt for `Claude Code-credentials`). Once successful, subsequent background + /// refreshes go through our own keychain item without prompting. + func bootstrapSubscription() async { + subscriptionLoadState = .bootstrapping do { - let usage = try await SubscriptionClient.fetch() + let usage = try await ClaudeSubscriptionService.bootstrap() subscription = usage subscriptionError = nil subscriptionLoadState = .loaded await captureSnapshots(for: usage) - } catch SubscriptionError.noCredentials { - subscription = nil - subscriptionError = nil - subscriptionLoadState = .noCredentials + } catch let err as ClaudeSubscriptionService.FetchError { + applyFetchError(err) } catch { - subscription = nil subscriptionError = String(describing: error) subscriptionLoadState = .failed - NSLog("CodeBurn: subscription fetch failed: \(error)") } } + /// Background refresh. No-op if the user has not yet connected. Never triggers + /// a keychain prompt — uses our own keychain item exclusively. + func refreshSubscription() async { + _ = await refreshSubscriptionReportingSuccess() + } + + /// Same as `refreshSubscription` but returns whether the fetch produced a + /// `.loaded` state, so the caller can anchor cadence timing on real success + /// rather than every attempt. + @discardableResult + func refreshSubscriptionReportingSuccess() async -> Bool { + guard ClaudeCredentialStore.isBootstrapCompleted else { + if subscriptionLoadState != .notBootstrapped { + subscriptionLoadState = .notBootstrapped + } + return false + } + let gen = claudeRefreshGen + if subscription == nil { subscriptionLoadState = .loading } + do { + guard let usage = try await ClaudeSubscriptionService.refreshIfBootstrapped() else { + return false + } + // Disconnect-during-fetch guard: if the user clicked Disconnect + // while we were awaiting Anthropic, the generation token will + // have advanced and we must drop this result instead of writing + // it back over the freshly-cleared state. + guard gen == claudeRefreshGen else { return false } + subscription = usage + subscriptionError = nil + subscriptionLoadState = .loaded + await captureSnapshots(for: usage) + return true + } catch let err as ClaudeSubscriptionService.FetchError { + guard gen == claudeRefreshGen else { return false } + applyFetchError(err) + return false + } catch { + guard gen == claudeRefreshGen else { return false } + subscriptionError = sanitizeForUI(String(describing: error)) + subscriptionLoadState = .failed + return false + } + } + + /// User-initiated disconnect — clears our keychain item and bootstrap flag, + /// plus all derived state so a reconnect (potentially under a different + /// account or tier) starts clean. capacityEstimates and the snapshot store + /// would otherwise contaminate "Based on last cycle" projections. + func disconnectSubscription() { + ClaudeSubscriptionService.disconnect() + // Bump the generation token so any in-flight refreshSubscription that + // resumes after this point detects the disconnect and discards its + // result instead of re-populating the cleared state. + claudeRefreshGen &+= 1 + subscription = nil + subscriptionError = nil + subscriptionLoadState = .notBootstrapped + capacityEstimates = [:] + Task.detached { await SubscriptionSnapshotStore.clearAll() } + // Notify the AppDelegate to clear its cadence-loop anchor so the next + // reconnect doesn't measure against a pre-disconnect timestamp. + NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil) + } + + // MARK: - Codex + + func bootstrapCodex() async { + codexLoadState = .bootstrapping + do { + let usage = try await CodexSubscriptionService.bootstrap() + codexUsage = usage + codexError = nil + codexLoadState = .loaded + } catch let err as CodexSubscriptionService.FetchError { + applyCodexFetchError(err) + } catch { + codexError = sanitizeForUI(String(describing: error)) + codexLoadState = .failed + } + } + + func refreshCodex() async { + _ = await refreshCodexReportingSuccess() + } + + @discardableResult + func refreshCodexReportingSuccess() async -> Bool { + guard CodexCredentialStore.isBootstrapCompleted else { + if codexLoadState != .notBootstrapped { codexLoadState = .notBootstrapped } + return false + } + let gen = codexRefreshGen + if codexUsage == nil { codexLoadState = .loading } + do { + guard let usage = try await CodexSubscriptionService.refreshIfBootstrapped() else { + return false + } + guard gen == codexRefreshGen else { return false } + codexUsage = usage + codexError = nil + codexLoadState = .loaded + return true + } catch let err as CodexSubscriptionService.FetchError { + guard gen == codexRefreshGen else { return false } + applyCodexFetchError(err) + return false + } catch { + guard gen == codexRefreshGen else { return false } + codexError = sanitizeForUI(String(describing: error)) + codexLoadState = .failed + return false + } + } + + func disconnectCodex() { + CodexSubscriptionService.disconnect() + codexRefreshGen &+= 1 + codexUsage = nil + codexError = nil + codexLoadState = .notBootstrapped + NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil) + } + + private func applyCodexFetchError(_ err: CodexSubscriptionService.FetchError) { + let sanitized = sanitizeForUI(err.errorDescription) + codexError = sanitized + if err.isTerminal { + codexLoadState = .terminalFailure(reason: sanitized) + } else if let retryAt = err.rateLimitRetryAt { + codexLoadState = .transientFailure(retryAt: retryAt) + } else if case .notBootstrapped = err { + codexLoadState = .notBootstrapped + } else if case let .bootstrapFailed(storeErr) = err, case .bootstrapNoSource = storeErr { + codexLoadState = .noCredentials + } else { + codexLoadState = .failed + } + } + + private func applyFetchError(_ err: ClaudeSubscriptionService.FetchError) { + let sanitized = sanitizeForUI(err.errorDescription) + subscriptionError = sanitized + if err.isTerminal { + subscriptionLoadState = .terminalFailure(reason: sanitized) + } else if let retryAt = err.rateLimitRetryAt { + subscriptionLoadState = .transientFailure(retryAt: retryAt) + } else if case .notBootstrapped = err { + subscriptionLoadState = .notBootstrapped + } else if case let .bootstrapFailed(storeErr) = err, case .bootstrapNoSource = storeErr { + subscriptionLoadState = .noCredentials + } else { + subscriptionLoadState = .failed + } + } + + /// Strip control characters and any token-shaped substrings from server-error + /// strings before they land in NSLog or the UI. Anthropic / OpenAI error + /// envelopes don't typically echo tokens, but we also surface this in + /// unified-log paths readable by other local users via `log stream`. + private func sanitizeForUI(_ s: String?) -> String? { + guard let s, !s.isEmpty else { return nil } + var cleaned = s.replacingOccurrences(of: "\u{0000}", with: "") + // Token-shaped redaction. Apply to all known auth-token formats so + // an error body that quotes the request/response token is masked. + let patterns: [(pattern: String, replacement: String)] = [ + (#"sk-ant-[A-Za-z0-9_-]+"#, "sk-ant-***"), + (#"sk-[A-Za-z0-9_-]{16,}"#, "sk-***"), + (#"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"#, "eyJ***"), + (#"(?i)Bearer\s+\S+"#, "Bearer ***"), + ] + for entry in patterns { + cleaned = cleaned.replacingOccurrences(of: entry.pattern, with: entry.replacement, options: .regularExpression) + } + // Cap length so a runaway server body cannot fill stderr. + if cleaned.count > 240 { cleaned = String(cleaned.prefix(240)) + "…" } + return cleaned + } + + /// Snapshot of live quota state for a given provider. Returns nil when the user + /// has not connected yet — the bar slot stays empty so we never trigger a + /// keychain prompt at startup. Once bootstrapped, the bar persists across all + /// subsequent states (loading / stale / transient failure / terminal failure) + /// so it doesn't flicker on every refresh tick. + /// Aggregate quota status across all connected providers, used by the menu + /// bar flame icon (color) and the popover warning row. Severity = worst + /// observed across any provider's worst window. Warning providers are + /// every connected provider at >= 70% utilization. + struct AggregateQuotaStatus { + let severity: QuotaSummary.Severity + let warnings: [(name: String, percent: Double)] // sorted desc by percent + } + + var aggregateQuotaStatus: AggregateQuotaStatus { + var providers: [(name: String, percent: Double)] = [] + if let usage = subscription, shouldIncludeCachedQuota(loadState: subscriptionLoadState) { + let worst = [ + usage.fiveHourPercent, + usage.sevenDayPercent, + usage.sevenDayOpusPercent, + usage.sevenDaySonnetPercent, + ].compactMap { $0 }.max() ?? 0 + if worst > 0 { providers.append(("Claude", worst)) } + } + if let usage = codexUsage, shouldIncludeCachedQuota(loadState: codexLoadState) { + let worst = max(usage.primary?.usedPercent ?? 0, usage.secondary?.usedPercent ?? 0) + if worst > 0 { providers.append(("Codex", worst)) } + } + let worst = providers.map(\.percent).max() ?? 0 + let severity = QuotaSummary.severity(for: worst / 100) + let sorted = providers.sorted { $0.percent > $1.percent } + let warnings = sorted.filter { $0.percent >= 70 } + return AggregateQuotaStatus(severity: severity, warnings: warnings) + } + + private func shouldIncludeCachedQuota(loadState: SubscriptionLoadState) -> Bool { + switch loadState { + case .notBootstrapped, .bootstrapping, .noCredentials: + return false + case .loading, .loaded, .failed, .terminalFailure, .transientFailure: + return true + } + } + + func quotaSummary(for filter: ProviderFilter) -> QuotaSummary? { + switch filter { + case .claude: return claudeQuotaSummary(filter: filter) + case .codex: return codexQuotaSummary(filter: filter) + default: return nil + } + } + + private func claudeQuotaSummary(filter: ProviderFilter) -> QuotaSummary? { + if case .notBootstrapped = subscriptionLoadState { return nil } + if case .bootstrapping = subscriptionLoadState { return nil } + if case .noCredentials = subscriptionLoadState { return nil } + + let connection: QuotaSummary.Connection = { + switch subscriptionLoadState { + case .notBootstrapped, .bootstrapping, .noCredentials: return .disconnected + case .loading: return subscription == nil ? .loading : .stale + case .loaded: return .connected + case .failed: return subscription == nil ? .loading : .stale + case let .terminalFailure(reason): return .terminalFailure(reason: reason) + case .transientFailure: return .transientFailure + } + }() + + var primary: QuotaSummary.Window? + var details: [QuotaSummary.Window] = [] + if let usage = subscription { + if let pct = usage.fiveHourPercent { + details.append(.init(label: "5-hour", percent: pct / 100, resetsAt: usage.fiveHourResetsAt)) + } + if let pct = usage.sevenDayPercent { + let weekly = QuotaSummary.Window(label: "Weekly", percent: pct / 100, resetsAt: usage.sevenDayResetsAt) + primary = weekly + details.append(weekly) + } + if let pct = usage.sevenDayOpusPercent { + details.append(.init(label: "Weekly · Opus", percent: pct / 100, resetsAt: usage.sevenDayOpusResetsAt)) + } + if let pct = usage.sevenDaySonnetPercent { + details.append(.init(label: "Weekly · Sonnet", percent: pct / 100, resetsAt: usage.sevenDaySonnetResetsAt)) + } + } + let plan = subscription?.tier.displayName + return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: plan, footerLines: []) + } + + private func codexQuotaSummary(filter: ProviderFilter) -> QuotaSummary? { + if case .notBootstrapped = codexLoadState { return nil } + if case .bootstrapping = codexLoadState { return nil } + if case .noCredentials = codexLoadState { return nil } + + let connection: QuotaSummary.Connection = { + switch codexLoadState { + case .notBootstrapped, .bootstrapping, .noCredentials: return .disconnected + case .loading: return codexUsage == nil ? .loading : .stale + case .loaded: return .connected + case .failed: return codexUsage == nil ? .loading : .stale + case let .terminalFailure(reason): return .terminalFailure(reason: reason) + case .transientFailure: return .transientFailure + } + }() + + var primary: QuotaSummary.Window? + var details: [QuotaSummary.Window] = [] + if let usage = codexUsage { + if let w = usage.primary { + let row = QuotaSummary.Window(label: w.windowLabel, percent: w.usedPercent / 100, resetsAt: w.resetsAt) + primary = row + details.append(row) + } + if let w = usage.secondary { + let row = QuotaSummary.Window(label: w.windowLabel, percent: w.usedPercent / 100, resetsAt: w.resetsAt) + // Some Codex plans (free / guest tiers) only return a secondary + // window. Promote it to primary so the chip bar always has a + // data source instead of rendering as an empty track. + if primary == nil { primary = row } + details.append(row) + } + // Surface per-model additional rate limits (e.g. "GPT-5.3-Codex-Spark") + // only when the user has actually hit them. Skipping zero rows keeps + // the popover compact for the common case where the user only uses + // the main Codex window. + for extra in usage.additionalLimits { + if let p = extra.primary, p.usedPercent > 0 { + details.append(.init(label: "\(extra.name) · \(p.windowLabel)", percent: p.usedPercent / 100, resetsAt: p.resetsAt)) + } + if let s = extra.secondary, s.usedPercent > 0 { + details.append(.init(label: "\(extra.name) · \(s.windowLabel)", percent: s.usedPercent / 100, resetsAt: s.resetsAt)) + } + } + } + let plan = codexUsage?.plan.displayName + var footerLines: [String] = [] + if let balance = codexUsage?.creditsBalance, balance > 0 { + // Format as plain dollars; ChatGPT settles in USD regardless of + // the user's display-currency preference. + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = "USD" + formatter.maximumFractionDigits = 2 + let formatted = formatter.string(from: NSNumber(value: balance)) ?? "$\(balance)" + footerLines.append("Credits remaining · \(formatted)") + } + return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: plan, footerLines: 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, @@ -174,7 +786,10 @@ final class AppStore { /// last 7 days of dailyHistory. Used as the "tokens consumed in 7-day window" reading paired /// with the API-reported percent for capacity estimation. private func effectiveTokensInLast7Days(history: [DailyHistoryEntry], asOf now: Date) -> Double { - let cutoff = ISO8601DateFormatter().string(from: now.addingTimeInterval(-7 * 86400)).prefix(10) + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + f.timeZone = .current + let cutoff = f.string(from: now.addingTimeInterval(-7 * 86400)) return history .filter { $0.date >= cutoff } .reduce(0.0) { $0 + $1.effectiveTokens } @@ -227,12 +842,16 @@ enum SupportedCurrency: String, CaseIterable, Identifiable { enum ProviderFilter: String, CaseIterable, Identifiable { case all = "All" case claude = "Claude" + case cline = "Cline" case codex = "Codex" case cursor = "Cursor" + case cursorAgent = "Cursor Agent" case copilot = "Copilot" case droid = "Droid" case gemini = "Gemini" + case ibmBob = "IBM Bob" case kiro = "Kiro" + case kimi = "Kimi" case kiloCode = "KiloCode" case openclaw = "OpenClaw" case opencode = "OpenCode" @@ -240,15 +859,23 @@ enum ProviderFilter: String, CaseIterable, Identifiable { case qwen = "Qwen" case omp = "OMP" case rooCode = "Roo Code" + case crush = "Crush" + case antigravity = "Antigravity" + case goose = "Goose" var id: String { rawValue } var providerKeys: [String] { switch self { - case .cursor: ["cursor", "cursor agent"] + case .cursor: ["cursor"] + case .cursorAgent: ["cursor-agent", "cursor agent"] + case .cline: ["cline"] case .rooCode: ["roo-code", "roo code"] case .kiloCode: ["kilo-code", "kilocode"] + case .ibmBob: ["ibm-bob", "ibm bob"] case .openclaw: ["openclaw"] + case .antigravity: ["antigravity"] + case .goose: ["goose"] default: [rawValue.lowercased()] } } @@ -257,29 +884,43 @@ enum ProviderFilter: String, CaseIterable, Identifiable { switch self { case .all: "all" case .claude: "claude" + case .cline: "cline" case .codex: "codex" case .cursor: "cursor" + case .cursorAgent: "cursor-agent" case .copilot: "copilot" case .droid: "droid" case .gemini: "gemini" + case .ibmBob: "ibm-bob" case .kiloCode: "kilo-code" case .kiro: "kiro" + case .kimi: "kimi" case .openclaw: "openclaw" case .opencode: "opencode" case .pi: "pi" case .qwen: "qwen" case .omp: "omp" case .rooCode: "roo-code" + case .crush: "crush" + case .antigravity: "antigravity" + case .goose: "goose" } } } +extension Notification.Name { + static let codeBurnSubscriptionDisconnected = Notification.Name("com.codeburn.subscriptionDisconnected") +} + enum SubscriptionLoadState: Sendable, Equatable { - case idle // never tried, awaiting user intent - case loading // fetch in progress - case loaded // success; subscription is populated - case noCredentials // tried; user has no Claude OAuth (API user / not logged in) - case failed // tried; error occurred + case notBootstrapped // no Keychain access yet — waiting for user to click Connect + case bootstrapping // user clicked Connect; reading Claude's keychain (PROMPTS) + case loading // background fetch in progress (subscription may already be populated) + case loaded // success; subscription is populated + case noCredentials // bootstrap tried; user has no Claude credentials at all + case failed // generic non-recoverable failure + case terminalFailure(reason: String?) // refresh-token invalid; user must reconnect + case transientFailure(retryAt: Date?) // 429 / network blip; backing off automatically } enum InsightMode: String, CaseIterable, Identifiable { @@ -296,7 +937,7 @@ enum Period: String, CaseIterable, Identifiable { case sevenDays = "7 Days" case thirtyDays = "30 Days" case month = "Month" - case all = "All" + case all = "6 Months" var id: String { rawValue } @@ -333,7 +974,7 @@ private let thousandsFormatter: NumberFormatter = { return f }() -extension Double { +@MainActor extension Double { func asCurrency() -> String { let state = CurrencyState.shared let converted = self * state.rate diff --git a/mac/Sources/CodeBurnMenubar/AppVersion.swift b/mac/Sources/CodeBurnMenubar/AppVersion.swift new file mode 100644 index 0000000..c5ee14a --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/AppVersion.swift @@ -0,0 +1,43 @@ +import Foundation + +enum AppVersion { + static var bundleShortVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" + } + + static var bundleBuildVersion: String { + Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "" + } + + static var normalizedBundleShortVersion: String { + normalize(bundleShortVersion) + } + + static var normalizedBundleBuildVersion: String { + normalize(bundleBuildVersion) + } + + static var displayBundleShortVersion: String { + display(bundleShortVersion) + } + + static func normalize(_ version: String) -> String { + let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.lowercased().hasPrefix("mac-v") { + return String(trimmed.dropFirst(5)) + } + if trimmed.lowercased().hasPrefix("v") { + return String(trimmed.dropFirst()) + } + return trimmed + } + + static func display(_ version: String) -> String { + let normalized = normalize(version) + guard !normalized.isEmpty else { return "v?" } + if normalized == "?" || normalized == "dev" || normalized == "dev-preview" || normalized == "—" { + return normalized + } + return "v\(normalized)" + } +} diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index 1c87221..6191575 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -3,8 +3,11 @@ import AppKit import Observation private let refreshIntervalSeconds: UInt64 = 30 -private let nanosPerSecond: UInt64 = 1_000_000_000 -private let refreshIntervalNanos: UInt64 = refreshIntervalSeconds * nanosPerSecond +private let forceRefreshWatchdogSeconds: TimeInterval = 90 +private let refreshLoopWatchdogSeconds: TimeInterval = 90 +private let statusPayloadRefreshWatchdogSeconds: TimeInterval = 60 +private let refreshRateLimitSeconds: TimeInterval = 5 +private let interactiveQuotaRefreshFloorSeconds: TimeInterval = 30 private let statusItemWidth: CGFloat = NSStatusItem.variableLength private let popoverWidth: CGFloat = 360 private let popoverHeight: CGFloat = 660 @@ -15,9 +18,12 @@ struct CodeBurnApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var delegate var body: some Scene { - // SwiftUI App needs at least one scene. Settings is invisible by default. + // The Settings scene gives us a real macOS Settings window with the + // standard ⌘, shortcut and the menubar "Settings…" item. Provider tabs + // (Claude today, Codex/Cursor/etc. in follow-ups) live inside SettingsView. Settings { - EmptyView() + SettingsView() + .environment(delegate.store) } } } @@ -26,49 +32,92 @@ struct CodeBurnApp: App { final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { private var statusItem: NSStatusItem! private var popover: NSPopover! - private let store = AppStore() + fileprivate let store = AppStore() let updateChecker = UpdateChecker() /// Held for the lifetime of the app to opt out of App Nap and Automatic Termination. private var backgroundActivity: NSObjectProtocol? + private var pendingRefreshWork: DispatchWorkItem? + private var refreshTimer: DispatchSourceTimer? + private var forceRefreshTask: Task? + private var forceRefreshStartedAt: Date? + private var forceRefreshGeneration: UInt64 = 0 + private var statusPayloadRefreshTask: Task? + private var statusPayloadRefreshStartedAt: Date? + private var statusPayloadRefreshGeneration: UInt64 = 0 + private var manualRefreshTask: Task? + private var manualRefreshGeneration: UInt64 = 0 + private var claudeQuotaRefreshTask: Task? + private var codexQuotaRefreshTask: Task? + private var refreshLoopHeartbeatAt: Date = .distantPast + private var lastLaunchAgentHeartbeatAt: Date = .distantPast + + func applicationWillFinishLaunching(_ notification: Notification) { + // Set accessory policy before the app's focus chain forms. On macOS Tahoe + // (26.x), setting it after didFinishLaunching causes ghost status items + // because the policy gets baked into the initial focus chain. + NSApp.setActivationPolicy(.accessory) + } + + private func observeSubscriptionDisconnect() { + NotificationCenter.default.addObserver( + forName: .codeBurnSubscriptionDisconnected, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.resetSubscriptionCadenceAnchor() + } + } + } func applicationDidFinishLaunching(_ notification: Notification) { - // On macOS Tahoe (26.x), accessory apps may fail to render their status item - // if the activation policy is set before the status item is created. Starting - // as a regular app and switching to accessory after setup works around this. - NSApp.setActivationPolicy(.regular) - NSApp.activate(ignoringOtherApps: true) - ProcessInfo.processInfo.automaticTerminationSupportEnabled = false ProcessInfo.processInfo.disableSuddenTermination() backgroundActivity = ProcessInfo.processInfo.beginActivity( - options: [.userInitiated, .automaticTerminationDisabled, .suddenTerminationDisabled], - reason: "CodeBurn menubar polls AI coding cost every 15 seconds while idle in the background." + options: [.automaticTerminationDisabled, .suddenTerminationDisabled], + reason: "CodeBurn menubar background refresh" ) restorePersistedCurrency() setupStatusItem() setupPopover() - - // Switch to accessory policy after status item is set up to hide from Dock - DispatchQueue.main.async { - NSApp.setActivationPolicy(.accessory) - } observeStore() startRefreshLoop() setupWakeObservers() setupDistributedNotificationListener() installLaunchAgentIfNeeded() registerLoginItemIfNeeded() + observeSubscriptionDisconnect() Task { await updateChecker.checkIfNeeded() } } private func setupWakeObservers() { + // Pause the refresh loop while the machine is asleep. Without this, + // Task.sleep keeps a wakeup pending across the suspension and the + // loop tick fires the same instant the wake notifications do, + // producing 2-3 concurrent CLI spawns within ms of every wake. + NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.willSleepNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.prepareRefreshPipelineForSleep() + } + } + + // didWakeNotification + screensDidWakeNotification can both fire on + // the same wake. forceRefreshTask squashes overlap; both notifications + // still bypass the short manual-click rate limit so a just-before-sleep + // refresh cannot block wake recovery. NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.didWakeNotification, object: nil, queue: .main ) { [weak self] _ in - Task { @MainActor in self?.forceRefresh() } + Task { @MainActor in + self?.recoverRefreshPipelineAfterInterruption(resetLoading: true, reason: "wake") + } } NSWorkspace.shared.notificationCenter.addObserver( @@ -76,7 +125,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { object: nil, queue: .main ) { [weak self] _ in - Task { @MainActor in self?.forceRefresh() } + Task { @MainActor in + self?.recoverRefreshPipelineAfterInterruption(resetLoading: true, reason: "screen wake") + } } } @@ -86,7 +137,73 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { object: nil, queue: .main ) { [weak self] _ in - Task { @MainActor in self?.forceRefresh() } + Task { @MainActor in + self?.handleLaunchAgentHeartbeat() + } + } + } + + private func handleLaunchAgentHeartbeat() { + let now = Date() + guard now.timeIntervalSince(lastLaunchAgentHeartbeatAt) >= refreshRateLimitSeconds else { return } + lastLaunchAgentHeartbeatAt = now + let loopAge = now.timeIntervalSince(refreshLoopHeartbeatAt) + guard refreshTimer == nil || loopAge > refreshLoopWatchdogSeconds else { + _ = store.clearStaleLoadingIfNeeded() + _ = clearStaleForceRefreshIfNeeded(now: now) + _ = clearStaleStatusPayloadRefreshIfNeeded(now: now) + return + } + if refreshTimer != nil { + NSLog("CodeBurn: refresh loop stale for %ds after launch agent - restarting", Int(loopAge)) + } + startRefreshLoop(forceQuotaOnStart: false) + } + + private func prepareRefreshPipelineForSleep() { + forceRefreshTask?.cancel() + forceRefreshTask = nil + forceRefreshStartedAt = nil + forceRefreshGeneration &+= 1 + manualRefreshTask?.cancel() + manualRefreshTask = nil + manualRefreshGeneration &+= 1 + statusPayloadRefreshTask?.cancel() + statusPayloadRefreshTask = nil + statusPayloadRefreshStartedAt = nil + statusPayloadRefreshGeneration &+= 1 + store.resetLoadingState() + stopRefreshTimer() + refreshLoopHeartbeatAt = .distantPast + lastRefreshTime = .distantPast + } + + private func recoverRefreshPipelineAfterInterruption(resetLoading: Bool, clearCache: Bool = false, reason: String) { + if resetLoading { + forceRefreshTask?.cancel() + forceRefreshTask = nil + forceRefreshStartedAt = nil + forceRefreshGeneration &+= 1 + manualRefreshTask?.cancel() + manualRefreshTask = nil + manualRefreshGeneration &+= 1 + statusPayloadRefreshTask?.cancel() + statusPayloadRefreshTask = nil + statusPayloadRefreshStartedAt = nil + statusPayloadRefreshGeneration &+= 1 + store.resetRefreshState(clearCache: clearCache) + } else { + _ = store.clearStaleLoadingIfNeeded() + } + let now = Date() + let loopAge = now.timeIntervalSince(refreshLoopHeartbeatAt) + if refreshTimer == nil || loopAge > refreshLoopWatchdogSeconds { + if refreshTimer != nil { + NSLog("CodeBurn: refresh loop stale for %ds after %@ - restarting", Int(loopAge), reason) + } + startRefreshLoop(forceQuotaOnStart: false) + } else { + runRefreshLoopTick(reason: reason, forcePayload: true, forceQuota: false) } } @@ -147,7 +264,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { guard !UserDefaults.standard.bool(forKey: key) else { return } let appPath = Bundle.main.bundlePath - let script = "tell application \"System Events\" to make login item at end with properties {path:\"\(appPath)\", hidden:false}" + let script = "tell application \"System Events\" to make login item at end with properties {path:\(appleScriptStringLiteral(appPath)), hidden:false}" let process = Process() process.launchPath = "/usr/bin/osascript" @@ -166,16 +283,115 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } } + private func appleScriptStringLiteral(_ value: String) -> String { + var escaped = value.replacingOccurrences(of: "\\", with: "\\\\") + escaped = escaped.replacingOccurrences(of: "\"", with: "\\\"") + escaped = escaped.replacingOccurrences(of: "\r", with: "") + escaped = escaped.replacingOccurrences(of: "\n", with: "") + return "\"\(escaped)\"" + } + private var lastRefreshTime: Date = .distantPast - private func forceRefresh() { - let now = Date() - guard now.timeIntervalSince(lastRefreshTime) > 5 else { return } - lastRefreshTime = now + @discardableResult + private func clearStaleForceRefreshIfNeeded(now: Date = Date()) -> Bool { + if forceRefreshTask != nil { + guard let started = forceRefreshStartedAt else { + NSLog("CodeBurn: force refresh task had no start timestamp - clearing") + forceRefreshTask?.cancel() + forceRefreshTask = nil + forceRefreshGeneration &+= 1 + store.resetLoadingState() + return true + } + let elapsed = now.timeIntervalSince(started) + guard elapsed > forceRefreshWatchdogSeconds else { return false } + NSLog("CodeBurn: force refresh stuck for %ds - cancelling and restarting", Int(elapsed)) + forceRefreshTask?.cancel() + forceRefreshTask = nil + forceRefreshStartedAt = nil + forceRefreshGeneration &+= 1 + store.resetLoadingState() + return true + } + return false + } - Task { - await store.refresh(includeOptimize: true, force: true) + @discardableResult + private func clearStaleStatusPayloadRefreshIfNeeded(now: Date = Date()) -> Bool { + if statusPayloadRefreshTask != nil { + guard let started = statusPayloadRefreshStartedAt else { + NSLog("CodeBurn: today status refresh task had no start timestamp - clearing") + statusPayloadRefreshTask?.cancel() + statusPayloadRefreshTask = nil + statusPayloadRefreshGeneration &+= 1 + return true + } + let elapsed = now.timeIntervalSince(started) + guard elapsed > statusPayloadRefreshWatchdogSeconds else { return false } + NSLog("CodeBurn: today status refresh stuck for %ds - cancelling", Int(elapsed)) + statusPayloadRefreshTask?.cancel() + statusPayloadRefreshTask = nil + statusPayloadRefreshStartedAt = nil + statusPayloadRefreshGeneration &+= 1 + return true + } + return false + } + + private func refreshTodayStatusPayloadIfNeeded(reason: String, force: Bool = false) { + let now = Date() + _ = clearStaleStatusPayloadRefreshIfNeeded(now: now) + guard statusPayloadRefreshTask == nil else { return } + guard force || store.needsStatusPayloadRefresh else { return } + + if let age = store.todayPayloadAgeSeconds, age > 120 { + NSLog("CodeBurn: today status payload stale for %ds on %@ refresh", age, reason) + } + + statusPayloadRefreshStartedAt = now + statusPayloadRefreshGeneration &+= 1 + let generation = statusPayloadRefreshGeneration + statusPayloadRefreshTask = Task { [weak self] in + guard let self else { return } + await self.store.refreshQuietly(period: .today, force: true) + self.refreshStatusButton() + guard self.statusPayloadRefreshGeneration == generation, !Task.isCancelled else { return } + self.statusPayloadRefreshTask = nil + self.statusPayloadRefreshStartedAt = nil + } + } + + private func forceRefresh(bypassRateLimit: Bool = false, forceQuota: Bool = false) { + let now = Date() + _ = clearStaleForceRefreshIfNeeded(now: now) + if forceRefreshTask != nil { + refreshTodayStatusPayloadIfNeeded(reason: "blocked force refresh") + } + guard forceRefreshTask == nil else { return } + if !bypassRateLimit { + guard now.timeIntervalSince(lastRefreshTime) > refreshRateLimitSeconds else { return } + } + lastRefreshTime = now + forceRefreshStartedAt = now + forceRefreshGeneration &+= 1 + let generation = forceRefreshGeneration + + forceRefreshTask = Task { + async let main: Void = store.refresh(includeOptimize: false, force: true, showLoading: true) + async let quotas: Bool = refreshLiveQuotaProgressIfDue(force: forceQuota) + if store.selectedPeriod != .today || store.selectedProvider != .all { + await store.refreshQuietly(period: .today) + } + _ = await main refreshStatusButton() + await MainActor.run { [weak self] in + guard let self, self.forceRefreshGeneration == generation else { return } + self.forceRefreshTask = nil + self.forceRefreshStartedAt = nil + self.lastRefreshTime = Date() + } + _ = await quotas } } @@ -201,21 +417,229 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } } - private func startRefreshLoop() { - Task { - await store.refresh(includeOptimize: true) - refreshStatusButton() + fileprivate var lastSubscriptionRefreshAt: Date? + fileprivate var lastCodexRefreshAt: Date? + + @discardableResult + private func refreshLiveQuotaProgressIfDue(force: Bool = false) async -> Bool { + let cadence = SubscriptionRefreshCadence.current + if !force && cadence == .manual { return false } + + let now = Date() + let threshold = force ? 0 : TimeInterval(cadence.rawValue) + let shouldRefreshClaude = force || now.timeIntervalSince(lastSubscriptionRefreshAt ?? .distantPast) >= threshold + let shouldRefreshCodex = force || now.timeIntervalSince(lastCodexRefreshAt ?? .distantPast) >= threshold + guard shouldRefreshClaude || shouldRefreshCodex else { return false } + + switch (shouldRefreshClaude, shouldRefreshCodex) { + case (true, true): + async let claude = refreshClaudeQuotaSingleFlight() + async let codex = refreshCodexQuotaSingleFlight() + if await claude { lastSubscriptionRefreshAt = Date() } + if await codex { lastCodexRefreshAt = Date() } + case (true, false): + if await refreshClaudeQuotaSingleFlight() { + lastSubscriptionRefreshAt = Date() + } + case (false, true): + if await refreshCodexQuotaSingleFlight() { + lastCodexRefreshAt = Date() + } + case (false, false): + break + } + return true + } + + private func refreshClaudeQuotaSingleFlight() async -> Bool { + if let task = claudeQuotaRefreshTask { + return await task.value + } + let task = Task { [store] in + await store.refreshSubscriptionReportingSuccess() + } + claudeQuotaRefreshTask = task + let result = await task.value + if claudeQuotaRefreshTask != nil { + claudeQuotaRefreshTask = nil + } + return result + } + + private func refreshCodexQuotaSingleFlight() async -> Bool { + if let task = codexQuotaRefreshTask { + return await task.value + } + let task = Task { [store] in + await store.refreshCodexReportingSuccess() + } + codexQuotaRefreshTask = task + let result = await task.value + if codexQuotaRefreshTask != nil { + codexQuotaRefreshTask = nil + } + return result + } + + private func refreshLiveQuotaProgressForPopoverOpen() { + let now = Date() + let claudeElapsed = now.timeIntervalSince(lastSubscriptionRefreshAt ?? .distantPast) + let codexElapsed = now.timeIntervalSince(lastCodexRefreshAt ?? .distantPast) + guard claudeElapsed >= interactiveQuotaRefreshFloorSeconds || + codexElapsed >= interactiveQuotaRefreshFloorSeconds else { return } + + Task { [weak self] in + guard let self else { return } + _ = await self.refreshLiveQuotaProgressIfDue(force: true) } } + private func refreshPayloadForPopoverOpen() { + guard store.needsInteractivePayloadRefresh else { return } + let shouldResetPipeline = store.shouldResetInteractiveRefreshPipeline + if shouldResetPipeline, let age = store.staleInteractivePayloadAgeSeconds { + NSLog("CodeBurn: popover opened with %ds stale payload cache - resetting refresh pipeline", age) + } + recoverRefreshPipelineAfterInterruption( + resetLoading: shouldResetPipeline, + reason: "popover open" + ) + } + + private func stopRefreshTimer() { + refreshTimer?.setEventHandler {} + refreshTimer?.cancel() + refreshTimer = nil + } + + private func runRefreshLoopTick(reason: String, forcePayload: Bool = false, forceQuota: Bool = false) { + refreshLoopHeartbeatAt = Date() + let hadForceRefreshInFlight = forceRefreshTask != nil + let clearedStaleForceRefresh = clearStaleForceRefreshIfNeeded() + let clearedStaleStatusRefresh = clearStaleStatusPayloadRefreshIfNeeded() + let clearedStaleLoading = store.clearStaleLoadingIfNeeded() + let statusPayloadStale = store.needsStatusPayloadRefresh + let sinceLast = Date().timeIntervalSince(lastRefreshTime) + let shouldForceRefresh = forcePayload || + clearedStaleForceRefresh || + clearedStaleLoading || + sinceLast >= TimeInterval(refreshIntervalSeconds) + + if shouldForceRefresh { + forceRefresh(bypassRateLimit: true, forceQuota: forceQuota) + } + + let forceRefreshWasBlocked = hadForceRefreshInFlight && forceRefreshTask != nil + if statusPayloadStale && (!shouldForceRefresh || forceRefreshWasBlocked || clearedStaleStatusRefresh) { + refreshTodayStatusPayloadIfNeeded(reason: reason, force: forcePayload) + } + } + + private func startRefreshLoop(forceQuotaOnStart: Bool = false) { + stopRefreshTimer() + runRefreshLoopTick(reason: "start", forcePayload: true, forceQuota: forceQuotaOnStart) + + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule( + deadline: .now() + .seconds(Int(refreshIntervalSeconds)), + repeating: .seconds(Int(refreshIntervalSeconds)), + leeway: .seconds(2) + ) + timer.setEventHandler { [weak self] in + Task { @MainActor [weak self] in + self?.runRefreshLoopTick(reason: "timer") + } + } + refreshTimer = timer + refreshLoopHeartbeatAt = Date() + timer.resume() + } + + @MainActor + func refreshSubscriptionNow() { + manualRefreshTask?.cancel() + manualRefreshGeneration &+= 1 + let generation = manualRefreshGeneration + forceRefreshTask?.cancel() + forceRefreshTask = nil + forceRefreshStartedAt = nil + forceRefreshGeneration &+= 1 + statusPayloadRefreshTask?.cancel() + statusPayloadRefreshTask = nil + statusPayloadRefreshStartedAt = nil + statusPayloadRefreshGeneration &+= 1 + pendingRefreshWork?.cancel() + pendingRefreshWork = nil + stopRefreshTimer() + store.resetRefreshState(clearCache: true) + lastRefreshTime = .distantPast + refreshStatusButton() + + manualRefreshTask = Task { [weak self] in + guard let self else { return } + // "Refresh Now" should refresh the menubar payload AND every + // connected provider's live quota. The user's intent is "make + // this match reality right now." + let needsTodayTotal = self.store.selectedPeriod != .today || self.store.selectedProvider != .all + async let payload: Void = self.store.refresh(includeOptimize: false, force: true, showLoading: true) + async let quotas: Bool = self.refreshLiveQuotaProgressIfDue(force: true) + if needsTodayTotal { + await self.store.refreshQuietly(period: .today, force: true) + } + _ = await payload + guard self.manualRefreshGeneration == generation, !Task.isCancelled else { return } + self.lastRefreshTime = Date() + self.refreshStatusButton() + _ = await quotas + guard self.manualRefreshGeneration == generation, !Task.isCancelled else { return } + self.manualRefreshTask = nil + if self.refreshTimer == nil { + self.startRefreshLoop() + } + } + } + + /// 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 + /// disruptive than waiting a full cadence after a reconnect. + @MainActor + func resetSubscriptionCadenceAnchor() { + lastSubscriptionRefreshAt = nil + lastCodexRefreshAt = nil + } + private func observeStore() { - withObservationTracking { - _ = store.payload - _ = store.todayPayload + // Read closure uses [weak self] so the implicit self capture from + // accessing store.* doesn't pin self for the lifetime of an + // unfired observation. withObservationTracking is one-shot per + // call: once any read property changes, onChange fires and the + // registration is consumed, then we re-arm. There is at most one + // active subscription at a time. + withObservationTracking { [weak self] in + guard let self else { return } + _ = self.store.payload + _ = self.store.todayPayload + // Track currency so the menubar title catches up immediately on + // currency switch instead of waiting for the next 30s payload tick. + _ = self.store.currency + // Track the live-quota state too so the flame icon re-tints on + // every subscription / codex usage update, not just every 30s. + _ = self.store.subscription + _ = self.store.subscriptionLoadState + _ = self.store.codexUsage + _ = self.store.codexLoadState } onChange: { [weak self] in - Task { @MainActor in - self?.refreshStatusButton() - self?.observeStore() + DispatchQueue.main.async { + guard let self else { return } + self.pendingRefreshWork?.cancel() + let work = DispatchWorkItem { [weak self] in + self?.refreshStatusButton() + self?.observeStore() + } + self.pendingRefreshWork = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: work) } } } @@ -255,18 +679,46 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { /// stubborn gap between icon and text on some macOS releases (the icon hugs the left edge /// of the status item, the title starts at its own baseline), so we inline both so they /// flow as one typographic unit with a single, controllable gap. + private static func flameTint(for severity: QuotaSummary.Severity) -> NSColor? { + switch severity { + case .normal: return nil // template, auto-adapt + case .warning: return NSColor.systemYellow // 70-90% + case .critical: return NSColor.systemOrange // 90-100% + case .danger: return NSColor.systemRed // 100%+ + } + } + private func refreshStatusButton() { guard let button = statusItem.button else { return } + // Skip while the popover is anchored to this button. Rewriting the + // attributedTitle changes the button's intrinsic width, which makes + // macOS reflow the status item in the menubar and detaches the + // anchored popover (it pops to a stale default position). The + // popoverDidClose delegate calls back through here once the popover + // is dismissed so the menubar cost catches up immediately on close. + if popover != nil && popover.isShown { return } // Clear any previously-set image so the attachment is the only glyph rendered. button.image = nil button.imagePosition = .noImage let font = NSFont.monospacedDigitSystemFont(ofSize: menubarTitleFontSize, weight: .medium) - let flameConfig = NSImage.SymbolConfiguration(pointSize: menubarTitleFontSize, weight: .medium) + 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 + // user gets a glanceable signal even when the menu bar is busy. + let aggregate = store.aggregateQuotaStatus + let tint = Self.flameTint(for: aggregate.severity) + 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 = true + flame?.isTemplate = (tint == nil) let attachment = NSTextAttachment() attachment.image = flame @@ -322,14 +774,42 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { if popover.isShown { popover.performClose(sender) } else { - NSApp.activate(ignoringOtherApps: true) + // Do NOT call NSApp.activate(ignoringOtherApps:) here. On macOS + // Tahoe an accessory app activating while a popover anchors to + // its NSStatusItem can race with the system menu bar's auto-hide + // logic and leave the user's apple-menu hidden until the popover + // closes. The popover's window takes keyboard focus on its own + // via makeKeyAndOrderFront, which is enough for keystrokes to + // reach the SwiftUI content. popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) - popover.contentViewController?.view.window?.makeKey() + if let window = popover.contentViewController?.view.window { + // Pin the popover's window above the status-bar layer but tag + // it as auxiliary so macOS Tahoe does not treat it as an + // app-level focus event — that's what was hiding the system + // menu bar (Terminal's apple-logo / Shell / Edit / View row) + // every time the popover opened. + window.level = .statusBar + window.collectionBehavior.insert(.fullScreenAuxiliary) + window.collectionBehavior.insert(.canJoinAllSpaces) + window.makeKeyAndOrderFront(nil) + } + refreshPayloadForPopoverOpen() + refreshLiveQuotaProgressForPopoverOpen() } } private func showContextMenu(from button: NSStatusBarButton) { let menu = NSMenu() + + let settingsItem = NSMenuItem(title: "Settings…", action: #selector(openSettings), keyEquivalent: ",") + settingsItem.target = self + menu.addItem(settingsItem) + + let refreshNow = NSMenuItem(title: "Refresh Now", action: #selector(refreshNowAction), keyEquivalent: "r") + refreshNow.target = self + menu.addItem(refreshNow) + + menu.addItem(.separator()) let updateItem = NSMenuItem(title: "Check for Updates", action: #selector(checkForUpdates), keyEquivalent: "") updateItem.target = self menu.addItem(updateItem) @@ -337,11 +817,48 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { let quitItem = NSMenuItem(title: "Quit CodeBurn", action: #selector(quitApp), keyEquivalent: "q") quitItem.target = self menu.addItem(quitItem) + statusItem.menu = menu button.performClick(nil) statusItem.menu = nil } + private var settingsWindowController: NSWindowController? + + @objc private func openSettings() { + // Accessory-policy apps (no Dock icon, no main menu) don't get the + // SwiftUI Settings scene wired into the responder chain reliably, so + // the standard `showSettingsWindow:` selector silently no-ops. We host + // the SwiftUI view in our own NSWindowController instead. + if let controller = settingsWindowController { + NSApp.activate(ignoringOtherApps: true) + controller.window?.makeKeyAndOrderFront(nil) + return + } + + let hosting = NSHostingController( + rootView: SettingsView().environment(store) + ) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 380), + styleMask: [.titled, .closable, .miniaturizable], + backing: .buffered, + defer: false + ) + window.title = "CodeBurn Settings" + window.contentViewController = hosting + window.center() + window.isReleasedWhenClosed = false + let controller = NSWindowController(window: window) + settingsWindowController = controller + NSApp.activate(ignoringOtherApps: true) + controller.showWindow(nil) + } + + @objc private func refreshNowAction() { + refreshSubscriptionNow() + } + private func codeburnAlertIcon() -> NSImage? { let config = NSImage.SymbolConfiguration(pointSize: 32, weight: .medium) guard let symbol = NSImage(systemSymbolName: "flame.fill", accessibilityDescription: "CodeBurn")? @@ -363,14 +880,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { await updateChecker.check() let alert = NSAlert() alert.icon = codeburnAlertIcon() - if updateChecker.updateAvailable, let latest = updateChecker.latestVersion { + if let error = updateChecker.updateError { + alert.messageText = "Update Check Failed" + alert.informativeText = error + alert.alertStyle = .warning + } else if updateChecker.updateAvailable, let latest = updateChecker.latestVersion { alert.messageText = "Update Available" - alert.informativeText = "v\(latest) is available (you have v\(updateChecker.currentVersion)). Run:\n\ncodeburn menubar --force" + alert.informativeText = "\(AppVersion.display(latest)) is available (you have \(AppVersion.display(updateChecker.currentVersion))). Run:\n\ncodeburn menubar --force" + alert.alertStyle = .informational } else { alert.messageText = "Up to Date" - alert.informativeText = "You're on the latest version (v\(updateChecker.currentVersion))." + alert.informativeText = "You're on the latest version (\(AppVersion.display(updateChecker.currentVersion)))." + alert.alertStyle = .informational } - alert.alertStyle = .informational alert.addButton(withTitle: "OK") alert.runModal() } @@ -385,4 +907,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { func popoverShouldDetach(_ popover: NSPopover) -> Bool { false } + + func popoverDidClose(_ notification: Notification) { + // Catch up on any menubar title updates that were skipped while the + // popover was anchored. + refreshStatusButton() + } } diff --git a/mac/Sources/CodeBurnMenubar/CurrencyState.swift b/mac/Sources/CodeBurnMenubar/CurrencyState.swift index e668139..c27acc1 100644 --- a/mac/Sources/CodeBurnMenubar/CurrencyState.swift +++ b/mac/Sources/CodeBurnMenubar/CurrencyState.swift @@ -10,8 +10,8 @@ private let minValidFXRate: Double = 0.0001 private let maxValidFXRate: Double = 1_000_000 private let fxFetchTimeoutSeconds: TimeInterval = 10 -@Observable -final class CurrencyState: @unchecked Sendable { +@MainActor @Observable +final class CurrencyState: Sendable { static let shared = CurrencyState() var code: String = "USD" @@ -31,7 +31,7 @@ final class CurrencyState: @unchecked Sendable { } } - static func symbolForCode(_ code: String) -> String { + nonisolated static func symbolForCode(_ code: String) -> String { // Some locales return "US$" for USD or "CA$" for CAD via NumberFormatter. Prefer the // plain glyph form everyone recognises. if let override = symbolOverrides[code] { return override } @@ -42,7 +42,7 @@ final class CurrencyState: @unchecked Sendable { return formatter.currencySymbol ?? code } - private static let symbolOverrides: [String: String] = [ + nonisolated private static let symbolOverrides: [String: String] = [ "USD": "$", "CAD": "$", "AUD": "$", diff --git a/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift b/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift new file mode 100644 index 0000000..e47db7b --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift @@ -0,0 +1,440 @@ +import Foundation +import Security + +/// Owns the lifecycle of Claude OAuth credentials end-to-end. Replaces +/// SubscriptionClient + SubscriptionRefreshGate with a model that mirrors +/// CodexBar's proven pattern: +/// +/// 1. **Bootstrap is user-initiated.** The first read of Claude's keychain +/// entry — which triggers a macOS keychain prompt — only happens when +/// the user clicks "Connect" in the Plan tab. The menubar does not +/// touch Claude's keychain on launch. +/// +/// 2. **We persist refreshed tokens.** When Anthropic returns a new access +/// token (or a rotated refresh token) we write it back to our own keychain +/// item. The next fetch uses it directly — one API call per cycle, not +/// three. This was the root cause of "connect once, never updates": the +/// previous code refreshed on every tick because the new token was +/// thrown away. +/// +/// 3. **Our own keychain item, not Claude's.** We bootstrap from Claude's +/// entry once, then maintain `com.codeburn.menubar.claude.oauth.v1` in +/// the user's keychain. Subsequent reads do not prompt because we own +/// that item's ACL. +/// +/// 4. **In-memory cache (5 min)** so back-to-back reads in the same refresh +/// cycle don't even hit the keychain. +enum ClaudeCredentialStore { + private static let bootstrapCompletedKey = "codeburn.claude.bootstrapCompleted" + private static let inMemoryTTL: TimeInterval = 5 * 60 + private static let proactiveRefreshMargin: TimeInterval = 5 * 60 + + private static let oauthClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" + private static let refreshURL = URL(string: "https://platform.claude.com/v1/oauth/token")! + + private static let claudeKeychainService = "Claude Code-credentials" + private static let credentialsRelativePath = ".claude/.credentials.json" + private static let maxCredentialBytes = 64 * 1024 + + /// Legacy local cache file. New writes use the macOS Keychain; this path is + /// read once for migration and then removed. + private static let cacheFilename = "claude-credentials.v1.json" + private static let ourKeychainService = "org.agentseal.codeburn.menubar.claude.oauth.v1" + private static let ourKeychainAccount = "default" + + private static let lock = NSLock() + private nonisolated(unsafe) static var memoryCache: CachedRecord? + + struct CachedRecord { + let record: CredentialRecord + let cachedAt: Date + + var isFresh: Bool { Date().timeIntervalSince(cachedAt) < ClaudeCredentialStore.inMemoryTTL } + } + + struct CredentialRecord: Codable, Equatable { + let accessToken: String + let refreshToken: String? + let expiresAt: Date? + let rateLimitTier: String? + } + + enum StoreError: Error, LocalizedError { + case bootstrapNoSource // neither file nor Claude keychain has credentials + case bootstrapDecodeFailed + case keychainWriteFailed(OSStatus) + case keychainReadFailed(OSStatus) + case refreshHTTPError(Int, String?) + case refreshNetworkError(Error) + case refreshDecodeFailed + case noRefreshToken + + var errorDescription: String? { + switch self { + case .bootstrapNoSource: + return "No Claude credentials found. Sign in with `claude` first." + case .bootstrapDecodeFailed: + return "Claude credentials are malformed." + case let .keychainWriteFailed(status): + return "Could not write to keychain (status \(status))." + case let .keychainReadFailed(status): + return "Could not read from keychain (status \(status))." + case let .refreshHTTPError(code, body): + return "Token refresh failed (HTTP \(code))\(body.map { ": \($0)" } ?? "")" + case let .refreshNetworkError(err): + return "Token refresh network error: \(err.localizedDescription)" + case .refreshDecodeFailed: + return "Token refresh response was malformed." + case .noRefreshToken: + return "No refresh token available; reconnect required." + } + } + + /// True when the failure means the user must re-authenticate (re-run + /// `claude` or click Reconnect). Used by the UI to distinguish between + /// "try again later" and "you must act". + var isTerminal: Bool { + if case let .refreshHTTPError(code, body) = self, code >= 400, code < 500 { + let lower = body?.lowercased() ?? "" + if lower.contains("invalid_grant") || lower.contains("invalid_client") || lower.contains("invalid_token") { + return true + } + return true // 4xx other than rate-limiting is terminal too + } + if case .noRefreshToken = self { return true } + return false + } + } + + // MARK: - Bootstrap state + + /// True once the user has explicitly connected (clicked Connect in the Plan + /// tab AND we successfully read their credentials). Persists across launches. + static var isBootstrapCompleted: Bool { + get { UserDefaults.standard.bool(forKey: bootstrapCompletedKey) } + set { UserDefaults.standard.set(newValue, forKey: bootstrapCompletedKey) } + } + + /// Reset bootstrap state. Used when the user explicitly wants to disconnect + /// or when the refresh token has been revoked terminally. + static func resetBootstrap() { + lock.withLock { memoryCache = nil } + deleteOurCache() + isBootstrapCompleted = false + } + + // MARK: - Public API + + /// User-initiated entry point. Reads from Claude's source (PROMPTS for the + /// keychain on first use), writes to our own keychain item, marks bootstrap + /// as completed. + @discardableResult + static func bootstrap() throws -> CredentialRecord { + let record = try readClaudeSource() + try writeOurCache(record: record) + isBootstrapCompleted = true + cacheInMemory(record) + return record + } + + /// Silent read for background refresh cycles. Reads only from our cache / + /// keychain item — never prompts. Returns nil if not bootstrapped. + static func currentRecord() throws -> CredentialRecord? { + guard isBootstrapCompleted else { return nil } + // Honour the in-memory TTL: a stale cached record can mask a token + // that another process (e.g. claude /login again) has just rotated + // on disk. Re-read the file when the cache passes the TTL. + if let cached = lock.withLock({ memoryCache }), cached.isFresh { + return cached.record + } + if let stored = try readOurCache() { + cacheInMemory(stored) + return stored + } + // Bootstrap flag is set but our cache file is missing — most likely + // a fresh install resetting state, or the user manually deleted the + // file. Force re-bootstrap on next user action. + isBootstrapCompleted = false + return nil + } + + /// Returns a token guaranteed to be either fresh or just-refreshed. If the + /// current token expires within `proactiveRefreshMargin`, refreshes ahead + /// of time and persists the new token. + static func freshAccessToken() async throws -> String? { + guard let record = try currentRecord() else { return nil } + if let expiresAt = record.expiresAt, expiresAt.timeIntervalSinceNow < proactiveRefreshMargin { + let updated = try await refreshAndPersist(record: record) + return updated.accessToken + } + return record.accessToken + } + + /// Called after an explicit 401. Refreshes, persists, returns the new token. + static func refreshAfter401() async throws -> String { + guard let record = try currentRecord() else { throw StoreError.noRefreshToken } + let updated = try await refreshAndPersist(record: record) + return updated.accessToken + } + + static func subscriptionTier() throws -> String? { + try currentRecord()?.rateLimitTier + } + + // MARK: - Bootstrap source + + private static func readClaudeSource() throws -> CredentialRecord { + if let fromFile = try? readClaudeFile() { return fromFile } + if let fromKeychain = try readClaudeKeychain() { return fromKeychain } + throw StoreError.bootstrapNoSource + } + + private static func readClaudeFile() throws -> CredentialRecord? { + let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(credentialsRelativePath) + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes) + return try parseClaudeBlob(data: sanitizeClaudeBlob(data)) + } + + /// Reads Claude's keychain credentials. The CLI has historically written + /// entries under different account names — older versions used "agentseal" + /// (a hardcoded company-style identifier) while Claude Code 2.1.x writes + /// under `$USER` (NSUserName()). After a user re-runs `/login`, both + /// entries can coexist and `SecItemCopyMatching` with kSecMatchLimitOne + /// often returns the older stale one. We try the user-keyed entry first + /// (the modern format), then fall back to the unscoped query for older + /// installations. + private static func readClaudeKeychain() throws -> CredentialRecord? { + if let record = try readClaudeKeychain(account: NSUserName()) { + return record + } + return try readClaudeKeychain(account: nil) + } + + private static func readClaudeKeychain(account: String?) throws -> CredentialRecord? { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: claudeKeychainService, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + ] + if let account { query[kSecAttrAccount as String] = account } + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { + throw StoreError.keychainReadFailed(status) + } + return try parseClaudeBlob(data: sanitizeClaudeBlob(data)) + } + + /// Claude Code's keychain writer line-wraps long values (newline + leading + /// spaces) mid-token, producing JSON with literal control chars inside string + /// values. Strip those plus pretty-print indentation between fields so the + /// JSON parser succeeds. + private static func sanitizeClaudeBlob(_ data: Data) -> Data { + guard var s = String(data: data, encoding: .utf8) else { return data } + s = s.replacingOccurrences(of: "\r", with: "") + if let regex = try? NSRegularExpression(pattern: "\\n[ \\t]*", options: []) { + let range = NSRange(s.startIndex.. CredentialRecord { + struct Root: Decodable { let claudeAiOauth: OAuth? } + struct OAuth: Decodable { + let accessToken: String? + let refreshToken: String? + let expiresAt: Double? + let rateLimitTier: String? + } + do { + let root = try JSONDecoder().decode(Root.self, from: data) + guard let oauth = root.claudeAiOauth, + let token = oauth.accessToken?.trimmingCharacters(in: .whitespacesAndNewlines), + !token.isEmpty + else { throw StoreError.bootstrapDecodeFailed } + return CredentialRecord( + accessToken: token, + refreshToken: oauth.refreshToken, + expiresAt: oauth.expiresAt.map { Date(timeIntervalSince1970: $0 / 1000.0) }, + rateLimitTier: oauth.rateLimitTier + ) + } catch { + throw StoreError.bootstrapDecodeFailed + } + } + + // MARK: - Local cache file (no keychain involvement) + + private static func cacheFileURL() -> URL { + let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support") + return support + .appendingPathComponent("CodeBurn", isDirectory: true) + .appendingPathComponent(cacheFilename) + } + + private static func readOurCache() throws -> CredentialRecord? { + if let record = try readOurKeychainCache() { + return record + } + + let url = cacheFileURL() + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + // Route through SafeFile.read so we lstat for symlinks before opening + // and bound the read with maxCredentialBytes. Without this, an + // attacker who can plant a symlink in ~/Library/Application Support/ + // CodeBurn/ between disconnect and reconnect could redirect our read + // to /dev/zero (unbounded memory) or another file the user owns. + let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes) + guard let record = try? JSONDecoder().decode(CredentialRecord.self, from: data) else { return nil } + try? writeOurKeychainCache(record: record) + try? FileManager.default.removeItem(at: url) + return record + } + + private static func writeOurCache(record: CredentialRecord) throws { + try writeOurKeychainCache(record: record) + } + + private static func readOurKeychainCache() throws -> CredentialRecord? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: ourKeychainService, + kSecAttrAccount as String: ourKeychainAccount, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { + throw StoreError.keychainReadFailed(status) + } + return try? JSONDecoder().decode(CredentialRecord.self, from: data) + } + + private static func writeOurKeychainCache(record: CredentialRecord) throws { + let url = cacheFileURL() + let data = try JSONEncoder().encode(record) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: ourKeychainService, + kSecAttrAccount as String: ourKeychainAccount, + ] + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + var add = query + add.merge(attributes) { _, new in new } + let addStatus = SecItemAdd(add as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw StoreError.keychainWriteFailed(addStatus) + } + } else if status != errSecSuccess { + throw StoreError.keychainWriteFailed(status) + } + try? FileManager.default.removeItem(at: url) + } + + private static func deleteOurCache() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: ourKeychainService, + kSecAttrAccount as String: ourKeychainAccount, + ] + SecItemDelete(query as CFDictionary) + try? FileManager.default.removeItem(at: cacheFileURL()) + } + + private static func cacheInMemory(_ record: CredentialRecord) { + lock.withLock { memoryCache = CachedRecord(record: record, cachedAt: Date()) } + } + + // MARK: - Refresh + + private static func refreshAndPersist(record: CredentialRecord) async throws -> CredentialRecord { + guard let refreshToken = record.refreshToken, !refreshToken.isEmpty else { + throw StoreError.noRefreshToken + } + + var request = URLRequest(url: refreshURL) + request.httpMethod = "POST" + request.timeoutInterval = 30 + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + var components = URLComponents() + components.queryItems = [ + URLQueryItem(name: "grant_type", value: "refresh_token"), + URLQueryItem(name: "refresh_token", value: refreshToken), + URLQueryItem(name: "client_id", value: oauthClientID), + ] + request.httpBody = (components.percentEncodedQuery ?? "").data(using: .utf8) + + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch { + throw StoreError.refreshNetworkError(error) + } + guard let http = response as? HTTPURLResponse else { + throw StoreError.refreshHTTPError(-1, nil) + } + guard http.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) + throw StoreError.refreshHTTPError(http.statusCode, body) + } + + struct RefreshResponse: Decodable { + let accessToken: String + let refreshToken: String? + let expiresIn: Int? + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case expiresIn = "expires_in" + } + } + guard let decoded = try? JSONDecoder().decode(RefreshResponse.self, from: data) else { + throw StoreError.refreshDecodeFailed + } + + // Anthropic may rotate the refresh token. If it did, the OLD one is + // already invalid server-side — discarding the new one would lock + // the user out permanently. So we cache the new record in memory + // BEFORE attempting the keychain write, and if the write fails we + // still return the new record (memory cache will serve subsequent + // calls inside the 5-min TTL while we keep retrying the persist). + let updated = CredentialRecord( + accessToken: decoded.accessToken, + refreshToken: decoded.refreshToken ?? record.refreshToken, + expiresAt: decoded.expiresIn.map { Date().addingTimeInterval(TimeInterval($0)) } ?? record.expiresAt, + rateLimitTier: record.rateLimitTier + ) + cacheInMemory(updated) + do { + try writeOurCache(record: updated) + } catch { + // Best effort — surface to logs but do not abandon the rotated + // token. Next refresh will retry persistence; UI will continue + // working from the in-memory cache. + NSLog("CodeBurn: cache write failed during refresh rotation: %@", String(describing: error)) + } + return updated + } +} + +private extension NSLock { + func withLock(_ body: () throws -> T) rethrows -> T { + lock(); defer { unlock() } + return try body() + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift b/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift new file mode 100644 index 0000000..f97641d --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift @@ -0,0 +1,241 @@ +import Foundation + +/// Orchestrates "given a credential record, fetch live quota from Anthropic +/// and surface a result the UI can render". All token persistence lives in +/// `ClaudeCredentialStore`; the only state this service holds is the +/// 429 backoff window for the usage endpoint. +enum ClaudeSubscriptionService { + private static let usageURL = URL(string: "https://api.anthropic.com/api/oauth/usage")! + private static let betaHeader = "oauth-2025-04-20" + private static let userAgent = "claude-code/2.1.0" + private static let usageBlockedUntilKey = "codeburn.claude.usage.blockedUntil" + + enum FetchError: Error, LocalizedError { + case notBootstrapped + case bootstrapFailed(ClaudeCredentialStore.StoreError) + case rateLimited(retryAt: Date) + case usageHTTPError(Int, String?) + case usageDecodeFailed + case network(Error) + case credential(ClaudeCredentialStore.StoreError) + + var errorDescription: String? { + switch self { + case .notBootstrapped: + return "Connect Claude in the Plan tab to start tracking quota." + case let .bootstrapFailed(err): + return err.errorDescription + case let .rateLimited(retryAt): + let f = RelativeDateTimeFormatter() + f.unitsStyle = .short + return "Anthropic rate-limited the quota endpoint. Retrying \(f.localizedString(for: retryAt, relativeTo: Date()))." + case let .usageHTTPError(code, body): + return "Quota fetch failed (HTTP \(code))\(body.map { ": \($0)" } ?? "")" + case .usageDecodeFailed: + return "Quota response was malformed." + case let .network(err): + return "Network error: \(err.localizedDescription)" + case let .credential(err): + return err.errorDescription + } + } + + /// True when the user must take action (re-run claude/login or click + /// Reconnect). Drives the red "Reconnect" UI path. + var isTerminal: Bool { + if case let .credential(err) = self { return err.isTerminal } + if case let .bootstrapFailed(err) = self { return err.isTerminal } + return false + } + + var rateLimitRetryAt: Date? { + if case let .rateLimited(retryAt) = self { return retryAt } + return nil + } + } + + // MARK: - Public API + + /// User-initiated. Reads Claude's keychain (PROMPTS), copies to our keychain, + /// then fetches usage. Idempotent — safe to call again to "reconnect". + static func bootstrap() async throws -> SubscriptionUsage { + // Honour the same 429 backoff that refreshIfBootstrapped respects. + // Without this, a user spamming Reconnect during a sustained + // rate-limit window hammers Anthropic on every click — exactly the + // pattern that escalates the backoff. + if let until = usageBlockedUntil(), until > Date() { + throw FetchError.rateLimited(retryAt: until) + } + let record: ClaudeCredentialStore.CredentialRecord + do { + record = try ClaudeCredentialStore.bootstrap() + } catch let err as ClaudeCredentialStore.StoreError { + throw FetchError.bootstrapFailed(err) + } + return try await fetchWithRecord(initial: record) + } + + /// Background refresh. Never prompts. Returns nil if not yet bootstrapped. + static func refreshIfBootstrapped() async throws -> SubscriptionUsage? { + guard ClaudeCredentialStore.isBootstrapCompleted else { + return nil + } + + // Honour an outstanding rate-limit window — we recorded a 429 recently + // and Anthropic told us when to come back. + if let until = usageBlockedUntil(), until > Date() { + throw FetchError.rateLimited(retryAt: until) + } + + do { + let token = try await ClaudeCredentialStore.freshAccessToken() + guard let token else { throw FetchError.notBootstrapped } + return try await fetch(token: token, allowOne401Recovery: true) + } catch let err as ClaudeCredentialStore.StoreError { + throw FetchError.credential(err) + } catch let err as FetchError { + throw err + } + } + + /// Reset everything — used on user-initiated disconnect. + static func disconnect() { + ClaudeCredentialStore.resetBootstrap() + clearUsageBlock() + } + + // MARK: - Internal + + private static func fetchWithRecord(initial record: ClaudeCredentialStore.CredentialRecord) async throws -> SubscriptionUsage { + do { + return try await fetch(token: record.accessToken, allowOne401Recovery: true) + } catch let err as FetchError { + throw err + } catch let err as ClaudeCredentialStore.StoreError { + throw FetchError.credential(err) + } + } + + private static func fetch(token: String, allowOne401Recovery: Bool) async throws -> SubscriptionUsage { + var request = URLRequest(url: usageURL) + request.httpMethod = "GET" + request.timeoutInterval = 30 + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(betaHeader, forHTTPHeaderField: "anthropic-beta") + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch { + throw FetchError.network(error) + } + guard let http = response as? HTTPURLResponse else { + throw FetchError.usageHTTPError(-1, nil) + } + + switch http.statusCode { + case 200: + clearUsageBlock() + do { + let decoded = try JSONDecoder().decode(UsageResponse.self, from: data) + let tier = try ClaudeCredentialStore.subscriptionTier() + return mapResponse(decoded, rawTier: tier) + } catch { + throw FetchError.usageDecodeFailed + } + case 401: + if allowOne401Recovery { + let newToken = try await ClaudeCredentialStore.refreshAfter401() + return try await fetch(token: newToken, allowOne401Recovery: false) + } + throw FetchError.usageHTTPError(401, String(data: data, encoding: .utf8)) + case 429: + let body = String(data: data, encoding: .utf8) + let retryAfter = parseRetryAfter(body: body) + let until = recordUsageRateLimit(retryAfterSeconds: retryAfter) + throw FetchError.rateLimited(retryAt: until) + default: + throw FetchError.usageHTTPError(http.statusCode, String(data: data, encoding: .utf8)) + } + } + + // MARK: - 429 backoff + + private static func usageBlockedUntil() -> Date? { + UserDefaults.standard.object(forKey: usageBlockedUntilKey) as? Date + } + + private static func clearUsageBlock() { + UserDefaults.standard.removeObject(forKey: usageBlockedUntilKey) + } + + @discardableResult + 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 + } + + private static func parseRetryAfter(body: String?) -> Int? { + guard let body, let data = body.data(using: .utf8) else { return nil } + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let n = json["retry_after"] as? Int { return n } + if let s = json["retry_after"] as? String, let n = Int(s) { return n } + } + return nil + } + + // MARK: - Response mapping + + private struct UsageResponse: Decodable { + let fiveHour: Window? + let sevenDay: Window? + let sevenDayOpus: Window? + let sevenDaySonnet: Window? + + enum CodingKeys: String, CodingKey { + case fiveHour = "five_hour" + case sevenDay = "seven_day" + case sevenDayOpus = "seven_day_opus" + case sevenDaySonnet = "seven_day_sonnet" + } + } + + private struct Window: Decodable { + let utilization: Double? + let resetsAt: String? + enum CodingKeys: String, CodingKey { + case utilization + case resetsAt = "resets_at" + } + } + + private static func mapResponse(_ r: UsageResponse, rawTier: String?) -> SubscriptionUsage { + SubscriptionUsage( + tier: SubscriptionUsage.tier(from: rawTier), + rawTier: rawTier, + fiveHourPercent: r.fiveHour?.utilization, + fiveHourResetsAt: parseDate(r.fiveHour?.resetsAt), + sevenDayPercent: r.sevenDay?.utilization, + sevenDayResetsAt: parseDate(r.sevenDay?.resetsAt), + sevenDayOpusPercent: r.sevenDayOpus?.utilization, + sevenDayOpusResetsAt: parseDate(r.sevenDayOpus?.resetsAt), + sevenDaySonnetPercent: r.sevenDaySonnet?.utilization, + sevenDaySonnetResetsAt: parseDate(r.sevenDaySonnet?.resetsAt), + fetchedAt: Date() + ) + } + + private static func parseDate(_ s: String?) -> Date? { + guard let s, !s.isEmpty else { return nil } + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let d = f.date(from: s) { return d } + f.formatOptions = [.withInternetDateTime] + return f.date(from: s) + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift b/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift new file mode 100644 index 0000000..cffae7b --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift @@ -0,0 +1,336 @@ +import Foundation +import Security + +/// Owns the Codex (ChatGPT-mode) OAuth credential lifecycle. Mirrors +/// ClaudeCredentialStore but reads from ~/.codex/auth.json — Codex CLI +/// already stores its tokens as plaintext JSON in the home directory, so +/// no keychain prompt is involved on bootstrap. After the user clicks +/// Connect we cache a copy under ~/Library/Application Support/CodeBurn so +/// we keep using rotated tokens after refresh. +enum CodexCredentialStore { + private static let bootstrapCompletedKey = "codeburn.codex.bootstrapCompleted" + private static let inMemoryTTL: TimeInterval = 5 * 60 + private static let proactiveRefreshMargin: TimeInterval = 5 * 60 + + private static let oauthClientID = "app_EMoamEEZ73f0CkXaXp7hrann" + private static let refreshURL = URL(string: "https://auth.openai.com/oauth/token")! + private static let codexAuthPath = ".codex/auth.json" + private static let maxCredentialBytes = 64 * 1024 + + private static let cacheFilename = "codex-credentials.v1.json" + private static let ourKeychainService = "org.agentseal.codeburn.menubar.codex.oauth.v1" + private static let ourKeychainAccount = "default" + + private static let lock = NSLock() + private nonisolated(unsafe) static var memoryCache: CachedRecord? + + struct CachedRecord { + let record: CredentialRecord + let cachedAt: Date + + var isFresh: Bool { Date().timeIntervalSince(cachedAt) < CodexCredentialStore.inMemoryTTL } + } + + struct CredentialRecord: Codable, Equatable { + let accessToken: String + let refreshToken: String + let idToken: String? + let accountId: String? + let expiresAt: Date? + } + + enum StoreError: Error, LocalizedError { + case bootstrapNoSource + case bootstrapDecodeFailed + case bootstrapNotChatGPT // user is on API-key mode; we need ChatGPT mode for quota + case fileWriteFailed(String) + case refreshHTTPError(Int, String?) + case refreshNetworkError(Error) + case refreshDecodeFailed + case noRefreshToken + + var errorDescription: String? { + switch self { + case .bootstrapNoSource: + return "No Codex credentials found at ~/.codex/auth.json. Run `codex` to sign in." + case .bootstrapDecodeFailed: + return "Codex credentials are malformed." + case .bootstrapNotChatGPT: + return "Codex is in API-key mode; live quota tracking is only available for ChatGPT subscriptions." + case let .fileWriteFailed(message): + return "Could not write to local cache: \(message)" + case let .refreshHTTPError(code, body): + return "Codex token refresh failed (HTTP \(code))\(body.map { ": \($0)" } ?? "")" + case let .refreshNetworkError(err): + return "Codex token refresh network error: \(err.localizedDescription)" + case .refreshDecodeFailed: + return "Codex token refresh response was malformed." + case .noRefreshToken: + return "No refresh token available; reconnect required." + } + } + + /// True when the user must take action: rerun `codex` to re-authenticate + /// or switch from API-key to ChatGPT mode. Drives the red Reconnect path. + var isTerminal: Bool { + if case let .refreshHTTPError(code, body) = self, code >= 400, code < 500 { + let lower = body?.lowercased() ?? "" + if lower.contains("refresh_token_expired") || + lower.contains("refresh_token_reused") || + lower.contains("refresh_token_invalidated") || + lower.contains("invalid_grant") + { + return true + } + return true + } + switch self { + case .noRefreshToken, .bootstrapNotChatGPT, .bootstrapNoSource: return true + default: return false + } + } + } + + // MARK: - Bootstrap state + + static var isBootstrapCompleted: Bool { + get { UserDefaults.standard.bool(forKey: bootstrapCompletedKey) } + set { UserDefaults.standard.set(newValue, forKey: bootstrapCompletedKey) } + } + + static func resetBootstrap() { + lock.withLock { memoryCache = nil } + deleteOurCache() + isBootstrapCompleted = false + } + + // MARK: - Public API + + @discardableResult + static func bootstrap() throws -> CredentialRecord { + let record = try readCodexAuth() + try writeOurCache(record: record) + isBootstrapCompleted = true + cacheInMemory(record) + return record + } + + static func currentRecord() throws -> CredentialRecord? { + guard isBootstrapCompleted else { return nil } + if let cached = lock.withLock({ memoryCache }), cached.isFresh { + return cached.record + } + if let stored = try readOurCache() { + cacheInMemory(stored) + return stored + } + isBootstrapCompleted = false + return nil + } + + static func freshAccessToken() async throws -> String? { + guard let record = try currentRecord() else { return nil } + if let expiresAt = record.expiresAt, expiresAt.timeIntervalSinceNow < proactiveRefreshMargin { + let updated = try await refreshAndPersist(record: record) + return updated.accessToken + } + return record.accessToken + } + + static func refreshAfter401() async throws -> String { + guard let record = try currentRecord() else { throw StoreError.noRefreshToken } + let updated = try await refreshAndPersist(record: record) + return updated.accessToken + } + + // MARK: - Bootstrap source: ~/.codex/auth.json + + private static func readCodexAuth() throws -> CredentialRecord { + let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(codexAuthPath) + guard FileManager.default.fileExists(atPath: url.path) else { + throw StoreError.bootstrapNoSource + } + let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes) + struct Root: Decodable { + let auth_mode: String? + let tokens: Tokens? + } + struct Tokens: Decodable { + let access_token: String? + let refresh_token: String? + let id_token: String? + let account_id: String? + } + do { + let root = try JSONDecoder().decode(Root.self, from: data) + // Live quota is only meaningful for ChatGPT-mode auth. API-key users + // have a different billing surface (/v1/usage) which we do not yet + // implement here. + guard root.auth_mode == "chatgpt" else { + throw StoreError.bootstrapNotChatGPT + } + guard let tokens = root.tokens, + let access = tokens.access_token?.trimmingCharacters(in: .whitespacesAndNewlines), + let refresh = tokens.refresh_token?.trimmingCharacters(in: .whitespacesAndNewlines), + !access.isEmpty, !refresh.isEmpty + else { + throw StoreError.bootstrapDecodeFailed + } + return CredentialRecord( + accessToken: access, + refreshToken: refresh, + idToken: tokens.id_token, + accountId: tokens.account_id, + expiresAt: nil // Codex CLI does not record expiresAt in auth.json + ) + } catch let err as StoreError { + throw err + } catch { + throw StoreError.bootstrapDecodeFailed + } + } + + // MARK: - Local cache file + + private static func cacheFileURL() -> URL { + let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support") + return support + .appendingPathComponent("CodeBurn", isDirectory: true) + .appendingPathComponent(cacheFilename) + } + + private static func readOurCache() throws -> CredentialRecord? { + if let record = try readOurKeychainCache() { + return record + } + + let url = cacheFileURL() + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + // Symlink-defense + size cap (same hardening as ClaudeCredentialStore). + let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes) + guard let record = try? JSONDecoder().decode(CredentialRecord.self, from: data) else { return nil } + try? writeOurKeychainCache(record: record) + try? FileManager.default.removeItem(at: url) + return record + } + + private static func writeOurCache(record: CredentialRecord) throws { + try writeOurKeychainCache(record: record) + } + + private static func readOurKeychainCache() throws -> CredentialRecord? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: ourKeychainService, + kSecAttrAccount as String: ourKeychainAccount, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { + throw StoreError.fileWriteFailed("keychain read failed with status \(status)") + } + return try? JSONDecoder().decode(CredentialRecord.self, from: data) + } + + private static func writeOurKeychainCache(record: CredentialRecord) throws { + let url = cacheFileURL() + let data = try JSONEncoder().encode(record) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: ourKeychainService, + kSecAttrAccount as String: ourKeychainAccount, + ] + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + var add = query + add.merge(attributes) { _, new in new } + let addStatus = SecItemAdd(add as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw StoreError.fileWriteFailed("keychain write failed with status \(addStatus)") + } + } else if status != errSecSuccess { + throw StoreError.fileWriteFailed("keychain update failed with status \(status)") + } + try? FileManager.default.removeItem(at: url) + } + + private static func deleteOurCache() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: ourKeychainService, + kSecAttrAccount as String: ourKeychainAccount, + ] + SecItemDelete(query as CFDictionary) + try? FileManager.default.removeItem(at: cacheFileURL()) + } + + private static func cacheInMemory(_ record: CredentialRecord) { + lock.withLock { memoryCache = CachedRecord(record: record, cachedAt: Date()) } + } + + // MARK: - Refresh + + private static func refreshAndPersist(record: CredentialRecord) async throws -> CredentialRecord { + guard !record.refreshToken.isEmpty else { throw StoreError.noRefreshToken } + + var request = URLRequest(url: refreshURL) + request.httpMethod = "POST" + request.timeoutInterval = 30 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + let body: [String: String] = [ + "client_id": oauthClientID, + "grant_type": "refresh_token", + "refresh_token": record.refreshToken, + "scope": "openid profile email", + ] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch { + throw StoreError.refreshNetworkError(error) + } + guard let http = response as? HTTPURLResponse else { + throw StoreError.refreshHTTPError(-1, nil) + } + guard http.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) + throw StoreError.refreshHTTPError(http.statusCode, body) + } + + struct RefreshResponse: Decodable { + let access_token: String + let refresh_token: String? + let id_token: String? + let expires_in: Int? + } + guard let decoded = try? JSONDecoder().decode(RefreshResponse.self, from: data) else { + throw StoreError.refreshDecodeFailed + } + + let updated = CredentialRecord( + accessToken: decoded.access_token, + refreshToken: decoded.refresh_token ?? record.refreshToken, + idToken: decoded.id_token ?? record.idToken, + accountId: record.accountId, + expiresAt: decoded.expires_in.map { Date().addingTimeInterval(TimeInterval($0)) } ?? record.expiresAt + ) + cacheInMemory(updated) + do { + try writeOurCache(record: updated) + } catch { + NSLog("CodeBurn: codex cache write failed during refresh rotation: %@", String(describing: error)) + } + return updated + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift b/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift new file mode 100644 index 0000000..ac3bd94 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift @@ -0,0 +1,243 @@ +import Foundation + +/// Mirror of ClaudeSubscriptionService for Codex (ChatGPT-mode). Hits +/// /backend-api/wham/usage with the bearer token from CodexCredentialStore, +/// applies an independent 429 backoff, and surfaces terminal vs transient +/// failures to the UI. +enum CodexSubscriptionService { + private static let usageURL = URL(string: "https://chatgpt.com/backend-api/wham/usage")! + private static let usageBlockedUntilKey = "codeburn.codex.usage.blockedUntil" + + enum FetchError: Error, LocalizedError { + case notBootstrapped + case bootstrapFailed(CodexCredentialStore.StoreError) + case rateLimited(retryAt: Date) + case usageHTTPError(Int, String?) + case usageDecodeFailed + case network(Error) + case credential(CodexCredentialStore.StoreError) + + var errorDescription: String? { + switch self { + case .notBootstrapped: + return "Connect Codex in Settings to start tracking quota." + case let .bootstrapFailed(err): return err.errorDescription + case let .rateLimited(retryAt): + let f = RelativeDateTimeFormatter() + f.unitsStyle = .short + return "ChatGPT rate-limited the quota endpoint. Retrying \(f.localizedString(for: retryAt, relativeTo: Date()))." + case let .usageHTTPError(code, body): + return "Codex quota fetch failed (HTTP \(code))\(body.map { ": \($0)" } ?? "")" + case .usageDecodeFailed: return "Codex quota response was malformed." + case let .network(err): return "Network error: \(err.localizedDescription)" + case let .credential(err): return err.errorDescription + } + } + + var isTerminal: Bool { + if case let .credential(err) = self { return err.isTerminal } + if case let .bootstrapFailed(err) = self { return err.isTerminal } + return false + } + + var rateLimitRetryAt: Date? { + if case let .rateLimited(retryAt) = self { return retryAt } + return nil + } + } + + static func bootstrap() async throws -> CodexUsage { + // Honour the same 429 backoff that refreshIfBootstrapped respects. + // A user clicking Reconnect during a sustained ChatGPT rate-limit + // window would otherwise re-hit /wham/usage on every click and keep + // the backoff window pegged. + if let until = usageBlockedUntil(), until > Date() { + throw FetchError.rateLimited(retryAt: until) + } + let record: CodexCredentialStore.CredentialRecord + do { + record = try CodexCredentialStore.bootstrap() + } catch let err as CodexCredentialStore.StoreError { + throw FetchError.bootstrapFailed(err) + } + return try await fetchWithToken(record.accessToken, allowOne401Recovery: true) + } + + static func refreshIfBootstrapped() async throws -> CodexUsage? { + guard CodexCredentialStore.isBootstrapCompleted else { return nil } + if let until = usageBlockedUntil(), until > Date() { + throw FetchError.rateLimited(retryAt: until) + } + do { + let token = try await CodexCredentialStore.freshAccessToken() + guard let token else { throw FetchError.notBootstrapped } + return try await fetchWithToken(token, allowOne401Recovery: true) + } catch let err as CodexCredentialStore.StoreError { + throw FetchError.credential(err) + } + } + + static func disconnect() { + CodexCredentialStore.resetBootstrap() + clearUsageBlock() + } + + private static func fetchWithToken(_ token: String, allowOne401Recovery: Bool) async throws -> CodexUsage { + var request = URLRequest(url: usageURL) + request.httpMethod = "GET" + request.timeoutInterval = 30 + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("CodeBurn", forHTTPHeaderField: "User-Agent") + // chatgpt.com routes the rate_limit envelope per ChatGPT account. Without + // this header the response often comes back as a guest-shape document + // missing rate_limit entirely, which our decoder then fails on. + if let accountId = try? CodexCredentialStore.currentRecord()?.accountId, !accountId.isEmpty { + request.setValue(accountId, forHTTPHeaderField: "ChatGPT-Account-Id") + } + + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch { + throw FetchError.network(error) + } + guard let http = response as? HTTPURLResponse else { + throw FetchError.usageHTTPError(-1, nil) + } + + switch http.statusCode { + case 200: + clearUsageBlock() + do { + return try decodeUsage(data: data) + } catch { + // Do not log the response body — it's user-account data from + // chatgpt.com and is readable by other local users via + // `log stream`. The decode error type alone is enough to + // bisect schema drift if needed. + NSLog("CodeBurn: codex usage decode failed: %@", String(describing: error)) + throw FetchError.usageDecodeFailed + } + case 401: + if allowOne401Recovery { + let newToken = try await CodexCredentialStore.refreshAfter401() + return try await fetchWithToken(newToken, allowOne401Recovery: false) + } + throw FetchError.usageHTTPError(401, String(data: data, encoding: .utf8)) + case 429: + // Honour the RFC Retry-After header when present — ChatGPT's quota + // endpoint sometimes sets it to a window shorter than our 5-min + // floor, and ignoring it forced users to wait longer than the + // server actually wanted. + let retryAfter = parseRetryAfterHeader(http.value(forHTTPHeaderField: "Retry-After")) + let until = recordUsageRateLimit(retryAfterSeconds: retryAfter) + throw FetchError.rateLimited(retryAt: until) + default: + throw FetchError.usageHTTPError(http.statusCode, String(data: data, encoding: .utf8)) + } + } + + private struct UsageDTO: Decodable { + let plan_type: String? + let rate_limit: RateLimit? + let additional_rate_limits: [AdditionalLimitDTO]? + let credits: Credits? + + struct RateLimit: Decodable { + let primary_window: WindowDTO? + let secondary_window: WindowDTO? + } + struct AdditionalLimitDTO: Decodable { + let limit_name: String? + let rate_limit: RateLimit? + } + struct WindowDTO: Decodable { + let used_percent: Double? + let reset_at: Int? + let limit_window_seconds: Int? + } + // chatgpt.com sometimes serializes balance as a Double ("balance": 0.0) + // and other times as a String ("balance": "0.00"). Mirror CodexBar's + // resilient decode so a schema drift on either shape doesn't blow up + // the whole quota fetch. + struct Credits: Decodable { + let balance: Double? + enum CodingKeys: String, CodingKey { case balance } + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + if let n = try? c.decode(Double.self, forKey: .balance) { + balance = n + } else if let s = try? c.decode(String.self, forKey: .balance), let n = Double(s) { + balance = n + } else { + balance = nil + } + } + } + } + + private static func decodeUsage(data: Data) throws -> CodexUsage { + let root = try JSONDecoder().decode(UsageDTO.self, from: data) + let additional: [CodexUsage.AdditionalLimit] = (root.additional_rate_limits ?? []).compactMap { dto in + guard let name = dto.limit_name, !name.isEmpty else { return nil } + return CodexUsage.AdditionalLimit( + name: name, + primary: makeWindow(dto.rate_limit?.primary_window), + secondary: makeWindow(dto.rate_limit?.secondary_window) + ) + } + return CodexUsage( + plan: CodexUsage.planType(from: root.plan_type), + primary: makeWindow(root.rate_limit?.primary_window), + secondary: makeWindow(root.rate_limit?.secondary_window), + additionalLimits: additional, + creditsBalance: root.credits?.balance, + fetchedAt: Date() + ) + } + + private static func makeWindow(_ dto: UsageDTO.WindowDTO?) -> CodexUsage.Window? { + guard let dto, let used = dto.used_percent, let windowSeconds = dto.limit_window_seconds else { + return nil + } + let resetsAt = dto.reset_at.map { Date(timeIntervalSince1970: TimeInterval($0)) } + return CodexUsage.Window(usedPercent: used, resetsAt: resetsAt, limitWindowSeconds: windowSeconds) + } + + // MARK: - 429 backoff + + private static func usageBlockedUntil() -> Date? { + UserDefaults.standard.object(forKey: usageBlockedUntilKey) as? Date + } + + private static func clearUsageBlock() { + UserDefaults.standard.removeObject(forKey: usageBlockedUntilKey) + } + + @discardableResult + /// RFC 7231 says Retry-After is either a delta-seconds or an HTTP-date. + /// chatgpt.com appears to send delta-seconds today; we still parse both + /// shapes defensively so a future change to HTTP-date doesn't drop us + /// onto the silent 5-minute floor. + 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 + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift b/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift new file mode 100644 index 0000000..719b117 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift @@ -0,0 +1,98 @@ +import Foundation + +/// Codex (ChatGPT-mode) live quota snapshot returned by /backend-api/wham/usage. +/// Two windows are exposed: primary (typically the 5-hour rolling window) and +/// secondary (typically the weekly window). Window size is dynamic per +/// account — `limitWindowSeconds` tells us whether it's a 5-hour or 7-day +/// boundary so we can label correctly. +struct CodexUsage: Sendable, Equatable { + enum PlanType: Sendable, Equatable { + case guest, free, go, plus, pro, prolite, freeWorkspace, team + case business, education, quorum, k12, enterprise, edu + /// Captures any plan_type string OpenAI ships that we haven't enumerated + /// yet, so the Settings/Plan UI can still show "Plan: " instead of + /// a generic "Subscription" placeholder. Preserves forward compatibility + /// without requiring a CodeBurn update for every new tier. + case unknown(String) + + var displayName: String { + switch self { + case .guest: "Guest" + case .free: "Free" + case .go: "Go" + case .plus: "Plus" + case .pro: "Pro" + case .prolite: "Pro Lite" + case .freeWorkspace: "Free Workspace" + case .team: "Team" + case .business: "Business" + case .education: "Education" + case .quorum: "Quorum" + case .k12: "K-12" + case .enterprise: "Enterprise" + case .edu: "Edu" + case let .unknown(raw): raw.isEmpty ? "Subscription" : raw.capitalized + } + } + } + + struct Window: Sendable, Equatable { + let usedPercent: Double // 0.0 ... 100.0 + let resetsAt: Date? + let limitWindowSeconds: Int + + /// Human label inferred from window size: 5h, 1d, 7d, etc. + var windowLabel: String { + switch limitWindowSeconds { + case 0..<3600: return "Hourly" + case 3600..<7200: return "Hour" + case 18000..<19000: return "5-hour" + case 86400..<87000: return "Daily" + case 604800..<605000: return "Weekly" + default: + let hours = limitWindowSeconds / 3600 + if hours < 24 { return "\(hours)-hour" } + return "\(hours / 24)-day" + } + } + } + + /// Additional per-model / per-feature quotas exposed by ChatGPT alongside + /// the main rate_limit (e.g. "GPT-5.3-Codex-Spark"). Each entry has its + /// own primary/secondary windows. Only ones with non-zero utilization are + /// surfaced in the popover so users on plans that don't touch these + /// features don't see clutter. + struct AdditionalLimit: Sendable, Equatable { + let name: String + let primary: Window? + let secondary: Window? + } + + let plan: PlanType + let primary: Window? + let secondary: Window? + let additionalLimits: [AdditionalLimit] + let creditsBalance: Double? + let fetchedAt: Date + + static func planType(from raw: String?) -> PlanType { + guard let raw = raw?.lowercased() else { return .unknown("") } + switch raw { + case "guest": return .guest + case "free": return .free + case "go": return .go + case "plus": return .plus + case "pro": return .pro + case "prolite", "pro_lite", "pro-lite": return .prolite + case "free_workspace": return .freeWorkspace + case "team": return .team + case "business": return .business + case "education": return .education + case "quorum": return .quorum + case "k12": return .k12 + case "enterprise": return .enterprise + case "edu": return .edu + default: return .unknown(raw) + } + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/DataClient.swift b/mac/Sources/CodeBurnMenubar/Data/DataClient.swift index a6884be..4b0083c 100644 --- a/mac/Sources/CodeBurnMenubar/Data/DataClient.swift +++ b/mac/Sources/CodeBurnMenubar/Data/DataClient.swift @@ -6,7 +6,7 @@ import Foundation /// Pipe file descriptors pinned forever. private let maxPayloadBytes = 20 * 1024 * 1024 private let maxStderrBytes = 256 * 1024 -private let spawnTimeoutSeconds: UInt64 = 20 +private let spawnTimeoutSeconds: UInt64 = 45 enum DataClientError: Error { case spawn(String) @@ -61,21 +61,27 @@ struct DataClient { throw DataClientError.spawn(error.localizedDescription) } - // Drain both pipes concurrently so a large stderr can't deadlock stdout (the child - // blocks on write once the pipe buffer fills). `drain` also enforces a byte cap. - async let stdoutData = drain(outPipe.fileHandleForReading, limit: maxPayloadBytes) - async let stderrData = drain(errPipe.fileHandleForReading, limit: maxStderrBytes) - - // Wall-clock timeout: if the CLI hangs (parser stuck, disk stall), kill it. let timeoutTask = Task.detached(priority: .utility) { try? await Task.sleep(nanoseconds: spawnTimeoutSeconds * 1_000_000_000) if process.isRunning { - process.terminate() + NSLog("CodeBurn: CLI subprocess timed out after %llus for %@ — terminating", + spawnTimeoutSeconds, subcommand.joined(separator: " ")) + terminateWithEscalation(process) } } defer { timeoutTask.cancel() } - let (out, err) = await (stdoutData, stderrData) + let outHandle = outPipe.fileHandleForReading + let errHandle = errPipe.fileHandleForReading + let (out, err) = await withTaskCancellationHandler { + async let stdoutData = drain(outHandle, limit: maxPayloadBytes) + async let stderrData = drain(errHandle, limit: maxStderrBytes) + return await (stdoutData, stderrData) + } onCancel: { + terminateWithEscalation(process) + } + try? outHandle.close() + try? errHandle.close() process.waitUntilExit() if out.count >= maxPayloadBytes { @@ -86,22 +92,45 @@ struct DataClient { return ProcessResult(stdout: out, stderr: stderrString, exitCode: process.terminationStatus) } - /// Pulls bytes off a pipe until EOF or `limit`. Intentionally uses `availableData`, which - /// returns empty on EOF -- no blocking once the child exits. + private static func terminateWithEscalation(_ process: Process) { + guard process.isRunning else { return } + process.terminate() + let pid = process.processIdentifier + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.5) { + if process.isRunning { kill(pid, SIGKILL) } + } + } + private static func drain(_ handle: FileHandle, limit: Int) async -> Data { - await Task.detached(priority: .utility) { - var buffer = Data() - while buffer.count < limit { - let chunk = handle.availableData - if chunk.isEmpty { break } - let remaining = limit - buffer.count - if chunk.count > remaining { - buffer.append(chunk.prefix(remaining)) - break - } - buffer.append(chunk) + let fd = handle.fileDescriptor + let flags = Darwin.fcntl(fd, F_GETFL) + if flags >= 0 { + _ = Darwin.fcntl(fd, F_SETFL, flags | O_NONBLOCK) + } else { + NSLog("CodeBurn: fcntl F_GETFL failed on fd %d, drain may block", fd) + } + + var buffer = Data() + var chunk = [UInt8](repeating: 0, count: 65_536) + + while buffer.count < limit && !Task.isCancelled { + let toRead = min(chunk.count, limit - buffer.count) + let n = chunk.withUnsafeMutableBufferPointer { ptr in + Darwin.read(fd, ptr.baseAddress!, toRead) } - return buffer - }.value + if n > 0 { + buffer.append(contentsOf: chunk.prefix(n)) + } else if n == 0 { + break + } else if errno == EAGAIN || errno == EWOULDBLOCK { + try? await Task.sleep(nanoseconds: 5_000_000) + } else if errno == EINTR { + continue + } else { + NSLog("CodeBurn: drain read() failed on fd %d: errno %d", fd, errno) + break + } + } + return buffer } } diff --git a/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift b/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift new file mode 100644 index 0000000..c76f6ba --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift @@ -0,0 +1,75 @@ +import Foundation + +/// Per-provider live-quota snapshot consumed by the AgentTab progress bar +/// and the hover-detail popover. Today only Claude has a real quota source +/// (Anthropic /api/oauth/usage); future providers (Cursor, Copilot, etc.) +/// will plug in by producing the same struct from their own auth path. +struct QuotaSummary: Equatable { + enum Connection: Equatable { + case connected + case disconnected // no credentials present + case loading + case stale // had data once, current fetch is in flight + case transientFailure // backing off; show last-known data dimmed + case terminalFailure(reason: String?) // user must reconnect + } + + let providerFilter: ProviderFilter + let connection: Connection + let primary: Window? // weekly utilization, the headline bar + let details: [Window] // 5h, weekly, opus, sonnet — full hover card + /// Display label for the user's plan (e.g. "Max 20x", "Pro Lite"). Shown + /// in the top-right corner of the hover detail popover so users can + /// confirm at a glance which subscription is feeding the bar. + let planLabel: String? + /// Optional footer rows that the popover renders below the window list. + /// Used today only by Codex to surface the on-account credits balance, + /// but kept generic so future providers can add provider-specific facts + /// (e.g. "Anthropic incident in progress", "Cursor team seat"). + let footerLines: [String] + + struct Window: Equatable { + let label: String + let percent: Double // 0..1 + let resetsAt: Date? + } + + /// Color band thresholds for the inline chip bar and aggregate menubar + /// flame tint. Four tiers so the icon can step from "you're approaching + /// your limit" (yellow) through "you're about to hit the wall" (orange) + /// to "you're over" (red) — matches what the user expects from a warning + /// indicator in the menu bar. + static func severity(for percent: Double) -> Severity { + if percent >= 1.0 { return .danger } + if percent >= 0.9 { return .critical } + if percent >= 0.7 { return .warning } + return .normal + } + + enum Severity { + case normal // <70% + case warning // 70-90% + case critical // 90-100% + case danger // >=100% + } +} + +extension QuotaSummary.Window { + /// Human-readable countdown like "2h 11m" or "3d 14h" or "now". + var resetsInLabel: String { + guard let resetsAt else { return "" } + let seconds = max(0, resetsAt.timeIntervalSinceNow) + if seconds < 60 { return "now" } + let minutes = Int(seconds / 60) + let hours = minutes / 60 + let days = hours / 24 + if days > 0 { return "\(days)d \(hours % 24)h" } + if hours > 0 { return "\(hours)h \(minutes % 60)m" } + return "\(minutes)m" + } + + var percentLabel: String { + let pct = Int((percent * 100).rounded()) + return "\(pct)%" + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/SubscriptionClient.swift b/mac/Sources/CodeBurnMenubar/Data/SubscriptionClient.swift deleted file mode 100644 index 3f71e30..0000000 --- a/mac/Sources/CodeBurnMenubar/Data/SubscriptionClient.swift +++ /dev/null @@ -1,268 +0,0 @@ -import Foundation -import Security - -private let credentialsRelativePath = ".claude/.credentials.json" -private let keychainService = "Claude Code-credentials" -private let oauthClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" -private let refreshURL = URL(string: "https://platform.claude.com/v1/oauth/token")! -private let usageURL = URL(string: "https://api.anthropic.com/api/oauth/usage")! -private let betaHeader = "oauth-2025-04-20" -private let userAgent = "claude-code/2.1.0" -private let requestTimeout: TimeInterval = 30 - -private let maxCredentialBytes = 64 * 1024 - -enum SubscriptionError: Error, LocalizedError { - case noCredentials - case credentialsInvalid - case refreshFailed(Int, String?) - case usageFetchFailed(Int, String?) - case decodeFailed(Error) - - var errorDescription: String? { - switch self { - case .noCredentials: "No Claude OAuth credentials found" - case .credentialsInvalid: "Claude OAuth credentials malformed" - case let .refreshFailed(code, body): "Token refresh failed (\(code))\(body.map { ": \($0)" } ?? "")" - case let .usageFetchFailed(code, body): "Usage fetch failed (\(code))\(body.map { ": \($0)" } ?? "")" - case let .decodeFailed(err): "Decode failed: \(err.localizedDescription)" - } - } -} - -struct SubscriptionClient { - static func fetch() async throws -> SubscriptionUsage { - let creds = try loadCredentials() - - // Try the usage call with the existing token first. Only refresh on 401. - do { - let response = try await fetchUsage(token: creds.accessToken) - return mapResponse(response, rawTier: creds.rateLimitTier) - } catch SubscriptionError.usageFetchFailed(401, _) { - guard let refreshToken = creds.refreshToken, !refreshToken.isEmpty else { - throw SubscriptionError.usageFetchFailed(401, "no refresh token available") - } - let newToken = try await refreshAccessToken(refreshToken: refreshToken) - let response = try await fetchUsage(token: newToken) - return mapResponse(response, rawTier: creds.rateLimitTier) - } - } - - // MARK: - Credentials - - private static func loadCredentials() throws -> StoredCredentials { - if let data = try readFileCredentials() { - return try parseCredentials(data: sanitizeKeychainData(data)) - } - if let creds = try readKeychainCredentials() { - return creds - } - throw SubscriptionError.noCredentials - } - - private static func readFileCredentials() throws -> Data? { - let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(credentialsRelativePath) - guard FileManager.default.fileExists(atPath: url.path) else { return nil } - // SafeFile refuses to follow symlinks and caps the read, so a 6 GB /dev/urandom - // masquerading as the creds file can't blow up the app. - return try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes) - } - - private static func readKeychainCredentials() throws -> StoredCredentials? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainService, - kSecMatchLimit as String: kSecMatchLimitOne, - kSecReturnData as String: true, - ] - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - if status == errSecItemNotFound { return nil } - guard status == errSecSuccess, let data = result as? Data else { - NSLog("CodeBurn: keychain query failed status=\(status)") - return nil - } - return try parseCredentials(data: sanitizeKeychainData(data)) - } - - /// Claude Code's keychain writer line-wraps long string values (newline + leading spaces) - /// mid-token, producing JSON with literal control chars and stray spaces inside string - /// values. Replace every newline (CR/LF) plus the run of spaces/tabs that follows it. - /// Drops both the wrapping in tokens AND pretty-print indentation between fields (both - /// produce valid, compact JSON afterward). - private static func sanitizeKeychainData(_ data: Data) -> Data { - guard var s = String(data: data, encoding: .utf8) else { return data } - s = s.replacingOccurrences(of: "\r", with: "") - let regex = try? NSRegularExpression(pattern: "\\n[ \\t]*", options: []) - if let regex { - let range = NSRange(s.startIndex.. StoredCredentials { - do { - let root = try JSONDecoder().decode(CredentialsRoot.self, from: data) - guard let oauth = root.claudeAiOauth else { throw SubscriptionError.credentialsInvalid } - let token = oauth.accessToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !token.isEmpty else { throw SubscriptionError.credentialsInvalid } - let expiresAt = oauth.expiresAt.map { Date(timeIntervalSince1970: $0 / 1000.0) } - return StoredCredentials( - accessToken: token, - refreshToken: oauth.refreshToken, - expiresAt: expiresAt, - rateLimitTier: oauth.rateLimitTier - ) - } catch let err as SubscriptionError { - throw err - } catch { - throw SubscriptionError.decodeFailed(error) - } - } - - // MARK: - Refresh - - private static func refreshAccessToken(refreshToken: String) async throws -> String { - var request = URLRequest(url: refreshURL) - request.httpMethod = "POST" - request.timeoutInterval = requestTimeout - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - request.setValue("application/json", forHTTPHeaderField: "Accept") - var components = URLComponents() - components.queryItems = [ - URLQueryItem(name: "grant_type", value: "refresh_token"), - URLQueryItem(name: "refresh_token", value: refreshToken), - URLQueryItem(name: "client_id", value: oauthClientID), - ] - request.httpBody = (components.percentEncodedQuery ?? "").data(using: .utf8) - - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse else { - throw SubscriptionError.refreshFailed(-1, nil) - } - guard http.statusCode == 200 else { - let body = String(data: data, encoding: .utf8) - throw SubscriptionError.refreshFailed(http.statusCode, body) - } - do { - let decoded = try JSONDecoder().decode(TokenRefreshResponse.self, from: data) - return decoded.accessToken - } catch { - throw SubscriptionError.decodeFailed(error) - } - } - - // MARK: - Usage fetch - - private static func fetchUsage(token: String) async throws -> UsageResponse { - var request = URLRequest(url: usageURL) - request.httpMethod = "GET" - request.timeoutInterval = requestTimeout - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.setValue(betaHeader, forHTTPHeaderField: "anthropic-beta") - request.setValue(userAgent, forHTTPHeaderField: "User-Agent") - - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse else { - throw SubscriptionError.usageFetchFailed(-1, nil) - } - guard http.statusCode == 200 else { - let body = String(data: data, encoding: .utf8) - throw SubscriptionError.usageFetchFailed(http.statusCode, body) - } - do { - return try JSONDecoder().decode(UsageResponse.self, from: data) - } catch { - throw SubscriptionError.decodeFailed(error) - } - } - - // MARK: - Mapping - - private static func mapResponse(_ r: UsageResponse, rawTier: String?) -> SubscriptionUsage { - SubscriptionUsage( - tier: SubscriptionUsage.tier(from: rawTier), - rawTier: rawTier, - fiveHourPercent: r.fiveHour?.utilization, - fiveHourResetsAt: parseDate(r.fiveHour?.resetsAt), - sevenDayPercent: r.sevenDay?.utilization, - sevenDayResetsAt: parseDate(r.sevenDay?.resetsAt), - sevenDayOpusPercent: r.sevenDayOpus?.utilization, - sevenDayOpusResetsAt: parseDate(r.sevenDayOpus?.resetsAt), - sevenDaySonnetPercent: r.sevenDaySonnet?.utilization, - sevenDaySonnetResetsAt: parseDate(r.sevenDaySonnet?.resetsAt), - fetchedAt: Date() - ) - } - - private static func parseDate(_ s: String?) -> Date? { - guard let s, !s.isEmpty else { return nil } - let f = ISO8601DateFormatter() - f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let d = f.date(from: s) { return d } - f.formatOptions = [.withInternetDateTime] - return f.date(from: s) - } -} - -// MARK: - Internal models - -private struct StoredCredentials { - let accessToken: String - let refreshToken: String? - let expiresAt: Date? - let rateLimitTier: String? -} - -private struct CredentialsRoot: Decodable { - let claudeAiOauth: OAuthBlock? -} - -private struct OAuthBlock: Decodable { - let accessToken: String? - let refreshToken: String? - let expiresAt: Double? - let rateLimitTier: String? -} - -private struct TokenRefreshResponse: Decodable { - let accessToken: String - let refreshToken: String? - let expiresIn: Int? - - enum CodingKeys: String, CodingKey { - case accessToken = "access_token" - case refreshToken = "refresh_token" - case expiresIn = "expires_in" - } -} - -private struct UsageResponse: Decodable { - let fiveHour: Window? - let sevenDay: Window? - let sevenDayOpus: Window? - let sevenDaySonnet: Window? - - enum CodingKeys: String, CodingKey { - case fiveHour = "five_hour" - case sevenDay = "seven_day" - case sevenDayOpus = "seven_day_opus" - case sevenDaySonnet = "seven_day_sonnet" - } -} - -private struct Window: Decodable { - let utilization: Double? - let resetsAt: String? - - enum CodingKeys: String, CodingKey { - case utilization - case resetsAt = "resets_at" - } -} diff --git a/mac/Sources/CodeBurnMenubar/Data/SubscriptionRefreshCadence.swift b/mac/Sources/CodeBurnMenubar/Data/SubscriptionRefreshCadence.swift new file mode 100644 index 0000000..3701d25 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/SubscriptionRefreshCadence.swift @@ -0,0 +1,42 @@ +import Foundation + +/// User-configurable cadence for /api/oauth/usage polling. Mirrors CodexBar's +/// "manual / 1m / 2m / 5m / 15m" preset set so users on tight rate-limit +/// budgets can dial it down and power users can dial it up. Stored as the raw +/// number of seconds in UserDefaults; `manual = 0` means "never auto-refresh". +enum SubscriptionRefreshCadence: Int, CaseIterable, Identifiable { + case manual = 0 + case oneMinute = 60 + case twoMinutes = 120 + case fiveMinutes = 300 + case fifteenMinutes = 900 + + var id: Int { rawValue } + + var label: String { + switch self { + case .manual: return "Manual" + case .oneMinute: return "1 minute" + case .twoMinutes: return "2 minutes" + case .fiveMinutes: return "5 minutes" + case .fifteenMinutes: return "15 minutes" + } + } + + static let defaultsKey = "codeburn.claude.refreshCadenceSeconds" + static let `default`: SubscriptionRefreshCadence = .twoMinutes + + static var current: SubscriptionRefreshCadence { + get { + // UserDefaults.integer returns 0 when the key is missing — that + // happens to alias `manual`, which is wrong for a fresh install. + // Probe with object(forKey:) so we can distinguish "never set" + // from "set to manual" and seed the default on first run. + if UserDefaults.standard.object(forKey: defaultsKey) == nil { + return .default + } + return SubscriptionRefreshCadence(rawValue: UserDefaults.standard.integer(forKey: defaultsKey)) ?? .default + } + set { UserDefaults.standard.set(newValue.rawValue, forKey: defaultsKey) } + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/SubscriptionSnapshotStore.swift b/mac/Sources/CodeBurnMenubar/Data/SubscriptionSnapshotStore.swift index 931154a..9357ee9 100644 --- a/mac/Sources/CodeBurnMenubar/Data/SubscriptionSnapshotStore.swift +++ b/mac/Sources/CodeBurnMenubar/Data/SubscriptionSnapshotStore.swift @@ -76,6 +76,13 @@ enum SubscriptionSnapshotStore { /// Test seam: clear all snapshots. static func resetForTesting() async { + await clearAll() + } + + /// Wipe all snapshots from disk. Called when the user disconnects so the + /// "Based on last cycle" projections do not contaminate a reconnect under + /// a different account or tier. + static func clearAll() async { await SnapshotLock.shared.run { try? FileManager.default.removeItem(atPath: snapshotsPath()) } diff --git a/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift b/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift index ddf7dc4..5441794 100644 --- a/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift +++ b/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift @@ -1,10 +1,28 @@ import Foundation import Observation -private let releasesAPI = "https://api.github.com/repos/getagentseal/codeburn/releases/latest" +private let releasesAPI = "https://api.github.com/repos/getagentseal/codeburn/releases?per_page=20" private let checkIntervalSeconds: TimeInterval = 2 * 24 * 60 * 60 private let lastCheckKey = "UpdateChecker.lastCheckDate" private let cachedVersionKey = "UpdateChecker.latestVersion" +private let updateTimeoutSeconds: UInt64 = 120 +private let maxUpdateStderrBytes = 64 * 1024 + +private final class LockedDataBuffer: @unchecked Sendable { + private let lock = NSLock() + private var data = Data() + + func append(_ chunk: Data, limit: Int) { + lock.withLock { + guard data.count < limit else { return } + data.append(Data(chunk.prefix(limit - data.count))) + } + } + + func snapshot() -> Data { + lock.withLock { data } + } +} @MainActor @Observable @@ -16,14 +34,14 @@ final class UpdateChecker { var updateAvailable: Bool { guard let latest = latestVersion else { return false } let current = currentVersion - let normalizedLatest = latest.hasPrefix("v") ? String(latest.dropFirst()) : latest - let normalizedCurrent = current.hasPrefix("v") ? String(current.dropFirst()) : current + let normalizedLatest = AppVersion.normalize(latest) + let normalizedCurrent = AppVersion.normalize(current) guard !normalizedCurrent.isEmpty && normalizedCurrent != "dev" else { return false } return normalizedLatest.compare(normalizedCurrent, options: .numeric) == .orderedDescending } var currentVersion: String { - Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" + AppVersion.normalizedBundleShortVersion } func checkIfNeeded() async { @@ -37,19 +55,24 @@ final class UpdateChecker { } func check() async { + updateError = nil guard let url = URL(string: releasesAPI) else { return } var request = URLRequest(url: url) request.setValue("codeburn-menubar-updater", forHTTPHeaderField: "User-Agent") request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") do { - let (data, _) = try await URLSession.shared.data(for: request) - let release = try JSONDecoder().decode(GitHubRelease.self, from: data) - guard let asset = release.assets.first(where: { - $0.name.hasPrefix("CodeBurnMenubar-") && $0.name.hasSuffix(".zip") - }) else { return } + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 + throw UpdateCheckError.http(status) + } + let releases = try JSONDecoder().decode([GitHubRelease].self, from: data) + guard let resolved = Self.resolveLatestMenubarRelease(in: releases) else { + throw UpdateCheckError.missingMenubarAsset + } - let version = asset.name + let version = resolved.asset.name .replacingOccurrences(of: "CodeBurnMenubar-", with: "") .replacingOccurrences(of: ".zip", with: "") @@ -57,28 +80,58 @@ final class UpdateChecker { UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: lastCheckKey) UserDefaults.standard.set(version, forKey: cachedVersionKey) } catch { + updateError = "Update check failed: \(error.localizedDescription)" NSLog("CodeBurn: update check failed: \(error)") } } + nonisolated static func resolveLatestMenubarRelease(in releases: [GitHubRelease]) -> (release: GitHubRelease, asset: GitHubAsset)? { + for release in releases where release.tag_name.hasPrefix("mac-v") { + guard let asset = release.assets.first(where: { + $0.name.hasPrefix("CodeBurnMenubar-v") && $0.name.hasSuffix(".zip") + }) else { continue } + guard release.assets.contains(where: { $0.name == "\(asset.name).sha256" }) else { continue } + return (release, asset) + } + return nil + } + func performUpdate() { isUpdating = true updateError = nil let process = CodeburnCLI.makeProcess(subcommand: ["menubar", "--force"]) let errPipe = Pipe() + let errBuffer = LockedDataBuffer() process.standardOutput = FileHandle.nullDevice process.standardError = errPipe + errPipe.fileHandleForReading.readabilityHandler = { handle in + let chunk = handle.availableData + guard !chunk.isEmpty else { return } + errBuffer.append(chunk, limit: maxUpdateStderrBytes) + } + + let timeoutTask = Task.detached(priority: .utility) { + try? await Task.sleep(nanoseconds: updateTimeoutSeconds * 1_000_000_000) + if process.isRunning { + NSLog("CodeBurn: update subprocess timed out after %llus - terminating", updateTimeoutSeconds) + process.terminate() + } + } process.terminationHandler = { [weak self] proc in - let errData = errPipe.fileHandleForReading.readDataToEndOfFile() - let stderr = String(data: errData, encoding: .utf8) ?? "" + timeoutTask.cancel() + errPipe.fileHandleForReading.readabilityHandler = nil + let stderrData = errBuffer.snapshot() + let stderr = Self.sanitizeForDisplay(String(data: stderrData, encoding: .utf8) ?? "") Task { @MainActor in guard let self else { return } + self.isUpdating = false if proc.terminationStatus != 0 { - self.isUpdating = false self.updateError = stderr.isEmpty ? "Update failed (exit \(proc.terminationStatus))" : stderr NSLog("CodeBurn: update failed (exit \(proc.terminationStatus)): \(stderr)") + } else { + self.latestVersion = nil } } } @@ -91,14 +144,41 @@ final class UpdateChecker { NSLog("CodeBurn: update spawn failed: \(error)") } } + + nonisolated private static func sanitizeForDisplay(_ value: String) -> String { + var cleaned = value.replacingOccurrences(of: "\u{0000}", with: "") + let patterns: [(String, String)] = [ + (#"sk-ant-[A-Za-z0-9_-]+"#, "sk-ant-***"), + (#"sk-[A-Za-z0-9_-]{16,}"#, "sk-***"), + (#"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"#, "eyJ***"), + (#"(?i)Bearer\s+\S+"#, "Bearer ***"), + ] + for (pattern, replacement) in patterns { + cleaned = cleaned.replacingOccurrences(of: pattern, with: replacement, options: .regularExpression) + } + if cleaned.count > 1_000 { cleaned = String(cleaned.prefix(1_000)) + "..." } + return cleaned.trimmingCharacters(in: .whitespacesAndNewlines) + } } -private struct GitHubRelease: Decodable { +enum UpdateCheckError: LocalizedError { + case http(Int) + case missingMenubarAsset + + var errorDescription: String? { + switch self { + case let .http(status): "GitHub returned HTTP \(status)." + case .missingMenubarAsset: "No mac-v release with a menubar zip and checksum was found." + } + } +} + +struct GitHubRelease: Decodable { let tag_name: String let assets: [GitHubAsset] } -private struct GitHubAsset: Decodable { +struct GitHubAsset: Decodable { let name: String let browser_download_url: String } diff --git a/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift b/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift index 4f4a5f8..83251de 100644 --- a/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift +++ b/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift @@ -13,20 +13,50 @@ enum CodeburnCLI { /// PATH additions for GUI-launched apps, which otherwise get a minimal PATH that misses /// Homebrew and npm global installs. private static let additionalPathEntries = ["/opt/homebrew/bin", "/usr/local/bin"] + private static let persistedPathFilename = "codeburn-cli-path.v1" /// Returns the argv that launches the CLI. Dev override via `CODEBURN_BIN` is honoured only /// if every whitespace-delimited token passes `safeArgPattern`. Otherwise falls back to the /// plain `codeburn` name (resolved via PATH). static func baseArgv() -> [String] { - guard let raw = ProcessInfo.processInfo.environment["CODEBURN_BIN"], !raw.isEmpty else { - return ["codeburn"] + if ProcessInfo.processInfo.environment["CODEBURN_ALLOW_DEV_BIN"] == "1", + let raw = ProcessInfo.processInfo.environment["CODEBURN_BIN"], + !raw.isEmpty + { + let parts = raw.split(separator: " ", omittingEmptySubsequences: true).map(String.init) + guard parts.allSatisfy(isSafe) else { + NSLog("CodeBurn: refusing unsafe CODEBURN_BIN; using installed codeburn") + return installedArgv() + } + return parts } - let parts = raw.split(separator: " ", omittingEmptySubsequences: true).map(String.init) - guard parts.allSatisfy(isSafe) else { - NSLog("CodeBurn: refusing unsafe CODEBURN_BIN; using default 'codeburn'") - return ["codeburn"] + + return installedArgv() + } + + private static func installedArgv() -> [String] { + if let persisted = persistedCLIPath(), isSafe(persisted), FileManager.default.isExecutableFile(atPath: persisted) { + return [persisted] } - return parts + for candidate in additionalPathEntries.map({ "\($0)/codeburn" }) { + if FileManager.default.isExecutableFile(atPath: candidate) { + return [candidate] + } + } + return ["codeburn"] + } + + private static func persistedCLIPath() -> String? { + let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support") + let url = support + .appendingPathComponent("CodeBurn", isDirectory: true) + .appendingPathComponent(persistedPathFilename) + guard let value = try? String(contentsOf: url, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty, + value.hasPrefix("/") + else { return nil } + return value } /// Builds a `Process` that runs the CLI with the given subcommand args. Uses `/usr/bin/env` diff --git a/mac/Sources/CodeBurnMenubar/Theme/ThemeState.swift b/mac/Sources/CodeBurnMenubar/Theme/ThemeState.swift index a2cb7ee..9e0444e 100644 --- a/mac/Sources/CodeBurnMenubar/Theme/ThemeState.swift +++ b/mac/Sources/CodeBurnMenubar/Theme/ThemeState.swift @@ -1,4 +1,5 @@ import SwiftUI +import Observation enum AccentPreset: String, CaseIterable, Identifiable { case ember = "Ember" @@ -72,6 +73,7 @@ enum AccentPreset: String, CaseIterable, Identifiable { } @MainActor +@Observable final class ThemeState { static let shared = ThemeState() diff --git a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift index 77b6165..82f2ceb 100644 --- a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift +++ b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift @@ -1,28 +1,111 @@ +import AppKit import SwiftUI +/// Shared state read by the NSEvent local monitor closure. The closure +/// snapshots its captured environment at install time, so SwiftUI @State +/// can't be used directly — a reference-type holder keeps the latest hover +/// status visible to the monitor across SwiftUI updates. +@MainActor +final class AgentTabStripScrollState { + static let shared = AgentTabStripScrollState() + var isStripHovered: Bool = false +} + struct AgentTabStrip: View { @Environment(AppStore.self) private var store + @State private var stripViewportWidth: CGFloat = 0 + @State private var stripContentWidth: CGFloat = 0 + @State private var scrollWheelMonitor: Any? var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 5) { - ForEach(visibleFilters) { filter in - Button { - Task { await store.switchTo(provider: filter) } - } label: { - AgentTab( - filter: filter, - cost: cost(for: filter), - isActive: store.selectedProvider == filter + GeometryReader { viewportGeo in + ScrollViewReader { proxy in + HStack(spacing: 4) { + if isOverflowing { + Button { + selectAdjacentProvider(direction: -1, proxy: proxy) + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 10, weight: .semibold)) + .frame(width: 18, height: 18) + } + .buttonStyle(.plain) + .foregroundStyle(canMoveBackward ? Color.primary : Color.secondary.opacity(0.35)) + .disabled(!canMoveBackward) + .help("Show previous providers") + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 5) { + ForEach(visibleFilters) { filter in + AgentTab( + filter: filter, + cost: cost(for: filter), + isActive: store.selectedProvider == filter, + quota: store.quotaSummary(for: filter) + ) { + store.switchTo(provider: filter) + withAnimation(.easeInOut(duration: 0.18)) { + proxy.scrollTo(filter.id, anchor: .center) + } + } + .id(filter.id) + } + } + .background( + GeometryReader { contentGeo in + Color.clear + .onAppear { + stripContentWidth = contentGeo.size.width + } + .onChange(of: contentGeo.size.width) { _, newWidth in + stripContentWidth = newWidth + } + } ) } - .buttonStyle(.plain) + .padding(.horizontal, 12) + .padding(.top, 8) + .padding(.bottom, 4) + .onHover { hovering in + AgentTabStripScrollState.shared.isStripHovered = hovering + } + + if isOverflowing { + Button { + selectAdjacentProvider(direction: 1, proxy: proxy) + } label: { + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .frame(width: 18, height: 18) + } + .buttonStyle(.plain) + .foregroundStyle(canMoveForward ? Color.primary : Color.secondary.opacity(0.35)) + .disabled(!canMoveForward) + .help("Show next providers") + } + } + .onAppear { + stripViewportWidth = viewportGeo.size.width + installScrollWheelMonitorIfNeeded() + withAnimation(.easeInOut(duration: 0.18)) { + proxy.scrollTo(store.selectedProvider.id, anchor: .center) + } + } + .onChange(of: viewportGeo.size.width) { _, newWidth in + stripViewportWidth = newWidth + } + .onChange(of: store.selectedProvider) { _, newProvider in + withAnimation(.easeInOut(duration: 0.18)) { + proxy.scrollTo(newProvider.id, anchor: .center) + } + } + .onDisappear { + removeScrollWheelMonitorIfNeeded() } } - .padding(.horizontal, 12) - .padding(.top, 8) - .padding(.bottom, 4) } + .frame(height: 38) } private var todayAll: MenubarPayload { @@ -46,6 +129,9 @@ struct AgentTabStrip: View { private func cost(for filter: ProviderFilter) -> Double? { let data = periodAll if filter == .all { return data.current.cost } + if filter == store.selectedProvider, store.hasCachedData { + return store.payload.current.cost + } let providers = Dictionary( data.current.providers.map { ($0.key.lowercased(), $0.value) }, uniquingKeysWith: + @@ -54,23 +140,100 @@ struct AgentTabStrip: View { sum + (providers[key] ?? 0) } } + + private var currentFilterIndex: Int { + visibleFilters.firstIndex(of: store.selectedProvider) ?? 0 + } + + private var canMoveBackward: Bool { currentFilterIndex > 0 } + private var canMoveForward: Bool { currentFilterIndex < visibleFilters.count - 1 } + private var isOverflowing: Bool { stripContentWidth > (stripViewportWidth - 30) } + + private func selectAdjacentProvider(direction: Int, proxy: ScrollViewProxy) { + guard !visibleFilters.isEmpty else { return } + let targetIndex = min(max(currentFilterIndex + direction, 0), visibleFilters.count - 1) + let target = visibleFilters[targetIndex] + store.switchTo(provider: target) + withAnimation(.easeInOut(duration: 0.18)) { + proxy.scrollTo(target.id, anchor: .center) + } + } + + /// Standard mouse wheels emit vertical-only scroll deltas, which a horizontal + /// `ScrollView` ignores. While the cursor is over the strip we transpose + /// vertical-axis scroll fields onto the horizontal axis so the underlying + /// NSScrollView receives a real horizontal delta. Trackpad events (precise + /// deltas, with native horizontal component) are passed through untouched + /// so vertical scrolling elsewhere in the popover is unaffected. + private func installScrollWheelMonitorIfNeeded() { + guard scrollWheelMonitor == nil else { return } + scrollWheelMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in + guard AgentTabStripScrollState.shared.isStripHovered, + !event.hasPreciseScrollingDeltas, + abs(event.scrollingDeltaX) < 0.001, + abs(event.scrollingDeltaY) > 0, + let cg = event.cgEvent?.copy() else { + return event + } + let lineDeltaY = cg.getIntegerValueField(.scrollWheelEventDeltaAxis1) + let pointDeltaY = cg.getDoubleValueField(.scrollWheelEventPointDeltaAxis1) + let fixedDeltaY = cg.getDoubleValueField(.scrollWheelEventFixedPtDeltaAxis1) + cg.setIntegerValueField(.scrollWheelEventDeltaAxis1, value: 0) + cg.setDoubleValueField(.scrollWheelEventPointDeltaAxis1, value: 0) + cg.setDoubleValueField(.scrollWheelEventFixedPtDeltaAxis1, value: 0) + cg.setIntegerValueField(.scrollWheelEventDeltaAxis2, value: lineDeltaY) + cg.setDoubleValueField(.scrollWheelEventPointDeltaAxis2, value: pointDeltaY) + cg.setDoubleValueField(.scrollWheelEventFixedPtDeltaAxis2, value: fixedDeltaY) + return NSEvent(cgEvent: cg) ?? event + } + } + + private func removeScrollWheelMonitorIfNeeded() { + if let monitor = scrollWheelMonitor { + NSEvent.removeMonitor(monitor) + scrollWheelMonitor = nil + } + } } private struct AgentTab: View { let filter: ProviderFilter let cost: Double? let isActive: Bool + let quota: QuotaSummary? + let onTap: () -> Void + + @State private var hoverPopoverShown = false + @State private var hoverEnterTask: DispatchWorkItem? + @State private var hoverExitTask: DispatchWorkItem? + @State private var clickDismissed = false + + /// Providers whose AgentTab chip reserves a 3pt bar slot underneath the + /// label, even when not yet connected. Driven by which providers we + /// actually implement live-quota fetching for in AppStore.quotaSummary. + static func providerSupportsQuota(_ filter: ProviderFilter) -> Bool { + switch filter { + case .claude, .codex: return true + default: return false + } + } var body: some View { - HStack(spacing: 5) { - Text(filter.rawValue) - .font(.system(size: 11.5, weight: .medium)) - .tracking(-0.05) - if let cost, cost > 0 { - Text(cost.asCompactCurrency()) - .font(.codeMono(size: 10.5, weight: .medium)) - .foregroundStyle(isActive ? AnyShapeStyle(.white.opacity(0.8)) : AnyShapeStyle(.secondary)) - .tracking(-0.2) + VStack(spacing: 3) { + HStack(spacing: 5) { + Text(filter.rawValue) + .font(.system(size: 11.5, weight: .medium)) + .tracking(-0.05) + if let cost, cost > 0 { + Text(cost.asCompactCurrency()) + .font(.codeMono(size: 10.5, weight: .medium)) + .foregroundStyle(isActive ? AnyShapeStyle(.white.opacity(0.8)) : AnyShapeStyle(.secondary)) + .tracking(-0.2) + } + } + if quota != nil { + AgentTabQuotaBar(quota: quota, isActive: isActive) + .frame(height: 3) } } .padding(.horizontal, 10) @@ -81,6 +244,233 @@ private struct AgentTab: View { ) .foregroundStyle(isActive ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary)) .contentShape(Rectangle()) + .onTapGesture { + hoverPopoverShown = false + hoverEnterTask?.cancel() + clickDismissed = true + onTap() + } + .onHover { hovering in + hoverEnterTask?.cancel() + hoverExitTask?.cancel() + if !hovering { + clickDismissed = false + let task = DispatchWorkItem { hoverPopoverShown = false } + hoverExitTask = task + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: task) + } else if !clickDismissed, quota != nil { + let task = DispatchWorkItem { hoverPopoverShown = true } + hoverEnterTask = task + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: task) + } + } + .popover(isPresented: $hoverPopoverShown) { + if let quota { + QuotaDetailPopover(quota: quota) + } + } + } +} + +/// Thin progress bar drawn inside an AgentTab chip when that provider has a live quota +/// source. Width matches the chip; color shifts green → amber → red at 70% / 90%. +private struct AgentTabQuotaBar: View { + let quota: QuotaSummary? + let isActive: Bool + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(trackColor) + if let percent = filledFraction { + Capsule() + .fill(barColor) + .frame(width: max(2, geo.size.width * CGFloat(percent))) + .animation(.easeOut(duration: 0.25), value: percent) + } + if case .terminalFailure = quota?.connection { + // Hatched/red strip to telegraph "broken; reconnect needed". + Capsule() + .fill(Color.red.opacity(0.7)) + } + } + } + } + + private var filledFraction: Double? { + guard let pct = quota?.primary?.percent else { return nil } + return min(max(pct, 0), 1) + } + + private var barColor: Color { + guard let pct = quota?.primary?.percent else { return .clear } + switch QuotaSummary.severity(for: pct) { + case .normal: return isActive ? Color.white : Color.green.opacity(0.85) + case .warning: return Color.yellow + case .critical: return Color.orange + case .danger: return Color.red + } + } + + private var trackColor: Color { + isActive ? Color.white.opacity(0.20) : Color.secondary.opacity(0.18) + } +} + +private struct QuotaDetailPopover: View { + let quota: QuotaSummary + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + switch quota.connection { + case .terminalFailure(let reason): + terminalFailureCard(reason: reason) + case .disconnected: + Text(disconnectedMessage) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + case .loading where quota.details.isEmpty: + Text("Loading…") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + default: + rowsCard + } + } + .padding(12) + .frame(width: 260) + } + + private var disconnectedMessage: String { + switch quota.providerFilter { + case .codex: return "Sign in with `codex` (ChatGPT mode) to track quota." + case .claude: return "Sign in to Claude Code to track quota." + default: return "Sign in to track quota." + } + } + + private var rowsCard: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Text("\(quota.providerFilter.rawValue) usage") + .font(.system(size: 11, weight: .semibold)) + if case .stale = quota.connection { + Text("stale") + .font(.system(size: 9.5)) + .foregroundStyle(.secondary) + } else if case .transientFailure = quota.connection { + Text("retrying") + .font(.system(size: 9.5)) + .foregroundStyle(.orange) + } + Spacer() + if let plan = quota.planLabel, !plan.isEmpty { + Text(plan) + .font(.system(size: 9.5, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(Color.secondary.opacity(0.12)) + ) + // Size to content. Plan names are bounded short strings + // ("Max 20x", "Pro Lite", "Free Workspace"); a forced + // maxWidth was making short labels look stretched. + .fixedSize(horizontal: true, vertical: false) + } + } + ForEach(Array(quota.details.enumerated()), id: \.offset) { _, w in + QuotaDetailRow(window: w) + } + if !quota.footerLines.isEmpty { + Divider() + .padding(.top, 2) + ForEach(Array(quota.footerLines.enumerated()), id: \.offset) { _, line in + Text(line) + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + } + } + } + } + + private func terminalFailureCard(reason: String?) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(reconnectTitle) + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(.red) + Text(reason ?? defaultReconnectReason) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(2) + Text(reconnectInstruction) + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + } + } + + private var reconnectTitle: String { + switch quota.providerFilter { + case .codex: return "Reconnect Codex" + default: return "Reconnect Claude" + } + } + + private var defaultReconnectReason: String { + switch quota.providerFilter { + case .codex: return "Refresh token rejected by OpenAI." + default: return "Refresh token rejected by Anthropic." + } + } + + private var reconnectInstruction: String { + switch quota.providerFilter { + case .codex: return "Run `codex login` in your terminal, then click Reconnect." + default: return "Open Claude Code in your terminal and type `/login`, then click Reconnect." + } + } +} + +private struct QuotaDetailRow: View { + let window: QuotaSummary.Window + + var body: some View { + HStack(spacing: 8) { + Text(window.label) + .font(.system(size: 10.5)) + .frame(width: 92, alignment: .leading) + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(Color.secondary.opacity(0.18)) + Capsule() + .fill(barColor) + .frame(width: max(2, geo.size.width * CGFloat(min(max(window.percent, 0), 1)))) + } + } + .frame(height: 4) + Text(window.percentLabel) + .font(.codeMono(size: 10.5, weight: .medium)) + .frame(width: 36, alignment: .trailing) + if !window.resetsInLabel.isEmpty { + Text(window.resetsInLabel) + .font(.codeMono(size: 10)) + .foregroundStyle(.secondary) + .frame(width: 50, alignment: .trailing) + } + } + } + + private var barColor: Color { + switch QuotaSummary.severity(for: window.percent) { + case .normal: return Color.green.opacity(0.85) + case .warning: return Color.yellow + case .critical: return Color.orange + case .danger: return Color.red + } } } @@ -89,19 +479,26 @@ extension ProviderFilter { switch self { case .all: return Theme.brandAccent case .claude: return Theme.categoricalClaude + case .cline: return Color(red: 0x23/255.0, green: 0x8A/255.0, blue: 0x7E/255.0) case .codex: return Theme.categoricalCodex case .cursor: return Theme.categoricalCursor + case .cursorAgent: return Color(red: 0x4E/255.0, green: 0xC9/255.0, blue: 0xB0/255.0) case .copilot: return Color(red: 0x6D/255.0, green: 0x8F/255.0, blue: 0xA6/255.0) case .droid: return Color(red: 0x7C/255.0, green: 0x3A/255.0, blue: 0xED/255.0) case .gemini: return Color(red: 0x44/255.0, green: 0x85/255.0, blue: 0xF4/255.0) + case .ibmBob: return Color(red: 0x0F/255.0, green: 0x62/255.0, blue: 0xFE/255.0) case .kiloCode: return Color(red: 0x00/255.0, green: 0x96/255.0, blue: 0x88/255.0) case .kiro: return Color(red: 0x4A/255.0, green: 0x9E/255.0, blue: 0xC4/255.0) + case .kimi: return Color(red: 0xA4/255.0, green: 0xC6/255.0, blue: 0x39/255.0) case .openclaw: return Color(red: 0xDA/255.0, green: 0x70/255.0, blue: 0x56/255.0) case .opencode: return Color(red: 0x5B/255.0, green: 0x83/255.0, blue: 0x5B/255.0) case .pi: return Color(red: 0xB2/255.0, green: 0x6B/255.0, blue: 0x3D/255.0) case .qwen: return Color(red: 0x61/255.0, green: 0x5E/255.0, blue: 0xEB/255.0) case .omp: return Color(red: 0x8B/255.0, green: 0x5C/255.0, blue: 0xB0/255.0) case .rooCode: return Color(red: 0x4C/255.0, green: 0xAF/255.0, blue: 0x50/255.0) + case .crush: return Color(red: 0xE0/255.0, green: 0x6C/255.0, blue: 0x9F/255.0) + case .antigravity: return Color(red: 0xFF/255.0, green: 0x7A/255.0, blue: 0x45/255.0) + case .goose: return Color(red: 0xB7/255.0, green: 0x8D/255.0, blue: 0x52/255.0) } } } diff --git a/mac/Sources/CodeBurnMenubar/Views/FindingsSection.swift b/mac/Sources/CodeBurnMenubar/Views/FindingsSection.swift index 86f174c..aff1e83 100644 --- a/mac/Sources/CodeBurnMenubar/Views/FindingsSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/FindingsSection.swift @@ -213,11 +213,11 @@ private struct HistoryStats { private func computeHistoryStats(history: [DailyHistoryEntry]) -> HistoryStats { var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = TimeZone(identifier: "UTC")! + calendar.timeZone = .current let formatter: DateFormatter = { let f = DateFormatter() f.dateFormat = "yyyy-MM-dd" - f.timeZone = TimeZone(identifier: "UTC") + f.timeZone = .current return f }() let now = Date() diff --git a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift index 5b143b2..3374bd9 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift @@ -5,6 +5,36 @@ private let trendBarWidth: CGFloat = 13 private let trendBarGap: CGFloat = 4 private let trendChartHeight: CGFloat = 90 +// Cached formatters and a calendar to avoid allocating fresh ones on every +// SwiftUI body re-eval. Hover scrubbing on the trend bars triggers many +// re-evals per second; a fresh DateFormatter / Calendar each time was a +// measurable hot spot. +private let yyyymmdd: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + f.timeZone = .current + return f +}() + +private let prettyDayFormat: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "EEE MMM d" + return f +}() + +private let mmmDayFormat: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "MMM d" + f.timeZone = .current + return f +}() + +private let gregorianCalendar: Calendar = { + var c = Calendar(identifier: .gregorian) + c.timeZone = .current + return c +}() + /// Three switchable insight visualizations: Calendar (this month), Forecast (burn rate), /// Pulse (efficiency KPIs). Pills at top toggle between them. struct HeatmapSection: View { @@ -25,10 +55,14 @@ struct HeatmapSection: View { } private var visibleModes: [InsightMode] { - // Plan sources from Claude's OAuth usage endpoint, so it only makes sense when the - // Claude provider tab is selected. Hidden on All/Cursor/Codex/etc. + // 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. InsightMode.allCases.filter { mode in - if mode == .plan { return store.selectedProvider == .claude } + if mode == .plan { + return store.selectedProvider == .claude || store.selectedProvider == .codex + } return true } } @@ -42,7 +76,12 @@ struct HeatmapSection: View { @ViewBuilder private var content: some View { switch store.selectedInsight { - case .plan: PlanInsight(usage: store.subscription) + case .plan: + if store.selectedProvider == .codex { + CodexPlanInsight() + } else { + PlanInsight(usage: store.subscription) + } case .trend: TrendInsight(days: store.payload.history.daily) case .forecast: ForecastInsight(days: store.payload.history.daily) case .pulse: PulseInsight(payload: store.payload) @@ -342,13 +381,8 @@ private struct BarTooltipCard: View { } private func prettyDate(_ ymd: String) -> String { - let parser = DateFormatter() - parser.dateFormat = "yyyy-MM-dd" - parser.timeZone = .current - guard let date = parser.date(from: ymd) else { return ymd } - let display = DateFormatter() - display.dateFormat = "EEE MMM d" - return display.string(from: date) + guard let date = yyyymmdd.date(from: ymd) else { return ymd } + return prettyDayFormat.string(from: date) } private struct MiniStat: View { @@ -370,7 +404,7 @@ private struct MiniStat: View { } private struct TrendBar: Identifiable { - let id = UUID() + var id: String { date } let date: String let cost: Double let inputTokens: Double @@ -391,14 +425,8 @@ private struct TrendStats { } private func buildTrendBars(from days: [DailyHistoryEntry]) -> [TrendBar] { - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = .current - let formatter: DateFormatter = { - let f = DateFormatter() - f.dateFormat = "yyyy-MM-dd" - f.timeZone = .current - return f - }() + let calendar = gregorianCalendar + let formatter = yyyymmdd let entryByDate = Dictionary(days.map { ($0.date, $0) }, uniquingKeysWith: { _, new in new }) let today = calendar.startOfDay(for: Date()) let todayKey = formatter.string(from: today) @@ -426,14 +454,8 @@ private func computeTrendStats(bars: [TrendBar], allDays: [DailyHistoryEntry]) - let avg = bars.isEmpty ? 0 : total / Double(bars.count) let peak = bars.filter { $0.cost > 0 }.max(by: { $0.cost < $1.cost }) - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = .current - let formatter: DateFormatter = { - let f = DateFormatter() - f.dateFormat = "yyyy-MM-dd" - f.timeZone = .current - return f - }() + let calendar = gregorianCalendar + let formatter = yyyymmdd let today = calendar.startOfDay(for: Date()) let priorWindowStart = calendar.date(byAdding: .day, value: -(2 * trendDays - 1), to: today) let thisWindowStart = calendar.date(byAdding: .day, value: -(trendDays - 1), to: today) @@ -515,7 +537,7 @@ private struct ForecastInsight: View { guard previous > 0 else { return "no prior month" } let diff = ((projection - previous) / previous) * 100 let sign = diff >= 0 ? "+" : "" - return "\(sign)\(String(format: "%.0f", diff))% vs last month ($\(String(format: "%.0f", previous)))" + return "\(sign)\(String(format: "%.0f", diff))% vs last month (\(previous.asCompactCurrency()))" } } @@ -546,14 +568,8 @@ private struct ForecastStats { } private func computeForecast(days: [DailyHistoryEntry]) -> ForecastStats { - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = .current - let formatter: DateFormatter = { - let f = DateFormatter() - f.dateFormat = "yyyy-MM-dd" - f.timeZone = .current - return f - }() + let calendar = gregorianCalendar + let formatter = yyyymmdd let now = Date() let comps = calendar.dateComponents([.year, .month, .day], from: now) guard @@ -793,24 +809,13 @@ private struct AllStats { let historyDayCount: Int } -private func computeAllStats(payload: MenubarPayload) -> AllStats { +@MainActor private func computeAllStats(payload: MenubarPayload) -> AllStats { let history = payload.history.daily let favoriteModel = payload.current.topModels.first?.name ?? "—" - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = .current - let formatter: DateFormatter = { - let f = DateFormatter() - f.dateFormat = "yyyy-MM-dd" - f.timeZone = .current - return f - }() - let displayFormatter: DateFormatter = { - let f = DateFormatter() - f.dateFormat = "MMM d" - f.timeZone = .current - return f - }() + let calendar = gregorianCalendar + let formatter = yyyymmdd + let displayFormatter = mmmDayFormat let now = Date() let today = calendar.startOfDay(for: now) @@ -848,13 +853,21 @@ private func computeAllStats(payload: MenubarPayload) -> AllStats { var longestStreak = 0 var running = 0 - let sortedDates = history.map(\.date).sorted() - for date in sortedDates { - if (costByDate[date] ?? 0) > 0 { - running += 1 - longestStreak = max(longestStreak, running) - } else { - running = 0 + if let firstDate = history.map(\.date).min(), + let lastDate = history.map(\.date).max(), + let start = formatter.date(from: firstDate), + let end = formatter.date(from: lastDate) { + var cursor = start + while cursor <= end { + let key = formatter.string(from: cursor) + if (costByDate[key] ?? 0) > 0 { + running += 1 + longestStreak = max(longestStreak, running) + } else { + running = 0 + } + guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break } + cursor = next } } @@ -887,28 +900,36 @@ private struct PlanInsight: View { var body: some View { Group { switch store.subscriptionLoadState { - case .idle: - PlanIdleView() - case .loading: + case .notBootstrapped: + PlanConnectView { Task { await store.bootstrapSubscription() } } + case .bootstrapping: PlanLoadingView() + case .loading: + if let usage { + loadedBody(usage: usage) + } else { + PlanLoadingView() + } case .noCredentials: PlanNoCredentialsView() case .failed: PlanFailedView(error: store.subscriptionError) + case .transientFailure: + if let usage { + loadedBody(usage: usage) + } else { + PlanFailedView(error: store.subscriptionError ?? "Anthropic temporarily unreachable — retrying.") + } + case let .terminalFailure(reason): + PlanReconnectView(reason: reason) { Task { await store.bootstrapSubscription() } } case .loaded: if let usage { loadedBody(usage: usage) } else { - PlanNoCredentialsView() + PlanLoadingView() } } } - .task { - // Lazy-trigger fetch the first time Plan is opened. - if store.subscriptionLoadState == .idle { - await store.refreshSubscription() - } - } } @ViewBuilder @@ -1006,26 +1027,6 @@ private struct PlanInsight: View { // MARK: - Plan empty/loading/failure states -private struct PlanIdleView: View { - var body: some View { - VStack(spacing: 8) { - Image(systemName: "person.crop.circle.dashed") - .font(.system(size: 22)) - .foregroundStyle(.tertiary) - Text("Loading your plan...") - .font(.system(size: 11.5, weight: .medium)) - .foregroundStyle(.secondary) - Text("macOS may ask permission to read your Claude Code credentials.") - .font(.system(size: 10)) - .foregroundStyle(.tertiary) - .multilineTextAlignment(.center) - .frame(maxWidth: 260) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - } -} - private struct PlanLoadingView: View { var body: some View { VStack(spacing: 8) { @@ -1043,27 +1044,27 @@ private struct PlanNoCredentialsView: View { @Environment(AppStore.self) private var store var body: some View { - VStack(spacing: 8) { + VStack(spacing: 10) { Image(systemName: "key.slash") - .font(.system(size: 20)) + .font(.system(size: 24)) .foregroundStyle(.tertiary) - Text("No Claude subscription connected") + Text("No Claude credentials found") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.primary) - Text("Sign in with Claude Code, then click Retry.") + Text("Sign in with Claude Code first: open `claude` in your terminal and type `/login`. Then click Try Again.") .font(.system(size: 10.5)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) - .frame(maxWidth: 260) - Button("Retry") { - Task { await store.refreshSubscription() } + .frame(maxWidth: 280) + Button("Try Again") { + Task { await store.bootstrapSubscription() } } .controlSize(.small) .buttonStyle(.borderedProminent) .tint(Theme.brandAccent) } .frame(maxWidth: .infinity) - .padding(.vertical, 14) + .padding(.vertical, 16) } } @@ -1099,6 +1100,175 @@ private struct PlanFailedView: View { } } +/// Shown the very first time a user opens the Plan tab. Clicking Connect is the +/// only path to triggering the macOS keychain prompt for Claude Code credentials — +/// the menubar app does not touch the keychain at startup. +private struct PlanConnectView: View { + let onConnect: () -> Void + + var body: some View { + VStack(spacing: 10) { + Image(systemName: "link.circle") + .font(.system(size: 26)) + .foregroundStyle(Theme.brandAccent) + Text("Connect Claude subscription") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.primary) + Text("CodeBurn will read your Claude Code credentials once. macOS will ask permission. After that, the live quota bar shows next to the Claude tab and updates automatically.") + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 280) + Button("Connect", action: onConnect) + .controlSize(.small) + .buttonStyle(.borderedProminent) + .tint(Theme.brandAccent) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 18) + } +} + +/// Shown when the refresh token has been invalidated (typically because the user +/// re-authenticated on another device). Clicking the button re-runs bootstrap, +/// which reads Claude's credentials source again and writes a fresh copy to our +/// own keychain item. +private struct PlanReconnectView: View { + let reason: String? + let onReconnect: () -> Void + + var body: some View { + VStack(spacing: 10) { + Image(systemName: "arrow.triangle.2.circlepath.circle") + .font(.system(size: 24)) + .foregroundStyle(.red) + Text("Reconnect Claude") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.primary) + Text(reason ?? "Your Claude session has expired. Open Claude Code in your terminal and type `/login`, then click Reconnect.") + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 280) + .lineLimit(3) + Button("Reconnect", action: onReconnect) + .controlSize(.small) + .buttonStyle(.borderedProminent) + .tint(.red) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + } +} + +/// Plan tab for Codex. Mirrors PlanInsight's layout but reads from +/// store.codexUsage / store.codexLoadState. We deliberately skip the +/// "On pace at reset" projection here — that math is fed by local +/// per-message Claude spend extrapolated against the API quota windows; +/// our local Codex spend isn't an apples-to-apples signal for the +/// ChatGPT-subscription rate windows reported by wham/usage. Add when +/// we wire a comparable extrapolator. +private struct CodexPlanInsight: View { + @Environment(AppStore.self) private var store + + var body: some View { + Group { + switch store.codexLoadState { + case .notBootstrapped: + PlanConnectView { Task { await store.bootstrapCodex() } } + case .bootstrapping: + PlanLoadingView() + case .loading: + if let usage = store.codexUsage { + loadedBody(usage: usage) + } else { + PlanLoadingView() + } + case .noCredentials: + PlanNoCredentialsView() + case .failed: + PlanFailedView(error: store.codexError) + case .transientFailure: + if let usage = store.codexUsage { + loadedBody(usage: usage) + } else { + PlanFailedView(error: store.codexError ?? "ChatGPT temporarily unreachable — retrying.") + } + case let .terminalFailure(reason): + PlanReconnectView(reason: reason) { Task { await store.bootstrapCodex() } } + case .loaded: + if let usage = store.codexUsage { + loadedBody(usage: usage) + } else { + PlanLoadingView() + } + } + } + } + + @ViewBuilder + private func loadedBody(usage: CodexUsage) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + Text(usage.plan.displayName) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) + Spacer() + if let resetsAt = (usage.primary ?? usage.secondary)?.resetsAt { + Text("Resets \(relativeReset(resetsAt))") + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + } + } + if let primary = usage.primary { + UtilizationRow( + label: "\(primary.windowLabel) window", + percent: primary.usedPercent, + resetsAt: primary.resetsAt, + projection: nil + ) + } + if let secondary = usage.secondary { + UtilizationRow( + label: "\(secondary.windowLabel) window", + percent: secondary.usedPercent, + resetsAt: secondary.resetsAt, + projection: nil + ) + } + // Surface non-zero per-model rate limits (Codex Spark, etc.) so + // power users see them; idle ones stay collapsed. + ForEach(Array(usage.additionalLimits.enumerated()), id: \.offset) { _, limit in + if let p = limit.primary, p.usedPercent > 0 { + UtilizationRow( + label: "\(limit.name) · \(p.windowLabel)", + percent: p.usedPercent, + resetsAt: p.resetsAt, + projection: nil + ) + } + if let s = limit.secondary, s.usedPercent > 0 { + UtilizationRow( + label: "\(limit.name) · \(s.windowLabel)", + percent: s.usedPercent, + resetsAt: s.resetsAt, + projection: nil + ) + } + } + } + .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 struct WindowProjection { enum Source { case linear, historicalBaseline } let percent: Double diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift index 44879d8..7bad14b 100644 --- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift +++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift @@ -41,9 +41,30 @@ struct MenuBarContent: View { } } - if store.isLoading { - BurnLoadingOverlay(periodLabel: store.selectedPeriod.rawValue) + // Overlay fires only on cold cache for the current key. This + // avoids the 1-frame `$0.00` flash on first-time period/provider + // switches. When the fetch fails (CLI subprocess timeout, parse + // error, etc.), surface a retry card instead of leaving the + // user stuck on a perpetual "Loading..." spinner. + if !store.hasCachedData { + if store.isCurrentKeyLoading || !store.hasAttemptedCurrentKeyLoad { + BurnLoadingOverlay(periodLabel: store.selectedPeriod.rawValue) + .transition(.opacity) + } else if let err = store.lastError { + FetchErrorOverlay( + error: err, + periodLabel: store.selectedPeriod.rawValue, + retry: { Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) } } + ) .transition(.opacity) + } else { + FetchErrorOverlay( + error: "The last refresh stopped before returning data. CodeBurn will keep retrying, or you can retry now.", + periodLabel: store.selectedPeriod.rawValue, + retry: { Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) } } + ) + .transition(.opacity) + } } } .frame(height: 520) @@ -55,20 +76,34 @@ struct MenuBarContent: View { StarBanner() } - .id(store.accentPreset) } - /// True when a specific provider tab is selected and that provider has no spend in the - /// currently selected period. The .all tab is exempt -- it always shows aggregated data. private var isFilteredEmpty: Bool { guard store.selectedProvider != .all else { return false } - return store.payload.current.cost <= 0 && store.payload.current.calls == 0 + if store.payload.current.cost > 0 || store.payload.current.calls > 0 { return false } + if providerHasCostInAllPayload { return false } + return true + } + + private var providerHasCostInAllPayload: Bool { + guard let allPayload = store.periodAllPayload else { return false } + let providers = Dictionary( + allPayload.current.providers.map { ($0.key.lowercased(), $0.value) }, + uniquingKeysWith: + + ) + return store.selectedProvider.providerKeys.contains { key in + (providers[key] ?? 0) > 0 + } } /// Show the tab row whenever the CLI detected at least one AI coding tool installed /// on this machine. Hidden only when nothing is detected, which means there's /// nothing to filter by anyway. private var showAgentTabs: Bool { + // Sticky: once any cached payload has reported providers, keep the tab strip + // visible. Without this, the strip disappears for one frame on a period + // switch when the new key's payload is still empty. + if store.hasAnyProvidersInCache { return true } let payload = store.todayPayload ?? store.payload return !payload.current.providers.isEmpty } @@ -99,11 +134,54 @@ private struct EmptyProviderState: View { case .sevenDays: "the last 7 days" case .thirtyDays: "the last 30 days" case .month: "this month" - case .all: "all time" + case .all: "the last 6 months" } } } +/// Shown when a fetch failed and the cache is still empty for this key. The +/// user previously sat on the "Loading…" spinner forever — the popover had +/// no path to recover beyond the next 30s tick (which would just re-fail). +/// Now they see what broke and can retry directly. +private struct FetchErrorOverlay: View { + let error: String + let periodLabel: String + let retry: () -> Void + + var body: some View { + ZStack { + Rectangle().fill(.ultraThinMaterial) + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 28)) + .foregroundStyle(Theme.brandAccent) + Text("Couldn't load \(periodLabel)") + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(.primary) + Text(displayError) + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 280) + .lineLimit(3) + Button("Retry", action: retry) + .buttonStyle(.borderedProminent) + .tint(Theme.brandAccent) + .controlSize(.small) + } + .padding(.horizontal, 20) + } + } + + /// Strip the leading subprocess noise that creeps into NSError descriptions + /// so the visible message is the actual cause, not the framework wrapper. + private var displayError: String { + let trimmed = error.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.count <= 240 { return trimmed } + return String(trimmed.prefix(240)) + "…" + } +} + /// Translucent overlay that blurs whatever's behind it (the previous tab/period content) /// and centers an animated burning flame -- the brand mark filling up bottom-to-top in /// yellow→orange→red, looping. @@ -185,24 +263,31 @@ private struct BurnFlame: View { private struct Header: View { @Environment(UpdateChecker.self) private var updateChecker + @Environment(AppStore.self) private var store var body: some View { - 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") - .font(.system(size: 10.5)) - .foregroundStyle(.secondary) + 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") + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + } + Spacer() + if updateChecker.updateAvailable || updateChecker.updateError != nil { + UpdateBadge() + } + AccentPicker() } - Spacer() - if updateChecker.updateAvailable { - UpdateBadge() - } - AccentPicker() + // Compact warning row when any connected provider crosses 70%. + // Lists all warning providers with their worst-window percent so + // the user knows whether to slow down on Claude, Codex, or both. + QuotaWarningRow(status: store.aggregateQuotaStatus) } .padding(.horizontal, 14) .padding(.top, 10) @@ -210,6 +295,61 @@ private struct Header: View { } } +private struct QuotaWarningRow: View { + let status: AppStore.AggregateQuotaStatus + + var body: some View { + if !status.warnings.isEmpty { + HStack(spacing: 6) { + Image(systemName: severityIcon) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(severityColor) + Text(message) + .font(.system(size: 10.5, weight: .medium)) + .foregroundStyle(severityColor) + Spacer(minLength: 0) + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 5) + .fill(severityColor.opacity(0.12)) + ) + } + } + + private var message: String { + let parts = status.warnings.map { "\($0.name) \(Int($0.percent.rounded()))%" } + if parts.count == 1 { + // Reads "Claude over limit (105%)" when any provider exceeds the + // quota cap, instead of the awkward "Claude 105% of quota used". + if case .danger = status.severity { + return "\(status.warnings[0].name) over limit (\(Int(status.warnings[0].percent.rounded()))%)" + } + return "\(parts[0]) of quota used" + } + return parts.joined(separator: " · ") + } + + private var severityColor: Color { + switch status.severity { + case .normal: return .secondary + case .warning: return .yellow + case .critical: return .orange + case .danger: return .red + } + } + + private var severityIcon: String { + switch status.severity { + case .normal: return "info.circle" + case .warning: return "exclamationmark.circle" + case .critical: return "exclamationmark.triangle" + case .danger: return "octagon" + } + } +} + private struct AccentPicker: View { @Environment(AppStore.self) private var store @@ -269,18 +409,25 @@ private struct UpdateBadge: View { var body: some View { Button { - updateChecker.performUpdate() + if updateChecker.updateAvailable { + updateChecker.performUpdate() + } else { + Task { await updateChecker.check() } + } } label: { HStack(spacing: 4) { if updateChecker.isUpdating { ProgressView() .controlSize(.mini) .scaleEffect(0.7) + } else if updateChecker.updateError != nil { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 10)) } else { Image(systemName: "arrow.down.circle.fill") .font(.system(size: 10)) } - Text(updateChecker.isUpdating ? "Updating..." : "Update") + Text(updateChecker.isUpdating ? "Updating..." : (updateChecker.updateError == nil ? "Update" : "Failed")) .font(.system(size: 10, weight: .medium)) } .padding(.horizontal, 8) @@ -290,6 +437,7 @@ private struct UpdateBadge: View { .tint(Theme.brandAccent) .controlSize(.mini) .disabled(updateChecker.isUpdating) + .help(updateChecker.updateError ?? "Install the latest menubar build") } } @@ -397,7 +545,7 @@ struct FooterBar: View { .fixedSize() Button { - Task { await store.refresh(includeOptimize: true, force: true) } + refreshNow() } label: { Image(systemName: store.isLoading ? "arrow.triangle.2.circlepath" : "arrow.clockwise") .font(.system(size: 11, weight: .medium)) @@ -422,7 +570,7 @@ struct FooterBar: View { Spacer() - Text("v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?")") + Text(AppVersion.displayBundleShortVersion) .font(.system(size: 10, weight: .regular, design: .monospaced)) .foregroundStyle(.tertiary) @@ -443,6 +591,14 @@ struct FooterBar: View { TerminalLauncher.open(subcommand: ["report"]) } + private func refreshNow() { + if let delegate = NSApp.delegate as? AppDelegate { + delegate.refreshSubscriptionNow() + } else { + Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) } + } + } + private enum ExportFormat { case csv, json var cliName: String { self == .csv ? "csv" : "json" } @@ -457,7 +613,7 @@ struct FooterBar: View { Task { let downloads = (NSHomeDirectory() as NSString).appendingPathComponent("Downloads") let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" + formatter.dateFormat = "yyyy-MM-dd-HHmmss" let base = "codeburn-\(formatter.string(from: Date()))" let outputPath = (downloads as NSString).appendingPathComponent(base + format.suffix) @@ -466,13 +622,17 @@ struct FooterBar: View { ]) do { - try process.run() - process.waitUntilExit() - if process.terminationStatus == 0 { - NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: outputPath)]) - } else { - NSLog("CodeBurn: \(format.cliName.uppercased()) export exited with status \(process.terminationStatus)") + let fmt = format + process.terminationHandler = { proc in + Task { @MainActor in + if proc.terminationStatus == 0 { + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: outputPath)]) + } else { + NSLog("CodeBurn: \(fmt.cliName.uppercased()) export exited with status \(proc.terminationStatus)") + } + } } + try process.run() } catch { NSLog("CodeBurn: \(format.cliName.uppercased()) export failed: \(error)") } @@ -483,21 +643,18 @@ struct FooterBar: View { /// thread right away so the UI redraws the next frame, then fetches a fresh rate in the /// background. CLI config is persisted so other codeburn commands stay in sync. private func applyCurrency(code: String) { - store.currency = code let symbol = CurrencyState.symbolForCode(code) Task { let cached = await FXRateCache.shared.cachedRate(for: code) - await MainActor.run { + if let cached { + store.currency = code CurrencyState.shared.apply(code: code, rate: cached, symbol: symbol) } let fresh = await FXRateCache.shared.rate(for: code) - if let fresh, fresh != cached { - await MainActor.run { - CurrencyState.shared.apply(code: code, rate: fresh, symbol: symbol) - } - } + store.currency = code + CurrencyState.shared.apply(code: code, rate: fresh ?? cached, symbol: symbol) } CLICurrencyConfig.persist(code: code) diff --git a/mac/Sources/CodeBurnMenubar/Views/PeriodSegmentedControl.swift b/mac/Sources/CodeBurnMenubar/Views/PeriodSegmentedControl.swift index a636932..065e363 100644 --- a/mac/Sources/CodeBurnMenubar/Views/PeriodSegmentedControl.swift +++ b/mac/Sources/CodeBurnMenubar/Views/PeriodSegmentedControl.swift @@ -7,7 +7,7 @@ struct PeriodSegmentedControl: View { HStack(spacing: 1) { ForEach(Period.allCases) { period in Button { - Task { await store.switchTo(period: period) } + store.switchTo(period: period) } label: { Text(period.rawValue) .font(.system(size: 11, weight: .medium)) diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift new file mode 100644 index 0000000..a317380 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift @@ -0,0 +1,365 @@ +import SwiftUI + +/// macOS-standard tabbed Settings window. New per-provider sections (Codex, +/// Cursor, Copilot, etc.) plug in as additional tabs. Each tab owns its own +/// concerns; this top-level view only hosts the TabView shell. +struct SettingsView: View { + @Environment(AppStore.self) private var store + + var body: some View { + TabView { + GeneralSettingsTab() + .tabItem { Label("General", systemImage: "gearshape") } + + ClaudeSettingsTab() + .tabItem { Label("Claude", systemImage: "brain") } + + CodexSettingsTab() + .tabItem { Label("Codex", systemImage: "chevron.left.forwardslash.chevron.right") } + + AboutSettingsTab() + .tabItem { Label("About", systemImage: "info.circle") } + } + .frame(width: 520, height: 400) + } +} + +// MARK: - General + +private struct GeneralSettingsTab: View { + @Environment(AppStore.self) private var store + + var body: some View { + Form { + Section("Display") { + Picker("Currency", selection: Binding( + get: { store.currency }, + set: { applyCurrency(code: $0) } + )) { + ForEach(["USD", "EUR", "GBP", "INR", "JPY", "AUD", "CAD"], id: \.self) { code in + Text(code).tag(code) + } + } + Picker("Accent", selection: Binding( + get: { store.accentPreset }, + set: { store.accentPreset = $0 } + )) { + ForEach(AccentPreset.allCases) { preset in + Text(preset.rawValue).tag(preset) + } + } + } + } + .formStyle(.grouped) + .padding() + } + + private func applyCurrency(code: String) { + let symbol = CurrencyState.symbolForCode(code) + Task { + let cached = await FXRateCache.shared.cachedRate(for: code) + if let cached { + store.currency = code + CurrencyState.shared.apply(code: code, rate: cached, symbol: symbol) + } + let fresh = await FXRateCache.shared.rate(for: code) + store.currency = code + CurrencyState.shared.apply(code: code, rate: fresh ?? cached, symbol: symbol) + } + CLICurrencyConfig.persist(code: code) + } +} + +// MARK: - Claude + +private struct ClaudeSettingsTab: View { + @Environment(AppStore.self) private var store + + var body: some View { + Form { + Section("Connection") { + ClaudeConnectionRow() + } + Section("Quota Refresh") { + Picker("Update every", selection: Binding( + get: { SubscriptionRefreshCadence.current }, + set: { SubscriptionRefreshCadence.current = $0 } + )) { + ForEach(SubscriptionRefreshCadence.allCases) { cadence in + Text(cadence.label).tag(cadence) + } + } + .pickerStyle(.menu) + Text("Anthropic rate-limits this endpoint per account. 2 minutes is plenty for the 5-hour and weekly windows; pick Manual if you only want updates on demand.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + Button("Refresh Now") { + Task { await store.refreshSubscription() } + } + } + } + .formStyle(.grouped) + .padding() + } +} + +private struct ClaudeConnectionRow: View { + @Environment(AppStore.self) private var store + @State private var showDisconnectConfirm = false + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: stateIcon) + .font(.system(size: 18)) + .foregroundStyle(stateTint) + .frame(width: 22) + VStack(alignment: .leading, spacing: 2) { + Text(stateTitle) + .font(.system(size: 12, weight: .semibold)) + Text(stateDetail) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(2) + } + Spacer() + actionButton + } + .padding(.vertical, 4) + } + + private var stateIcon: String { + switch store.subscriptionLoadState { + case .loaded: return "checkmark.circle.fill" + case .terminalFailure: return "exclamationmark.triangle.fill" + case .transientFailure: return "clock.arrow.circlepath" + case .bootstrapping, .loading: return "ellipsis.circle" + case .notBootstrapped, .noCredentials: return "link.circle" + case .failed: return "xmark.circle" + } + } + + private var stateTint: Color { + switch store.subscriptionLoadState { + case .loaded: return .green + case .terminalFailure, .failed: return .red + case .transientFailure: return .orange + default: return .secondary + } + } + + private var stateTitle: String { + switch store.subscriptionLoadState { + case .loaded: return "Connected" + case let .terminalFailure(reason): return reason ?? "Reconnect required" + case .transientFailure: return "Backing off" + case .bootstrapping: return "Connecting…" + case .loading: return "Refreshing…" + case .notBootstrapped, .noCredentials: return "Not connected" + case .failed: return "Couldn't load plan data" + } + } + + private var stateDetail: String { + switch store.subscriptionLoadState { + case .loaded: + if let tier = store.subscription?.tier.displayName { + return "Plan: \(tier)" + } + return "Live quota tracked from Anthropic." + case .terminalFailure: return "Open Claude Code in your terminal and type `/login`, then click Reconnect." + case .transientFailure: return store.subscriptionError ?? "Anthropic rate-limited; auto-retrying." + case .bootstrapping: return "macOS may ask permission to read your credentials." + case .loading: return "Background refresh in progress." + case .notBootstrapped, .noCredentials: return "Click Connect to read your Claude Code credentials and start tracking quota." + case .failed: return store.subscriptionError ?? "" + } + } + + @ViewBuilder + private var actionButton: some View { + switch store.subscriptionLoadState { + case .loaded, .transientFailure, .loading: + Button("Disconnect") { showDisconnectConfirm = true } + .confirmationDialog( + "Disconnect Claude?", + isPresented: $showDisconnectConfirm + ) { + Button("Disconnect", role: .destructive) { + store.disconnectSubscription() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("CodeBurn will stop tracking quota and delete its local copy of your Claude credentials. Your Claude Code keychain entry is untouched — Claude Code keeps working.") + } + case .terminalFailure, .noCredentials, .failed: + Button("Reconnect") { Task { await store.bootstrapSubscription() } } + .buttonStyle(.borderedProminent) + case .notBootstrapped: + Button("Connect") { Task { await store.bootstrapSubscription() } } + .buttonStyle(.borderedProminent) + case .bootstrapping: + ProgressView().controlSize(.small) + } + } +} + +// MARK: - Codex + +private struct CodexSettingsTab: View { + @Environment(AppStore.self) private var store + + var body: some View { + Form { + Section("Connection") { + CodexConnectionRow() + } + Section { + Text("Codex live-quota tracking reads `~/.codex/auth.json` once on Connect, then keeps a local copy under Application Support so subsequent quota fetches don't re-read the original. Only ChatGPT-mode auth (Plus / Pro / Team / Business) is supported — API-key users are billed per request and have a different reporting surface.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } header: { + Text("How it works") + } + } + .formStyle(.grouped) + .padding() + } +} + +private struct CodexConnectionRow: View { + @Environment(AppStore.self) private var store + @State private var showDisconnectConfirm = false + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: stateIcon) + .font(.system(size: 18)) + .foregroundStyle(stateTint) + .frame(width: 22) + VStack(alignment: .leading, spacing: 2) { + Text(stateTitle) + .font(.system(size: 12, weight: .semibold)) + Text(stateDetail) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(2) + } + Spacer() + actionButton + } + .padding(.vertical, 4) + } + + private var stateIcon: String { + switch store.codexLoadState { + case .loaded: return "checkmark.circle.fill" + case .terminalFailure: return "exclamationmark.triangle.fill" + case .transientFailure: return "clock.arrow.circlepath" + case .bootstrapping, .loading: return "ellipsis.circle" + case .notBootstrapped, .noCredentials: return "link.circle" + case .failed: return "xmark.circle" + } + } + + private var stateTint: Color { + switch store.codexLoadState { + case .loaded: return .green + case .terminalFailure, .failed: return .red + case .transientFailure: return .orange + default: return .secondary + } + } + + private var stateTitle: String { + switch store.codexLoadState { + case .loaded: return "Connected" + case let .terminalFailure(reason): return reason ?? "Reconnect required" + case .transientFailure: return "Backing off" + case .bootstrapping: return "Connecting…" + case .loading: return "Refreshing…" + case .notBootstrapped, .noCredentials: return "Not connected" + case .failed: return "Couldn't load Codex quota" + } + } + + private var stateDetail: String { + switch store.codexLoadState { + case .loaded: + if let plan = store.codexUsage?.plan.displayName { + return "Plan: \(plan)" + } + return "Live quota tracked from chatgpt.com." + case .terminalFailure: + // Be specific about the cause: the message we already surface in + // codexError will say "API-key mode" if that's the situation, so + // the generic "run codex login" hint covers both cases. + if let err = store.codexError, err.lowercased().contains("api-key") { + return "Codex is in API-key mode. Run `codex login` and choose a ChatGPT plan to enable quota tracking." + } + return "Run `codex login` in your terminal to sign in again, then click Reconnect." + case .transientFailure: return store.codexError ?? "ChatGPT rate-limited; auto-retrying." + case .bootstrapping: return "Reading ~/.codex/auth.json." + case .loading: return "Background refresh in progress." + case .notBootstrapped, .noCredentials: + return "Click Connect to read your Codex CLI credentials. If Connect fails, run `codex login` in your terminal first to create ~/.codex/auth.json." + case .failed: return store.codexError ?? "" + } + } + + @ViewBuilder + private var actionButton: some View { + switch store.codexLoadState { + case .loaded, .transientFailure, .loading: + Button("Disconnect") { showDisconnectConfirm = true } + .confirmationDialog( + "Disconnect Codex?", + isPresented: $showDisconnectConfirm + ) { + Button("Disconnect", role: .destructive) { + store.disconnectCodex() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("CodeBurn will stop tracking quota and delete its local copy of your Codex credentials. Your ~/.codex/auth.json is untouched — Codex CLI keeps working.") + } + case .terminalFailure, .noCredentials, .failed: + Button("Reconnect") { Task { await store.bootstrapCodex() } } + .buttonStyle(.borderedProminent) + case .notBootstrapped: + Button("Connect") { Task { await store.bootstrapCodex() } } + .buttonStyle(.borderedProminent) + case .bootstrapping: + ProgressView().controlSize(.small) + } + } +} + +// MARK: - About + +private struct AboutSettingsTab: View { + private let appVersion: String = AppVersion.normalizedBundleShortVersion + private let buildVersion: String = AppVersion.normalizedBundleBuildVersion + + var body: some View { + VStack(spacing: 14) { + Image(systemName: "flame.fill") + .font(.system(size: 40)) + .foregroundStyle(Theme.brandAccent) + Text("CodeBurn") + .font(.system(size: 18, weight: .semibold)) + Text("AI Coding Cost Tracker") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + Text("Version \(appVersion) (\(buildVersion))") + .font(.codeMono(size: 11)) + .foregroundStyle(.secondary) + HStack(spacing: 10) { + Link("GitHub", destination: URL(string: "https://github.com/getagentseal/codeburn")!) + Link("Issues", destination: URL(string: "https://github.com/getagentseal/codeburn/issues")!) + } + .font(.system(size: 12)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } +} diff --git a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift new file mode 100644 index 0000000..fd75fec --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift @@ -0,0 +1,84 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +private func menubarPayload(cost: Double) -> MenubarPayload { + MenubarPayload( + generated: "test", + current: CurrentBlock( + label: "Today", + cost: cost, + calls: 1, + sessions: 1, + oneShotRate: nil, + inputTokens: 1, + outputTokens: 1, + cacheHitPercent: 0, + topActivities: [], + topModels: [], + providers: ["claude": cost] + ), + optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []), + history: HistoryBlock(daily: []) + ) +} + +@Suite("AppStore refresh recovery") +@MainActor +struct AppStoreRefreshRecoveryTests { + @Test("stale visible payload triggers hard recovery without clearing cache") + func stalePayloadTriggersHardRecoveryWithoutClearingCache() { + let store = AppStore() + store.setCachedPayloadForTesting( + menubarPayload(cost: 92.33), + period: .today, + provider: .all, + fetchedAt: Date().addingTimeInterval(-180) + ) + + #expect(store.todayPayload?.current.cost == 92.33) + #expect(store.needsInteractivePayloadRefresh) + #expect(store.needsStatusPayloadRefresh) + #expect(store.hasStaleInteractivePayload) + #expect(store.shouldResetInteractiveRefreshPipeline) + + store.resetRefreshState(clearCache: false) + + #expect(store.todayPayload?.current.cost == 92.33) + } + + @Test("fresh visible payload does not trigger hard recovery") + func freshPayloadDoesNotTriggerHardRecovery() { + let store = AppStore() + store.setCachedPayloadForTesting( + menubarPayload(cost: 164.06), + period: .today, + provider: .all, + fetchedAt: Date() + ) + + #expect(!store.needsInteractivePayloadRefresh) + #expect(!store.needsStatusPayloadRefresh) + #expect(!store.hasStaleInteractivePayload) + #expect(!store.shouldResetInteractiveRefreshPipeline) + } + + @Test("missing today status payload needs status refresh") + func missingTodayStatusPayloadNeedsStatusRefresh() { + let store = AppStore() + + #expect(store.todayPayload == nil) + #expect(store.needsStatusPayloadRefresh) + } + + @Test("missing unattempted payload triggers hard recovery") + func missingUnattemptedPayloadTriggersHardRecovery() { + let store = AppStore() + + #expect(!store.hasCachedData) + #expect(!store.hasAttemptedCurrentKeyLoad) + #expect(store.needsInteractivePayloadRefresh) + #expect(store.hasMissingInteractivePayloadWithoutAttempt) + #expect(store.shouldResetInteractiveRefreshPipeline) + } +} diff --git a/mac/Tests/CodeBurnMenubarTests/AppVersionTests.swift b/mac/Tests/CodeBurnMenubarTests/AppVersionTests.swift new file mode 100644 index 0000000..898f5e0 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/AppVersionTests.swift @@ -0,0 +1,19 @@ +import Testing +@testable import CodeBurnMenubar + +@Suite("AppVersion") +struct AppVersionTests { + @Test("display avoids duplicate v prefix") + func displayAvoidsDuplicatePrefix() { + #expect(AppVersion.display("0.9.8") == "v0.9.8") + #expect(AppVersion.display("v0.9.8") == "v0.9.8") + #expect(AppVersion.display("mac-v0.9.8") == "v0.9.8") + } + + @Test("bundle metadata stores unprefixed semver") + func normalizeBundleVersion() { + #expect(AppVersion.normalize("v0.9.8") == "0.9.8") + #expect(AppVersion.normalize("mac-v0.9.8") == "0.9.8") + #expect(AppVersion.normalize("dev") == "dev") + } +} diff --git a/mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift b/mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift new file mode 100644 index 0000000..44f52b5 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift @@ -0,0 +1,39 @@ +import Testing +@testable import CodeBurnMenubar + +@Suite("UpdateChecker") +struct UpdateCheckerTests { + @Test("selects newest mac release with zip and checksum") + func selectsNewestMacReleaseWithChecksum() { + let releases = [ + GitHubRelease( + tag_name: "v0.9.9", + assets: [GitHubAsset(name: "codeburn-0.9.9.tgz", browser_download_url: "https://example.test/cli")] + ), + GitHubRelease( + tag_name: "mac-v0.9.8", + assets: [ + GitHubAsset(name: "CodeBurnMenubar-v0.9.8.zip", browser_download_url: "https://example.test/app"), + GitHubAsset(name: "CodeBurnMenubar-v0.9.8.zip.sha256", browser_download_url: "https://example.test/app.sha256"), + ] + ), + ] + + let resolved = UpdateChecker.resolveLatestMenubarRelease(in: releases) + + #expect(resolved?.release.tag_name == "mac-v0.9.8") + #expect(resolved?.asset.name == "CodeBurnMenubar-v0.9.8.zip") + } + + @Test("ignores mac release missing checksum") + func ignoresMacReleaseMissingChecksum() { + let releases = [ + GitHubRelease( + tag_name: "mac-v0.9.8", + assets: [GitHubAsset(name: "CodeBurnMenubar-v0.9.8.zip", browser_download_url: "https://example.test/app")] + ), + ] + + #expect(UpdateChecker.resolveLatestMenubarRelease(in: releases) == nil) + } +} diff --git a/package-lock.json b/package-lock.json index 7077b1b..ad40db2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "codeburn", - "version": "0.9.4", + "version": "0.9.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codeburn", - "version": "0.9.4", + "version": "0.9.9", "license": "MIT", "dependencies": { "chalk": "^5.4.1", "commander": "^13.1.0", "ink": "^7.0.0", - "react": "^19.2.5" + "react": "^19.2.5", + "strip-ansi": "^7.2.0" }, "bin": { "codeburn": "dist/cli.js" @@ -26,7 +27,7 @@ "vitest": "^3.1.0" }, "engines": { - "node": ">=22" + "node": ">=22.13.0" } }, "node_modules/@alcalzone/ansi-tokenize": { diff --git a/package.json b/package.json index 73a7c38..243c768 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codeburn", - "version": "0.9.4", + "version": "0.9.9", "description": "See where your AI coding tokens go - by task, tool, model, and project", "type": "module", "main": "./dist/cli.js", @@ -12,7 +12,7 @@ ], "scripts": { "bundle-litellm": "node scripts/bundle-litellm.mjs", - "build": "node scripts/bundle-litellm.mjs && tsup", + "build": "node scripts/bundle-litellm.mjs && tsup && node -e \"const fs=require('fs'); fs.copyFileSync('src/cli.ts','dist/cli.js'); fs.chmodSync('dist/cli.js',0o755)\"", "dev": "tsx src/cli.ts", "test": "vitest", "prepublishOnly": "npm run build" @@ -21,6 +21,8 @@ "claude-code", "cursor", "codex", + "kimi", + "ibm-bob", "opencode", "pi", "codebuff", @@ -31,7 +33,7 @@ "developer-tools" ], "engines": { - "node": ">=22" + "node": ">=22.13.0" }, "author": "AgentSeal ", "license": "MIT", @@ -47,7 +49,8 @@ "chalk": "^5.4.1", "commander": "^13.1.0", "ink": "^7.0.0", - "react": "^19.2.5" + "react": "^19.2.5", + "strip-ansi": "^7.2.0" }, "devDependencies": { "@types/node": "^22.19.17", diff --git a/src/bash-utils.ts b/src/bash-utils.ts index c578972..2e5fe0d 100644 --- a/src/bash-utils.ts +++ b/src/bash-utils.ts @@ -1,12 +1,14 @@ import { basename } from 'path' +import stripAnsi from 'strip-ansi' function stripQuotedStrings(command: string): string { return command.replace(/"[^"]*"|'[^']*'/g, match => ' '.repeat(match.length)) } -export function extractBashCommands(command: string): string[] { - if (!command || !command.trim()) return [] +export function extractBashCommands(rawCommand: string): string[] { + if (!rawCommand || !rawCommand.trim()) return [] + const command = stripAnsi(rawCommand) const stripped = stripQuotedStrings(command) const separatorRegex = /\s*(?:&&|;|\|)\s*/g diff --git a/src/classifier.ts b/src/classifier.ts index 33b52b2..9a5de49 100644 --- a/src/classifier.ts +++ b/src/classifier.ts @@ -53,6 +53,10 @@ function getAllTools(turn: ParsedTurn): string[] { return turn.assistantCalls.flatMap(c => c.tools) } +function getAllSkills(turn: ParsedTurn): string[] { + return turn.assistantCalls.flatMap(c => c.skills ?? []) +} + function classifyByToolPattern(turn: ParsedTurn): TaskCategory | null { const tools = getAllTools(turn) if (tools.length === 0) return null @@ -89,12 +93,38 @@ function classifyByToolPattern(turn: ParsedTurn): TaskCategory | null { return null } +/// Picks the category whose keyword pattern matches earliest in the message. +/// On a tie (same start index) the candidate listed first in `candidates` wins, +/// so callers control tie-break priority by ordering. Returns null when no +/// pattern matches. The first-match heuristic fixes the long-standing problem +/// where "add error handling" was tagged Debugging because the DEBUG regex was +/// checked before FEATURE; now FEATURE wins because "add" appears before +/// "error". Issue #196. +function firstMatchingCategory( + text: string, + candidates: ReadonlyArray<{ regex: RegExp; category: TaskCategory }>, +): TaskCategory | null { + let best: { index: number; order: number; category: TaskCategory } | null = null + for (let i = 0; i < candidates.length; i++) { + const c = candidates[i]! + const m = c.regex.exec(text) + if (!m) continue + if (!best || m.index < best.index || (m.index === best.index && i < best.order)) { + best = { index: m.index, order: i, category: c.category } + } + } + return best?.category ?? null +} + function refineByKeywords(category: TaskCategory, userMessage: string): TaskCategory { if (category === 'coding') { - if (DEBUG_KEYWORDS.test(userMessage)) return 'debugging' - if (REFACTOR_KEYWORDS.test(userMessage)) return 'refactoring' - if (FEATURE_KEYWORDS.test(userMessage)) return 'feature' - return 'coding' + // Tie-break order (when two keywords match at the same index): refactoring + // first because its words are the most specific, then feature, then debug. + return firstMatchingCategory(userMessage, [ + { regex: REFACTOR_KEYWORDS, category: 'refactoring' }, + { regex: FEATURE_KEYWORDS, category: 'feature' }, + { regex: DEBUG_KEYWORDS, category: 'debugging' }, + ]) ?? 'coding' } if (category === 'exploration') { @@ -109,8 +139,14 @@ function refineByKeywords(category: TaskCategory, userMessage: string): TaskCate function classifyConversation(userMessage: string): TaskCategory { if (BRAINSTORM_KEYWORDS.test(userMessage)) return 'brainstorming' if (RESEARCH_KEYWORDS.test(userMessage)) return 'exploration' - if (DEBUG_KEYWORDS.test(userMessage)) return 'debugging' - if (FEATURE_KEYWORDS.test(userMessage)) return 'feature' + // Same first-match-wins logic as refineByKeywords so a chat-only message + // starting with a feature verb does not flip to debugging because of an + // incidental "error" or "fix" word later in the same sentence. + const debugOrFeature = firstMatchingCategory(userMessage, [ + { regex: FEATURE_KEYWORDS, category: 'feature' }, + { regex: DEBUG_KEYWORDS, category: 'debugging' }, + ]) + if (debugOrFeature) return debugOrFeature if (FILE_PATTERNS.test(userMessage)) return 'coding' if (SCRIPT_PATTERNS.test(userMessage)) return 'coding' if (URL_PATTERN.test(userMessage)) return 'exploration' @@ -159,5 +195,12 @@ export function classifyTurn(turn: ParsedTurn): ClassifiedTurn { } } - return { ...turn, category, retries: countRetries(turn), hasEdits: turnHasEdits(turn) } + const result: ClassifiedTurn = { ...turn, category, retries: countRetries(turn), hasEdits: turnHasEdits(turn) } + + if (category === 'general') { + const skills = getAllSkills(turn) + if (skills.length > 0) result.subCategory = skills[0] + } + + return result } diff --git a/src/cli-date.ts b/src/cli-date.ts index 66831b9..250884b 100644 --- a/src/cli-date.ts +++ b/src/cli-date.ts @@ -1,4 +1,5 @@ import type { DateRange } from './types.js' +import { toDateString } from './daily-cache.js' const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/ @@ -7,19 +8,68 @@ const END_OF_DAY_MINUTES = 59 const END_OF_DAY_SECONDS = 59 const END_OF_DAY_MS = 999 +// "All Time" is intentionally bounded to the last 6 months. Older data is +// rarely actionable for a cost tracker, and capping the range keeps the parse +// path bounded so providers like Codex/Cursor with sparse multi-year history +// still load in seconds. Users who need an unbounded window can use +// `--from` / `--to`. +const ALL_TIME_MONTHS = 6 + +export type Period = 'today' | 'week' | '30days' | 'month' | 'all' + +export const PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all'] + +// Short labels suitable for the dashboard tab strip. Long-form labels for +// header text come from `getDateRange().label`. +export const PERIOD_LABELS: Record = { + today: 'Today', + week: '7 Days', + '30days': '30 Days', + month: 'This Month', + all: '6 Months', +} + +const VALID_PERIODS: ReadonlyArray = ['today', 'week', '30days', 'month', 'all'] + +export function toPeriod(s: string): Period { + if ((VALID_PERIODS as readonly string[]).includes(s)) return s as Period + // Fail loudly instead of silently coercing to 'week'. Previously a typo + // like `-p mounth` produced a quiet 7-day report and the user thought + // they were viewing the month. + process.stderr.write( + `codeburn: unknown period "${s}". Valid values: ${VALID_PERIODS.join(', ')}.\n` + ) + process.exit(1) +} + function parseLocalDate(s: string): Date { if (!ISO_DATE_RE.test(s)) { throw new Error(`Invalid date format "${s}": expected YYYY-MM-DD`) } const [y, m, d] = s.split('-').map(Number) as [number, number, number] - return new Date(y, m - 1, d) + const date = new Date(y, m - 1, d) + // JS Date silently rolls overflow forward (Feb 31 → Mar 3). That makes a + // typo like `--from 2026-02-31 --to 2026-03-15` quietly drop sessions + // dated Feb 28 - Mar 2. Reject overflow so the user gets a loud error + // instead of an off-by-N-days date range. + if (date.getFullYear() !== y || date.getMonth() !== m - 1 || date.getDate() !== d) { + throw new Error(`Invalid date "${s}": ${m}/${d}/${y} is not a real calendar date`) + } + return date } export function parseDateRangeFlags(from: string | undefined, to: string | undefined): DateRange | null { if (from === undefined && to === undefined) return null const now = new Date() - const start = from !== undefined ? parseLocalDate(from) : new Date(0) + // When --from is omitted, default to 6 months back (the same window the + // dashboard's "all" period uses) instead of epoch. Previously a bare + // `--to 2026-01-01` opened a 55-year scan from 1970 which is rarely what + // the user meant and is expensive on machines with many session files. + const ALL_TIME_FALLBACK_MS = 6 * 31 * 24 * 60 * 60 * 1000 + const start = from !== undefined + ? parseLocalDate(from) + : new Date(now.getTime() - ALL_TIME_FALLBACK_MS) const endDate = to !== undefined ? parseLocalDate(to) : new Date(now.getFullYear(), now.getMonth(), now.getDate()) const end = new Date( @@ -37,3 +87,64 @@ export function parseDateRangeFlags(from: string | undefined, to: string | undef } return { start, end } } + +/** + * Returns the date range and a human-readable label for a named period. + * + * Accepts a string (rather than the strict `Period` type) because the CLI + * surfaces a few extra inputs not exposed in the dashboard tab strip + * (e.g. `'yesterday'`). Unknown values fall back to `'week'`. + * + * Note: `'all'` is bounded to the last 6 months. Use `--from`/`--to` for + * an unbounded historical window. + */ +export function getDateRange(period: string): { range: DateRange; label: string } { + const now = new Date() + const end = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + END_OF_DAY_HOURS, + END_OF_DAY_MINUTES, + END_OF_DAY_SECONDS, + END_OF_DAY_MS, + ) + + switch (period) { + case 'today': { + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + return { range: { start, end }, label: `Today (${toDateString(start)})` } + } + case 'yesterday': { + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1) + const yesterdayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, END_OF_DAY_HOURS, END_OF_DAY_MINUTES, END_OF_DAY_SECONDS, END_OF_DAY_MS) + return { range: { start, end: yesterdayEnd }, label: `Yesterday (${toDateString(start)})` } + } + case 'week': { + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7) + return { range: { start, end }, label: 'Last 7 Days' } + } + case 'month': { + const start = new Date(now.getFullYear(), now.getMonth(), 1) + return { range: { start, end }, label: `${now.toLocaleString('default', { month: 'long' })} ${now.getFullYear()}` } + } + case '30days': { + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30) + return { range: { start, end }, label: 'Last 30 Days' } + } + case 'all': { + const start = new Date(now.getFullYear(), now.getMonth() - ALL_TIME_MONTHS, 1) + return { range: { start, end }, label: 'Last 6 months' } + } + default: { + process.stderr.write( + `codeburn: unknown period "${period}". Valid values: today, week, 30days, month, all.\n` + ) + process.exit(1) + } + } +} + +export function formatDateRangeLabel(from: string | undefined, to: string | undefined): string { + return `${from ?? 'all'} to ${to ?? 'today'}` +} diff --git a/src/cli.ts b/src/cli.ts index 116866c..dec3d49 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,888 +1,15 @@ -import { Command } from 'commander' -import { installMenubarApp } from './menubar-installer.js' -import { exportCsv, exportJson, type PeriodExport } from './export.js' -import { loadPricing, setModelAliases } from './models.js' -import { parseAllSessions, filterProjectsByName } from './parser.js' -import { convertCost } from './currency.js' -import { renderStatusBar } from './format.js' -import { type PeriodData, type ProviderCost } from './menubar-json.js' -import { buildMenubarPayload } from './menubar-json.js' -import { getDaysInRange, ensureCacheHydrated, emptyCache, MS_PER_DAY, BACKFILL_DAYS, toDateString } from './daily-cache.js' -import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from './day-aggregator.js' -import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js' -import { renderDashboard } from './dashboard.js' -import { parseDateRangeFlags } from './cli-date.js' -import { runOptimize, scanAndDetect } from './optimize.js' -import { renderCompare } from './compare.js' -import { getAllProviders } from './providers/index.js' -import { clearPlan, readConfig, readPlan, saveConfig, savePlan, getConfigFilePath, type PlanId } from './config.js' -import { clampResetDay, getPlanUsageOrNull, type PlanUsage } from './plan-usage.js' -import { getPresetPlan, isPlanId, isPlanProvider, planDisplayName } from './plans.js' -import { createRequire } from 'node:module' - -const require = createRequire(import.meta.url) -const { version } = require('../package.json') -import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js' - -async function hydrateCache() { - try { - return await ensureCacheHydrated( - (range) => parseAllSessions(range, 'all'), - aggregateProjectsIntoDays, - ) - } catch { - return emptyCache() - } +#!/usr/bin/env node +// This launcher must stay parseable by Node 18. Do NOT add static imports. +const [major, minor] = process.versions.node.split('.').map(Number) +if (major < 22 || (major === 22 && minor < 13)) { + process.stderr.write( + `codeburn requires Node.js >= 22.13.0 (current: ${process.version})\n` + + 'Upgrade at https://nodejs.org/\n', + ) + process.exit(1) } -function getDateRange(period: string): { range: DateRange; label: string } { - const now = new Date() - const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999) - - switch (period) { - case 'today': { - const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()) - return { range: { start, end }, label: `Today (${toDateString(start)})` } - } - case 'yesterday': { - const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1) - const yesterdayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 23, 59, 59, 999) - return { range: { start, end: yesterdayEnd }, label: `Yesterday (${toDateString(start)})` } - } - case 'week': { - const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7) - return { range: { start, end }, label: 'Last 7 Days' } - } - case 'month': { - const start = new Date(now.getFullYear(), now.getMonth(), 1) - return { range: { start, end }, label: `${now.toLocaleString('default', { month: 'long' })} ${now.getFullYear()}` } - } - case '30days': { - const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30) - return { range: { start, end }, label: 'Last 30 Days' } - } - case 'all': { - // Cap "All Time" to the last 6 months. Older data is rarely actionable for a cost - // tracker and keeps the parse path bounded so providers like Codex/Cursor with sparse - // data still load in seconds. - const start = new Date(now.getFullYear(), now.getMonth() - 6, now.getDate()) - return { range: { start, end }, label: 'Last 6 months' } - } - default: { - const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7) - return { range: { start, end }, label: 'Last 7 Days' } - } - } -} - -type Period = 'today' | 'week' | '30days' | 'month' | 'all' - -function toPeriod(s: string): Period { - if (s === 'today') return 'today' - if (s === 'month') return 'month' - if (s === '30days') return '30days' - if (s === 'all') return 'all' - return 'week' -} - -function collect(val: string, acc: string[]): string[] { - acc.push(val) - return acc -} - -function parseNumber(value: string): number { - return Number(value) -} - -function parseInteger(value: string): number { - return parseInt(value, 10) -} - -type JsonPlanSummary = { - id: PlanId - budget: number - spent: number - percentUsed: number - status: 'under' | 'near' | 'over' - projectedMonthEnd: number - daysUntilReset: number - periodStart: string - periodEnd: string -} - -function toJsonPlanSummary(planUsage: PlanUsage): JsonPlanSummary { - return { - id: planUsage.plan.id, - budget: convertCost(planUsage.budgetUsd), - spent: convertCost(planUsage.spentApiEquivalentUsd), - percentUsed: Math.round(planUsage.percentUsed * 10) / 10, - status: planUsage.status, - projectedMonthEnd: convertCost(planUsage.projectedMonthUsd), - daysUntilReset: planUsage.daysUntilReset, - periodStart: planUsage.periodStart.toISOString(), - periodEnd: planUsage.periodEnd.toISOString(), - } -} - -async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise { - await loadPricing() - const { range, label } = getDateRange(period) - const projects = filterProjectsByName(await parseAllSessions(range, provider), project, exclude) - const report: ReturnType & { plan?: JsonPlanSummary } = buildJsonReport(projects, label, period) - const planUsage = await getPlanUsageOrNull() - if (planUsage) { - report.plan = toJsonPlanSummary(planUsage) - } - console.log(JSON.stringify(report, null, 2)) -} - -const program = new Command() - .name('codeburn') - .description('See where your AI coding tokens go - by task, tool, model, and project') - .version(version) - .option('--verbose', 'print warnings to stderr on read failures and skipped files') - -program.hook('preAction', async (thisCommand) => { - const config = await readConfig() - setModelAliases(config.modelAliases ?? {}) - if (thisCommand.opts<{ verbose?: boolean }>().verbose) { - process.env['CODEBURN_VERBOSE'] = '1' - } - await loadCurrency() +import('./main.js').catch((err) => { + process.stderr.write(String(err?.message ?? err) + '\n') + process.exit(1) }) - -function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: string) { - const sessions = projects.flatMap(p => p.sessions) - const { code } = getCurrency() - - const totalCostUSD = projects.reduce((s, p) => s + p.totalCostUSD, 0) - const totalCalls = projects.reduce((s, p) => s + p.totalApiCalls, 0) - const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0) - const totalInput = sessions.reduce((s, sess) => s + sess.totalInputTokens, 0) - const totalOutput = sessions.reduce((s, sess) => s + sess.totalOutputTokens, 0) - const totalCacheRead = sessions.reduce((s, sess) => s + sess.totalCacheReadTokens, 0) - const totalCacheWrite = sessions.reduce((s, sess) => s + sess.totalCacheWriteTokens, 0) - // Match src/menubar-json.ts:cacheHitPercent: reads over reads+fresh-input. cache_write - // counts tokens being stored, not served, so it doesn't belong in the denominator. - const cacheHitDenom = totalInput + totalCacheRead - const cacheHitPercent = cacheHitDenom > 0 ? Math.round((totalCacheRead / cacheHitDenom) * 1000) / 10 : 0 - - const dailyMap: Record = {} - for (const sess of sessions) { - for (const turn of sess.turns) { - if (!turn.timestamp) { continue } - const day = dateKey(turn.timestamp) - if (!dailyMap[day]) { dailyMap[day] = { cost: 0, calls: 0 } } - for (const call of turn.assistantCalls) { - dailyMap[day].cost += call.costUSD - dailyMap[day].calls += 1 - } - } - } - const daily = Object.entries(dailyMap).sort().map(([date, d]) => ({ - date, - cost: convertCost(d.cost), - calls: d.calls, - })) - - const projectList = projects.map(p => ({ - name: p.project, - path: p.projectPath, - cost: convertCost(p.totalCostUSD), - avgCostPerSession: p.sessions.length > 0 - ? convertCost(p.totalCostUSD / p.sessions.length) - : null, - calls: p.totalApiCalls, - sessions: p.sessions.length, - })) - - const modelMap: Record = {} - for (const sess of sessions) { - for (const [model, d] of Object.entries(sess.modelBreakdown)) { - if (!modelMap[model]) { modelMap[model] = { calls: 0, cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } } - modelMap[model].calls += d.calls - modelMap[model].cost += d.costUSD - modelMap[model].inputTokens += d.tokens.inputTokens - modelMap[model].outputTokens += d.tokens.outputTokens - modelMap[model].cacheReadTokens += d.tokens.cacheReadInputTokens - modelMap[model].cacheWriteTokens += d.tokens.cacheCreationInputTokens - } - } - const models = Object.entries(modelMap) - .sort(([, a], [, b]) => b.cost - a.cost) - .map(([name, { cost, ...rest }]) => ({ name, ...rest, cost: convertCost(cost) })) - - const catMap: Record = {} - for (const sess of sessions) { - for (const [cat, d] of Object.entries(sess.categoryBreakdown)) { - if (!catMap[cat]) { catMap[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 } } - catMap[cat].turns += d.turns - catMap[cat].cost += d.costUSD - catMap[cat].editTurns += d.editTurns - catMap[cat].oneShotTurns += d.oneShotTurns - } - } - const activities = Object.entries(catMap) - .sort(([, a], [, b]) => b.cost - a.cost) - .map(([cat, d]) => ({ - category: CATEGORY_LABELS[cat as TaskCategory] ?? cat, - cost: convertCost(d.cost), - turns: d.turns, - editTurns: d.editTurns, - oneShotTurns: d.oneShotTurns, - oneShotRate: d.editTurns > 0 ? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10 : null, - })) - - const toolMap: Record = {} - const mcpMap: Record = {} - const bashMap: Record = {} - for (const sess of sessions) { - for (const [tool, d] of Object.entries(sess.toolBreakdown)) { - toolMap[tool] = (toolMap[tool] ?? 0) + d.calls - } - for (const [server, d] of Object.entries(sess.mcpBreakdown)) { - mcpMap[server] = (mcpMap[server] ?? 0) + d.calls - } - for (const [cmd, d] of Object.entries(sess.bashBreakdown)) { - bashMap[cmd] = (bashMap[cmd] ?? 0) + d.calls - } - } - - const sortedMap = (m: Record) => - Object.entries(m).sort(([, a], [, b]) => b - a).map(([name, calls]) => ({ name, calls })) - - const topSessions = projects - .flatMap(p => p.sessions.map(s => ({ project: p.project, sessionId: s.sessionId, date: s.firstTimestamp ? dateKey(s.firstTimestamp) : null, cost: convertCost(s.totalCostUSD), calls: s.apiCalls }))) - .sort((a, b) => b.cost - a.cost) - .slice(0, 5) - - return { - generated: new Date().toISOString(), - currency: code, - period, - periodKey, - overview: { - cost: convertCost(totalCostUSD), - calls: totalCalls, - sessions: totalSessions, - cacheHitPercent, - tokens: { - input: totalInput, - output: totalOutput, - cacheRead: totalCacheRead, - cacheWrite: totalCacheWrite, - }, - }, - daily, - projects: projectList, - models, - activities, - tools: sortedMap(toolMap), - mcpServers: sortedMap(mcpMap), - shellCommands: sortedMap(bashMap), - topSessions, - } -} - -program - .command('report', { isDefault: true }) - .description('Interactive usage dashboard') - .option('-p, --period ', 'Starting period: today, week, 30days, month, all', 'week') - .option('--from ', 'Start date (YYYY-MM-DD). Overrides --period when set') - .option('--to ', 'End date (YYYY-MM-DD). Overrides --period when set') - .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') - .option('--format ', 'Output format: tui, json', 'tui') - .option('--project ', 'Show only projects matching name (repeatable)', collect, []) - .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) - .option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInt, 30) - .action(async (opts) => { - let customRange: DateRange | null = null - try { - customRange = parseDateRangeFlags(opts.from, opts.to) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - console.error(`\n Error: ${message}\n`) - process.exit(1) - } - - const period = toPeriod(opts.period) - if (opts.format === 'json') { - await loadPricing() - await hydrateCache() - if (customRange) { - const label = `${opts.from ?? 'all'} to ${opts.to ?? 'today'}` - const projects = filterProjectsByName( - await parseAllSessions(customRange, opts.provider), - opts.project, - opts.exclude, - ) - console.log(JSON.stringify(buildJsonReport(projects, label, 'custom'), null, 2)) - } else { - await runJsonReport(period, opts.provider, opts.project, opts.exclude) - } - return - } - await hydrateCache() - await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange) - }) - -function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData { - const sessions = projects.flatMap(p => p.sessions) - const catTotals: Record = {} - const modelTotals: Record = {} - let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0 - - for (const sess of sessions) { - inputTokens += sess.totalInputTokens - outputTokens += sess.totalOutputTokens - cacheReadTokens += sess.totalCacheReadTokens - cacheWriteTokens += sess.totalCacheWriteTokens - for (const [cat, d] of Object.entries(sess.categoryBreakdown)) { - if (!catTotals[cat]) catTotals[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 } - catTotals[cat].turns += d.turns - catTotals[cat].cost += d.costUSD - catTotals[cat].editTurns += d.editTurns - catTotals[cat].oneShotTurns += d.oneShotTurns - } - for (const [model, d] of Object.entries(sess.modelBreakdown)) { - if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0 } - modelTotals[model].calls += d.calls - modelTotals[model].cost += d.costUSD - } - } - - return { - label, - cost: projects.reduce((s, p) => s + p.totalCostUSD, 0), - calls: projects.reduce((s, p) => s + p.totalApiCalls, 0), - sessions: projects.reduce((s, p) => s + p.sessions.length, 0), - inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, - categories: Object.entries(catTotals) - .sort(([, a], [, b]) => b.cost - a.cost) - .map(([cat, d]) => ({ name: CATEGORY_LABELS[cat as TaskCategory] ?? cat, ...d })), - models: Object.entries(modelTotals) - .sort(([, a], [, b]) => b.cost - a.cost) - .map(([name, d]) => ({ name, ...d })), - } -} - -program - .command('status') - .description('Compact status output (today + week + month)') - .option('--format ', 'Output format: terminal, menubar-json, json', 'terminal') - .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') - .option('--project ', 'Show only projects matching name (repeatable)', collect, []) - .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) - .option('--period ', 'Primary period for menubar-json: today, week, 30days, month, all', 'today') - .option('--no-optimize', 'Skip optimize findings (menubar-json only, faster)') - .action(async (opts) => { - await loadPricing() - const pf = opts.provider - const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude) - if (opts.format === 'menubar-json') { - const periodInfo = getDateRange(opts.period) - const now = new Date() - const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) - const yesterdayStr = toDateString(new Date(todayStart.getTime() - MS_PER_DAY)) - const isAllProviders = pf === 'all' - - const cache = await hydrateCache() - - // CURRENT PERIOD DATA - // - .all provider: assemble from cache + today (fast) - // - specific provider: parse the period range with provider filter (correct, but slower) - let currentData: PeriodData - let scanProjects: ProjectSummary[] - let scanRange: DateRange - - if (isAllProviders) { - // Parse only today's sessions; historical data comes from cache to avoid double-counting - const todayRange: DateRange = { start: todayStart, end: new Date() } - const todayProjects = fp(await parseAllSessions(todayRange, 'all')) - const todayDays = aggregateProjectsIntoDays(todayProjects) - const rangeStartStr = toDateString(periodInfo.range.start) - const rangeEndStr = toDateString(periodInfo.range.end) - const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr) - const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr) - const allDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date)) - currentData = buildPeriodDataFromDays(allDays, periodInfo.label) - scanProjects = todayProjects - scanRange = periodInfo.range - } else { - const projects = fp(await parseAllSessions(periodInfo.range, pf)) - currentData = buildPeriodData(periodInfo.label, projects) - scanProjects = projects - scanRange = periodInfo.range - } - - // PROVIDERS - // For .all: enumerate every provider with cost across the period (from cache) + installed-but-zero. - // For specific: just this single provider with its scoped cost. - const allProviders = await getAllProviders() - const displayNameByName = new Map(allProviders.map(p => [p.name, p.displayName])) - const providers: ProviderCost[] = [] - if (isAllProviders) { - // Parse only today; historical provider costs come from cache - const todayRangeForProviders: DateRange = { start: todayStart, end: new Date() } - const todayDaysForProviders = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRangeForProviders, 'all'))) - const rangeStartStr = toDateString(periodInfo.range.start) - const todayStr = toDateString(todayStart) - const allDaysForProviders = [ - ...getDaysInRange(cache, rangeStartStr, yesterdayStr), - ...todayDaysForProviders.filter(d => d.date === todayStr), - ] - const providerTotals: Record = {} - for (const d of allDaysForProviders) { - for (const [name, p] of Object.entries(d.providers)) { - providerTotals[name] = (providerTotals[name] ?? 0) + p.cost - } - } - for (const [name, cost] of Object.entries(providerTotals)) { - providers.push({ name: displayNameByName.get(name) ?? name, cost }) - } - for (const p of allProviders) { - if (providers.some(pc => pc.name === p.displayName)) continue - const sources = await p.discoverSessions() - if (sources.length > 0) providers.push({ name: p.displayName, cost: 0 }) - } - } else { - const display = displayNameByName.get(pf) ?? pf - providers.push({ name: display, cost: currentData.cost }) - } - - // DAILY HISTORY (last 365 days) - // Cache stores per-provider cost+calls per day in DailyEntry.providers, so we can derive - // a provider-filtered history without re-parsing. Tokens aren't broken down per provider - // in the cache, so the filtered view shows zero tokens (heatmap/trend still works on cost). - const historyStartStr = toDateString(new Date(todayStart.getTime() - BACKFILL_DAYS * MS_PER_DAY)) - const allCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr) - // Parse only today for history; historical days come from cache - const todayRangeForHistory: DateRange = { start: todayStart, end: new Date() } - const allTodayDaysForHistory = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRangeForHistory, 'all'))) - const todayStrForHistory = toDateString(todayStart) - const fullHistory = [...allCacheDays, ...allTodayDaysForHistory.filter(d => d.date === todayStrForHistory)] - const dailyHistory = fullHistory.map(d => { - if (isAllProviders) { - const topModels = Object.entries(d.models) - .filter(([name]) => name !== '') - .sort(([, a], [, b]) => b.cost - a.cost) - .slice(0, 5) - .map(([name, m]) => ({ - name, - cost: m.cost, - calls: m.calls, - inputTokens: m.inputTokens, - outputTokens: m.outputTokens, - })) - return { - date: d.date, - cost: d.cost, - calls: d.calls, - inputTokens: d.inputTokens, - outputTokens: d.outputTokens, - cacheReadTokens: d.cacheReadTokens, - cacheWriteTokens: d.cacheWriteTokens, - topModels, - } - } - const prov = d.providers[pf] ?? { calls: 0, cost: 0 } - return { - date: d.date, - cost: prov.cost, - calls: prov.calls, - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - topModels: [], - } - }) - - const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange) - console.log(JSON.stringify(buildMenubarPayload(currentData, providers, optimize, dailyHistory))) - return - } - - if (opts.format === 'json') { - await hydrateCache() - const todayData = buildPeriodData('today', fp(await parseAllSessions(getDateRange('today').range, pf))) - const monthData = buildPeriodData('month', fp(await parseAllSessions(getDateRange('month').range, pf))) - const { code, rate } = getCurrency() - const payload: { - currency: string - today: { cost: number; calls: number } - month: { cost: number; calls: number } - plan?: JsonPlanSummary - } = { - currency: code, - today: { cost: Math.round(todayData.cost * rate * 100) / 100, calls: todayData.calls }, - month: { cost: Math.round(monthData.cost * rate * 100) / 100, calls: monthData.calls }, - } - const planUsage = await getPlanUsageOrNull() - if (planUsage) { - payload.plan = toJsonPlanSummary(planUsage) - } - console.log(JSON.stringify(payload)) - return - } - - await hydrateCache() - const monthProjects = fp(await parseAllSessions(getDateRange('month').range, pf)) - console.log(renderStatusBar(monthProjects)) - }) - -program - .command('today') - .description('Today\'s usage dashboard') - .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') - .option('--format ', 'Output format: tui, json', 'tui') - .option('--project ', 'Show only projects matching name (repeatable)', collect, []) - .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) - .option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInt, 30) - .action(async (opts) => { - if (opts.format === 'json') { - await runJsonReport('today', opts.provider, opts.project, opts.exclude) - return - } - await hydrateCache() - await renderDashboard('today', opts.provider, opts.refresh, opts.project, opts.exclude) - }) - -program - .command('month') - .description('This month\'s usage dashboard') - .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') - .option('--format ', 'Output format: tui, json', 'tui') - .option('--project ', 'Show only projects matching name (repeatable)', collect, []) - .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) - .option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInt, 30) - .action(async (opts) => { - if (opts.format === 'json') { - await runJsonReport('month', opts.provider, opts.project, opts.exclude) - return - } - await hydrateCache() - await renderDashboard('month', opts.provider, opts.refresh, opts.project, opts.exclude) - }) - -program - .command('export') - .description('Export usage data to CSV or JSON (includes 1 day, 7 days, 30 days)') - .option('-f, --format ', 'Export format: csv, json', 'csv') - .option('-o, --output ', 'Output file path') - .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') - .option('--project ', 'Show only projects matching name (repeatable)', collect, []) - .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) - .action(async (opts) => { - await loadPricing() - await hydrateCache() - const pf = opts.provider - const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude) - const periods: PeriodExport[] = [ - { label: 'Today', projects: fp(await parseAllSessions(getDateRange('today').range, pf)) }, - { label: '7 Days', projects: fp(await parseAllSessions(getDateRange('week').range, pf)) }, - { label: '30 Days', projects: fp(await parseAllSessions(getDateRange('30days').range, pf)) }, - ] - - if (periods.every(p => p.projects.length === 0)) { - console.log('\n No usage data found.\n') - return - } - - const defaultName = `codeburn-${toDateString(new Date())}` - const outputPath = opts.output ?? `${defaultName}.${opts.format}` - - let savedPath: string - try { - if (opts.format === 'json') { - savedPath = await exportJson(periods, outputPath) - } else { - savedPath = await exportCsv(periods, outputPath) - } - } catch (err) { - // Protection guards in export.ts (symlink refusal, non-codeburn folder refusal, etc.) - // throw with a user-readable message. Print just the message, not the stack, so the CLI - // doesn't spray its internals at the user. - const message = err instanceof Error ? err.message : String(err) - console.error(`\n Export failed: ${message}\n`) - process.exit(1) - } - - console.log(`\n Exported (Today + 7 Days + 30 Days) to: ${savedPath}\n`) - }) - -program - .command('menubar') - .description('Install and launch the macOS menubar app (one command, no clone)') - .option('--force', 'Reinstall even if an older copy is already in ~/Applications') - .action(async (opts: { force?: boolean }) => { - try { - const result = await installMenubarApp({ force: opts.force }) - console.log(`\n Ready. ${result.installedPath}\n`) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - console.error(`\n Menubar install failed: ${message}\n`) - process.exit(1) - } - }) - -program - .command('currency [code]') - .description('Set display currency (e.g. codeburn currency GBP)') - .option('--symbol ', 'Override the currency symbol') - .option('--reset', 'Reset to USD (removes currency config)') - .action(async (code?: string, opts?: { symbol?: string; reset?: boolean }) => { - if (opts?.reset) { - const config = await readConfig() - delete config.currency - await saveConfig(config) - console.log('\n Currency reset to USD.\n') - return - } - - if (!code) { - const { code: activeCode, rate, symbol } = getCurrency() - if (activeCode === 'USD' && rate === 1) { - console.log('\n Currency: USD (default)') - console.log(` Config: ${getConfigFilePath()}\n`) - } else { - console.log(`\n Currency: ${activeCode}`) - console.log(` Symbol: ${symbol}`) - console.log(` Rate: 1 USD = ${rate} ${activeCode}`) - console.log(` Config: ${getConfigFilePath()}\n`) - } - return - } - - const upperCode = code.toUpperCase() - if (!isValidCurrencyCode(upperCode)) { - console.error(`\n "${code}" is not a valid ISO 4217 currency code.\n`) - process.exitCode = 1 - return - } - - const config = await readConfig() - config.currency = { - code: upperCode, - ...(opts?.symbol ? { symbol: opts.symbol } : {}), - } - await saveConfig(config) - - await loadCurrency() - const { rate, symbol } = getCurrency() - - console.log(`\n Currency set to ${upperCode}.`) - console.log(` Symbol: ${symbol}`) - console.log(` Rate: 1 USD = ${rate} ${upperCode}`) - console.log(` Config saved to ${getConfigFilePath()}\n`) - }) - -program - .command('model-alias [from] [to]') - .description('Map a provider model name to a canonical one for pricing (e.g. codeburn model-alias my-model claude-opus-4-6)') - .option('--remove ', 'Remove an alias') - .option('--list', 'List configured aliases') - .action(async (from?: string, to?: string, opts?: { remove?: string; list?: boolean }) => { - const config = await readConfig() - const aliases = config.modelAliases ?? {} - - if (opts?.list || (!from && !opts?.remove)) { - const entries = Object.entries(aliases) - if (entries.length === 0) { - console.log('\n No model aliases configured.') - console.log(` Config: ${getConfigFilePath()}\n`) - } else { - console.log('\n Model aliases:') - for (const [src, dst] of entries) { - console.log(` ${src} -> ${dst}`) - } - console.log(` Config: ${getConfigFilePath()}\n`) - } - return - } - - if (opts?.remove) { - if (!(opts.remove in aliases)) { - console.error(`\n Alias not found: ${opts.remove}\n`) - process.exitCode = 1 - return - } - delete aliases[opts.remove] - config.modelAliases = Object.keys(aliases).length > 0 ? aliases : undefined - await saveConfig(config) - console.log(`\n Removed alias: ${opts.remove}\n`) - return - } - - if (!from || !to) { - console.error('\n Usage: codeburn model-alias \n') - process.exitCode = 1 - return - } - - aliases[from] = to - config.modelAliases = aliases - await saveConfig(config) - console.log(`\n Alias saved: ${from} -> ${to}`) - console.log(` Config: ${getConfigFilePath()}\n`) - }) - -program - .command('plan [action] [id]') - .description('Show or configure a subscription plan for overage tracking') - .option('--format ', 'Output format: text or json', 'text') - .option('--monthly-usd ', 'Monthly plan price in USD (for custom)', parseNumber) - .option('--provider ', 'Provider scope: all, claude, codex, cursor', 'all') - .option('--reset-day ', 'Day of month plan resets (1-28)', parseInteger, 1) - .action(async (action?: string, id?: string, opts?: { format?: string; monthlyUsd?: number; provider?: string; resetDay?: number }) => { - const mode = action ?? 'show' - - if (mode === 'show') { - const plan = await readPlan() - const displayPlan = !plan || plan.id === 'none' - ? { id: 'none', monthlyUsd: 0, provider: 'all', resetDay: 1, setAt: null } - : { - id: plan.id, - monthlyUsd: plan.monthlyUsd, - provider: plan.provider, - resetDay: clampResetDay(plan.resetDay), - setAt: plan.setAt, - } - if (opts?.format === 'json') { - console.log(JSON.stringify(displayPlan)) - return - } - if (!plan || plan.id === 'none') { - console.log('\n Plan: none') - console.log(' API-pricing view is active.') - console.log(` Config: ${getConfigFilePath()}\n`) - return - } - console.log(`\n Plan: ${planDisplayName(plan.id)} (${plan.id})`) - console.log(` Budget: $${plan.monthlyUsd}/month`) - console.log(` Provider: ${plan.provider}`) - console.log(` Reset day: ${clampResetDay(plan.resetDay)}`) - console.log(` Set at: ${plan.setAt}`) - console.log(` Config: ${getConfigFilePath()}\n`) - return - } - - if (mode === 'reset') { - await clearPlan() - console.log('\n Plan reset. API-pricing view is active.\n') - return - } - - if (mode !== 'set') { - console.error('\n Usage: codeburn plan [set | reset]\n') - process.exitCode = 1 - return - } - - if (!id || !isPlanId(id)) { - console.error(`\n Plan id must be one of: claude-pro, claude-max, cursor-pro, custom, none; got "${id ?? ''}".\n`) - process.exitCode = 1 - return - } - - const resetDay = opts?.resetDay ?? 1 - if (!Number.isInteger(resetDay) || resetDay < 1 || resetDay > 28) { - console.error(`\n --reset-day must be an integer from 1 to 28; got ${resetDay}.\n`) - process.exitCode = 1 - return - } - - if (id === 'none') { - await clearPlan() - console.log('\n Plan reset. API-pricing view is active.\n') - return - } - - if (id === 'custom') { - if (opts?.monthlyUsd === undefined) { - console.error('\n Custom plans require --monthly-usd .\n') - process.exitCode = 1 - return - } - const monthlyUsd = opts.monthlyUsd - if (!Number.isFinite(monthlyUsd) || monthlyUsd <= 0) { - console.error(`\n --monthly-usd must be a positive number; got ${opts.monthlyUsd}.\n`) - process.exitCode = 1 - return - } - const provider = opts?.provider ?? 'all' - if (!isPlanProvider(provider)) { - console.error(`\n --provider must be one of: all, claude, codex, cursor; got "${provider}".\n`) - process.exitCode = 1 - return - } - await savePlan({ - id: 'custom', - monthlyUsd, - provider, - resetDay, - setAt: new Date().toISOString(), - }) - console.log(`\n Plan set to custom ($${monthlyUsd}/month, ${provider}, reset day ${resetDay}).`) - console.log(` Config saved to ${getConfigFilePath()}\n`) - return - } - - const preset = getPresetPlan(id) - if (!preset) { - console.error(`\n Unknown preset "${id}".\n`) - process.exitCode = 1 - return - } - - await savePlan({ - ...preset, - resetDay, - setAt: new Date().toISOString(), - }) - console.log(`\n Plan set to ${planDisplayName(preset.id)} ($${preset.monthlyUsd}/month).`) - console.log(` Provider: ${preset.provider}`) - console.log(` Reset day: ${resetDay}`) - console.log(` Config saved to ${getConfigFilePath()}\n`) - }) - -program - .command('optimize') - .description('Find token waste and get exact fixes') - .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', '30days') - .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') - .action(async (opts) => { - await loadPricing() - await hydrateCache() - const { range, label } = getDateRange(opts.period) - const projects = await parseAllSessions(range, opts.provider) - await runOptimize(projects, label, range) - }) - -program - .command('compare') - .description('Compare two AI models side-by-side') - .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', 'all') - .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') - .action(async (opts) => { - await loadPricing() - await hydrateCache() - const { range } = getDateRange(opts.period) - await renderCompare(range, opts.provider) - }) - -program - .command('yield') - .description('Track which AI spend shipped to main vs reverted/abandoned (experimental)') - .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', 'week') - .action(async (opts) => { - const { computeYield, formatYieldSummary } = await import('./yield.js') - await loadPricing() - await hydrateCache() - const { range, label } = getDateRange(opts.period) - console.log(`\n Analyzing yield for ${label}...\n`) - const summary = await computeYield(range, process.cwd()) - console.log(formatYieldSummary(summary)) - }) - -program.parse() diff --git a/src/codex-cache.ts b/src/codex-cache.ts new file mode 100644 index 0000000..d408cb5 --- /dev/null +++ b/src/codex-cache.ts @@ -0,0 +1,143 @@ +import { readFile, mkdir, stat, open, rename, unlink } from 'fs/promises' +import { existsSync } from 'fs' +import { randomBytes } from 'crypto' +import { join } from 'path' +import { homedir } from 'os' + +import type { ParsedProviderCall } from './providers/types.js' + +const CODEX_CACHE_VERSION = 1 +const CACHE_FILE = 'codex-results.json' + +type FileFingerprint = { mtimeMs: number; sizeBytes: number } + +type FileEntry = { + mtimeMs: number + sizeBytes: number + project: string + calls: ParsedProviderCall[] +} + +type ResultCache = { + version: number + files: Record +} + +function getCacheDir(): string { + return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn') +} + +function getCachePath(): string { + return join(getCacheDir(), CACHE_FILE) +} + +let memCache: ResultCache | null = null + +async function loadCache(): Promise { + if (memCache) return memCache + try { + const raw = await readFile(getCachePath(), 'utf-8') + const cache = JSON.parse(raw) as ResultCache + if (cache.version === CODEX_CACHE_VERSION && cache.files && typeof cache.files === 'object') { + memCache = cache + return cache + } + } catch {} + memCache = { version: CODEX_CACHE_VERSION, files: {} } + return memCache +} + +function getEntry(cache: ResultCache, filePath: string, fp: FileFingerprint): FileEntry | null { + if (!Object.hasOwn(cache.files, filePath)) return null + const entry = cache.files[filePath] + if (entry && entry.mtimeMs === fp.mtimeMs && entry.sizeBytes === fp.sizeBytes) { + return entry + } + return null +} + +export async function readCachedCodexResults( + filePath: string, +): Promise { + try { + const s = await stat(filePath) + const cache = await loadCache() + const entry = getEntry(cache, filePath, { mtimeMs: s.mtimeMs, sizeBytes: s.size }) + return entry?.calls ?? null + } catch {} + return null +} + +export async function getCachedCodexProject( + filePath: string, +): Promise { + try { + const s = await stat(filePath) + const cache = await loadCache() + const entry = getEntry(cache, filePath, { mtimeMs: s.mtimeMs, sizeBytes: s.size }) + return entry?.project ?? null + } catch {} + return null +} + +export async function fingerprintFile( + filePath: string, +): Promise { + try { + const s = await stat(filePath) + return { mtimeMs: s.mtimeMs, sizeBytes: s.size } + } catch { + return null + } +} + +export async function writeCachedCodexResults( + filePath: string, + project: string, + calls: ParsedProviderCall[], + fingerprint: FileFingerprint, +): Promise { + try { + const cache = await loadCache() + cache.files[filePath] = { + mtimeMs: fingerprint.mtimeMs, + sizeBytes: fingerprint.sizeBytes, + project, + calls, + } + } catch {} +} + +export async function flushCodexCache(): Promise { + if (!memCache) return + try { + // Evict entries for files that no longer exist on disk + const paths = Object.keys(memCache.files) + for (const p of paths) { + try { + await stat(p) + } catch { + delete memCache.files[p] + } + } + + const dir = getCacheDir() + if (!existsSync(dir)) await mkdir(dir, { recursive: true }) + const finalPath = getCachePath() + const tempPath = `${finalPath}.${randomBytes(8).toString('hex')}.tmp` + const payload = JSON.stringify(memCache) + const handle = await open(tempPath, 'w', 0o600) + try { + await handle.writeFile(payload, { encoding: 'utf-8' }) + await handle.sync() + } finally { + await handle.close() + } + try { + await rename(tempPath, finalPath) + } catch (err) { + try { await unlink(tempPath) } catch {} + throw err + } + } catch {} +} diff --git a/src/compare.tsx b/src/compare.tsx index af1d147..2fd71d4 100644 --- a/src/compare.tsx +++ b/src/compare.tsx @@ -7,6 +7,7 @@ import { formatCost } from './format.js' import { parseAllSessions } from './parser.js' import { getAllProviders } from './providers/index.js' import type { ProjectSummary, DateRange } from './types.js' +import { patchStdoutForWindows } from './ink-win.js' const ORANGE = '#FF8C42' const GREEN = '#5BF5A0' @@ -330,16 +331,40 @@ export function CompareView({ projects, onBack }: CompareViewProps) { const newModels = aggregateModelStats(projects) setModels(newModels) - if (pickedNames) { - const hasA = newModels.some(m => m.model === pickedNames[0]) - const hasB = newModels.some(m => m.model === pickedNames[1]) - if (hasA && hasB) { - setLoadTrigger(t => t + 1) - } else { - setPickedNames(null) - setPhase('select') - } + if (!pickedNames) return + const hasA = newModels.some(m => m.model === pickedNames[0]) + const hasB = newModels.some(m => m.model === pickedNames[1]) + if (!hasA || !hasB) { + setPickedNames(null) + setPhase('select') + return } + + // When the periodic CLI refresh updates `projects` while the user is + // reading the results page, recompute the comparison rows IN PLACE rather + // than flipping to a loading screen. Previously every 30s tick bounced the + // user to a loading flash and reset their scroll position; the slow part + // (scanSelfCorrections, which walks every provider's session dir) is + // skipped on these refreshes — corrections drift slowly enough that + // staying with the existing values until the user re-enters compare from + // scratch is fine. + if (phase === 'results') { + const a = newModels.find(m => m.model === pickedNames[0]) + const b = newModels.find(m => m.model === pickedNames[1]) + if (!a || !b) return + const aCopy = { ...a, selfCorrections: selectedA?.selfCorrections ?? 0 } + const bCopy = { ...b, selfCorrections: selectedB?.selfCorrections ?? 0 } + setSelectedA(aCopy) + setSelectedB(bCopy) + setRows(computeComparison(aCopy, bCopy)) + setCategories(computeCategoryComparison(projects, a.model, b.model)) + setStyle(computeWorkingStyle(projects, a.model, b.model)) + return + } + + // Initial load (or returning from select after picking) — full pipeline, + // including scanSelfCorrections. + setLoadTrigger(t => t + 1) }, [projects]) useEffect(() => { @@ -448,6 +473,7 @@ export async function renderCompare(range: DateRange, provider: string): Promise return } + patchStdoutForWindows() const projects = await parseAllSessions(range, provider) const { waitUntilExit } = render( process.exit(0)} /> diff --git a/src/config.ts b/src/config.ts index 47a2b50..12fec8f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,7 @@ import { readFile, writeFile, mkdir, rename } from 'fs/promises' import { join } from 'path' import { homedir } from 'os' +import { randomBytes } from 'crypto' export type PlanId = 'claude-pro' | 'claude-max' | 'claude-max-5x' | 'cursor-pro' | 'custom' | 'none' export type PlanProvider = 'claude' | 'codex' | 'cursor' | 'all' @@ -42,7 +43,11 @@ export async function readConfig(): Promise { export async function saveConfig(config: CodeburnConfig): Promise { await mkdir(getConfigDir(), { recursive: true }) const configPath = getConfigPath() - const tmpPath = `${configPath}.tmp` + // Randomize the temp path so two simultaneous saveConfig calls (from + // overlapping menubar + CLI runs, for example) do not race on the same + // staging file. The previous fixed `.tmp` suffix could leave one + // process reading partial bytes the other was mid-writing. + const tmpPath = `${configPath}.${randomBytes(8).toString('hex')}.tmp` await writeFile(tmpPath, JSON.stringify(config, null, 2) + '\n', 'utf-8') await rename(tmpPath, configPath) } diff --git a/src/currency.ts b/src/currency.ts index 8788e07..92f0364 100644 --- a/src/currency.ts +++ b/src/currency.ts @@ -47,13 +47,24 @@ function resolveSymbol(code: string): string { return parts.find(p => p.type === 'currency')?.value ?? code } -function getFractionDigits(code: string): number { +export function getFractionDigits(code: string): number { return new Intl.NumberFormat('en', { style: 'currency', currency: code, }).resolvedOptions().maximumFractionDigits ?? 2 } +/// Round a converted cost to the currency's natural decimal places. JPY/KRW/CLP +/// resolve to 0 fraction digits — exporting those with `round2` produced rows +/// like `¥412.37` while the dashboard rendered `¥412`, breaking finance reports +/// that compare the two surfaces. +export function roundForActiveCurrency(value: number): number { + const code = getCurrency().code + const digits = getFractionDigits(code) + const factor = Math.pow(10, digits) + return Math.round(value * factor) / factor +} + function getCacheDir(): string { return join(homedir(), '.cache', 'codeburn') } @@ -98,13 +109,19 @@ async function getExchangeRate(code: string): Promise { const cached = await loadCachedRate(code) if (cached) return cached + let rate: number try { - const rate = await fetchRate(code) - await cacheRate(code, rate) - return rate + rate = await fetchRate(code) } catch { return 1 } + // Persist the rate, but never let a cache-write failure (disk full, no + // permissions, etc.) cause us to return the USD-equivalent fallback. + // The original code wrapped fetch + cacheRate in one try/catch, so a + // disk-full at write time would discard a perfectly good rate and silently + // make every cost render as if the user had selected USD. + cacheRate(code, rate).catch(() => {}) + return rate } export async function loadCurrency(): Promise { @@ -137,9 +154,13 @@ export function getCostColumnHeader(): string { } export function convertCost(costUSD: number): number { - const digits = getFractionDigits(active.code) - const factor = 10 ** digits - return Math.round(costUSD * active.rate * factor) / factor + // Return the unrounded converted cost. Rounding here meant zero-fraction + // currencies (JPY, KRW, CLP) clamped every per-session cost to the nearest + // whole unit before aggregation; a project with 1000 sessions averaging + // ¥0.4 each would aggregate to ¥0 instead of ¥400 because each row was + // rounded independently. formatCost (and the export rowsToCsv path) round + // at the display boundary instead. + return costUSD * active.rate } export function formatCost(costUSD: number): string { @@ -151,5 +172,6 @@ export function formatCost(costUSD: number): string { if (cost >= 1) return `${symbol}${cost.toFixed(2)}` if (cost >= 0.01) return `${symbol}${cost.toFixed(3)}` - return `${symbol}${cost.toFixed(4)}` + if (cost >= 0.0001) return `${symbol}${cost.toFixed(4)}` + return `${symbol}${cost.toFixed(2)}` } diff --git a/src/cursor-cache.ts b/src/cursor-cache.ts index 62cc394..390dcfa 100644 --- a/src/cursor-cache.ts +++ b/src/cursor-cache.ts @@ -1,10 +1,17 @@ -import { readFile, writeFile, mkdir, stat } from 'fs/promises' +import { readFile, writeFile, mkdir, rename, stat, unlink } from 'fs/promises' import { join } from 'path' import { homedir } from 'os' +import { randomBytes } from 'crypto' import type { ParsedProviderCall } from './providers/types.js' -const CURSOR_CACHE_VERSION = 2 +// Bumped to 3 for the workspace-aware breakdown change: the cursor parser +// now derives `sessionId` from the bubble row key (the real composer id) +// rather than the empty `conversationId` JSON field, and the workspace +// router relies on those composer ids to bucket calls per project. +// Version 2 caches contain `sessionId: 'unknown'` for every call and would +// route everything to the orphan project, so we invalidate them. +const CURSOR_CACHE_VERSION = 3 type ResultCache = { version?: number @@ -50,18 +57,30 @@ export async function readCachedResults(dbPath: string): Promise { - try { - const fp = await getDbFingerprint(dbPath) - if (!fp) return + const fp = await getDbFingerprint(dbPath) + if (!fp) return - const dir = getCacheDir() - await mkdir(dir, { recursive: true }) - const cache: ResultCache = { - version: CURSOR_CACHE_VERSION, - dbMtimeMs: fp.mtimeMs, - dbSizeBytes: fp.size, - calls, - } - await writeFile(getCachePath(), JSON.stringify(cache), 'utf-8') - } catch {} + const dir = getCacheDir() + await mkdir(dir, { recursive: true }).catch(() => {}) + const cache: ResultCache = { + version: CURSOR_CACHE_VERSION, + dbMtimeMs: fp.mtimeMs, + dbSizeBytes: fp.size, + calls, + } + + // Atomic write: stage to a randomized temp file in the same directory, + // then rename onto the final path. rename() is atomic on POSIX, so a + // crash mid-write never leaves a half-written cache, and concurrent + // CLI invocations using their own random temp names cannot interleave + // bytes in the destination file (they only race on the final rename, + // last-writer-wins, both with valid content). + const target = getCachePath() + const tempPath = `${target}.${randomBytes(8).toString('hex')}.tmp` + try { + await writeFile(tempPath, JSON.stringify(cache), 'utf-8') + await rename(tempPath, target) + } catch { + await unlink(tempPath).catch(() => {}) + } } diff --git a/src/daily-cache.ts b/src/daily-cache.ts index 6e30727..ab43017 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -5,8 +5,19 @@ import { homedir } from 'os' import { join } from 'path' import type { DateRange, ProjectSummary } from './types.js' -export const DAILY_CACHE_VERSION = 4 -const MIN_SUPPORTED_VERSION = 2 +// Bumped to 6 alongside the Claude 1-hour cache-write pricing fix: prior +// daily entries priced all Claude cache writes at the 5-minute rate, so +// cached historical cost/model/provider/category totals would remain +// under-reported unless discarded and recomputed from raw sessions. +export const DAILY_CACHE_VERSION = 6 +// MIN_SUPPORTED_VERSION bumped to 6 too. The migration path +// (isMigratableCache + migrateDays) only fills in missing default fields; +// it does NOT recompute the providers / categories / models rollups from +// session data, because those raw sessions are not stored in the cache. +// So a migrated v5 cache would carry forward stale pricing totals for +// the full cache retention window. Setting the floor to 6 forces older +// caches to be discarded and recomputed cleanly. +const MIN_SUPPORTED_VERSION = 6 const DAILY_CACHE_FILENAME = 'daily-cache.json' export type DailyEntry = { @@ -133,10 +144,24 @@ export function addNewDays(cache: DailyCache, incoming: DailyEntry[], newestDate byDate.set(day.date, day) } const merged = Array.from(byDate.values()).sort((a, b) => a.date.localeCompare(b.date)) + // Prune entries older than the BACKFILL window so the cache file does not + // grow unbounded over years of daily use. The "all time" / 6-month period + // and the BACKFILL_DAYS bootstrap both fit comfortably inside this cap. + // Anchor the cap on the newestDate boundary so a stale or stuck clock + // can't accidentally evict everything. Skip the prune entirely if + // newestDate is malformed — an invalid Date would produce a NaN cutoff + // and `d.date >= "Invalid Date"` would silently drop every entry. + const cutoffDate = new Date(`${newestDate}T00:00:00Z`) + let pruned = merged + if (!isNaN(cutoffDate.getTime())) { + cutoffDate.setUTCDate(cutoffDate.getUTCDate() - DAILY_CACHE_RETENTION_DAYS) + const cutoff = toDateString(cutoffDate) + pruned = merged.filter(d => d.date >= cutoff) + } const nextLast = cache.lastComputedDate && cache.lastComputedDate > newestDate ? cache.lastComputedDate : newestDate - return { version: DAILY_CACHE_VERSION, lastComputedDate: nextLast, days: merged } + return { version: DAILY_CACHE_VERSION, lastComputedDate: nextLast, days: pruned } } export function getDaysInRange(cache: DailyCache, start: string, end: string): DailyEntry[] { @@ -153,6 +178,10 @@ export function withDailyCacheLock(fn: () => Promise): Promise { export const MS_PER_DAY = 24 * 60 * 60 * 1000 export const BACKFILL_DAYS = 365 +// Keep 2 years of history so the longest UI-exposed period (6 months +// today, with headroom for future longer windows) always reads from +// cache while old entries get pruned. +export const DAILY_CACHE_RETENTION_DAYS = 730 export function toDateString(date: Date): string { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` @@ -165,7 +194,7 @@ export async function ensureCacheHydrated( const now = new Date() const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const yesterdayEnd = new Date(todayStart.getTime() - 1) - const yesterdayStr = toDateString(new Date(todayStart.getTime() - MS_PER_DAY)) + const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)) return withDailyCacheLock(async () => { let c = await loadDailyCache() @@ -183,7 +212,7 @@ export async function ensureCacheHydrated( parseInt(c.lastComputedDate.slice(5, 7)) - 1, parseInt(c.lastComputedDate.slice(8, 10)) + 1 ) - : new Date(todayStart.getTime() - BACKFILL_DAYS * MS_PER_DAY) + : new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS) if (gapStart.getTime() <= yesterdayEnd.getTime()) { const gapRange: DateRange = { start: gapStart, end: yesterdayEnd } diff --git a/src/dashboard.tsx b/src/dashboard.tsx index f84254d..4add882 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -4,34 +4,27 @@ import React, { useState, useCallback, useEffect, useRef } from 'react' import { render, Box, Text, useInput, useApp, useWindowSize } from 'ink' import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js' import { formatCost, formatTokens } from './format.js' +import { aggregateModelEfficiency } from './model-efficiency.js' import { parseAllSessions, filterProjectsByName } from './parser.js' import { loadPricing } from './models.js' import { getAllProviders } from './providers/index.js' import { scanAndDetect, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js' -import { estimateContextBudget, discoverProjectCwd, type ContextBudget } from './context-budget.js' +import { estimateContextBudget, type ContextBudget } from './context-budget.js' import { dateKey } from './day-aggregator.js' import { CompareView } from './compare.js' import { getPlanUsageOrNull, type PlanUsage } from './plan-usage.js' import { planDisplayName } from './plans.js' -import { join } from 'path' +import { getDateRange, PERIODS, PERIOD_LABELS, type Period, formatDateRangeLabel } from './cli-date.js' +import { patchStdoutForWindows } from './ink-win.js' -type Period = 'today' | 'week' | '30days' | 'month' | 'all' type View = 'dashboard' | 'optimize' | 'compare' -const PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all'] -const PERIOD_LABELS: Record = { - today: 'Today', - week: '7 Days', - '30days': '30 Days', - month: 'This Month', - all: 'All Time', -} - const MIN_WIDE = 90 const ORANGE = '#FF8C42' const DIM = '#555555' const GOLD = '#FFD700' const PLAN_BAR_WIDTH = 10 +const HEAVY_PERIODS = new Set(['30days', 'month', 'all']) const LANG_DISPLAY_NAMES: Record = { javascript: 'JavaScript', typescript: 'TypeScript', python: 'Python', @@ -59,8 +52,10 @@ const PROVIDER_COLORS: Record = { claude: '#FF8C42', codex: '#5BF5A0', cursor: '#00B4D8', + 'ibm-bob': '#0F62FE', opencode: '#A78BFA', pi: '#F472B6', + kimi: '#B6E34A', all: '#FF8C42', } @@ -103,16 +98,16 @@ function gradientColor(pct: number): string { return toHex(lerp(255, 245, t), lerp(140, 91, t), lerp(66, 91, t)) } -function getDateRange(period: Period): { start: Date; end: Date } { - const now = new Date() - const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999) - switch (period) { - case 'today': return { start: new Date(now.getFullYear(), now.getMonth(), now.getDate()), end } - case 'week': return { start: new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7), end } - case '30days': return { start: new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30), end } - case 'month': return { start: new Date(now.getFullYear(), now.getMonth(), 1), end } - case 'all': return { start: new Date(0), end } - } +function getPeriodRange(period: Period): { start: Date; end: Date } { + return getDateRange(period).range +} + +function isHeavyPeriod(period: Period): boolean { + return HEAVY_PERIODS.has(period) +} + +function nextTick(): Promise { + return new Promise(resolve => setImmediate(resolve)) } type Layout = { dashWidth: number; wide: boolean; halfWidth: number; barWidth: number } @@ -262,16 +257,19 @@ function DailyActivity({ projects, days = 14, pw, bw }: { projects: ProjectSumma ) } -const _homeEncoded = homedir().replace(/\//g, '-') +const _home = homedir() +const _homePrefix = _home.endsWith('/') ? _home : _home + '/' -function shortProject(encoded: string): string { - let path = encoded.replace(/^-/, '') - if (path.startsWith(_homeEncoded.replace(/^-/, ''))) { - path = path.slice(_homeEncoded.replace(/^-/, '').length).replace(/^-/, '') - } - path = path.replace(/^private-tmp-[^-]+-[^-]+-/, '').replace(/^private-tmp-/, '').replace(/^tmp-/, '') +export function shortProject(absPath: string): string { + const normalized = absPath.replace(/\\/g, '/') + let path: string + if (normalized === _home) path = '' + else if (normalized.startsWith(_homePrefix)) path = normalized.slice(_homePrefix.length) + else path = normalized + path = path.replace(/^\/+/, '') + path = path.replace(/^private\/tmp\/[^/]+\/[^/]+\//, '').replace(/^private\/tmp\//, '').replace(/^tmp\//, '') if (!path) return 'home' - const parts = path.split('-').filter(Boolean) + const parts = path.split('/').filter(Boolean) if (parts.length <= 3) return parts.join('/') return parts.slice(-3).join('/') } @@ -297,7 +295,7 @@ function ProjectBreakdown({ projects, pw, bw, budgets }: { projects: ProjectSumm return ( - {fit(shortProject(project.project), nw)} + {fit(shortProject(project.projectPath), nw)} {formatCost(project.totalCostUSD).padStart(8)} {avgCost.padStart(PROJECT_COL_AVG)} {String(project.sessions.length).padStart(6)} @@ -312,10 +310,13 @@ function ProjectBreakdown({ projects, pw, bw, budgets }: { projects: ProjectSumm const MODEL_COL_COST = 8 const MODEL_COL_CACHE = 7 const MODEL_COL_CALLS = 7 +const MODEL_COL_ONESHOT = 7 const MODEL_NAME_WIDTH = 14 +const MIN_EDIT_TURNS_FOR_RATE = 5 function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { const modelTotals: Record = {} + const modelEfficiency = aggregateModelEfficiency(projects) for (const project of projects) { for (const session of project.sessions) { for (const [model, data] of Object.entries(session.modelBreakdown)) { @@ -333,11 +334,15 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: return ( - {''.padEnd(bw + 1 + MODEL_NAME_WIDTH)}{'cost'.padStart(MODEL_COL_COST)}{'cache'.padStart(MODEL_COL_CACHE)}{'calls'.padStart(MODEL_COL_CALLS)} + {''.padEnd(bw + 1 + MODEL_NAME_WIDTH)}{'cost'.padStart(MODEL_COL_COST)}{'cache'.padStart(MODEL_COL_CACHE)}{'calls'.padStart(MODEL_COL_CALLS)}{'1-shot'.padStart(MODEL_COL_ONESHOT)} {sorted.map(([model, data], i) => { const totalInput = data.freshInput + data.cacheRead + data.cacheWrite const cacheHit = totalInput > 0 ? (data.cacheRead / totalInput) * 100 : 0 const cacheLabel = totalInput > 0 ? `${cacheHit.toFixed(1)}%` : '-' + const efficiency = modelEfficiency.get(model) + const oneShotLabel = efficiency && efficiency.editTurns >= MIN_EDIT_TURNS_FOR_RATE && efficiency.oneShotRate !== null + ? `${efficiency.oneShotRate.toFixed(1)}%` + : '-' return ( @@ -345,6 +350,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: {formatCost(data.costUSD).padStart(MODEL_COL_COST)} {cacheLabel.padStart(MODEL_COL_CACHE)} {String(data.calls).padStart(MODEL_COL_CALLS)} + {oneShotLabel.padStart(MODEL_COL_ONESHOT)} ) })} @@ -352,8 +358,11 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: ) } +const SKILL_SUB_ROWS_LIMIT = 5 + function ActivityBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { const categoryTotals: Record = {} + const skillTotals: Record = {} for (const project of projects) { for (const session of project.sessions) { for (const [cat, data] of Object.entries(session.categoryBreakdown)) { @@ -363,24 +372,47 @@ function ActivityBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; p categoryTotals[cat].editTurns += data.editTurns categoryTotals[cat].oneShotTurns += data.oneShotTurns } + for (const [skill, data] of Object.entries(session.skillBreakdown ?? {})) { + if (!skillTotals[skill]) skillTotals[skill] = { turns: 0, costUSD: 0, editTurns: 0, oneShotTurns: 0 } + skillTotals[skill].turns += data.turns + skillTotals[skill].costUSD += data.costUSD + skillTotals[skill].editTurns += data.editTurns + skillTotals[skill].oneShotTurns += data.oneShotTurns + } } } const sorted = Object.entries(categoryTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD) + const sortedSkills = Object.entries(skillTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD).slice(0, SKILL_SUB_ROWS_LIMIT) const maxCost = sorted[0]?.[1]?.costUSD ?? 0 return ( {''.padEnd(bw + 14)}{'cost'.padStart(8)}{'turns'.padStart(6)}{'1-shot'.padStart(7)} - {sorted.map(([cat, data]) => { + {sorted.flatMap(([cat, data]) => { const oneShotPct = data.editTurns > 0 ? Math.round((data.oneShotTurns / data.editTurns) * 100) + '%' : '-' - return ( + const rows = [ {fit(CATEGORY_LABELS[cat as TaskCategory] ?? cat, 13)} {formatCost(data.costUSD).padStart(8)} {String(data.turns).padStart(6)} {String(oneShotPct).padStart(7)} - - ) + , + ] + if (cat === 'general' && sortedSkills.length > 0) { + for (const [skill, sd] of sortedSkills) { + const subPct = sd.editTurns > 0 ? Math.round((sd.oneShotTurns / sd.editTurns) * 100) + '%' : '-' + rows.push( + + + {fit(` /${skill}`, 13)} + {formatCost(sd.costUSD).padStart(8)} + {String(sd.turns).padStart(6)} + {String(subPct).padStart(7)} + , + ) + } + } + return rows })} ) @@ -423,7 +455,7 @@ const TOP_SESSIONS_CALLS_COL = 6 function TopSessions({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { const allSessions = projects.flatMap(p => - p.sessions.map(s => ({ ...s, projectName: p.project })) + p.sessions.map(s => ({ ...s, projectPath: p.projectPath })) ) const top = [...allSessions].sort((a, b) => b.totalCostUSD - a.totalCostUSD).slice(0, 5) @@ -441,7 +473,7 @@ function TopSessions({ projects, pw, bw }: { projects: ProjectSummary[]; pw: num const date = session.firstTimestamp ? session.firstTimestamp.slice(0, TOP_SESSIONS_DATE_LEN) : '----------' - const label = `${date} ${shortProject(session.projectName)}` + const label = `${date} ${shortProject(session.projectPath)}` return ( @@ -494,8 +526,10 @@ const PROVIDER_DISPLAY_NAMES: Record = { claude: 'Claude', codex: 'Codex', cursor: 'Cursor', + 'ibm-bob': 'IBM Bob', opencode: 'OpenCode', pi: 'Pi', + kimi: 'Kimi', } function getProviderDisplayName(name: string): string { return PROVIDER_DISPLAY_NAMES[name] ?? name } @@ -516,9 +550,43 @@ function PeriodTabs({ active, providerName, showProvider }: { active: Period; pr ) } +/// Header for an action's intended destination. Helps users distinguish a +/// permanent CLAUDE.md rule from a one-time session opener so they don't +/// accidentally bake a single-run constraint into their project's permanent +/// instructions. Issue #277. +function actionDestinationHeader(action: WasteAction): string { + switch (action.type) { + case 'file-content': + return `── Suggested ${action.path} addition `.padEnd(64, '─') + case 'command': + return '── Run this command '.padEnd(64, '─') + case 'paste': { + switch (action.destination) { + case 'claude-md': + return '── Suggested CLAUDE.md addition (permanent rule) '.padEnd(64, '─') + case 'session-opener': + return '── One-time session opener (do not add to CLAUDE.md) '.padEnd(64, '─') + case 'prompt': + return '── Ask Claude in the current session '.padEnd(64, '─') + case 'shell-config': + return '── Add to your shell config '.padEnd(64, '─') + default: + return '── Suggested action '.padEnd(64, '─') + } + } + } +} + function FindingAction({ action }: { action: WasteAction }) { const lines = action.type === 'file-content' ? action.content.split('\n') : action.type === 'command' ? action.text.split('\n') : [action.text] - return (<>{action.label}{lines.map((line, i) => {line})}) + const header = actionDestinationHeader(action) + return ( + <> + {header} + {action.label} + {lines.map((line, i) => {line})} + + ) } function FindingPanel({ index, finding, costRate, width }: { index: number; finding: WasteFinding; costRate: number; width: number }) { @@ -544,13 +612,23 @@ function FindingPanel({ index, finding, costRate, width }: { index: number; find const GRADE_COLORS: Record = { A: '#5BF5A0', B: '#5BF5A0', C: GOLD, D: ORANGE, F: '#F55B5B' } -function OptimizeView({ findings, costRate, projects, label, width, healthScore, healthGrade }: { findings: WasteFinding[]; costRate: number; projects: ProjectSummary[]; label: string; width: number; healthScore: number; healthGrade: string }) { +// Each finding panel takes ~6-8 lines. Show 3 at a time so the window fits a +// 30-line terminal alongside the optimize header + status bar; users page +// with j/k. Without this cap, 4 new detectors + 7 originals scrolled findings +// off the alt-buffer top and the user couldn't see the StatusBar at all. +const FINDINGS_WINDOW_SIZE = 3 + +function OptimizeView({ findings, costRate, projects, label, width, healthScore, healthGrade, cursor }: { findings: WasteFinding[]; costRate: number; projects: ProjectSummary[]; label: string; width: number; healthScore: number; healthGrade: string; cursor: number }) { const periodCost = projects.reduce((s, p) => s + p.totalCostUSD, 0) const totalTokens = findings.reduce((s, f) => s + f.tokensSaved, 0) const totalCost = totalTokens * costRate const pctRaw = periodCost > 0 ? (totalCost / periodCost) * 100 : 0 const pct = pctRaw >= 1 ? pctRaw.toFixed(0) : pctRaw.toFixed(1) const gradeColor = GRADE_COLORS[healthGrade] ?? DIM + const total = findings.length + const start = total === 0 ? 0 : Math.min(cursor, Math.max(0, total - FINDINGS_WINDOW_SIZE)) + const end = Math.min(start + FINDINGS_WINDOW_SIZE, total) + const visible = findings.slice(start, end) return ( @@ -561,29 +639,38 @@ function OptimizeView({ findings, costRate, projects, label, width, healthScore, ({healthScore}/100) Savings: ~{formatTokens(totalTokens)} tokens (~{formatCost(totalCost)}, ~{pct}% of spend) + {total > FINDINGS_WINDOW_SIZE && ( + Showing {start + 1}–{end} of {total} · j/k to scroll + )} - {findings.map((f, i) => )} + {visible.map((f, i) => )} Token estimates are approximate. ) } -function StatusBar({ width, showProvider, view, findingCount, optimizeAvailable, compareAvailable }: { width: number; showProvider?: boolean; view?: View; findingCount?: number; optimizeAvailable?: boolean; compareAvailable?: boolean }) { +function StatusBar({ width, showProvider, view, findingCount, optimizeAvailable, compareAvailable, customRange }: { width: number; showProvider?: boolean; view?: View; findingCount?: number; optimizeAvailable?: boolean; compareAvailable?: boolean; customRange?: boolean }) { const isOptimize = view === 'optimize' return ( {isOptimize - ? <>b back - : <>{'<'}{'>'} switch } - q quit - 1 today - 2 week - 3 30 days - 4 month - 5 all time - {!isOptimize && optimizeAvailable && findingCount != null && findingCount > 0 && ( - <> o optimize ({findingCount}) + ? <>b back j/k scroll + : !customRange + ? <>{'<'}{'>'} switch + : null} + q quit + {!customRange && !isOptimize && ( + <> + 1 today + 2 week + 3 30 days + 4 month + 5 6 months + + )} + {!isOptimize && optimizeAvailable && ( + <> o optimize{findingCount != null && findingCount > 0 ? ({findingCount}) : null} )} {!isOptimize && compareAvailable && ( <> c compare @@ -620,7 +707,7 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets, ) } -function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, initialPlanUsage, refreshSeconds, projectFilter, excludeFilter }: { +function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, initialPlanUsage, refreshSeconds, projectFilter, excludeFilter, customRange, customRangeLabel }: { initialProjects: ProjectSummary[] initialPeriod: Period initialProvider: string @@ -628,6 +715,8 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, refreshSeconds?: number projectFilter?: string[] excludeFilter?: string[] + customRange?: DateRange | null + customRangeLabel?: string }) { const { exit } = useApp() const [period, setPeriod] = useState(initialPeriod) @@ -637,18 +726,27 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, const [detectedProviders, setDetectedProviders] = useState([]) const [view, setView] = useState('dashboard') const [optimizeResult, setOptimizeResult] = useState(null) + const [optimizeLoading, setOptimizeLoading] = useState(false) const [projectBudgets, setProjectBudgets] = useState>(new Map()) const [planUsage, setPlanUsage] = useState(initialPlanUsage) + // Cursor for the OptimizeView's findings window. Reset whenever the user + // leaves the optimize view OR the underlying findings change so a long + // findings list never strands the user past the new array length. + const [findingsCursor, setFindingsCursor] = useState(0) + const isCustomRange = customRange != null const { columns } = useWindowSize() const { dashWidth } = getLayout(columns) const multipleProviders = detectedProviders.length > 1 - const optimizeAvailable = activeProvider === 'all' || activeProvider === 'claude' + const optimizeAvailable = !isCustomRange && (activeProvider === 'all' || activeProvider === 'claude') const modelCount = new Set( projects.flatMap(p => p.sessions.flatMap(s => Object.keys(s.modelBreakdown))) ).size const compareAvailable = modelCount >= 2 const debounceRef = useRef | null>(null) const reloadGenerationRef = useRef(0) + const reloadInFlightRef = useRef(false) + const currentReloadRef = useRef<{ period: Period; provider: string } | null>(null) + const pendingReloadRef = useRef<{ period: Period; provider: string } | null>(null) const findingCount = optimizeResult?.findings.length ?? 0 useEffect(() => { @@ -665,13 +763,11 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, useEffect(() => { let cancelled = false async function loadBudgets() { - const claudeDir = join(homedir(), '.claude', 'projects') const budgets = new Map() for (const project of projects.slice(0, 8)) { if (cancelled) return - const cwd = await discoverProjectCwd(join(claudeDir, project.project)) - if (!cwd) continue - budgets.set(project.project, await estimateContextBudget(cwd)) + if (!project.projectPath.startsWith('/')) continue + budgets.set(project.project, await estimateContextBudget(project.projectPath)) } if (!cancelled) setProjectBudgets(budgets) } @@ -679,24 +775,31 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, return () => { cancelled = true } }, [projects]) - useEffect(() => { - if (!optimizeAvailable) { setOptimizeResult(null); return } - let cancelled = false - async function scan() { - if (projects.length === 0) { setOptimizeResult(null); return } - const result = await scanAndDetect(projects, getDateRange(period)) - if (!cancelled) setOptimizeResult(result) - } - scan() - return () => { cancelled = true } - }, [projects, period, optimizeAvailable]) - const reloadData = useCallback(async (p: Period, prov: string) => { + if (reloadInFlightRef.current) { + const current = currentReloadRef.current + if (current?.period === p && current.provider === prov) { + pendingReloadRef.current = null + return + } + reloadGenerationRef.current++ + pendingReloadRef.current = { period: p, provider: prov } + return + } + reloadInFlightRef.current = true + currentReloadRef.current = { period: p, provider: prov } const generation = ++reloadGenerationRef.current setLoading(true) + setOptimizeLoading(false) setOptimizeResult(null) try { - const range = getDateRange(p) + if (isHeavyPeriod(p)) { + setProjects([]) + setProjectBudgets(new Map()) + await nextTick() + if (reloadGenerationRef.current !== generation) return + } + const range = getPeriodRange(p) const data = await parseAllSessions(range, prov) if (reloadGenerationRef.current !== generation) return @@ -713,18 +816,51 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, if (reloadGenerationRef.current === generation) { setLoading(false) } + reloadInFlightRef.current = false + currentReloadRef.current = null + const pending = pendingReloadRef.current + pendingReloadRef.current = null + if (pending) { + void reloadData(pending.period, pending.provider) + } } }, [projectFilter, excludeFilter]) + const loadOptimizeResult = useCallback(async () => { + if (!optimizeAvailable || projects.length === 0 || optimizeLoading) return + setView('optimize') + setFindingsCursor(0) + if (optimizeResult) return + + const generation = reloadGenerationRef.current + setOptimizeLoading(true) + try { + const result = await scanAndDetect(projects, getPeriodRange(period)) + if (reloadGenerationRef.current === generation) setOptimizeResult(result) + } catch (error) { + console.error(error) + } finally { + if (reloadGenerationRef.current === generation) setOptimizeLoading(false) + } + }, [optimizeAvailable, projects, period, optimizeLoading, optimizeResult]) + useEffect(() => { if (!refreshSeconds || refreshSeconds <= 0) return + if (isHeavyPeriod(period)) return const id = setInterval(() => { reloadData(period, activeProvider) }, refreshSeconds * 1000) return () => clearInterval(id) }, [refreshSeconds, period, activeProvider, reloadData]) const switchPeriod = useCallback((np: Period) => { if (np === period) return + // Clear projects + flip loading synchronously so the dashboard never + // renders the new period label over the old period's numbers between + // setPeriod() and the reloadData() promise resolving. Without this, + // there's a frame-to-hundreds-of-ms window where users saw wrong + // figures captioned with the new period. setPeriod(np) + setProjects([]) + setLoading(true) if (debounceRef.current) clearTimeout(debounceRef.current) debounceRef.current = setTimeout(() => { reloadData(np, activeProvider) }, 600) }, [period, activeProvider, reloadData]) @@ -732,21 +868,40 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, const switchPeriodImmediate = useCallback(async (np: Period) => { if (np === period) return setPeriod(np) + setProjects([]) + setLoading(true) if (debounceRef.current) clearTimeout(debounceRef.current) await reloadData(np, activeProvider) }, [period, activeProvider, reloadData]) useInput((input, key) => { if (input === 'q') { exit(); return } - if (input === 'o' && findingCount > 0 && view === 'dashboard' && optimizeAvailable) { setView('optimize'); return } - if ((input === 'b' || key.escape) && view === 'optimize') { setView('dashboard'); return } + if (input === 'o' && view === 'dashboard' && optimizeAvailable) { void loadOptimizeResult(); return } + if ((input === 'b' || key.escape) && view === 'optimize') { setView('dashboard'); setFindingsCursor(0); return } + if (view === 'optimize') { + const total = optimizeResult?.findings.length ?? 0 + const maxStart = Math.max(0, total - FINDINGS_WINDOW_SIZE) + if (input === 'j' || key.downArrow) { setFindingsCursor(c => Math.min(c + 1, maxStart)); return } + if (input === 'k' || key.upArrow) { setFindingsCursor(c => Math.max(c - 1, 0)); return } + } if (input === 'c' && compareAvailable && view === 'dashboard') { setView('compare'); return } + if ((input === 'b' || key.escape) && view === 'compare') { setView('dashboard'); return } if (input === 'p' && multipleProviders && view !== 'compare') { const opts = ['all', ...detectedProviders]; const next = opts[(opts.indexOf(activeProvider) + 1) % opts.length] setActiveProvider(next); setView('dashboard') if (debounceRef.current) clearTimeout(debounceRef.current) reloadData(period, next); return } + // Period switches reload the underlying data. Disable them while the + // compare view is mounted; the compare view re-aggregates from + // `projects` and would visibly change underneath the user without any + // affordance back to the dashboard. Press `b` or Esc to return first. + if (view === 'compare') return + // Also disable while a custom --from/--to range is in effect. Switching + // period would silently abandon the user's explicit range and reload + // standard period data; the period tab strip is hidden in this mode so + // users have no expectation that 1-5 should do anything. + if (isCustomRange) return const idx = PERIODS.indexOf(period) if (key.leftArrow) switchPeriod(PERIODS[(idx - 1 + PERIODS.length) % PERIODS.length]!) else if (key.rightArrow || key.tab) switchPeriod(PERIODS[(idx + 1) % PERIODS.length]!) @@ -757,33 +912,48 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, else if (input === '5') switchPeriodImmediate('all') }) - if (loading) { + const headerLabel = customRangeLabel ?? PERIOD_LABELS[period] + + if (loading || optimizeLoading) { return ( - + {!isCustomRange && } + {isCustomRange && } {view === 'compare' ? Model Comparison - Loading {PERIOD_LABELS[period]} model data... + Loading {headerLabel} model data... - : Loading {PERIOD_LABELS[period]}...} - {view !== 'compare' && } + : view === 'optimize' + ? Scanning {headerLabel}... + : Loading {headerLabel}...} + {view !== 'compare' && } ) } return ( - + {!isCustomRange && } + {isCustomRange && } {view === 'compare' ? setView('dashboard')} /> : view === 'optimize' && optimizeResult - ? + ? : } - {view !== 'compare' && } + {view !== 'compare' && } + + ) +} + +function CustomRangeBanner({ label, width }: { label: string; width: number }) { + return ( + + Custom range: + {label} ) } @@ -799,15 +969,16 @@ function StaticDashboard({ projects, period, activeProvider, planUsage }: { proj ) } -export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number, projectFilter?: string[], excludeFilter?: string[], customRange?: DateRange | null): Promise { +export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number, projectFilter?: string[], excludeFilter?: string[], customRange?: DateRange | null, customRangeLabel?: string): Promise { await loadPricing() - const range = customRange ?? getDateRange(period) + const range = customRange ?? getPeriodRange(period) const filteredProjects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter) const planUsage = await getPlanUsageOrNull() const isTTY = process.stdin.isTTY && process.stdout.isTTY + patchStdoutForWindows() if (isTTY) { const { waitUntilExit } = render( - + ) await waitUntilExit() } else { diff --git a/src/data/litellm-snapshot.json b/src/data/litellm-snapshot.json index 2ec14bd..7a7ec4a 100644 --- a/src/data/litellm-snapshot.json +++ b/src/data/litellm-snapshot.json @@ -1 +1 @@ -{"ai21.j2-mid-v1":[0.0000125,0.0000125,null,null],"ai21.j2-ultra-v1":[0.0000188,0.0000188,null,null],"ai21.jamba-1-5-large-v1:0":[0.000002,0.000008,null,null],"ai21.jamba-1-5-mini-v1:0":[2e-7,4e-7,null,null],"ai21.jamba-instruct-v1:0":[5e-7,7e-7,null,null],"us.writer.palmyra-x4-v1:0":[0.0000025,0.00001,null,null],"us.writer.palmyra-x5-v1:0":[6e-7,0.000006,null,null],"writer.palmyra-x4-v1:0":[0.0000025,0.00001,null,null],"writer.palmyra-x5-v1:0":[6e-7,0.000006,null,null],"amazon.nova-lite-v1:0":[6e-8,2.4e-7,null,null],"amazon.nova-2-lite-v1:0":[3e-7,0.0000025,null,7.5e-8],"amazon.nova-2-pro-preview-20251202-v1:0":[0.0000021875,0.0000175,null,5.46875e-7],"apac.amazon.nova-2-lite-v1:0":[3.3e-7,0.00000275,null,8.25e-8],"apac.amazon.nova-2-pro-preview-20251202-v1:0":[0.0000021875,0.0000175,null,5.46875e-7],"eu.amazon.nova-2-lite-v1:0":[3.3e-7,0.00000275,null,8.25e-8],"eu.amazon.nova-2-pro-preview-20251202-v1:0":[0.0000021875,0.0000175,null,5.46875e-7],"us.amazon.nova-2-lite-v1:0":[3.3e-7,0.00000275,null,8.25e-8],"us.amazon.nova-2-pro-preview-20251202-v1:0":[0.0000021875,0.0000175,null,5.46875e-7],"amazon.nova-2-multimodal-embeddings-v1:0":[1.35e-7,0,null,null],"amazon.nova-micro-v1:0":[3.5e-8,1.4e-7,null,null],"amazon.nova-pro-v1:0":[8e-7,0.0000032,null,null],"amazon.rerank-v1:0":[0,0,null,null],"amazon.titan-embed-image-v1":[8e-7,0,null,null],"amazon.titan-embed-text-v1":[1e-7,0,null,null],"amazon.titan-embed-text-v2:0":[2e-7,0,null,null],"twelvelabs.marengo-embed-2-7-v1:0":[0.00007,0,null,null],"us.twelvelabs.marengo-embed-2-7-v1:0":[0.00007,0,null,null],"eu.twelvelabs.marengo-embed-2-7-v1:0":[0.00007,0,null,null],"amazon.titan-text-express-v1":[0.0000013,0.0000017,null,null],"amazon.titan-text-lite-v1":[3e-7,4e-7,null,null],"amazon.titan-text-premier-v1:0":[5e-7,0.0000015,null,null],"anthropic.claude-3-5-haiku-20241022-v1:0":[8e-7,0.000004,0.000001,8e-8],"anthropic.claude-haiku-4-5-20251001-v1:0":[0.000001,0.000005,0.00000125,1e-7],"anthropic.claude-haiku-4-5@20251001":[0.000001,0.000005,0.00000125,1e-7],"anthropic.claude-3-5-sonnet-20240620-v1:0":[0.000003,0.000015,0.00000375,3e-7],"anthropic.claude-3-5-sonnet-20241022-v2:0":[0.000003,0.000015,0.00000375,3e-7],"anthropic.claude-3-7-sonnet-20240620-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7],"anthropic.claude-3-7-sonnet-20250219-v1:0":[0.000003,0.000015,0.00000375,3e-7],"anthropic.claude-3-haiku-20240307-v1:0":[2.5e-7,0.00000125,3.125e-7,2.5e-8],"anthropic.claude-3-opus-20240229-v1:0":[0.000015,0.000075,0.00001875,0.0000015],"anthropic.claude-3-sonnet-20240229-v1:0":[0.000003,0.000015,0.00000375,3e-7],"anthropic.claude-instant-v1":[8e-7,0.0000024,null,null],"anthropic.claude-opus-4-1-20250805-v1:0":[0.000015,0.000075,0.00001875,0.0000015],"anthropic.claude-opus-4-20250514-v1:0":[0.000015,0.000075,0.00001875,0.0000015],"anthropic.claude-opus-4-5-20251101-v1:0":[0.000005,0.000025,0.00000625,5e-7],"anthropic.claude-opus-4-6-v1":[0.000005,0.000025,0.00000625,5e-7],"global.anthropic.claude-opus-4-6-v1":[0.000005,0.000025,0.00000625,5e-7],"us.anthropic.claude-opus-4-6-v1":[0.0000055,0.0000275,0.000006875,5.5e-7],"eu.anthropic.claude-opus-4-6-v1":[0.0000055,0.0000275,0.000006875,5.5e-7],"au.anthropic.claude-opus-4-6-v1":[0.0000055,0.0000275,0.000006875,5.5e-7],"anthropic.claude-opus-4-7":[0.000005,0.000025,0.00000625,5e-7],"anthropic.claude-mythos-preview":[0,0,null,null],"global.anthropic.claude-opus-4-7":[0.000005,0.000025,0.00000625,5e-7],"us.anthropic.claude-opus-4-7":[0.0000055,0.0000275,0.000006875,5.5e-7],"eu.anthropic.claude-opus-4-7":[0.0000055,0.0000275,0.000006875,5.5e-7],"au.anthropic.claude-opus-4-7":[0.0000055,0.0000275,0.000006875,5.5e-7],"anthropic.claude-sonnet-4-6":[0.000003,0.000015,0.00000375,3e-7],"global.anthropic.claude-sonnet-4-6":[0.000003,0.000015,0.00000375,3e-7],"us.anthropic.claude-sonnet-4-6":[0.0000033,0.0000165,0.000004125,3.3e-7],"eu.anthropic.claude-sonnet-4-6":[0.0000033,0.0000165,0.000004125,3.3e-7],"au.anthropic.claude-sonnet-4-6":[0.0000033,0.0000165,0.000004125,3.3e-7],"anthropic.claude-sonnet-4-20250514-v1:0":[0.000003,0.000015,0.00000375,3e-7],"anthropic.claude-sonnet-4-5-20250929-v1:0":[0.000003,0.000015,0.00000375,3e-7],"anthropic.claude-v1":[0.000008,0.000024,null,null],"anthropic.claude-v2:1":[0.000008,0.000024,null,null],"apac.amazon.nova-lite-v1:0":[6.3e-8,2.52e-7,null,null],"apac.amazon.nova-micro-v1:0":[3.7e-8,1.48e-7,null,null],"apac.amazon.nova-pro-v1:0":[8.4e-7,0.00000336,null,null],"apac.anthropic.claude-3-5-sonnet-20240620-v1:0":[0.000003,0.000015,0.00000375,3e-7],"apac.anthropic.claude-3-5-sonnet-20241022-v2:0":[0.000003,0.000015,0.00000375,3e-7],"apac.anthropic.claude-3-haiku-20240307-v1:0":[2.5e-7,0.00000125,3.125e-7,2.5e-8],"apac.anthropic.claude-haiku-4-5-20251001-v1:0":[0.0000011,0.0000055,0.000001375,1.1e-7],"apac.anthropic.claude-3-sonnet-20240229-v1:0":[0.000003,0.000015,0.00000375,3e-7],"apac.anthropic.claude-sonnet-4-20250514-v1:0":[0.000003,0.000015,0.00000375,3e-7],"au.anthropic.claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7],"babbage-002":[4e-7,4e-7,null,null],"chatdolphin":[5e-7,5e-7,null,null],"chatgpt-4o-latest":[0.000005,0.000015,null,null],"gpt-4o-transcribe-diarize":[0.0000025,0.00001,null,null],"claude-haiku-4-5-20251001":[0.000001,0.000005,0.00000125,1e-7],"claude-haiku-4-5":[0.000001,0.000005,0.00000125,1e-7],"claude-3-7-sonnet-20250219":[0.000003,0.000015,0.00000375,3e-7],"claude-3-haiku-20240307":[2.5e-7,0.00000125,3e-7,3e-8],"claude-3-opus-20240229":[0.000015,0.000075,0.00001875,0.0000015],"claude-4-opus-20250514":[0.000015,0.000075,0.00001875,0.0000015],"claude-4-sonnet-20250514":[0.000003,0.000015,0.00000375,3e-7],"claude-sonnet-4-5":[0.000003,0.000015,0.00000375,3e-7],"claude-sonnet-4-5-20250929":[0.000003,0.000015,0.00000375,3e-7],"claude-sonnet-4-6":[0.000003,0.000015,0.00000375,3e-7],"claude-sonnet-4-5-20250929-v1:0":[0.000003,0.000015,0.00000375,3e-7],"claude-opus-4-1":[0.000015,0.000075,0.00001875,0.0000015],"claude-opus-4-1-20250805":[0.000015,0.000075,0.00001875,0.0000015],"claude-opus-4-20250514":[0.000015,0.000075,0.00001875,0.0000015],"claude-opus-4-5-20251101":[0.000005,0.000025,0.00000625,5e-7],"claude-opus-4-5":[0.000005,0.000025,0.00000625,5e-7],"claude-opus-4-6":[0.000005,0.000025,0.00000625,5e-7],"claude-opus-4-6-20260205":[0.000005,0.000025,0.00000625,5e-7],"claude-opus-4-7":[0.000005,0.000025,0.00000625,5e-7],"claude-opus-4-7-20260416":[0.000005,0.000025,0.00000625,5e-7],"claude-sonnet-4-20250514":[0.000003,0.000015,0.00000375,3e-7],"codex-mini-latest":[0.0000015,0.000006,null,3.75e-7],"cohere.command-light-text-v14":[3e-7,6e-7,null,null],"cohere.command-r-plus-v1:0":[0.000003,0.000015,null,null],"cohere.command-r-v1:0":[5e-7,0.0000015,null,null],"cohere.command-text-v14":[0.0000015,0.000002,null,null],"cohere.embed-english-v3":[1e-7,0,null,null],"cohere.embed-multilingual-v3":[1e-7,0,null,null],"cohere.embed-v4:0":[1.2e-7,0,null,null],"cohere.rerank-v3-5:0":[0,0,null,null],"command":[0.000001,0.000002,null,null],"command-a-03-2025":[0.0000025,0.00001,null,null],"command-light":[3e-7,6e-7,null,null],"command-nightly":[0.000001,0.000002,null,null],"command-r":[1.5e-7,6e-7,null,null],"command-r-08-2024":[1.5e-7,6e-7,null,null],"command-r-plus":[0.0000025,0.00001,null,null],"command-r-plus-08-2024":[0.0000025,0.00001,null,null],"command-r7b-12-2024":[1.5e-7,3.75e-8,null,null],"computer-use-preview":[0.000003,0.000012,null,null],"deepseek-chat":[2.8e-7,4.2e-7,null,2.8e-8],"deepseek-reasoner":[2.8e-7,4.2e-7,null,2.8e-8],"davinci-002":[0.000002,0.000002,null,null],"deepseek.v3-v1:0":[5.8e-7,0.00000168,null,null],"deepseek.v3.2":[6.2e-7,0.00000185,null,null],"dolphin":[5e-7,5e-7,null,null],"deepseek-v3-2-251201":[0,0,null,null],"glm-4-7-251222":[0,0,null,null],"kimi-k2-thinking-251104":[0,0,null,null],"doubao-embedding":[0,0,null,null],"doubao-embedding-large":[0,0,null,null],"doubao-embedding-large-text-240915":[0,0,null,null],"doubao-embedding-large-text-250515":[0,0,null,null],"doubao-embedding-text-240715":[0,0,null,null],"embed-english-light-v2.0":[1e-7,0,null,null],"embed-english-light-v3.0":[1e-7,0,null,null],"embed-english-v2.0":[1e-7,0,null,null],"embed-english-v3.0":[1e-7,0,null,null],"embed-multilingual-v2.0":[1e-7,0,null,null],"embed-multilingual-v3.0":[1e-7,0,null,null],"embed-multilingual-light-v3.0":[0.0001,0,null,null],"eu.amazon.nova-lite-v1:0":[7.8e-8,3.12e-7,null,null],"eu.amazon.nova-micro-v1:0":[4.6e-8,1.84e-7,null,null],"eu.amazon.nova-pro-v1:0":[0.00000105,0.0000042,null,null],"eu.anthropic.claude-3-5-haiku-20241022-v1:0":[2.5e-7,0.00000125,3.125e-7,2.5e-8],"eu.anthropic.claude-haiku-4-5-20251001-v1:0":[0.0000011,0.0000055,0.000001375,1.1e-7],"eu.anthropic.claude-3-5-sonnet-20240620-v1:0":[0.000003,0.000015,0.00000375,3e-7],"eu.anthropic.claude-3-5-sonnet-20241022-v2:0":[0.000003,0.000015,0.00000375,3e-7],"eu.anthropic.claude-3-7-sonnet-20250219-v1:0":[0.000003,0.000015,0.00000375,3e-7],"eu.anthropic.claude-3-haiku-20240307-v1:0":[2.5e-7,0.00000125,3.125e-7,2.5e-8],"eu.anthropic.claude-3-opus-20240229-v1:0":[0.000015,0.000075,0.00001875,0.0000015],"eu.anthropic.claude-3-sonnet-20240229-v1:0":[0.000003,0.000015,0.00000375,3e-7],"eu.anthropic.claude-opus-4-1-20250805-v1:0":[0.000015,0.000075,0.00001875,0.0000015],"eu.anthropic.claude-opus-4-20250514-v1:0":[0.000015,0.000075,0.00001875,0.0000015],"eu.anthropic.claude-sonnet-4-20250514-v1:0":[0.000003,0.000015,0.00000375,3e-7],"eu.anthropic.claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7],"eu.meta.llama3-2-1b-instruct-v1:0":[1.3e-7,1.3e-7,null,null],"eu.meta.llama3-2-3b-instruct-v1:0":[1.9e-7,1.9e-7,null,null],"eu.mistral.pixtral-large-2502-v1:0":[0.000002,0.000006,null,null],"fireworks-ai-4.1b-to-16b":[2e-7,2e-7,null,null],"fireworks-ai-56b-to-176b":[0.0000012,0.0000012,null,null],"fireworks-ai-above-16b":[9e-7,9e-7,null,null],"fireworks-ai-default":[0,0,null,null],"fireworks-ai-embedding-150m-to-350m":[1.6e-8,0,null,null],"fireworks-ai-embedding-up-to-150m":[8e-9,0,null,null],"fireworks-ai-moe-up-to-56b":[5e-7,5e-7,null,null],"fireworks-ai-up-to-4b":[2e-7,2e-7,null,null],"ft:babbage-002":[0.0000016,0.0000016,null,null],"ft:davinci-002":[0.000012,0.000012,null,null],"ft:gpt-3.5-turbo":[0.000003,0.000006,null,null],"ft:gpt-3.5-turbo-0125":[0.000003,0.000006,null,null],"ft:gpt-3.5-turbo-0613":[0.000003,0.000006,null,null],"ft:gpt-3.5-turbo-1106":[0.000003,0.000006,null,null],"ft:gpt-4-0613":[0.00003,0.00006,null,null],"ft:gpt-4o-2024-08-06":[0.00000375,0.000015,null,0.000001875],"ft:gpt-4o-2024-11-20":[0.00000375,0.000015,0.000001875,null],"ft:gpt-4o-mini-2024-07-18":[3e-7,0.0000012,null,1.5e-7],"ft:gpt-4.1-2025-04-14":[0.000003,0.000012,null,7.5e-7],"ft:gpt-4.1-mini-2025-04-14":[8e-7,0.0000032,null,2e-7],"ft:gpt-4.1-nano-2025-04-14":[2e-7,8e-7,null,5e-8],"ft:o4-mini-2025-04-16":[0.000004,0.000016,null,0.000001],"gemini-2.0-flash":[1e-7,4e-7,null,2.5e-8],"gemini-2.0-flash-001":[1.5e-7,6e-7,null,3.75e-8],"gemini-2.0-flash-lite":[7.5e-8,3e-7,null,1.875e-8],"gemini-2.0-flash-lite-001":[7.5e-8,3e-7,null,1.875e-8],"gemini-2.5-flash":[3e-7,0.0000025,null,3e-8],"gemini-2.5-flash-image":[3e-7,0.0000025,null,3e-8],"gemini-3-pro-image-preview":[0.000002,0.000012,null,null],"gemini-3.1-flash-image-preview":[5e-7,0.000003,null,null],"gemini-3.1-flash-lite-preview":[2.5e-7,0.0000015,null,2.5e-8],"deep-research-pro-preview-12-2025":[0.000002,0.000012,null,null],"gemini-2.5-flash-lite":[1e-7,4e-7,null,1e-8],"gemini-2.5-flash-lite-preview-09-2025":[1e-7,4e-7,null,1e-8],"gemini-2.5-flash-preview-09-2025":[3e-7,0.0000025,null,7.5e-8],"gemini-live-2.5-flash-preview-native-audio-09-2025":[3e-7,0.000002,null,7.5e-8],"gemini-2.5-flash-lite-preview-06-17":[1e-7,4e-7,null,2.5e-8],"gemini-2.5-pro":[0.00000125,0.00001,null,1.25e-7],"gemini-3-pro-preview":[0.000002,0.000012,null,2e-7],"gemini-3.1-pro-preview":[0.000002,0.000012,null,2e-7],"gemini-3.1-pro-preview-customtools":[0.000002,0.000012,null,2e-7],"gemini-2.5-pro-preview-tts":[0.00000125,0.00001,null,1.25e-7],"gemini-robotics-er-1.5-preview":[3e-7,0.0000025,null,0],"gemini-2.5-computer-use-preview-10-2025":[0.00000125,0.00001,null,null],"gemini-embedding-001":[1.5e-7,0,null,null],"gemini-embedding-2-preview":[2e-7,0,null,null],"gemini-embedding-2":[2e-7,0,null,null],"gemini-flash-experimental":[0,0,null,null],"gemini-3-flash-preview":[5e-7,0.000003,null,5e-8],"google.gemma-3-12b-it":[9e-8,2.9e-7,null,null],"google.gemma-3-27b-it":[2.3e-7,3.8e-7,null,null],"google.gemma-3-4b-it":[4e-8,8e-8,null,null],"global.anthropic.claude-sonnet-4-5-20250929-v1:0":[0.000003,0.000015,0.00000375,3e-7],"global.anthropic.claude-sonnet-4-20250514-v1:0":[0.000003,0.000015,0.00000375,3e-7],"global.anthropic.claude-haiku-4-5-20251001-v1:0":[0.000001,0.000005,0.00000125,1e-7],"global.amazon.nova-2-lite-v1:0":[3e-7,0.0000025,null,7.5e-8],"gpt-3.5-turbo":[5e-7,0.0000015,null,null],"gpt-3.5-turbo-0125":[5e-7,0.0000015,null,null],"gpt-3.5-turbo-1106":[0.000001,0.000002,null,null],"gpt-3.5-turbo-16k":[0.000003,0.000004,null,null],"gpt-3.5-turbo-instruct":[0.0000015,0.000002,null,null],"gpt-3.5-turbo-instruct-0914":[0.0000015,0.000002,null,null],"gpt-4":[0.00003,0.00006,null,null],"gpt-4-0125-preview":[0.00001,0.00003,null,null],"gpt-4-0314":[0.00003,0.00006,null,null],"gpt-4-0613":[0.00003,0.00006,null,null],"gpt-4-1106-preview":[0.00001,0.00003,null,null],"gpt-4-turbo":[0.00001,0.00003,null,null],"gpt-4-turbo-2024-04-09":[0.00001,0.00003,null,null],"gpt-4-turbo-preview":[0.00001,0.00003,null,null],"gpt-4.1":[0.000002,0.000008,null,5e-7],"gpt-4.1-2025-04-14":[0.000002,0.000008,null,5e-7],"gpt-4.1-mini":[4e-7,0.0000016,null,1e-7],"gpt-4.1-mini-2025-04-14":[4e-7,0.0000016,null,1e-7],"gpt-4.1-nano":[1e-7,4e-7,null,2.5e-8],"gpt-4.1-nano-2025-04-14":[1e-7,4e-7,null,2.5e-8],"gpt-4o":[0.0000025,0.00001,null,0.00000125],"gpt-4o-2024-05-13":[0.000005,0.000015,null,null],"gpt-4o-2024-08-06":[0.0000025,0.00001,null,0.00000125],"gpt-4o-2024-11-20":[0.0000025,0.00001,null,0.00000125],"gpt-4o-audio-preview":[0.0000025,0.00001,null,null],"gpt-4o-audio-preview-2024-12-17":[0.0000025,0.00001,null,null],"gpt-4o-audio-preview-2025-06-03":[0.0000025,0.00001,null,null],"gpt-audio":[0.0000025,0.00001,null,null],"gpt-audio-1.5":[0.0000025,0.00001,null,null],"gpt-audio-2025-08-28":[0.0000025,0.00001,null,null],"gpt-audio-mini":[6e-7,0.0000024,null,null],"gpt-audio-mini-2025-10-06":[6e-7,0.0000024,null,null],"gpt-audio-mini-2025-12-15":[6e-7,0.0000024,null,null],"gpt-4o-mini":[1.5e-7,6e-7,null,7.5e-8],"gpt-4o-mini-2024-07-18":[1.5e-7,6e-7,null,7.5e-8],"gpt-4o-mini-audio-preview":[1.5e-7,6e-7,null,null],"gpt-4o-mini-audio-preview-2024-12-17":[1.5e-7,6e-7,null,null],"gpt-4o-mini-realtime-preview":[6e-7,0.0000024,null,3e-7],"gpt-4o-mini-realtime-preview-2024-12-17":[6e-7,0.0000024,null,3e-7],"gpt-4o-mini-search-preview":[1.5e-7,6e-7,null,7.5e-8],"gpt-4o-mini-search-preview-2025-03-11":[1.5e-7,6e-7,null,7.5e-8],"gpt-4o-mini-transcribe":[0.00000125,0.000005,null,null],"gpt-4o-mini-tts":[0.0000025,0.00001,null,null],"gpt-4o-realtime-preview":[0.000005,0.00002,null,0.0000025],"gpt-4o-realtime-preview-2024-12-17":[0.000005,0.00002,null,0.0000025],"gpt-4o-realtime-preview-2025-06-03":[0.000005,0.00002,null,0.0000025],"gpt-4o-search-preview":[0.0000025,0.00001,null,0.00000125],"gpt-4o-search-preview-2025-03-11":[0.0000025,0.00001,null,0.00000125],"gpt-4o-transcribe":[0.0000025,0.00001,null,null],"gpt-image-1.5":[0.000005,0.00001,null,0.00000125],"gpt-image-1.5-2025-12-16":[0.000005,0.00001,null,0.00000125],"gpt-5":[0.00000125,0.00001,null,1.25e-7],"gpt-5.1":[0.00000125,0.00001,null,1.25e-7],"gpt-5.1-2025-11-13":[0.00000125,0.00001,null,1.25e-7],"gpt-5.1-chat-latest":[0.00000125,0.00001,null,1.25e-7],"gpt-5.2":[0.00000175,0.000014,null,1.75e-7],"gpt-5.2-2025-12-11":[0.00000175,0.000014,null,1.75e-7],"gpt-5.2-chat-latest":[0.00000175,0.000014,null,1.75e-7],"gpt-5.3-chat-latest":[0.00000175,0.000014,null,1.75e-7],"gpt-5.2-pro":[0.000021,0.000168,null,null],"gpt-5.2-pro-2025-12-11":[0.000021,0.000168,null,null],"gpt-5.5":[0.000005,0.00003,null,5e-7],"gpt-5.5-2026-04-23":[0.000005,0.00003,null,5e-7],"gpt-5.5-pro":[0.00006,0.00036,null,0.000006],"gpt-5.5-pro-2026-04-23":[0.00006,0.00036,null,0.000006],"gpt-5.4":[0.0000025,0.000015,null,2.5e-7],"gpt-5.4-2026-03-05":[0.0000025,0.000015,null,2.5e-7],"gpt-5.4-pro":[0.00003,0.00018,null,0.000003],"gpt-5.4-pro-2026-03-05":[0.00003,0.00018,null,0.000003],"gpt-5.4-mini":[7.5e-7,0.0000045,null,7.5e-8],"gpt-5.4-mini-2026-03-17":[7.5e-7,0.0000045,null,7.5e-8],"gpt-5.4-nano":[2e-7,0.00000125,null,2e-8],"gpt-5.4-nano-2026-03-17":[2e-7,0.00000125,null,2e-8],"gpt-5-pro":[0.000015,0.00012,null,null],"gpt-5-pro-2025-10-06":[0.000015,0.00012,null,null],"gpt-5-2025-08-07":[0.00000125,0.00001,null,1.25e-7],"gpt-5-chat":[0.00000125,0.00001,null,1.25e-7],"gpt-5-chat-latest":[0.00000125,0.00001,null,1.25e-7],"gpt-5-codex":[0.00000125,0.00001,null,1.25e-7],"gpt-5.1-codex":[0.00000125,0.00001,null,1.25e-7],"gpt-5.1-codex-max":[0.00000125,0.00001,null,1.25e-7],"gpt-5.1-codex-mini":[2.5e-7,0.000002,null,2.5e-8],"gpt-5.2-codex":[0.00000175,0.000014,null,1.75e-7],"gpt-5.3-codex":[0.00000175,0.000014,null,1.75e-7],"gpt-5-mini":[2.5e-7,0.000002,null,2.5e-8],"gpt-5-mini-2025-08-07":[2.5e-7,0.000002,null,2.5e-8],"gpt-5-nano":[5e-8,4e-7,null,5e-9],"gpt-5-nano-2025-08-07":[5e-8,4e-7,null,5e-9],"gpt-realtime":[0.000004,0.000016,null,4e-7],"gpt-realtime-1.5":[0.000004,0.000016,null,4e-7],"gpt-realtime-mini":[6e-7,0.0000024,null,null],"gpt-realtime-2025-08-28":[0.000004,0.000016,null,4e-7],"j2-light":[0.000003,0.000003,null,null],"j2-mid":[0.00001,0.00001,null,null],"j2-ultra":[0.000015,0.000015,null,null],"jamba-1.5":[2e-7,4e-7,null,null],"jamba-1.5-large":[0.000002,0.000008,null,null],"jamba-1.5-large@001":[0.000002,0.000008,null,null],"jamba-1.5-mini":[2e-7,4e-7,null,null],"jamba-1.5-mini@001":[2e-7,4e-7,null,null],"jamba-large-1.6":[0.000002,0.000008,null,null],"jamba-large-1.7":[0.000002,0.000008,null,null],"jamba-mini-1.6":[2e-7,4e-7,null,null],"jamba-mini-1.7":[2e-7,4e-7,null,null],"jina-reranker-v2-base-multilingual":[1.8e-8,1.8e-8,null,null],"jp.anthropic.claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7],"jp.anthropic.claude-haiku-4-5-20251001-v1:0":[0.0000011,0.0000055,0.000001375,1.1e-7],"meta.llama2-13b-chat-v1":[7.5e-7,0.000001,null,null],"meta.llama2-70b-chat-v1":[0.00000195,0.00000256,null,null],"meta.llama3-1-405b-instruct-v1:0":[0.00000532,0.000016,null,null],"meta.llama3-1-70b-instruct-v1:0":[9.9e-7,9.9e-7,null,null],"meta.llama3-1-8b-instruct-v1:0":[2.2e-7,2.2e-7,null,null],"meta.llama3-2-11b-instruct-v1:0":[3.5e-7,3.5e-7,null,null],"meta.llama3-2-1b-instruct-v1:0":[1e-7,1e-7,null,null],"meta.llama3-2-3b-instruct-v1:0":[1.5e-7,1.5e-7,null,null],"meta.llama3-2-90b-instruct-v1:0":[0.000002,0.000002,null,null],"meta.llama3-3-70b-instruct-v1:0":[7.2e-7,7.2e-7,null,null],"meta.llama3-70b-instruct-v1:0":[0.00000265,0.0000035,null,null],"meta.llama3-8b-instruct-v1:0":[3e-7,6e-7,null,null],"meta.llama4-maverick-17b-instruct-v1:0":[2.4e-7,9.7e-7,null,null],"meta.llama4-scout-17b-instruct-v1:0":[1.7e-7,6.6e-7,null,null],"minimax.minimax-m2":[3e-7,0.0000012,null,null],"minimax.minimax-m2.1":[3e-7,0.0000012,null,null],"minimax.minimax-m2.5":[3e-7,0.0000012,null,null],"mistral.devstral-2-123b":[4e-7,0.000002,null,null],"mistral.magistral-small-2509":[5e-7,0.0000015,null,null],"mistral.ministral-3-14b-instruct":[2e-7,2e-7,null,null],"mistral.ministral-3-3b-instruct":[1e-7,1e-7,null,null],"mistral.ministral-3-8b-instruct":[1.5e-7,1.5e-7,null,null],"mistral.mistral-7b-instruct-v0:2":[1.5e-7,2e-7,null,null],"mistral.mistral-large-2402-v1:0":[0.000008,0.000024,null,null],"mistral.mistral-large-2407-v1:0":[0.000003,0.000009,null,null],"mistral.mistral-large-3-675b-instruct":[5e-7,0.0000015,null,null],"mistral.mistral-small-2402-v1:0":[0.000001,0.000003,null,null],"mistral.mixtral-8x7b-instruct-v0:1":[4.5e-7,7e-7,null,null],"mistral.voxtral-mini-3b-2507":[4e-8,4e-8,null,null],"mistral.voxtral-small-24b-2507":[1e-7,3e-7,null,null],"moonshot.kimi-k2-thinking":[6e-7,0.0000025,null,null],"moonshotai.kimi-k2.5":[6e-7,0.000003,null,null],"multimodalembedding":[8e-7,0,null,null],"multimodalembedding@001":[8e-7,0,null,null],"nvidia.nemotron-nano-12b-v2":[2e-7,6e-7,null,null],"nvidia.nemotron-nano-9b-v2":[6e-8,2.3e-7,null,null],"nvidia.nemotron-nano-3-30b":[6e-8,2.4e-7,null,null],"nvidia.nemotron-super-3-120b":[1.5e-7,6.5e-7,null,null],"o1":[0.000015,0.00006,null,0.0000075],"o1-2024-12-17":[0.000015,0.00006,null,0.0000075],"o1-pro":[0.00015,0.0006,null,null],"o1-pro-2025-03-19":[0.00015,0.0006,null,null],"o3":[0.000002,0.000008,null,5e-7],"o3-2025-04-16":[0.000002,0.000008,null,5e-7],"o3-deep-research":[0.00001,0.00004,null,0.0000025],"o3-deep-research-2025-06-26":[0.00001,0.00004,null,0.0000025],"o3-mini":[0.0000011,0.0000044,null,5.5e-7],"o3-mini-2025-01-31":[0.0000011,0.0000044,null,5.5e-7],"o3-pro":[0.00002,0.00008,null,null],"o3-pro-2025-06-10":[0.00002,0.00008,null,null],"o4-mini":[0.0000011,0.0000044,null,2.75e-7],"o4-mini-2025-04-16":[0.0000011,0.0000044,null,2.75e-7],"o4-mini-deep-research":[0.000002,0.000008,null,5e-7],"o4-mini-deep-research-2025-06-26":[0.000002,0.000008,null,5e-7],"omni-moderation-2024-09-26":[0,0,null,null],"omni-moderation-latest":[0,0,null,null],"openai.gpt-oss-120b-1:0":[1.5e-7,6e-7,null,null],"openai.gpt-oss-20b-1:0":[7e-8,3e-7,null,null],"openai.gpt-oss-safeguard-120b":[1.5e-7,6e-7,null,null],"openai.gpt-oss-safeguard-20b":[7e-8,2e-7,null,null],"qwen.qwen3-coder-480b-a35b-v1:0":[2.2e-7,0.0000018,null,null],"qwen.qwen3-235b-a22b-2507-v1:0":[2.2e-7,8.8e-7,null,null],"qwen.qwen3-coder-30b-a3b-v1:0":[1.5e-7,6e-7,null,null],"qwen.qwen3-32b-v1:0":[1.5e-7,6e-7,null,null],"qwen.qwen3-next-80b-a3b":[1.5e-7,0.0000012,null,null],"qwen.qwen3-vl-235b-a22b":[5.3e-7,0.00000266,null,null],"qwen.qwen3-coder-next":[5e-7,0.0000012,null,null],"rerank-english-v2.0":[0,0,null,null],"rerank-english-v3.0":[0,0,null,null],"rerank-multilingual-v2.0":[0,0,null,null],"rerank-multilingual-v3.0":[0,0,null,null],"rerank-v3.5":[0,0,null,null],"text-embedding-004":[1e-7,0,null,null],"text-embedding-005":[1e-7,0,null,null],"text-embedding-3-large":[1.3e-7,0,null,null],"text-embedding-3-small":[2e-8,0,null,null],"text-embedding-ada-002":[1e-7,0,null,null],"text-embedding-ada-002-v2":[1e-7,0,null,null],"text-embedding-large-exp-03-07":[1e-7,0,null,null],"text-embedding-preview-0409":[6.25e-9,0,null,null],"text-moderation-007":[0,0,null,null],"text-moderation-latest":[0,0,null,null],"text-moderation-stable":[0,0,null,null],"text-multilingual-embedding-002":[1e-7,0,null,null],"text-unicorn":[0.00001,0.000028,null,null],"text-unicorn@001":[0.00001,0.000028,null,null],"together-ai-21.1b-41b":[8e-7,8e-7,null,null],"together-ai-4.1b-8b":[2e-7,2e-7,null,null],"together-ai-41.1b-80b":[9e-7,9e-7,null,null],"together-ai-8.1b-21b":[3e-7,3e-7,null,null],"together-ai-81.1b-110b":[0.0000018,0.0000018,null,null],"together-ai-embedding-151m-to-350m":[1.6e-8,0,null,null],"together-ai-embedding-up-to-150m":[8e-9,0,null,null],"together-ai-up-to-4b":[1e-7,1e-7,null,null],"us.amazon.nova-lite-v1:0":[6e-8,2.4e-7,null,null],"us.amazon.nova-micro-v1:0":[3.5e-8,1.4e-7,null,null],"us.amazon.nova-premier-v1:0":[0.0000025,0.0000125,null,null],"us.amazon.nova-pro-v1:0":[8e-7,0.0000032,null,null],"us.anthropic.claude-3-5-haiku-20241022-v1:0":[8e-7,0.000004,0.000001,8e-8],"us.anthropic.claude-haiku-4-5-20251001-v1:0":[0.0000011,0.0000055,0.000001375,1.1e-7],"us.anthropic.claude-3-5-sonnet-20240620-v1:0":[0.000003,0.000015,0.00000375,3e-7],"us.anthropic.claude-3-5-sonnet-20241022-v2:0":[0.000003,0.000015,0.00000375,3e-7],"us.anthropic.claude-3-7-sonnet-20250219-v1:0":[0.000003,0.000015,0.00000375,3e-7],"us.anthropic.claude-3-haiku-20240307-v1:0":[2.5e-7,0.00000125,3.125e-7,2.5e-8],"us.anthropic.claude-3-opus-20240229-v1:0":[0.000015,0.000075,0.00001875,0.0000015],"us.anthropic.claude-3-sonnet-20240229-v1:0":[0.000003,0.000015,0.00000375,3e-7],"us.anthropic.claude-opus-4-1-20250805-v1:0":[0.000015,0.000075,0.00001875,0.0000015],"us.anthropic.claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7],"us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7],"au.anthropic.claude-haiku-4-5-20251001-v1:0":[0.0000011,0.0000055,0.000001375,1.1e-7],"us.anthropic.claude-opus-4-20250514-v1:0":[0.000015,0.000075,0.00001875,0.0000015],"us.anthropic.claude-opus-4-5-20251101-v1:0":[0.0000055,0.0000275,0.000006875,5.5e-7],"global.anthropic.claude-opus-4-5-20251101-v1:0":[0.000005,0.000025,0.00000625,5e-7],"eu.anthropic.claude-opus-4-5-20251101-v1:0":[0.000005,0.000025,0.00000625,5e-7],"us.anthropic.claude-sonnet-4-20250514-v1:0":[0.000003,0.000015,0.00000375,3e-7],"us.deepseek.r1-v1:0":[0.00000135,0.0000054,null,null],"us.deepseek.v3.2":[6.2e-7,0.00000185,null,null],"eu.deepseek.v3.2":[7.4e-7,0.00000222,null,null],"us.meta.llama3-1-405b-instruct-v1:0":[0.00000532,0.000016,null,null],"us.meta.llama3-1-70b-instruct-v1:0":[9.9e-7,9.9e-7,null,null],"us.meta.llama3-1-8b-instruct-v1:0":[2.2e-7,2.2e-7,null,null],"us.meta.llama3-2-11b-instruct-v1:0":[3.5e-7,3.5e-7,null,null],"us.meta.llama3-2-1b-instruct-v1:0":[1e-7,1e-7,null,null],"us.meta.llama3-2-3b-instruct-v1:0":[1.5e-7,1.5e-7,null,null],"us.meta.llama3-2-90b-instruct-v1:0":[0.000002,0.000002,null,null],"us.meta.llama3-3-70b-instruct-v1:0":[7.2e-7,7.2e-7,null,null],"us.meta.llama4-maverick-17b-instruct-v1:0":[2.4e-7,9.7e-7,null,null],"us.meta.llama4-scout-17b-instruct-v1:0":[1.7e-7,6.6e-7,null,null],"us.mistral.pixtral-large-2502-v1:0":[0.000002,0.000006,null,null],"zai.glm-4.7":[6e-7,0.0000022,null,null],"zai.glm-4.7-flash":[7e-8,4e-7,null,null],"zai.glm-5":[0.000001,0.0000032,null,null],"gpt-4o-mini-tts-2025-03-20":[0.0000025,0.00001,null,null],"gpt-4o-mini-tts-2025-12-15":[0.0000025,0.00001,null,null],"gpt-4o-mini-transcribe-2025-03-20":[0.00000125,0.000005,null,null],"gpt-4o-mini-transcribe-2025-12-15":[0.00000125,0.000005,null,null],"gpt-5-search-api":[0.00000125,0.00001,null,1.25e-7],"gpt-5-search-api-2025-10-14":[0.00000125,0.00001,null,1.25e-7],"gpt-realtime-mini-2025-10-06":[6e-7,0.0000024,null,6e-8],"gpt-realtime-mini-2025-12-15":[6e-7,0.0000024,null,6e-8],"gemini-2.0-flash-exp-image-generation":[0,0,null,null],"gemini-2.5-flash-native-audio-latest":[3e-7,0.0000025,null,null],"gemini-2.5-flash-native-audio-preview-09-2025":[3e-7,0.0000025,null,null],"gemini-2.5-flash-native-audio-preview-12-2025":[3e-7,0.0000025,null,null],"gemini-3.1-flash-live-preview":[7.5e-7,0.0000045,null,null],"gemini-2.5-flash-preview-tts":[3e-7,0.0000025,null,null],"gemini-flash-latest":[3e-7,0.0000025,null,3e-8],"gemini-flash-lite-latest":[1e-7,4e-7,null,1e-8],"gemini-pro-latest":[0.00000125,0.00001,null,1.25e-7],"gemini-exp-1206":[3e-7,0.0000025,null,3e-8],"anyscale/HuggingFaceH4/zephyr-7b-beta":[1.5e-7,1.5e-7,null,null],"HuggingFaceH4/zephyr-7b-beta":[1.5e-7,1.5e-7,null,null],"anyscale/codellama/CodeLlama-34b-Instruct-hf":[0.000001,0.000001,null,null],"codellama/CodeLlama-34b-Instruct-hf":[0.000001,0.000001,null,null],"anyscale/codellama/CodeLlama-70b-Instruct-hf":[0.000001,0.000001,null,null],"codellama/CodeLlama-70b-Instruct-hf":[0.000001,0.000001,null,null],"anyscale/google/gemma-7b-it":[1.5e-7,1.5e-7,null,null],"google/gemma-7b-it":[1.5e-7,1.5e-7,null,null],"anyscale/meta-llama/Llama-2-13b-chat-hf":[2.5e-7,2.5e-7,null,null],"meta-llama/Llama-2-13b-chat-hf":[2.5e-7,2.5e-7,null,null],"anyscale/meta-llama/Llama-2-70b-chat-hf":[0.000001,0.000001,null,null],"meta-llama/Llama-2-70b-chat-hf":[0.000001,0.000001,null,null],"anyscale/meta-llama/Llama-2-7b-chat-hf":[1.5e-7,1.5e-7,null,null],"meta-llama/Llama-2-7b-chat-hf":[1.5e-7,1.5e-7,null,null],"anyscale/meta-llama/Meta-Llama-3-70B-Instruct":[0.000001,0.000001,null,null],"meta-llama/Meta-Llama-3-70B-Instruct":[0.000001,0.000001,null,null],"anyscale/meta-llama/Meta-Llama-3-8B-Instruct":[1.5e-7,1.5e-7,null,null],"meta-llama/Meta-Llama-3-8B-Instruct":[1.5e-7,1.5e-7,null,null],"anyscale/mistralai/Mistral-7B-Instruct-v0.1":[1.5e-7,1.5e-7,null,null],"mistralai/Mistral-7B-Instruct-v0.1":[1.5e-7,1.5e-7,null,null],"anyscale/mistralai/Mixtral-8x22B-Instruct-v0.1":[9e-7,9e-7,null,null],"mistralai/Mixtral-8x22B-Instruct-v0.1":[9e-7,9e-7,null,null],"anyscale/mistralai/Mixtral-8x7B-Instruct-v0.1":[1.5e-7,1.5e-7,null,null],"mistralai/Mixtral-8x7B-Instruct-v0.1":[1.5e-7,1.5e-7,null,null],"azure/ada":[1e-7,0,null,null],"ada":[1e-7,0,null,null],"azure/codex-mini":[0.0000015,0.000006,null,3.75e-7],"codex-mini":[0.0000015,0.000006,null,3.75e-7],"azure/command-r-plus":[0.000003,0.000015,null,null],"azure_ai/claude-haiku-4-5":[0.000001,0.000005,0.00000125,1e-7],"azure_ai/claude-opus-4-5":[0.000005,0.000025,0.00000625,5e-7],"azure_ai/claude-opus-4-6":[0.000005,0.000025,0.00000625,5e-7],"azure_ai/claude-opus-4-7":[0.000005,0.000025,0.00000625,5e-7],"azure_ai/claude-opus-4-1":[0.000015,0.000075,0.00001875,0.0000015],"azure_ai/claude-sonnet-4-5":[0.000003,0.000015,0.00000375,3e-7],"azure_ai/claude-sonnet-4-6":[0.000003,0.000015,0.00000375,3e-7],"azure/computer-use-preview":[0.000003,0.000012,null,null],"azure_ai/gpt-oss-120b":[1.5e-7,6e-7,null,null],"gpt-oss-120b":[1.5e-7,6e-7,null,null],"azure_ai/model_router":[1.4e-7,0,null,null],"model_router":[1.4e-7,0,null,null],"azure/eu/gpt-4o-2024-08-06":[0.00000275,0.000011,null,0.000001375],"eu/gpt-4o-2024-08-06":[0.00000275,0.000011,null,0.000001375],"azure/eu/gpt-4o-2024-11-20":[0.00000275,0.000011,0.00000138,null],"eu/gpt-4o-2024-11-20":[0.00000275,0.000011,0.00000138,null],"azure/eu/gpt-4o-mini-2024-07-18":[1.65e-7,6.6e-7,null,8.3e-8],"eu/gpt-4o-mini-2024-07-18":[1.65e-7,6.6e-7,null,8.3e-8],"azure/eu/gpt-4o-mini-realtime-preview-2024-12-17":[6.6e-7,0.00000264,null,3.3e-7],"eu/gpt-4o-mini-realtime-preview-2024-12-17":[6.6e-7,0.00000264,null,3.3e-7],"azure/eu/gpt-4o-realtime-preview-2024-10-01":[0.0000055,0.000022,null,0.00000275],"eu/gpt-4o-realtime-preview-2024-10-01":[0.0000055,0.000022,null,0.00000275],"azure/eu/gpt-4o-realtime-preview-2024-12-17":[0.0000055,0.000022,null,0.00000275],"eu/gpt-4o-realtime-preview-2024-12-17":[0.0000055,0.000022,null,0.00000275],"azure/eu/gpt-5-2025-08-07":[0.000001375,0.000011,null,1.375e-7],"eu/gpt-5-2025-08-07":[0.000001375,0.000011,null,1.375e-7],"azure/eu/gpt-5-mini-2025-08-07":[2.75e-7,0.0000022,null,2.75e-8],"eu/gpt-5-mini-2025-08-07":[2.75e-7,0.0000022,null,2.75e-8],"azure/eu/gpt-5.1":[0.00000138,0.000011,null,1.4e-7],"eu/gpt-5.1":[0.00000138,0.000011,null,1.4e-7],"azure/eu/gpt-5.1-chat":[0.00000138,0.000011,null,1.4e-7],"eu/gpt-5.1-chat":[0.00000138,0.000011,null,1.4e-7],"azure/eu/gpt-5.1-codex":[0.00000138,0.000011,null,1.4e-7],"eu/gpt-5.1-codex":[0.00000138,0.000011,null,1.4e-7],"azure/eu/gpt-5.1-codex-mini":[2.75e-7,0.0000022,null,2.8e-8],"eu/gpt-5.1-codex-mini":[2.75e-7,0.0000022,null,2.8e-8],"azure/eu/gpt-5-nano-2025-08-07":[5.5e-8,4.4e-7,null,5.5e-9],"eu/gpt-5-nano-2025-08-07":[5.5e-8,4.4e-7,null,5.5e-9],"azure/eu/o1-2024-12-17":[0.0000165,0.000066,null,0.00000825],"eu/o1-2024-12-17":[0.0000165,0.000066,null,0.00000825],"azure/eu/o1-mini-2024-09-12":[0.00000121,0.00000484,null,6.05e-7],"eu/o1-mini-2024-09-12":[0.00000121,0.00000484,null,6.05e-7],"azure/eu/o1-preview-2024-09-12":[0.0000165,0.000066,null,0.00000825],"eu/o1-preview-2024-09-12":[0.0000165,0.000066,null,0.00000825],"azure/eu/o3-mini-2025-01-31":[0.00000121,0.00000484,null,6.05e-7],"eu/o3-mini-2025-01-31":[0.00000121,0.00000484,null,6.05e-7],"azure/global-standard/gpt-4o-2024-08-06":[0.0000025,0.00001,null,0.00000125],"global-standard/gpt-4o-2024-08-06":[0.0000025,0.00001,null,0.00000125],"azure/global-standard/gpt-4o-2024-11-20":[0.0000025,0.00001,null,0.00000125],"global-standard/gpt-4o-2024-11-20":[0.0000025,0.00001,null,0.00000125],"azure/global-standard/gpt-4o-mini":[1.5e-7,6e-7,null,null],"global-standard/gpt-4o-mini":[1.5e-7,6e-7,null,null],"azure/global/gpt-4o-2024-08-06":[0.0000025,0.00001,null,0.00000125],"global/gpt-4o-2024-08-06":[0.0000025,0.00001,null,0.00000125],"azure/global/gpt-4o-2024-11-20":[0.0000025,0.00001,null,0.00000125],"global/gpt-4o-2024-11-20":[0.0000025,0.00001,null,0.00000125],"azure/global/gpt-5.1":[0.00000125,0.00001,null,1.25e-7],"global/gpt-5.1":[0.00000125,0.00001,null,1.25e-7],"azure/global/gpt-5.1-chat":[0.00000125,0.00001,null,1.25e-7],"global/gpt-5.1-chat":[0.00000125,0.00001,null,1.25e-7],"azure/global/gpt-5.1-codex":[0.00000125,0.00001,null,1.25e-7],"global/gpt-5.1-codex":[0.00000125,0.00001,null,1.25e-7],"azure/global/gpt-5.1-codex-mini":[2.5e-7,0.000002,null,2.5e-8],"global/gpt-5.1-codex-mini":[2.5e-7,0.000002,null,2.5e-8],"azure/gpt-3.5-turbo":[5e-7,0.0000015,null,null],"azure/gpt-3.5-turbo-0125":[5e-7,0.0000015,null,null],"azure/gpt-3.5-turbo-instruct-0914":[0.0000015,0.000002,null,null],"azure/gpt-35-turbo":[5e-7,0.0000015,null,null],"gpt-35-turbo":[5e-7,0.0000015,null,null],"azure/gpt-35-turbo-0125":[5e-7,0.0000015,null,null],"gpt-35-turbo-0125":[5e-7,0.0000015,null,null],"azure/gpt-35-turbo-1106":[0.000001,0.000002,null,null],"gpt-35-turbo-1106":[0.000001,0.000002,null,null],"azure/gpt-35-turbo-16k":[0.000003,0.000004,null,null],"gpt-35-turbo-16k":[0.000003,0.000004,null,null],"azure/gpt-35-turbo-16k-0613":[0.000003,0.000004,null,null],"gpt-35-turbo-16k-0613":[0.000003,0.000004,null,null],"azure/gpt-35-turbo-instruct":[0.0000015,0.000002,null,null],"gpt-35-turbo-instruct":[0.0000015,0.000002,null,null],"azure/gpt-35-turbo-instruct-0914":[0.0000015,0.000002,null,null],"gpt-35-turbo-instruct-0914":[0.0000015,0.000002,null,null],"azure/gpt-4":[0.00003,0.00006,null,null],"azure/gpt-4-0125-preview":[0.00001,0.00003,null,null],"azure/gpt-4-0613":[0.00003,0.00006,null,null],"azure/gpt-4-1106-preview":[0.00001,0.00003,null,null],"azure/gpt-4-32k":[0.00006,0.00012,null,null],"gpt-4-32k":[0.00006,0.00012,null,null],"azure/gpt-4-32k-0613":[0.00006,0.00012,null,null],"gpt-4-32k-0613":[0.00006,0.00012,null,null],"azure/gpt-4-turbo":[0.00001,0.00003,null,null],"azure/gpt-4-turbo-2024-04-09":[0.00001,0.00003,null,null],"azure/gpt-4-turbo-vision-preview":[0.00001,0.00003,null,null],"gpt-4-turbo-vision-preview":[0.00001,0.00003,null,null],"azure/gpt-4.1":[0.000002,0.000008,null,5e-7],"azure/gpt-4.1-2025-04-14":[0.000002,0.000008,null,5e-7],"azure/gpt-4.1-mini":[4e-7,0.0000016,null,1e-7],"azure/gpt-4.1-mini-2025-04-14":[4e-7,0.0000016,null,1e-7],"azure/gpt-4.1-nano":[1e-7,4e-7,null,2.5e-8],"azure/gpt-4.1-nano-2025-04-14":[1e-7,4e-7,null,2.5e-8],"azure/gpt-4.5-preview":[0.000075,0.00015,null,0.0000375],"gpt-4.5-preview":[0.000075,0.00015,null,0.0000375],"azure/gpt-4o":[0.0000025,0.00001,null,0.00000125],"azure/gpt-4o-2024-05-13":[0.000005,0.000015,null,null],"azure/gpt-4o-2024-08-06":[0.0000025,0.00001,null,0.00000125],"azure/gpt-4o-2024-11-20":[0.00000275,0.000011,null,0.00000125],"azure/gpt-audio-2025-08-28":[0.0000025,0.00001,null,null],"azure/gpt-audio-1.5-2026-02-23":[0.0000025,0.00001,null,null],"gpt-audio-1.5-2026-02-23":[0.0000025,0.00001,null,null],"azure/gpt-audio-mini-2025-10-06":[6e-7,0.0000024,null,null],"azure/gpt-4o-audio-preview-2024-12-17":[0.0000025,0.00001,null,null],"azure/gpt-4o-mini":[1.65e-7,6.6e-7,null,7.5e-8],"azure/gpt-4o-mini-2024-07-18":[1.65e-7,6.6e-7,null,7.5e-8],"azure/gpt-4o-mini-audio-preview-2024-12-17":[0.0000025,0.00001,null,null],"azure/gpt-4o-mini-realtime-preview-2024-12-17":[6e-7,0.0000024,null,3e-7],"azure/gpt-realtime-2025-08-28":[0.000004,0.000016,null,0.000004],"azure/gpt-realtime-1.5-2026-02-23":[0.000004,0.000016,null,0.000004],"gpt-realtime-1.5-2026-02-23":[0.000004,0.000016,null,0.000004],"azure/gpt-realtime-mini-2025-10-06":[6e-7,0.0000024,null,6e-8],"azure/gpt-4o-mini-transcribe":[0.00000125,0.000005,null,null],"azure/gpt-4o-mini-tts":[0.0000025,0.00001,null,null],"azure/gpt-4o-realtime-preview-2024-10-01":[0.000005,0.00002,null,0.0000025],"gpt-4o-realtime-preview-2024-10-01":[0.000005,0.00002,null,0.0000025],"azure/gpt-4o-realtime-preview-2024-12-17":[0.000005,0.00002,null,0.0000025],"azure/gpt-4o-transcribe":[0.0000025,0.00001,null,null],"azure/gpt-4o-transcribe-diarize":[0.0000025,0.00001,null,null],"azure/gpt-5.1-2025-11-13":[0.00000125,0.00001,null,1.25e-7],"azure/gpt-5.1-chat-2025-11-13":[0.00000125,0.00001,null,1.25e-7],"gpt-5.1-chat-2025-11-13":[0.00000125,0.00001,null,1.25e-7],"azure/gpt-5.1-codex-2025-11-13":[0.00000125,0.00001,null,1.25e-7],"gpt-5.1-codex-2025-11-13":[0.00000125,0.00001,null,1.25e-7],"azure/gpt-5.1-codex-mini-2025-11-13":[2.5e-7,0.000002,null,2.5e-8],"gpt-5.1-codex-mini-2025-11-13":[2.5e-7,0.000002,null,2.5e-8],"azure/gpt-5":[0.00000125,0.00001,null,1.25e-7],"azure/gpt-5-2025-08-07":[0.00000125,0.00001,null,1.25e-7],"azure/gpt-5-chat":[0.00000125,0.00001,null,1.25e-7],"azure/gpt-5-chat-latest":[0.00000125,0.00001,null,1.25e-7],"azure/gpt-5-codex":[0.00000125,0.00001,null,1.25e-7],"azure/gpt-5-mini":[2.5e-7,0.000002,null,2.5e-8],"azure/gpt-5-mini-2025-08-07":[2.5e-7,0.000002,null,2.5e-8],"azure/gpt-5-nano":[5e-8,4e-7,null,5e-9],"azure/gpt-5-nano-2025-08-07":[5e-8,4e-7,null,5e-9],"azure/gpt-5-pro":[0.000015,0.00012,null,null],"azure/gpt-5.1":[0.00000125,0.00001,null,1.25e-7],"azure/gpt-5.1-chat":[0.00000125,0.00001,null,1.25e-7],"gpt-5.1-chat":[0.00000125,0.00001,null,1.25e-7],"azure/gpt-5.1-codex":[0.00000125,0.00001,null,1.25e-7],"azure/gpt-5.1-codex-max":[0.00000125,0.00001,null,1.25e-7],"azure/gpt-5.1-codex-mini":[2.5e-7,0.000002,null,2.5e-8],"azure/gpt-5.2":[0.00000175,0.000014,null,1.75e-7],"azure/gpt-5.2-2025-12-11":[0.00000175,0.000014,null,1.75e-7],"azure/gpt-5.2-chat":[0.00000175,0.000014,null,1.75e-7],"gpt-5.2-chat":[0.00000175,0.000014,null,1.75e-7],"azure/gpt-5.2-chat-2025-12-11":[0.00000175,0.000014,null,1.75e-7],"gpt-5.2-chat-2025-12-11":[0.00000175,0.000014,null,1.75e-7],"azure/gpt-5.2-codex":[0.00000175,0.000014,null,1.75e-7],"azure/gpt-5.3-chat":[0.00000175,0.000014,null,1.75e-7],"gpt-5.3-chat":[0.00000175,0.000014,null,1.75e-7],"azure/gpt-5.3-codex":[0.00000175,0.000014,null,1.75e-7],"azure/gpt-5.2-pro":[0.000021,0.000168,null,null],"azure/gpt-5.2-pro-2025-12-11":[0.000021,0.000168,null,null],"azure/gpt-5.4":[0.0000025,0.000015,null,2.5e-7],"azure/gpt-5.4-2026-03-05":[0.0000025,0.000015,null,2.5e-7],"azure/gpt-5.4-pro":[0.00003,0.00018,null,0.000003],"azure/gpt-5.4-pro-2026-03-05":[0.00003,0.00018,null,0.000003],"azure/gpt-5.5":[0.000005,0.00003,null,5e-7],"azure/gpt-5.5-2026-04-23":[0.000005,0.00003,null,5e-7],"azure/gpt-5.5-pro":[0.00006,0.00036,null,0.000006],"azure/gpt-5.5-pro-2026-04-23":[0.00006,0.00036,null,0.000006],"azure/gpt-5.4-mini":[7.5e-7,0.0000045,null,7.5e-8],"azure/gpt-5.4-mini-2026-03-17":[7.5e-7,0.0000045,null,7.5e-8],"azure/gpt-5.4-nano":[2e-7,0.00000125,null,2e-8],"azure/gpt-5.4-nano-2026-03-17":[2e-7,0.00000125,null,2e-8],"azure/mistral-large-2402":[0.000008,0.000024,null,null],"mistral-large-2402":[0.000008,0.000024,null,null],"azure/mistral-large-latest":[0.000008,0.000024,null,null],"mistral-large-latest":[0.000008,0.000024,null,null],"azure/o1":[0.000015,0.00006,null,0.0000075],"azure/o1-2024-12-17":[0.000015,0.00006,null,0.0000075],"azure/o1-mini":[0.00000121,0.00000484,null,6.05e-7],"o1-mini":[0.00000121,0.00000484,null,6.05e-7],"azure/o1-mini-2024-09-12":[0.0000011,0.0000044,null,5.5e-7],"o1-mini-2024-09-12":[0.0000011,0.0000044,null,5.5e-7],"azure/o1-preview":[0.000015,0.00006,null,0.0000075],"o1-preview":[0.000015,0.00006,null,0.0000075],"azure/o1-preview-2024-09-12":[0.000015,0.00006,null,0.0000075],"o1-preview-2024-09-12":[0.000015,0.00006,null,0.0000075],"azure/o3":[0.000002,0.000008,null,5e-7],"azure/o3-2025-04-16":[0.000002,0.000008,null,5e-7],"azure/o3-deep-research":[0.00001,0.00004,null,0.0000025],"azure/o3-mini":[0.0000011,0.0000044,null,5.5e-7],"azure/o3-mini-2025-01-31":[0.0000011,0.0000044,null,5.5e-7],"azure/o3-pro":[0.00002,0.00008,null,null],"azure/o3-pro-2025-06-10":[0.00002,0.00008,null,null],"azure/o4-mini":[0.0000011,0.0000044,null,2.75e-7],"azure/o4-mini-2025-04-16":[0.0000011,0.0000044,null,2.75e-7],"azure/text-embedding-3-large":[1.3e-7,0,null,null],"azure/text-embedding-3-small":[2e-8,0,null,null],"azure/text-embedding-ada-002":[1e-7,0,null,null],"azure/us/gpt-4.1-2025-04-14":[0.0000022,0.0000088,null,5.5e-7],"us/gpt-4.1-2025-04-14":[0.0000022,0.0000088,null,5.5e-7],"azure/us/gpt-4.1-mini-2025-04-14":[4.4e-7,0.00000176,null,1.1e-7],"us/gpt-4.1-mini-2025-04-14":[4.4e-7,0.00000176,null,1.1e-7],"azure/us/gpt-4.1-nano-2025-04-14":[1.1e-7,4.4e-7,null,2.5e-8],"us/gpt-4.1-nano-2025-04-14":[1.1e-7,4.4e-7,null,2.5e-8],"azure/us/gpt-4o-2024-08-06":[0.00000275,0.000011,null,0.000001375],"us/gpt-4o-2024-08-06":[0.00000275,0.000011,null,0.000001375],"azure/us/gpt-4o-2024-11-20":[0.00000275,0.000011,0.00000138,null],"us/gpt-4o-2024-11-20":[0.00000275,0.000011,0.00000138,null],"azure/us/gpt-4o-mini-2024-07-18":[1.65e-7,6.6e-7,null,8.3e-8],"us/gpt-4o-mini-2024-07-18":[1.65e-7,6.6e-7,null,8.3e-8],"azure/us/gpt-4o-mini-realtime-preview-2024-12-17":[6.6e-7,0.00000264,null,3.3e-7],"us/gpt-4o-mini-realtime-preview-2024-12-17":[6.6e-7,0.00000264,null,3.3e-7],"azure/us/gpt-4o-realtime-preview-2024-10-01":[0.0000055,0.000022,null,0.00000275],"us/gpt-4o-realtime-preview-2024-10-01":[0.0000055,0.000022,null,0.00000275],"azure/us/gpt-4o-realtime-preview-2024-12-17":[0.0000055,0.000022,null,0.00000275],"us/gpt-4o-realtime-preview-2024-12-17":[0.0000055,0.000022,null,0.00000275],"azure/us/gpt-5-2025-08-07":[0.000001375,0.000011,null,1.375e-7],"us/gpt-5-2025-08-07":[0.000001375,0.000011,null,1.375e-7],"azure/us/gpt-5-mini-2025-08-07":[2.75e-7,0.0000022,null,2.75e-8],"us/gpt-5-mini-2025-08-07":[2.75e-7,0.0000022,null,2.75e-8],"azure/us/gpt-5-nano-2025-08-07":[5.5e-8,4.4e-7,null,5.5e-9],"us/gpt-5-nano-2025-08-07":[5.5e-8,4.4e-7,null,5.5e-9],"azure/us/gpt-5.1":[0.00000138,0.000011,null,1.4e-7],"us/gpt-5.1":[0.00000138,0.000011,null,1.4e-7],"azure/us/gpt-5.1-chat":[0.00000138,0.000011,null,1.4e-7],"us/gpt-5.1-chat":[0.00000138,0.000011,null,1.4e-7],"azure/us/gpt-5.1-codex":[0.00000138,0.000011,null,1.4e-7],"us/gpt-5.1-codex":[0.00000138,0.000011,null,1.4e-7],"azure/us/gpt-5.1-codex-mini":[2.75e-7,0.0000022,null,2.8e-8],"us/gpt-5.1-codex-mini":[2.75e-7,0.0000022,null,2.8e-8],"azure/us/o1-2024-12-17":[0.0000165,0.000066,null,0.00000825],"us/o1-2024-12-17":[0.0000165,0.000066,null,0.00000825],"azure/us/o1-mini-2024-09-12":[0.00000121,0.00000484,null,6.05e-7],"us/o1-mini-2024-09-12":[0.00000121,0.00000484,null,6.05e-7],"azure/us/o1-preview-2024-09-12":[0.0000165,0.000066,null,0.00000825],"us/o1-preview-2024-09-12":[0.0000165,0.000066,null,0.00000825],"azure/us/o3-2025-04-16":[0.0000022,0.0000088,null,5.5e-7],"us/o3-2025-04-16":[0.0000022,0.0000088,null,5.5e-7],"azure/us/o3-mini-2025-01-31":[0.00000121,0.00000484,null,6.05e-7],"us/o3-mini-2025-01-31":[0.00000121,0.00000484,null,6.05e-7],"azure/us/o4-mini-2025-04-16":[0.00000121,0.00000484,null,3.1e-7],"us/o4-mini-2025-04-16":[0.00000121,0.00000484,null,3.1e-7],"azure_ai/Cohere-embed-v3-english":[1e-7,0,null,null],"Cohere-embed-v3-english":[1e-7,0,null,null],"azure_ai/Cohere-embed-v3-multilingual":[1e-7,0,null,null],"Cohere-embed-v3-multilingual":[1e-7,0,null,null],"azure_ai/Llama-3.2-11B-Vision-Instruct":[3.7e-7,3.7e-7,null,null],"Llama-3.2-11B-Vision-Instruct":[3.7e-7,3.7e-7,null,null],"azure_ai/Llama-3.2-90B-Vision-Instruct":[0.00000204,0.00000204,null,null],"Llama-3.2-90B-Vision-Instruct":[0.00000204,0.00000204,null,null],"azure_ai/Llama-3.3-70B-Instruct":[7.1e-7,7.1e-7,null,null],"Llama-3.3-70B-Instruct":[7.1e-7,7.1e-7,null,null],"azure_ai/Llama-4-Maverick-17B-128E-Instruct-FP8":[0.00000141,3.5e-7,null,null],"Llama-4-Maverick-17B-128E-Instruct-FP8":[0.00000141,3.5e-7,null,null],"azure_ai/Llama-4-Scout-17B-16E-Instruct":[2e-7,7.8e-7,null,null],"Llama-4-Scout-17B-16E-Instruct":[2e-7,7.8e-7,null,null],"azure_ai/Meta-Llama-3-70B-Instruct":[0.0000011,3.7e-7,null,null],"Meta-Llama-3-70B-Instruct":[0.0000011,3.7e-7,null,null],"azure_ai/Meta-Llama-3.1-405B-Instruct":[0.00000533,0.000016,null,null],"Meta-Llama-3.1-405B-Instruct":[0.00000533,0.000016,null,null],"azure_ai/Meta-Llama-3.1-70B-Instruct":[0.00000268,0.00000354,null,null],"Meta-Llama-3.1-70B-Instruct":[0.00000268,0.00000354,null,null],"azure_ai/Meta-Llama-3.1-8B-Instruct":[3e-7,6.1e-7,null,null],"Meta-Llama-3.1-8B-Instruct":[3e-7,6.1e-7,null,null],"azure_ai/Phi-3-medium-128k-instruct":[1.7e-7,6.8e-7,null,null],"Phi-3-medium-128k-instruct":[1.7e-7,6.8e-7,null,null],"azure_ai/Phi-3-medium-4k-instruct":[1.7e-7,6.8e-7,null,null],"Phi-3-medium-4k-instruct":[1.7e-7,6.8e-7,null,null],"azure_ai/Phi-3-mini-128k-instruct":[1.3e-7,5.2e-7,null,null],"Phi-3-mini-128k-instruct":[1.3e-7,5.2e-7,null,null],"azure_ai/Phi-3-mini-4k-instruct":[1.3e-7,5.2e-7,null,null],"Phi-3-mini-4k-instruct":[1.3e-7,5.2e-7,null,null],"azure_ai/Phi-3-small-128k-instruct":[1.5e-7,6e-7,null,null],"Phi-3-small-128k-instruct":[1.5e-7,6e-7,null,null],"azure_ai/Phi-3-small-8k-instruct":[1.5e-7,6e-7,null,null],"Phi-3-small-8k-instruct":[1.5e-7,6e-7,null,null],"azure_ai/Phi-3.5-MoE-instruct":[1.6e-7,6.4e-7,null,null],"Phi-3.5-MoE-instruct":[1.6e-7,6.4e-7,null,null],"azure_ai/Phi-3.5-mini-instruct":[1.3e-7,5.2e-7,null,null],"Phi-3.5-mini-instruct":[1.3e-7,5.2e-7,null,null],"azure_ai/Phi-3.5-vision-instruct":[1.3e-7,5.2e-7,null,null],"Phi-3.5-vision-instruct":[1.3e-7,5.2e-7,null,null],"azure_ai/Phi-4":[1.25e-7,5e-7,null,null],"Phi-4":[1.25e-7,5e-7,null,null],"azure_ai/Phi-4-mini-instruct":[7.5e-8,3e-7,null,null],"Phi-4-mini-instruct":[7.5e-8,3e-7,null,null],"azure_ai/Phi-4-multimodal-instruct":[8e-8,3.2e-7,null,null],"Phi-4-multimodal-instruct":[8e-8,3.2e-7,null,null],"azure_ai/Phi-4-mini-reasoning":[8e-8,3.2e-7,null,null],"Phi-4-mini-reasoning":[8e-8,3.2e-7,null,null],"azure_ai/Phi-4-reasoning":[1.25e-7,5e-7,null,null],"Phi-4-reasoning":[1.25e-7,5e-7,null,null],"azure_ai/MAI-DS-R1":[0.00000135,0.0000054,null,null],"MAI-DS-R1":[0.00000135,0.0000054,null,null],"azure_ai/cohere-rerank-v3-english":[0,0,null,null],"cohere-rerank-v3-english":[0,0,null,null],"azure_ai/cohere-rerank-v3-multilingual":[0,0,null,null],"cohere-rerank-v3-multilingual":[0,0,null,null],"azure_ai/cohere-rerank-v3.5":[0,0,null,null],"cohere-rerank-v3.5":[0,0,null,null],"azure_ai/cohere-rerank-v4.0-pro":[0,0,null,null],"cohere-rerank-v4.0-pro":[0,0,null,null],"azure_ai/cohere-rerank-v4.0-fast":[0,0,null,null],"cohere-rerank-v4.0-fast":[0,0,null,null],"azure_ai/deepseek-v3.2":[5.8e-7,0.00000168,null,null],"deepseek-v3.2":[5.8e-7,0.00000168,null,null],"azure_ai/deepseek-v3.2-speciale":[5.8e-7,0.00000168,null,null],"deepseek-v3.2-speciale":[5.8e-7,0.00000168,null,null],"azure_ai/deepseek-r1":[0.00000135,0.0000054,null,null],"deepseek-r1":[0.00000135,0.0000054,null,null],"azure_ai/deepseek-v3":[0.00000114,0.00000456,null,null],"deepseek-v3":[0.00000114,0.00000456,null,null],"azure_ai/deepseek-v3-0324":[0.00000114,0.00000456,null,null],"deepseek-v3-0324":[0.00000114,0.00000456,null,null],"azure_ai/embed-v-4-0":[1.2e-7,0,null,null],"embed-v-4-0":[1.2e-7,0,null,null],"azure_ai/global/grok-3":[0.000003,0.000015,null,null],"global/grok-3":[0.000003,0.000015,null,null],"azure_ai/global/grok-3-mini":[2.5e-7,0.00000127,null,null],"global/grok-3-mini":[2.5e-7,0.00000127,null,null],"azure_ai/grok-3":[0.000003,0.000015,null,null],"grok-3":[0.000003,0.000015,null,null],"azure_ai/grok-3-mini":[2.5e-7,0.00000127,null,null],"grok-3-mini":[2.5e-7,0.00000127,null,null],"azure_ai/grok-4":[0.000003,0.000015,null,null],"grok-4":[0.000003,0.000015,null,null],"azure_ai/grok-4-fast-non-reasoning":[2e-7,5e-7,null,null],"grok-4-fast-non-reasoning":[2e-7,5e-7,null,null],"azure_ai/grok-4-fast-reasoning":[2e-7,5e-7,null,null],"grok-4-fast-reasoning":[2e-7,5e-7,null,null],"azure_ai/grok-4-1-fast-non-reasoning":[2e-7,5e-7,null,null],"grok-4-1-fast-non-reasoning":[2e-7,5e-7,null,null],"azure_ai/grok-4-1-fast-reasoning":[2e-7,5e-7,null,null],"grok-4-1-fast-reasoning":[2e-7,5e-7,null,null],"azure_ai/grok-code-fast-1":[2e-7,0.0000015,null,null],"grok-code-fast-1":[2e-7,0.0000015,null,null],"azure_ai/jais-30b-chat":[0.0032,0.00971,null,null],"jais-30b-chat":[0.0032,0.00971,null,null],"azure_ai/jamba-instruct":[5e-7,7e-7,null,null],"jamba-instruct":[5e-7,7e-7,null,null],"azure_ai/kimi-k2.5":[6e-7,0.000003,null,null],"kimi-k2.5":[6e-7,0.000003,null,null],"azure_ai/ministral-3b":[4e-8,4e-8,null,null],"ministral-3b":[4e-8,4e-8,null,null],"azure_ai/mistral-large":[0.000004,0.000012,null,null],"mistral-large":[0.000004,0.000012,null,null],"azure_ai/mistral-large-2407":[0.000002,0.000006,null,null],"mistral-large-2407":[0.000002,0.000006,null,null],"azure_ai/mistral-large-latest":[0.000002,0.000006,null,null],"azure_ai/mistral-large-3":[5e-7,0.0000015,null,null],"mistral-large-3":[5e-7,0.0000015,null,null],"azure_ai/mistral-medium-2505":[4e-7,0.000002,null,null],"mistral-medium-2505":[4e-7,0.000002,null,null],"azure_ai/mistral-nemo":[1.5e-7,1.5e-7,null,null],"mistral-nemo":[1.5e-7,1.5e-7,null,null],"azure_ai/mistral-small":[0.000001,0.000003,null,null],"mistral-small":[0.000001,0.000003,null,null],"azure_ai/mistral-small-2503":[1e-7,3e-7,null,null],"mistral-small-2503":[1e-7,3e-7,null,null],"bedrock/ap-northeast-1/anthropic.claude-instant-v1":[0.00000223,0.00000755,null,null],"ap-northeast-1/anthropic.claude-instant-v1":[0.00000223,0.00000755,null,null],"bedrock/ap-northeast-1/anthropic.claude-v1":[0.000008,0.000024,null,null],"ap-northeast-1/anthropic.claude-v1":[0.000008,0.000024,null,null],"bedrock/ap-northeast-1/anthropic.claude-v2:1":[0.000008,0.000024,null,null],"ap-northeast-1/anthropic.claude-v2:1":[0.000008,0.000024,null,null],"bedrock/ap-northeast-1/deepseek.v3.2":[7.4e-7,0.00000222,null,null],"ap-northeast-1/deepseek.v3.2":[7.4e-7,0.00000222,null,null],"bedrock/ap-northeast-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"ap-northeast-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"bedrock/ap-northeast-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"ap-northeast-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"bedrock/ap-northeast-1/moonshotai.kimi-k2-thinking":[7.3e-7,0.00000303,null,null],"ap-northeast-1/moonshotai.kimi-k2-thinking":[7.3e-7,0.00000303,null,null],"bedrock/ap-northeast-1/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null],"ap-northeast-1/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null],"bedrock/ap-northeast-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null],"ap-northeast-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null],"bedrock/moonshotai.kimi-k2-thinking":[7.3e-7,0.00000303,null,null],"moonshotai.kimi-k2-thinking":[7.3e-7,0.00000303,null,null],"bedrock/moonshotai.kimi-k2.5":[6e-7,0.00000303,null,null],"bedrock/ap-south-1/meta.llama3-70b-instruct-v1:0":[0.00000318,0.0000042,null,null],"ap-south-1/meta.llama3-70b-instruct-v1:0":[0.00000318,0.0000042,null,null],"bedrock/ap-south-1/meta.llama3-8b-instruct-v1:0":[3.6e-7,7.2e-7,null,null],"ap-south-1/meta.llama3-8b-instruct-v1:0":[3.6e-7,7.2e-7,null,null],"bedrock/ap-south-1/deepseek.v3.2":[7.4e-7,0.00000222,null,null],"ap-south-1/deepseek.v3.2":[7.4e-7,0.00000222,null,null],"bedrock/ap-south-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"ap-south-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"bedrock/ap-south-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"ap-south-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"bedrock/ap-south-1/moonshotai.kimi-k2-thinking":[7.1e-7,0.00000294,null,null],"ap-south-1/moonshotai.kimi-k2-thinking":[7.1e-7,0.00000294,null,null],"bedrock/ap-south-1/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null],"ap-south-1/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null],"bedrock/ap-south-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null],"ap-south-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null],"bedrock/ap-southeast-2/minimax.minimax-m2.5":[3.09e-7,0.000001236,null,null],"ap-southeast-2/minimax.minimax-m2.5":[3.09e-7,0.000001236,null,null],"bedrock/ap-southeast-3/deepseek.v3.2":[7.4e-7,0.00000222,null,null],"ap-southeast-3/deepseek.v3.2":[7.4e-7,0.00000222,null,null],"bedrock/ap-southeast-3/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"ap-southeast-3/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"bedrock/ap-southeast-3/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"ap-southeast-3/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"bedrock/ap-southeast-3/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null],"ap-southeast-3/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null],"bedrock/ap-southeast-3/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null],"ap-southeast-3/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null],"bedrock/ca-central-1/meta.llama3-70b-instruct-v1:0":[0.00000305,0.00000403,null,null],"ca-central-1/meta.llama3-70b-instruct-v1:0":[0.00000305,0.00000403,null,null],"bedrock/ca-central-1/meta.llama3-8b-instruct-v1:0":[3.5e-7,6.9e-7,null,null],"ca-central-1/meta.llama3-8b-instruct-v1:0":[3.5e-7,6.9e-7,null,null],"bedrock/eu-north-1/deepseek.v3.2":[7.4e-7,0.00000222,null,null],"eu-north-1/deepseek.v3.2":[7.4e-7,0.00000222,null,null],"bedrock/eu-north-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"eu-north-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"bedrock/eu-north-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"eu-north-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"bedrock/eu-north-1/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null],"eu-north-1/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null],"bedrock/eu-central-1/anthropic.claude-instant-v1":[0.00000248,0.00000838,null,null],"eu-central-1/anthropic.claude-instant-v1":[0.00000248,0.00000838,null,null],"bedrock/eu-central-1/anthropic.claude-v1":[0.000008,0.000024,null,null],"eu-central-1/anthropic.claude-v1":[0.000008,0.000024,null,null],"bedrock/eu-central-1/anthropic.claude-v2:1":[0.000008,0.000024,null,null],"eu-central-1/anthropic.claude-v2:1":[0.000008,0.000024,null,null],"bedrock/eu-central-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"eu-central-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"bedrock/eu-central-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"eu-central-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"bedrock/eu-central-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null],"eu-central-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null],"bedrock/eu-west-1/meta.llama3-70b-instruct-v1:0":[0.00000286,0.00000378,null,null],"eu-west-1/meta.llama3-70b-instruct-v1:0":[0.00000286,0.00000378,null,null],"bedrock/eu-west-1/meta.llama3-8b-instruct-v1:0":[3.2e-7,6.5e-7,null,null],"eu-west-1/meta.llama3-8b-instruct-v1:0":[3.2e-7,6.5e-7,null,null],"bedrock/eu-west-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"eu-west-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"bedrock/eu-west-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"eu-west-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"bedrock/eu-west-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null],"eu-west-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null],"bedrock/eu-west-2/meta.llama3-70b-instruct-v1:0":[0.00000345,0.00000455,null,null],"eu-west-2/meta.llama3-70b-instruct-v1:0":[0.00000345,0.00000455,null,null],"bedrock/eu-west-2/meta.llama3-8b-instruct-v1:0":[3.9e-7,7.8e-7,null,null],"eu-west-2/meta.llama3-8b-instruct-v1:0":[3.9e-7,7.8e-7,null,null],"bedrock/eu-west-2/minimax.minimax-m2.1":[4.7e-7,0.00000186,null,null],"eu-west-2/minimax.minimax-m2.1":[4.7e-7,0.00000186,null,null],"bedrock/eu-west-2/minimax.minimax-m2.5":[4.7e-7,0.00000186,null,null],"eu-west-2/minimax.minimax-m2.5":[4.7e-7,0.00000186,null,null],"bedrock/eu-west-2/qwen.qwen3-coder-next":[7.8e-7,0.00000186,null,null],"eu-west-2/qwen.qwen3-coder-next":[7.8e-7,0.00000186,null,null],"bedrock/eu-west-3/mistral.mistral-7b-instruct-v0:2":[2e-7,2.6e-7,null,null],"eu-west-3/mistral.mistral-7b-instruct-v0:2":[2e-7,2.6e-7,null,null],"bedrock/eu-west-3/mistral.mistral-large-2402-v1:0":[0.0000104,0.0000312,null,null],"eu-west-3/mistral.mistral-large-2402-v1:0":[0.0000104,0.0000312,null,null],"bedrock/eu-west-3/mistral.mixtral-8x7b-instruct-v0:1":[5.9e-7,9.1e-7,null,null],"eu-west-3/mistral.mixtral-8x7b-instruct-v0:1":[5.9e-7,9.1e-7,null,null],"bedrock/eu-south-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"eu-south-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"bedrock/eu-south-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"eu-south-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"bedrock/eu-south-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null],"eu-south-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null],"bedrock/invoke/anthropic.claude-3-5-sonnet-20240620-v1:0":[0.000003,0.000015,0.00000375,3e-7],"invoke/anthropic.claude-3-5-sonnet-20240620-v1:0":[0.000003,0.000015,0.00000375,3e-7],"bedrock/sa-east-1/meta.llama3-70b-instruct-v1:0":[0.00000445,0.00000588,null,null],"sa-east-1/meta.llama3-70b-instruct-v1:0":[0.00000445,0.00000588,null,null],"bedrock/sa-east-1/meta.llama3-8b-instruct-v1:0":[5e-7,0.00000101,null,null],"sa-east-1/meta.llama3-8b-instruct-v1:0":[5e-7,0.00000101,null,null],"bedrock/sa-east-1/deepseek.v3.2":[7.4e-7,0.00000222,null,null],"sa-east-1/deepseek.v3.2":[7.4e-7,0.00000222,null,null],"bedrock/sa-east-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"sa-east-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"bedrock/sa-east-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"sa-east-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"bedrock/sa-east-1/moonshotai.kimi-k2-thinking":[7.3e-7,0.00000303,null,null],"sa-east-1/moonshotai.kimi-k2-thinking":[7.3e-7,0.00000303,null,null],"bedrock/sa-east-1/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null],"sa-east-1/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null],"bedrock/sa-east-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null],"sa-east-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null],"bedrock/us-east-1/anthropic.claude-instant-v1":[8e-7,0.0000024,null,null],"us-east-1/anthropic.claude-instant-v1":[8e-7,0.0000024,null,null],"bedrock/us-east-1/anthropic.claude-v1":[0.000008,0.000024,null,null],"us-east-1/anthropic.claude-v1":[0.000008,0.000024,null,null],"bedrock/us-east-1/anthropic.claude-v2:1":[0.000008,0.000024,null,null],"us-east-1/anthropic.claude-v2:1":[0.000008,0.000024,null,null],"bedrock/us-east-1/meta.llama3-70b-instruct-v1:0":[0.00000265,0.0000035,null,null],"us-east-1/meta.llama3-70b-instruct-v1:0":[0.00000265,0.0000035,null,null],"bedrock/us-east-1/meta.llama3-8b-instruct-v1:0":[3e-7,6e-7,null,null],"us-east-1/meta.llama3-8b-instruct-v1:0":[3e-7,6e-7,null,null],"bedrock/us-east-1/mistral.mistral-7b-instruct-v0:2":[1.5e-7,2e-7,null,null],"us-east-1/mistral.mistral-7b-instruct-v0:2":[1.5e-7,2e-7,null,null],"bedrock/us-east-1/mistral.mistral-large-2402-v1:0":[0.000008,0.000024,null,null],"us-east-1/mistral.mistral-large-2402-v1:0":[0.000008,0.000024,null,null],"bedrock/us-east-1/mistral.mixtral-8x7b-instruct-v0:1":[4.5e-7,7e-7,null,null],"us-east-1/mistral.mixtral-8x7b-instruct-v0:1":[4.5e-7,7e-7,null,null],"bedrock/us-east-1/deepseek.v3.2":[6.2e-7,0.00000185,null,null],"us-east-1/deepseek.v3.2":[6.2e-7,0.00000185,null,null],"bedrock/us-east-1/minimax.minimax-m2.1":[3e-7,0.0000012,null,null],"us-east-1/minimax.minimax-m2.1":[3e-7,0.0000012,null,null],"bedrock/us-east-1/minimax.minimax-m2.5":[3e-7,0.0000012,null,null],"us-east-1/minimax.minimax-m2.5":[3e-7,0.0000012,null,null],"bedrock/us-east-1/moonshotai.kimi-k2-thinking":[6e-7,0.0000025,null,null],"us-east-1/moonshotai.kimi-k2-thinking":[6e-7,0.0000025,null,null],"bedrock/us-east-1/moonshotai.kimi-k2.5":[6e-7,0.000003,null,null],"us-east-1/moonshotai.kimi-k2.5":[6e-7,0.000003,null,null],"bedrock/us-east-1/qwen.qwen3-coder-next":[5e-7,0.0000012,null,null],"us-east-1/qwen.qwen3-coder-next":[5e-7,0.0000012,null,null],"bedrock/us-east-2/deepseek.v3.2":[6.2e-7,0.00000185,null,null],"us-east-2/deepseek.v3.2":[6.2e-7,0.00000185,null,null],"bedrock/us-east-2/minimax.minimax-m2.1":[3e-7,0.0000012,null,null],"us-east-2/minimax.minimax-m2.1":[3e-7,0.0000012,null,null],"bedrock/us-east-2/minimax.minimax-m2.5":[3e-7,0.0000012,null,null],"us-east-2/minimax.minimax-m2.5":[3e-7,0.0000012,null,null],"bedrock/us-east-2/moonshotai.kimi-k2-thinking":[6e-7,0.0000025,null,null],"us-east-2/moonshotai.kimi-k2-thinking":[6e-7,0.0000025,null,null],"bedrock/us-east-2/moonshotai.kimi-k2.5":[6e-7,0.000003,null,null],"us-east-2/moonshotai.kimi-k2.5":[6e-7,0.000003,null,null],"bedrock/us-east-2/qwen.qwen3-coder-next":[5e-7,0.0000012,null,null],"us-east-2/qwen.qwen3-coder-next":[5e-7,0.0000012,null,null],"bedrock/us-gov-east-1/amazon.nova-pro-v1:0":[9.6e-7,0.00000384,null,null],"us-gov-east-1/amazon.nova-pro-v1:0":[9.6e-7,0.00000384,null,null],"bedrock/us-gov-east-1/amazon.titan-embed-text-v1":[1e-7,0,null,null],"us-gov-east-1/amazon.titan-embed-text-v1":[1e-7,0,null,null],"bedrock/us-gov-east-1/amazon.titan-embed-text-v2:0":[2e-7,0,null,null],"us-gov-east-1/amazon.titan-embed-text-v2:0":[2e-7,0,null,null],"bedrock/us-gov-east-1/amazon.titan-text-express-v1":[0.0000013,0.0000017,null,null],"us-gov-east-1/amazon.titan-text-express-v1":[0.0000013,0.0000017,null,null],"bedrock/us-gov-east-1/amazon.titan-text-lite-v1":[3e-7,4e-7,null,null],"us-gov-east-1/amazon.titan-text-lite-v1":[3e-7,4e-7,null,null],"bedrock/us-gov-east-1/amazon.titan-text-premier-v1:0":[5e-7,0.0000015,null,null],"us-gov-east-1/amazon.titan-text-premier-v1:0":[5e-7,0.0000015,null,null],"bedrock/us-gov-east-1/anthropic.claude-3-5-sonnet-20240620-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7],"us-gov-east-1/anthropic.claude-3-5-sonnet-20240620-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7],"bedrock/us-gov-east-1/anthropic.claude-3-haiku-20240307-v1:0":[3e-7,0.0000015,3.75e-7,3e-8],"us-gov-east-1/anthropic.claude-3-haiku-20240307-v1:0":[3e-7,0.0000015,3.75e-7,3e-8],"bedrock/us-gov-east-1/anthropic.claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7],"us-gov-east-1/anthropic.claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7],"bedrock/us-gov-east-1/claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7],"us-gov-east-1/claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7],"bedrock/us-gov-east-1/meta.llama3-70b-instruct-v1:0":[0.00000265,0.0000035,null,null],"us-gov-east-1/meta.llama3-70b-instruct-v1:0":[0.00000265,0.0000035,null,null],"bedrock/us-gov-east-1/meta.llama3-8b-instruct-v1:0":[3e-7,0.00000265,null,null],"us-gov-east-1/meta.llama3-8b-instruct-v1:0":[3e-7,0.00000265,null,null],"bedrock/us-gov-west-1/amazon.nova-pro-v1:0":[9.6e-7,0.00000384,null,null],"us-gov-west-1/amazon.nova-pro-v1:0":[9.6e-7,0.00000384,null,null],"bedrock/us-gov-west-1/amazon.titan-embed-text-v1":[1e-7,0,null,null],"us-gov-west-1/amazon.titan-embed-text-v1":[1e-7,0,null,null],"bedrock/us-gov-west-1/amazon.titan-embed-text-v2:0":[2e-7,0,null,null],"us-gov-west-1/amazon.titan-embed-text-v2:0":[2e-7,0,null,null],"bedrock/us-gov-west-1/amazon.titan-text-express-v1":[0.0000013,0.0000017,null,null],"us-gov-west-1/amazon.titan-text-express-v1":[0.0000013,0.0000017,null,null],"bedrock/us-gov-west-1/amazon.titan-text-lite-v1":[3e-7,4e-7,null,null],"us-gov-west-1/amazon.titan-text-lite-v1":[3e-7,4e-7,null,null],"bedrock/us-gov-west-1/amazon.titan-text-premier-v1:0":[5e-7,0.0000015,null,null],"us-gov-west-1/amazon.titan-text-premier-v1:0":[5e-7,0.0000015,null,null],"bedrock/us-gov-west-1/anthropic.claude-3-7-sonnet-20250219-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7],"us-gov-west-1/anthropic.claude-3-7-sonnet-20250219-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7],"bedrock/us-gov-west-1/anthropic.claude-3-5-sonnet-20240620-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7],"us-gov-west-1/anthropic.claude-3-5-sonnet-20240620-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7],"bedrock/us-gov-west-1/anthropic.claude-3-haiku-20240307-v1:0":[3e-7,0.0000015,3.75e-7,3e-8],"us-gov-west-1/anthropic.claude-3-haiku-20240307-v1:0":[3e-7,0.0000015,3.75e-7,3e-8],"bedrock/us-gov-west-1/anthropic.claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7],"us-gov-west-1/anthropic.claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7],"bedrock/us-gov-west-1/claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7],"us-gov-west-1/claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7],"bedrock/us-gov-west-1/meta.llama3-70b-instruct-v1:0":[0.00000265,0.0000035,null,null],"us-gov-west-1/meta.llama3-70b-instruct-v1:0":[0.00000265,0.0000035,null,null],"bedrock/us-gov-west-1/meta.llama3-8b-instruct-v1:0":[3e-7,0.00000265,null,null],"us-gov-west-1/meta.llama3-8b-instruct-v1:0":[3e-7,0.00000265,null,null],"bedrock/us-west-1/meta.llama3-70b-instruct-v1:0":[0.00000265,0.0000035,null,null],"us-west-1/meta.llama3-70b-instruct-v1:0":[0.00000265,0.0000035,null,null],"bedrock/us-west-1/meta.llama3-8b-instruct-v1:0":[3e-7,6e-7,null,null],"us-west-1/meta.llama3-8b-instruct-v1:0":[3e-7,6e-7,null,null],"bedrock/us-west-2/anthropic.claude-instant-v1":[8e-7,0.0000024,null,null],"us-west-2/anthropic.claude-instant-v1":[8e-7,0.0000024,null,null],"bedrock/us-west-2/anthropic.claude-v1":[0.000008,0.000024,null,null],"us-west-2/anthropic.claude-v1":[0.000008,0.000024,null,null],"bedrock/us-west-2/anthropic.claude-v2:1":[0.000008,0.000024,null,null],"us-west-2/anthropic.claude-v2:1":[0.000008,0.000024,null,null],"bedrock/us-west-2/mistral.mistral-7b-instruct-v0:2":[1.5e-7,2e-7,null,null],"us-west-2/mistral.mistral-7b-instruct-v0:2":[1.5e-7,2e-7,null,null],"bedrock/us-west-2/mistral.mistral-large-2402-v1:0":[0.000008,0.000024,null,null],"us-west-2/mistral.mistral-large-2402-v1:0":[0.000008,0.000024,null,null],"bedrock/us-west-2/mistral.mixtral-8x7b-instruct-v0:1":[4.5e-7,7e-7,null,null],"us-west-2/mistral.mixtral-8x7b-instruct-v0:1":[4.5e-7,7e-7,null,null],"bedrock/us-west-2/deepseek.v3.2":[6.2e-7,0.00000185,null,null],"us-west-2/deepseek.v3.2":[6.2e-7,0.00000185,null,null],"bedrock/us-west-2/minimax.minimax-m2.1":[3e-7,0.0000012,null,null],"us-west-2/minimax.minimax-m2.1":[3e-7,0.0000012,null,null],"bedrock/us-west-2/minimax.minimax-m2.5":[3e-7,0.0000012,null,null],"us-west-2/minimax.minimax-m2.5":[3e-7,0.0000012,null,null],"bedrock/us-west-2/moonshotai.kimi-k2-thinking":[6e-7,0.0000025,null,null],"us-west-2/moonshotai.kimi-k2-thinking":[6e-7,0.0000025,null,null],"bedrock/us-west-2/moonshotai.kimi-k2.5":[6e-7,0.000003,null,null],"us-west-2/moonshotai.kimi-k2.5":[6e-7,0.000003,null,null],"bedrock/us-west-2/qwen.qwen3-coder-next":[5e-7,0.0000012,null,null],"us-west-2/qwen.qwen3-coder-next":[5e-7,0.0000012,null,null],"bedrock/us.anthropic.claude-3-5-haiku-20241022-v1:0":[8e-7,0.000004,0.000001,8e-8],"cerebras/llama-3.3-70b":[8.5e-7,0.0000012,null,null],"llama-3.3-70b":[8.5e-7,0.0000012,null,null],"cerebras/llama3.1-70b":[6e-7,6e-7,null,null],"llama3.1-70b":[6e-7,6e-7,null,null],"cerebras/llama3.1-8b":[1e-7,1e-7,null,null],"llama3.1-8b":[1e-7,1e-7,null,null],"cerebras/gpt-oss-120b":[3.5e-7,7.5e-7,null,null],"cerebras/qwen-3-32b":[4e-7,8e-7,null,null],"qwen-3-32b":[4e-7,8e-7,null,null],"cerebras/zai-glm-4.6":[0.00000225,0.00000275,null,null],"zai-glm-4.6":[0.00000225,0.00000275,null,null],"cerebras/zai-glm-4.7":[0.00000225,0.00000275,null,null],"zai-glm-4.7":[0.00000225,0.00000275,null,null],"cloudflare/@cf/meta/llama-2-7b-chat-fp16":[0.000001923,0.000001923,null,null],"@cf/meta/llama-2-7b-chat-fp16":[0.000001923,0.000001923,null,null],"cloudflare/@cf/meta/llama-2-7b-chat-int8":[0.000001923,0.000001923,null,null],"@cf/meta/llama-2-7b-chat-int8":[0.000001923,0.000001923,null,null],"cloudflare/@cf/mistral/mistral-7b-instruct-v0.1":[0.000001923,0.000001923,null,null],"@cf/mistral/mistral-7b-instruct-v0.1":[0.000001923,0.000001923,null,null],"cloudflare/@hf/thebloke/codellama-7b-instruct-awq":[0.000001923,0.000001923,null,null],"@hf/thebloke/codellama-7b-instruct-awq":[0.000001923,0.000001923,null,null],"codestral/codestral-2405":[0,0,null,null],"codestral-2405":[0,0,null,null],"codestral/codestral-latest":[0,0,null,null],"codestral-latest":[0,0,null,null],"cohere/embed-v4.0":[1.2e-7,0,null,null],"embed-v4.0":[1.2e-7,0,null,null],"dashscope/qwen-coder":[3e-7,0.0000015,null,null],"qwen-coder":[3e-7,0.0000015,null,null],"dashscope/qwen-max":[0.0000016,0.0000064,null,null],"qwen-max":[0.0000016,0.0000064,null,null],"dashscope/qwen-plus":[4e-7,0.0000012,null,null],"qwen-plus":[4e-7,0.0000012,null,null],"dashscope/qwen-plus-2025-01-25":[4e-7,0.0000012,null,null],"qwen-plus-2025-01-25":[4e-7,0.0000012,null,null],"dashscope/qwen-plus-2025-04-28":[4e-7,0.0000012,null,null],"qwen-plus-2025-04-28":[4e-7,0.0000012,null,null],"dashscope/qwen-plus-2025-07-14":[4e-7,0.0000012,null,null],"qwen-plus-2025-07-14":[4e-7,0.0000012,null,null],"dashscope/qwen-turbo":[5e-8,2e-7,null,null],"qwen-turbo":[5e-8,2e-7,null,null],"dashscope/qwen-turbo-2024-11-01":[5e-8,2e-7,null,null],"qwen-turbo-2024-11-01":[5e-8,2e-7,null,null],"dashscope/qwen-turbo-2025-04-28":[5e-8,2e-7,null,null],"qwen-turbo-2025-04-28":[5e-8,2e-7,null,null],"dashscope/qwen-turbo-latest":[5e-8,2e-7,null,null],"qwen-turbo-latest":[5e-8,2e-7,null,null],"dashscope/qwen3-next-80b-a3b-instruct":[1.5e-7,0.0000012,null,null],"qwen3-next-80b-a3b-instruct":[1.5e-7,0.0000012,null,null],"dashscope/qwen3-next-80b-a3b-thinking":[1.5e-7,0.0000012,null,null],"qwen3-next-80b-a3b-thinking":[1.5e-7,0.0000012,null,null],"dashscope/qwen3-vl-235b-a22b-instruct":[4e-7,0.0000016,null,null],"qwen3-vl-235b-a22b-instruct":[4e-7,0.0000016,null,null],"dashscope/qwen3-vl-235b-a22b-thinking":[4e-7,0.000004,null,null],"qwen3-vl-235b-a22b-thinking":[4e-7,0.000004,null,null],"dashscope/qwen3-vl-32b-instruct":[1.6e-7,6.4e-7,null,null],"qwen3-vl-32b-instruct":[1.6e-7,6.4e-7,null,null],"dashscope/qwen3-vl-32b-thinking":[1.6e-7,0.00000287,null,null],"qwen3-vl-32b-thinking":[1.6e-7,0.00000287,null,null],"dashscope/qwq-plus":[8e-7,0.0000024,null,null],"qwq-plus":[8e-7,0.0000024,null,null],"databricks/databricks-bge-large-en":[1.0003e-7,0,null,null],"databricks-bge-large-en":[1.0003e-7,0,null,null],"databricks/databricks-claude-3-7-sonnet":[0.0000029999900000000002,0.000015000020000000002,null,null],"databricks-claude-3-7-sonnet":[0.0000029999900000000002,0.000015000020000000002,null,null],"databricks/databricks-claude-haiku-4-5":[0.00000100002,0.00000500003,null,null],"databricks-claude-haiku-4-5":[0.00000100002,0.00000500003,null,null],"databricks/databricks-claude-opus-4":[0.000015000020000000002,0.00007500003000000001,null,null],"databricks-claude-opus-4":[0.000015000020000000002,0.00007500003000000001,null,null],"databricks/databricks-claude-opus-4-1":[0.000015000020000000002,0.00007500003000000001,null,null],"databricks-claude-opus-4-1":[0.000015000020000000002,0.00007500003000000001,null,null],"databricks/databricks-claude-opus-4-5":[0.00000500003,0.000025000010000000002,null,null],"databricks-claude-opus-4-5":[0.00000500003,0.000025000010000000002,null,null],"databricks/databricks-claude-sonnet-4":[0.0000029999900000000002,0.000015000020000000002,null,null],"databricks-claude-sonnet-4":[0.0000029999900000000002,0.000015000020000000002,null,null],"databricks/databricks-claude-sonnet-4-1":[0.0000029999900000000002,0.000015000020000000002,null,null],"databricks-claude-sonnet-4-1":[0.0000029999900000000002,0.000015000020000000002,null,null],"databricks/databricks-claude-sonnet-4-5":[0.0000029999900000000002,0.000015000020000000002,null,null],"databricks-claude-sonnet-4-5":[0.0000029999900000000002,0.000015000020000000002,null,null],"databricks/databricks-gemini-2-5-flash":[3.0001999999999996e-7,0.00000249998,null,null],"databricks-gemini-2-5-flash":[3.0001999999999996e-7,0.00000249998,null,null],"databricks/databricks-gemini-2-5-pro":[0.00000124999,0.000009999990000000002,null,null],"databricks-gemini-2-5-pro":[0.00000124999,0.000009999990000000002,null,null],"databricks/databricks-gemma-3-12b":[1.5000999999999998e-7,5.0001e-7,null,null],"databricks-gemma-3-12b":[1.5000999999999998e-7,5.0001e-7,null,null],"databricks/databricks-gpt-5":[0.00000124999,0.000009999990000000002,null,null],"databricks-gpt-5":[0.00000124999,0.000009999990000000002,null,null],"databricks/databricks-gpt-5-1":[0.00000124999,0.000009999990000000002,null,null],"databricks-gpt-5-1":[0.00000124999,0.000009999990000000002,null,null],"databricks/databricks-gpt-5-mini":[2.4997000000000006e-7,0.0000019999700000000004,null,null],"databricks-gpt-5-mini":[2.4997000000000006e-7,0.0000019999700000000004,null,null],"databricks/databricks-gpt-5-nano":[4.998e-8,3.9998000000000007e-7,null,null],"databricks-gpt-5-nano":[4.998e-8,3.9998000000000007e-7,null,null],"databricks/databricks-gpt-oss-120b":[1.5000999999999998e-7,5.9997e-7,null,null],"databricks-gpt-oss-120b":[1.5000999999999998e-7,5.9997e-7,null,null],"databricks/databricks-gpt-oss-20b":[7e-8,3.0001999999999996e-7,null,null],"databricks-gpt-oss-20b":[7e-8,3.0001999999999996e-7,null,null],"databricks/databricks-gte-large-en":[1.2999000000000001e-7,0,null,null],"databricks-gte-large-en":[1.2999000000000001e-7,0,null,null],"databricks/databricks-llama-2-70b-chat":[5.0001e-7,0.0000015000300000000002,null,null],"databricks-llama-2-70b-chat":[5.0001e-7,0.0000015000300000000002,null,null],"databricks/databricks-llama-4-maverick":[5.0001e-7,0.0000015000300000000002,null,null],"databricks-llama-4-maverick":[5.0001e-7,0.0000015000300000000002,null,null],"databricks/databricks-meta-llama-3-1-405b-instruct":[0.00000500003,0.000015000020000000002,null,null],"databricks-meta-llama-3-1-405b-instruct":[0.00000500003,0.000015000020000000002,null,null],"databricks/databricks-meta-llama-3-1-8b-instruct":[1.5000999999999998e-7,4.5003000000000007e-7,null,null],"databricks-meta-llama-3-1-8b-instruct":[1.5000999999999998e-7,4.5003000000000007e-7,null,null],"databricks/databricks-meta-llama-3-3-70b-instruct":[5.0001e-7,0.0000015000300000000002,null,null],"databricks-meta-llama-3-3-70b-instruct":[5.0001e-7,0.0000015000300000000002,null,null],"databricks/databricks-meta-llama-3-70b-instruct":[0.00000100002,0.0000029999900000000002,null,null],"databricks-meta-llama-3-70b-instruct":[0.00000100002,0.0000029999900000000002,null,null],"databricks/databricks-mixtral-8x7b-instruct":[5.0001e-7,0.00000100002,null,null],"databricks-mixtral-8x7b-instruct":[5.0001e-7,0.00000100002,null,null],"databricks/databricks-mpt-30b-instruct":[0.00000100002,0.00000100002,null,null],"databricks-mpt-30b-instruct":[0.00000100002,0.00000100002,null,null],"databricks/databricks-mpt-7b-instruct":[5.0001e-7,0,null,null],"databricks-mpt-7b-instruct":[5.0001e-7,0,null,null],"deepinfra/Gryphe/MythoMax-L2-13b":[8e-8,9e-8,null,null],"Gryphe/MythoMax-L2-13b":[8e-8,9e-8,null,null],"deepinfra/NousResearch/Hermes-3-Llama-3.1-405B":[0.000001,0.000001,null,null],"NousResearch/Hermes-3-Llama-3.1-405B":[0.000001,0.000001,null,null],"deepinfra/NousResearch/Hermes-3-Llama-3.1-70B":[3e-7,3e-7,null,null],"NousResearch/Hermes-3-Llama-3.1-70B":[3e-7,3e-7,null,null],"deepinfra/Qwen/QwQ-32B":[1.5e-7,4e-7,null,null],"Qwen/QwQ-32B":[1.5e-7,4e-7,null,null],"deepinfra/Qwen/Qwen2.5-72B-Instruct":[1.2e-7,3.9e-7,null,null],"Qwen/Qwen2.5-72B-Instruct":[1.2e-7,3.9e-7,null,null],"deepinfra/Qwen/Qwen2.5-7B-Instruct":[4e-8,1e-7,null,null],"Qwen/Qwen2.5-7B-Instruct":[4e-8,1e-7,null,null],"deepinfra/Qwen/Qwen2.5-VL-32B-Instruct":[2e-7,6e-7,null,null],"Qwen/Qwen2.5-VL-32B-Instruct":[2e-7,6e-7,null,null],"deepinfra/Qwen/Qwen3-14B":[6e-8,2.4e-7,null,null],"Qwen/Qwen3-14B":[6e-8,2.4e-7,null,null],"deepinfra/Qwen/Qwen3-235B-A22B":[1.8e-7,5.4e-7,null,null],"Qwen/Qwen3-235B-A22B":[1.8e-7,5.4e-7,null,null],"deepinfra/Qwen/Qwen3-235B-A22B-Instruct-2507":[9e-8,6e-7,null,null],"Qwen/Qwen3-235B-A22B-Instruct-2507":[9e-8,6e-7,null,null],"deepinfra/Qwen/Qwen3-235B-A22B-Thinking-2507":[3e-7,0.0000029,null,null],"Qwen/Qwen3-235B-A22B-Thinking-2507":[3e-7,0.0000029,null,null],"deepinfra/Qwen/Qwen3-30B-A3B":[8e-8,2.9e-7,null,null],"Qwen/Qwen3-30B-A3B":[8e-8,2.9e-7,null,null],"deepinfra/Qwen/Qwen3-32B":[1e-7,2.8e-7,null,null],"Qwen/Qwen3-32B":[1e-7,2.8e-7,null,null],"deepinfra/Qwen/Qwen3-Coder-480B-A35B-Instruct":[4e-7,0.0000016,null,null],"Qwen/Qwen3-Coder-480B-A35B-Instruct":[4e-7,0.0000016,null,null],"deepinfra/Qwen/Qwen3-Coder-480B-A35B-Instruct-Turbo":[2.9e-7,0.0000012,null,null],"Qwen/Qwen3-Coder-480B-A35B-Instruct-Turbo":[2.9e-7,0.0000012,null,null],"deepinfra/Qwen/Qwen3-Next-80B-A3B-Instruct":[1.4e-7,0.0000014,null,null],"Qwen/Qwen3-Next-80B-A3B-Instruct":[1.4e-7,0.0000014,null,null],"deepinfra/Qwen/Qwen3-Next-80B-A3B-Thinking":[1.4e-7,0.0000014,null,null],"Qwen/Qwen3-Next-80B-A3B-Thinking":[1.4e-7,0.0000014,null,null],"deepinfra/Sao10K/L3-8B-Lunaris-v1-Turbo":[4e-8,5e-8,null,null],"Sao10K/L3-8B-Lunaris-v1-Turbo":[4e-8,5e-8,null,null],"deepinfra/Sao10K/L3.1-70B-Euryale-v2.2":[6.5e-7,7.5e-7,null,null],"Sao10K/L3.1-70B-Euryale-v2.2":[6.5e-7,7.5e-7,null,null],"deepinfra/Sao10K/L3.3-70B-Euryale-v2.3":[6.5e-7,7.5e-7,null,null],"Sao10K/L3.3-70B-Euryale-v2.3":[6.5e-7,7.5e-7,null,null],"deepinfra/allenai/olmOCR-7B-0725-FP8":[2.7e-7,0.0000015,null,null],"allenai/olmOCR-7B-0725-FP8":[2.7e-7,0.0000015,null,null],"deepinfra/anthropic/claude-3-7-sonnet-latest":[0.0000033,0.0000165,null,3.3e-7],"anthropic/claude-3-7-sonnet-latest":[0.0000033,0.0000165,null,3.3e-7],"deepinfra/anthropic/claude-4-opus":[0.0000165,0.0000825,null,null],"anthropic/claude-4-opus":[0.0000165,0.0000825,null,null],"deepinfra/anthropic/claude-4-sonnet":[0.0000033,0.0000165,null,null],"anthropic/claude-4-sonnet":[0.0000033,0.0000165,null,null],"deepinfra/deepseek-ai/DeepSeek-R1":[7e-7,0.0000024,null,null],"deepseek-ai/DeepSeek-R1":[7e-7,0.0000024,null,null],"deepinfra/deepseek-ai/DeepSeek-R1-0528":[5e-7,0.00000215,null,4e-7],"deepseek-ai/DeepSeek-R1-0528":[5e-7,0.00000215,null,4e-7],"deepinfra/deepseek-ai/DeepSeek-R1-0528-Turbo":[0.000001,0.000003,null,null],"deepseek-ai/DeepSeek-R1-0528-Turbo":[0.000001,0.000003,null,null],"deepinfra/deepseek-ai/DeepSeek-R1-Distill-Llama-70B":[2e-7,6e-7,null,null],"deepseek-ai/DeepSeek-R1-Distill-Llama-70B":[2e-7,6e-7,null,null],"deepinfra/deepseek-ai/DeepSeek-R1-Distill-Qwen-32B":[2.7e-7,2.7e-7,null,null],"deepseek-ai/DeepSeek-R1-Distill-Qwen-32B":[2.7e-7,2.7e-7,null,null],"deepinfra/deepseek-ai/DeepSeek-R1-Turbo":[0.000001,0.000003,null,null],"deepseek-ai/DeepSeek-R1-Turbo":[0.000001,0.000003,null,null],"deepinfra/deepseek-ai/DeepSeek-V3":[3.8e-7,8.9e-7,null,null],"deepseek-ai/DeepSeek-V3":[3.8e-7,8.9e-7,null,null],"deepinfra/deepseek-ai/DeepSeek-V3-0324":[2.5e-7,8.8e-7,null,null],"deepseek-ai/DeepSeek-V3-0324":[2.5e-7,8.8e-7,null,null],"deepinfra/deepseek-ai/DeepSeek-V3.1":[2.7e-7,0.000001,null,2.16e-7],"deepseek-ai/DeepSeek-V3.1":[2.7e-7,0.000001,null,2.16e-7],"deepinfra/deepseek-ai/DeepSeek-V3.1-Terminus":[2.7e-7,0.000001,null,2.16e-7],"deepseek-ai/DeepSeek-V3.1-Terminus":[2.7e-7,0.000001,null,2.16e-7],"deepinfra/google/gemini-2.0-flash-001":[1e-7,4e-7,null,null],"google/gemini-2.0-flash-001":[1e-7,4e-7,null,null],"deepinfra/google/gemini-2.5-flash":[3e-7,0.0000025,null,null],"google/gemini-2.5-flash":[3e-7,0.0000025,null,null],"deepinfra/google/gemini-2.5-pro":[0.00000125,0.00001,null,null],"google/gemini-2.5-pro":[0.00000125,0.00001,null,null],"deepinfra/google/gemma-3-12b-it":[5e-8,1e-7,null,null],"google/gemma-3-12b-it":[5e-8,1e-7,null,null],"deepinfra/google/gemma-3-27b-it":[9e-8,1.6e-7,null,null],"google/gemma-3-27b-it":[9e-8,1.6e-7,null,null],"deepinfra/google/gemma-3-4b-it":[4e-8,8e-8,null,null],"google/gemma-3-4b-it":[4e-8,8e-8,null,null],"deepinfra/meta-llama/Llama-3.2-11B-Vision-Instruct":[4.9e-8,4.9e-8,null,null],"meta-llama/Llama-3.2-11B-Vision-Instruct":[4.9e-8,4.9e-8,null,null],"deepinfra/meta-llama/Llama-3.2-3B-Instruct":[2e-8,2e-8,null,null],"meta-llama/Llama-3.2-3B-Instruct":[2e-8,2e-8,null,null],"deepinfra/meta-llama/Llama-3.3-70B-Instruct":[2.3e-7,4e-7,null,null],"meta-llama/Llama-3.3-70B-Instruct":[2.3e-7,4e-7,null,null],"deepinfra/meta-llama/Llama-3.3-70B-Instruct-Turbo":[1.3e-7,3.9e-7,null,null],"meta-llama/Llama-3.3-70B-Instruct-Turbo":[1.3e-7,3.9e-7,null,null],"deepinfra/meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":[1.5e-7,6e-7,null,null],"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":[1.5e-7,6e-7,null,null],"deepinfra/meta-llama/Llama-4-Scout-17B-16E-Instruct":[8e-8,3e-7,null,null],"meta-llama/Llama-4-Scout-17B-16E-Instruct":[8e-8,3e-7,null,null],"deepinfra/meta-llama/Llama-Guard-3-8B":[5.5e-8,5.5e-8,null,null],"meta-llama/Llama-Guard-3-8B":[5.5e-8,5.5e-8,null,null],"deepinfra/meta-llama/Llama-Guard-4-12B":[1.8e-7,1.8e-7,null,null],"meta-llama/Llama-Guard-4-12B":[1.8e-7,1.8e-7,null,null],"deepinfra/meta-llama/Meta-Llama-3-8B-Instruct":[3e-8,6e-8,null,null],"deepinfra/meta-llama/Meta-Llama-3.1-70B-Instruct":[4e-7,4e-7,null,null],"meta-llama/Meta-Llama-3.1-70B-Instruct":[4e-7,4e-7,null,null],"deepinfra/meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo":[1e-7,2.8e-7,null,null],"meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo":[1e-7,2.8e-7,null,null],"deepinfra/meta-llama/Meta-Llama-3.1-8B-Instruct":[3e-8,5e-8,null,null],"meta-llama/Meta-Llama-3.1-8B-Instruct":[3e-8,5e-8,null,null],"deepinfra/meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo":[2e-8,3e-8,null,null],"meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo":[2e-8,3e-8,null,null],"deepinfra/microsoft/WizardLM-2-8x22B":[4.8e-7,4.8e-7,null,null],"microsoft/WizardLM-2-8x22B":[4.8e-7,4.8e-7,null,null],"deepinfra/microsoft/phi-4":[7e-8,1.4e-7,null,null],"microsoft/phi-4":[7e-8,1.4e-7,null,null],"deepinfra/mistralai/Mistral-Nemo-Instruct-2407":[2e-8,4e-8,null,null],"mistralai/Mistral-Nemo-Instruct-2407":[2e-8,4e-8,null,null],"deepinfra/mistralai/Mistral-Small-24B-Instruct-2501":[5e-8,8e-8,null,null],"mistralai/Mistral-Small-24B-Instruct-2501":[5e-8,8e-8,null,null],"deepinfra/mistralai/Mistral-Small-3.2-24B-Instruct-2506":[7.5e-8,2e-7,null,null],"mistralai/Mistral-Small-3.2-24B-Instruct-2506":[7.5e-8,2e-7,null,null],"deepinfra/mistralai/Mixtral-8x7B-Instruct-v0.1":[4e-7,4e-7,null,null],"deepinfra/moonshotai/Kimi-K2-Instruct":[5e-7,0.000002,null,null],"moonshotai/Kimi-K2-Instruct":[5e-7,0.000002,null,null],"deepinfra/moonshotai/Kimi-K2-Instruct-0905":[5e-7,0.000002,null,4e-7],"moonshotai/Kimi-K2-Instruct-0905":[5e-7,0.000002,null,4e-7],"deepinfra/nvidia/Llama-3.1-Nemotron-70B-Instruct":[6e-7,6e-7,null,null],"nvidia/Llama-3.1-Nemotron-70B-Instruct":[6e-7,6e-7,null,null],"deepinfra/nvidia/Llama-3.3-Nemotron-Super-49B-v1.5":[1e-7,4e-7,null,null],"nvidia/Llama-3.3-Nemotron-Super-49B-v1.5":[1e-7,4e-7,null,null],"deepinfra/nvidia/NVIDIA-Nemotron-Nano-9B-v2":[4e-8,1.6e-7,null,null],"nvidia/NVIDIA-Nemotron-Nano-9B-v2":[4e-8,1.6e-7,null,null],"deepinfra/openai/gpt-oss-120b":[5e-8,4.5e-7,null,null],"openai/gpt-oss-120b":[5e-8,4.5e-7,null,null],"deepinfra/openai/gpt-oss-20b":[4e-8,1.5e-7,null,null],"openai/gpt-oss-20b":[4e-8,1.5e-7,null,null],"deepinfra/zai-org/GLM-4.5":[4e-7,0.0000016,null,null],"zai-org/GLM-4.5":[4e-7,0.0000016,null,null],"deepseek/deepseek-chat":[2.8e-7,4.2e-7,0,2.8e-8],"deepseek/deepseek-coder":[1.4e-7,2.8e-7,null,null],"deepseek-coder":[1.4e-7,2.8e-7,null,null],"deepseek/deepseek-r1":[5.5e-7,0.00000219,null,null],"deepseek/deepseek-reasoner":[2.8e-7,4.2e-7,null,2.8e-8],"deepseek/deepseek-v3":[2.7e-7,0.0000011,0,7e-8],"deepseek/deepseek-v3.2":[2.8e-7,4e-7,null,null],"fireworks_ai/WhereIsAI/UAE-Large-V1":[1.6e-8,0,null,null],"WhereIsAI/UAE-Large-V1":[1.6e-8,0,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-coder-v2-instruct":[0.0000012,0.0000012,null,null],"accounts/fireworks/models/deepseek-coder-v2-instruct":[0.0000012,0.0000012,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1":[0.000003,0.000008,null,null],"accounts/fireworks/models/deepseek-r1":[0.000003,0.000008,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1-0528":[0.000003,0.000008,null,null],"accounts/fireworks/models/deepseek-r1-0528":[0.000003,0.000008,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1-basic":[5.5e-7,0.00000219,null,null],"accounts/fireworks/models/deepseek-r1-basic":[5.5e-7,0.00000219,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-v3":[9e-7,9e-7,null,null],"accounts/fireworks/models/deepseek-v3":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-v3-0324":[9e-7,9e-7,null,null],"accounts/fireworks/models/deepseek-v3-0324":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-v3p1":[5.6e-7,0.00000168,null,null],"accounts/fireworks/models/deepseek-v3p1":[5.6e-7,0.00000168,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-v3p1-terminus":[5.6e-7,0.00000168,null,null],"accounts/fireworks/models/deepseek-v3p1-terminus":[5.6e-7,0.00000168,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-v3p2":[5.6e-7,0.00000168,null,null],"accounts/fireworks/models/deepseek-v3p2":[5.6e-7,0.00000168,null,null],"fireworks_ai/accounts/fireworks/models/firefunction-v2":[9e-7,9e-7,null,null],"accounts/fireworks/models/firefunction-v2":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/glm-4p5":[5.5e-7,0.00000219,null,null],"accounts/fireworks/models/glm-4p5":[5.5e-7,0.00000219,null,null],"fireworks_ai/accounts/fireworks/models/glm-4p5-air":[2.2e-7,8.8e-7,null,null],"accounts/fireworks/models/glm-4p5-air":[2.2e-7,8.8e-7,null,null],"fireworks_ai/accounts/fireworks/models/glm-4p6":[5.5e-7,0.00000219,null,null],"accounts/fireworks/models/glm-4p6":[5.5e-7,0.00000219,null,null],"fireworks_ai/accounts/fireworks/models/glm-4p7":[6e-7,0.0000022,null,3e-7],"accounts/fireworks/models/glm-4p7":[6e-7,0.0000022,null,3e-7],"fireworks_ai/accounts/fireworks/models/gpt-oss-120b":[1.5e-7,6e-7,null,null],"accounts/fireworks/models/gpt-oss-120b":[1.5e-7,6e-7,null,null],"fireworks_ai/accounts/fireworks/models/gpt-oss-20b":[5e-8,2e-7,null,null],"accounts/fireworks/models/gpt-oss-20b":[5e-8,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/kimi-k2-instruct":[6e-7,0.0000025,null,null],"accounts/fireworks/models/kimi-k2-instruct":[6e-7,0.0000025,null,null],"fireworks_ai/accounts/fireworks/models/kimi-k2-instruct-0905":[6e-7,0.0000025,null,null],"accounts/fireworks/models/kimi-k2-instruct-0905":[6e-7,0.0000025,null,null],"fireworks_ai/accounts/fireworks/models/kimi-k2-thinking":[6e-7,0.0000025,null,null],"accounts/fireworks/models/kimi-k2-thinking":[6e-7,0.0000025,null,null],"fireworks_ai/accounts/fireworks/models/kimi-k2p5":[6e-7,0.000003,null,1e-7],"accounts/fireworks/models/kimi-k2p5":[6e-7,0.000003,null,1e-7],"fireworks_ai/accounts/fireworks/models/llama-v3p1-405b-instruct":[0.000003,0.000003,null,null],"accounts/fireworks/models/llama-v3p1-405b-instruct":[0.000003,0.000003,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p1-8b-instruct":[1e-7,1e-7,null,null],"accounts/fireworks/models/llama-v3p1-8b-instruct":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p2-11b-vision-instruct":[2e-7,2e-7,null,null],"accounts/fireworks/models/llama-v3p2-11b-vision-instruct":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p2-1b-instruct":[1e-7,1e-7,null,null],"accounts/fireworks/models/llama-v3p2-1b-instruct":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p2-3b-instruct":[1e-7,1e-7,null,null],"accounts/fireworks/models/llama-v3p2-3b-instruct":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p2-90b-vision-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/llama-v3p2-90b-vision-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama4-maverick-instruct-basic":[2.2e-7,8.8e-7,null,null],"accounts/fireworks/models/llama4-maverick-instruct-basic":[2.2e-7,8.8e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama4-scout-instruct-basic":[1.5e-7,6e-7,null,null],"accounts/fireworks/models/llama4-scout-instruct-basic":[1.5e-7,6e-7,null,null],"fireworks_ai/accounts/fireworks/models/minimax-m2p1":[3e-7,0.0000012,null,3e-8],"accounts/fireworks/models/minimax-m2p1":[3e-7,0.0000012,null,3e-8],"fireworks_ai/accounts/fireworks/models/mixtral-8x22b-instruct-hf":[0.0000012,0.0000012,null,null],"accounts/fireworks/models/mixtral-8x22b-instruct-hf":[0.0000012,0.0000012,null,null],"fireworks_ai/accounts/fireworks/models/qwen2-72b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen2-72b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-32b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-32b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/yi-large":[0.000003,0.000003,null,null],"accounts/fireworks/models/yi-large":[0.000003,0.000003,null,null],"fireworks_ai/glm-4p7":[6e-7,0.0000022,null,3e-7],"glm-4p7":[6e-7,0.0000022,null,3e-7],"fireworks_ai/kimi-k2p5":[6e-7,0.000003,null,1e-7],"kimi-k2p5":[6e-7,0.000003,null,1e-7],"fireworks_ai/minimax-m2p1":[3e-7,0.0000012,null,3e-8],"minimax-m2p1":[3e-7,0.0000012,null,3e-8],"fireworks_ai/nomic-ai/nomic-embed-text-v1":[8e-9,0,null,null],"nomic-ai/nomic-embed-text-v1":[8e-9,0,null,null],"fireworks_ai/nomic-ai/nomic-embed-text-v1.5":[8e-9,0,null,null],"nomic-ai/nomic-embed-text-v1.5":[8e-9,0,null,null],"fireworks_ai/thenlper/gte-base":[8e-9,0,null,null],"thenlper/gte-base":[8e-9,0,null,null],"fireworks_ai/thenlper/gte-large":[1.6e-8,0,null,null],"thenlper/gte-large":[1.6e-8,0,null,null],"friendliai/meta-llama-3.1-70b-instruct":[6e-7,6e-7,null,null],"meta-llama-3.1-70b-instruct":[6e-7,6e-7,null,null],"friendliai/meta-llama-3.1-8b-instruct":[1e-7,1e-7,null,null],"meta-llama-3.1-8b-instruct":[1e-7,1e-7,null,null],"gemini/gemini-live-2.5-flash-preview-native-audio-09-2025":[3e-7,0.000002,null,7.5e-8],"vertex_ai/gemini-3-pro-preview":[0.000002,0.000012,null,2e-7],"vertex_ai/gemini-3-flash-preview":[5e-7,0.000003,null,5e-8],"vertex_ai/gemini-3.1-pro-preview":[0.000002,0.000012,null,2e-7],"vertex_ai/gemini-3.1-pro-preview-customtools":[0.000002,0.000012,null,2e-7],"gemini/gemini-robotics-er-1.5-preview":[3e-7,0.0000025,null,0],"vertex_ai/gemini-embedding-2-preview":[1.5e-7,0,null,null],"vertex_ai/gemini-embedding-2":[2e-7,0,null,null],"gemini/gemini-embedding-001":[1.5e-7,0,null,null],"gemini/gemini-embedding-2-preview":[2e-7,0,null,null],"gemini/gemini-embedding-2":[2e-7,0,null,null],"gemini/gemini-1.5-flash":[7.5e-8,0,null,null],"gemini-1.5-flash":[7.5e-8,0,null,null],"gemini/gemini-2.0-flash":[1e-7,4e-7,null,2.5e-8],"gemini/gemini-2.0-flash-001":[1e-7,4e-7,null,2.5e-8],"gemini/gemini-2.0-flash-lite":[7.5e-8,3e-7,null,1.875e-8],"gemini/gemini-2.5-flash":[3e-7,0.0000025,null,3e-8],"gemini/gemini-2.5-flash-image":[3e-7,0.0000025,null,3e-8],"gemini/gemini-3-pro-image-preview":[0.000002,0.000012,null,null],"gemini/gemini-3.1-flash-image-preview":[2.5e-7,0.0000015,null,null],"gemini/deep-research-pro-preview-12-2025":[0.000002,0.000012,null,null],"gemini/gemini-2.5-flash-lite":[1e-7,4e-7,null,1e-8],"gemini/gemini-2.5-flash-lite-preview-09-2025":[1e-7,4e-7,null,1e-8],"gemini/gemini-2.5-flash-preview-09-2025":[3e-7,0.0000025,null,7.5e-8],"gemini/gemini-flash-latest":[3e-7,0.0000025,null,7.5e-8],"gemini/gemini-flash-lite-latest":[1e-7,4e-7,null,2.5e-8],"gemini/gemini-2.5-flash-lite-preview-06-17":[1e-7,4e-7,null,2.5e-8],"gemini/gemini-2.5-flash-preview-tts":[3e-7,0.0000025,null,null],"gemini/gemini-2.5-pro":[0.00000125,0.00001,null,1.25e-7],"gemini/gemini-2.5-computer-use-preview-10-2025":[0.00000125,0.00001,null,null],"gemini/gemini-3-pro-preview":[0.000002,0.000012,null,2e-7],"gemini/gemini-3.1-flash-lite-preview":[2.5e-7,0.0000015,null,2.5e-8],"gemini/gemini-3-flash-preview":[5e-7,0.000003,null,5e-8],"gemini/gemini-3.1-pro-preview":[0.000002,0.000012,null,2e-7],"gemini/gemini-3.1-pro-preview-customtools":[0.000002,0.000012,null,2e-7],"gemini/gemini-2.5-pro-preview-tts":[0.00000125,0.00001,null,1.25e-7],"gemini/gemini-exp-1114":[0,0,null,null],"gemini-exp-1114":[0,0,null,null],"gemini/gemini-exp-1206":[0,0,null,null],"gemini/gemini-gemma-2-27b-it":[3.5e-7,0.00000105,null,null],"gemini-gemma-2-27b-it":[3.5e-7,0.00000105,null,null],"gemini/gemini-gemma-2-9b-it":[3.5e-7,0.00000105,null,null],"gemini-gemma-2-9b-it":[3.5e-7,0.00000105,null,null],"gemini/gemma-3-27b-it":[0,0,null,null],"gemma-3-27b-it":[0,0,null,null],"gemini/learnlm-1.5-pro-experimental":[0,0,null,null],"learnlm-1.5-pro-experimental":[0,0,null,null],"gemini/lyria-3-clip-preview":[0,0,null,null],"lyria-3-clip-preview":[0,0,null,null],"gemini/lyria-3-pro-preview":[0,0,null,null],"lyria-3-pro-preview":[0,0,null,null],"gigachat/GigaChat-2-Lite":[0,0,null,null],"GigaChat-2-Lite":[0,0,null,null],"gigachat/GigaChat-2-Max":[0,0,null,null],"GigaChat-2-Max":[0,0,null,null],"gigachat/GigaChat-2-Pro":[0,0,null,null],"GigaChat-2-Pro":[0,0,null,null],"gigachat/Embeddings":[0,0,null,null],"Embeddings":[0,0,null,null],"gigachat/Embeddings-2":[0,0,null,null],"Embeddings-2":[0,0,null,null],"gigachat/EmbeddingsGigaR":[0,0,null,null],"EmbeddingsGigaR":[0,0,null,null],"gmi/anthropic/claude-opus-4.5":[0.000005,0.000025,null,null],"anthropic/claude-opus-4.5":[0.000005,0.000025,null,null],"gmi/anthropic/claude-sonnet-4.5":[0.000003,0.000015,null,null],"anthropic/claude-sonnet-4.5":[0.000003,0.000015,null,null],"gmi/anthropic/claude-sonnet-4":[0.000003,0.000015,null,null],"anthropic/claude-sonnet-4":[0.000003,0.000015,null,null],"gmi/anthropic/claude-opus-4":[0.000015,0.000075,null,null],"anthropic/claude-opus-4":[0.000015,0.000075,null,null],"gmi/openai/gpt-5.2":[0.00000175,0.000014,null,null],"openai/gpt-5.2":[0.00000175,0.000014,null,null],"gmi/openai/gpt-5.1":[0.00000125,0.00001,null,null],"openai/gpt-5.1":[0.00000125,0.00001,null,null],"gmi/openai/gpt-5":[0.00000125,0.00001,null,null],"openai/gpt-5":[0.00000125,0.00001,null,null],"gmi/openai/gpt-4o":[0.0000025,0.00001,null,null],"openai/gpt-4o":[0.0000025,0.00001,null,null],"gmi/openai/gpt-4o-mini":[1.5e-7,6e-7,null,null],"openai/gpt-4o-mini":[1.5e-7,6e-7,null,null],"gmi/deepseek-ai/DeepSeek-V3.2":[2.8e-7,4e-7,null,null],"deepseek-ai/DeepSeek-V3.2":[2.8e-7,4e-7,null,null],"gmi/deepseek-ai/DeepSeek-V3-0324":[2.8e-7,8.8e-7,null,null],"gmi/google/gemini-3-pro-preview":[0.000002,0.000012,null,null],"google/gemini-3-pro-preview":[0.000002,0.000012,null,null],"gmi/google/gemini-3-flash-preview":[5e-7,0.000003,null,null],"google/gemini-3-flash-preview":[5e-7,0.000003,null,null],"gmi/moonshotai/Kimi-K2-Thinking":[8e-7,0.0000012,null,null],"moonshotai/Kimi-K2-Thinking":[8e-7,0.0000012,null,null],"gmi/MiniMaxAI/MiniMax-M2.1":[3e-7,0.0000012,null,null],"MiniMaxAI/MiniMax-M2.1":[3e-7,0.0000012,null,null],"baseten/MiniMaxAI/MiniMax-M2.5":[3e-7,0.0000012,null,null],"MiniMaxAI/MiniMax-M2.5":[3e-7,0.0000012,null,null],"baseten/nvidia/Nemotron-120B-A12B":[3e-7,7.5e-7,null,null],"nvidia/Nemotron-120B-A12B":[3e-7,7.5e-7,null,null],"baseten/zai-org/GLM-5":[9.5e-7,0.00000315,null,null],"zai-org/GLM-5":[9.5e-7,0.00000315,null,null],"baseten/zai-org/GLM-4.7":[6e-7,0.0000022,null,null],"zai-org/GLM-4.7":[6e-7,0.0000022,null,null],"baseten/zai-org/GLM-4.6":[6e-7,0.0000022,null,null],"zai-org/GLM-4.6":[6e-7,0.0000022,null,null],"baseten/moonshotai/Kimi-K2.5":[6e-7,0.000003,null,null],"moonshotai/Kimi-K2.5":[6e-7,0.000003,null,null],"baseten/moonshotai/Kimi-K2-Thinking":[6e-7,0.0000025,null,null],"baseten/moonshotai/Kimi-K2-Instruct-0905":[6e-7,0.0000025,null,null],"baseten/openai/gpt-oss-120b":[1e-7,5e-7,null,null],"baseten/deepseek-ai/DeepSeek-V3.1":[5e-7,0.0000015,null,null],"baseten/deepseek-ai/DeepSeek-V3-0324":[7.7e-7,7.7e-7,null,null],"gmi/Qwen/Qwen3-VL-235B-A22B-Instruct-FP8":[3e-7,0.0000014,null,null],"Qwen/Qwen3-VL-235B-A22B-Instruct-FP8":[3e-7,0.0000014,null,null],"gmi/zai-org/GLM-4.7-FP8":[4e-7,0.000002,null,null],"zai-org/GLM-4.7-FP8":[4e-7,0.000002,null,null],"gradient_ai/anthropic-claude-3-opus":[0.000015,0.000075,null,null],"anthropic-claude-3-opus":[0.000015,0.000075,null,null],"gradient_ai/anthropic-claude-3.5-haiku":[8e-7,0.000004,null,null],"anthropic-claude-3.5-haiku":[8e-7,0.000004,null,null],"gradient_ai/anthropic-claude-3.5-sonnet":[0.000003,0.000015,null,null],"anthropic-claude-3.5-sonnet":[0.000003,0.000015,null,null],"gradient_ai/anthropic-claude-3.7-sonnet":[0.000003,0.000015,null,null],"anthropic-claude-3.7-sonnet":[0.000003,0.000015,null,null],"gradient_ai/deepseek-r1-distill-llama-70b":[9.9e-7,9.9e-7,null,null],"deepseek-r1-distill-llama-70b":[9.9e-7,9.9e-7,null,null],"gradient_ai/llama3-8b-instruct":[2e-7,2e-7,null,null],"llama3-8b-instruct":[2e-7,2e-7,null,null],"gradient_ai/llama3.3-70b-instruct":[6.5e-7,6.5e-7,null,null],"llama3.3-70b-instruct":[6.5e-7,6.5e-7,null,null],"gradient_ai/mistral-nemo-instruct-2407":[3e-7,3e-7,null,null],"mistral-nemo-instruct-2407":[3e-7,3e-7,null,null],"gradient_ai/openai-o3":[0.000002,0.000008,null,null],"openai-o3":[0.000002,0.000008,null,null],"gradient_ai/openai-o3-mini":[0.0000011,0.0000044,null,null],"openai-o3-mini":[0.0000011,0.0000044,null,null],"lemonade/Qwen3-Coder-30B-A3B-Instruct-GGUF":[0,0,null,null],"Qwen3-Coder-30B-A3B-Instruct-GGUF":[0,0,null,null],"lemonade/gpt-oss-20b-mxfp4-GGUF":[0,0,null,null],"gpt-oss-20b-mxfp4-GGUF":[0,0,null,null],"lemonade/gpt-oss-120b-mxfp-GGUF":[0,0,null,null],"gpt-oss-120b-mxfp-GGUF":[0,0,null,null],"lemonade/Gemma-3-4b-it-GGUF":[0,0,null,null],"Gemma-3-4b-it-GGUF":[0,0,null,null],"lemonade/Qwen3-4B-Instruct-2507-GGUF":[0,0,null,null],"Qwen3-4B-Instruct-2507-GGUF":[0,0,null,null],"amazon-nova/nova-micro-v1":[3.5e-8,1.4e-7,null,null],"nova-micro-v1":[3.5e-8,1.4e-7,null,null],"amazon-nova/nova-lite-v1":[6e-8,2.4e-7,null,null],"nova-lite-v1":[6e-8,2.4e-7,null,null],"amazon-nova/nova-premier-v1":[0.0000025,0.0000125,null,null],"nova-premier-v1":[0.0000025,0.0000125,null,null],"amazon-nova/nova-pro-v1":[8e-7,0.0000032,null,null],"nova-pro-v1":[8e-7,0.0000032,null,null],"groq/llama-3.1-8b-instant":[5e-8,8e-8,null,null],"llama-3.1-8b-instant":[5e-8,8e-8,null,null],"groq/llama-3.3-70b-versatile":[5.9e-7,7.9e-7,null,null],"llama-3.3-70b-versatile":[5.9e-7,7.9e-7,null,null],"groq/gemma-7b-it":[5e-8,8e-8,null,null],"gemma-7b-it":[5e-8,8e-8,null,null],"groq/meta-llama/llama-guard-4-12b":[2e-7,2e-7,null,null],"meta-llama/llama-guard-4-12b":[2e-7,2e-7,null,null],"groq/meta-llama/llama-4-maverick-17b-128e-instruct":[2e-7,6e-7,null,null],"meta-llama/llama-4-maverick-17b-128e-instruct":[2e-7,6e-7,null,null],"groq/meta-llama/llama-4-scout-17b-16e-instruct":[1.1e-7,3.4e-7,null,null],"meta-llama/llama-4-scout-17b-16e-instruct":[1.1e-7,3.4e-7,null,null],"groq/moonshotai/kimi-k2-instruct-0905":[0.000001,0.000003,null,5e-7],"moonshotai/kimi-k2-instruct-0905":[0.000001,0.000003,null,5e-7],"groq/openai/gpt-oss-120b":[1.5e-7,6e-7,null,7.5e-8],"groq/openai/gpt-oss-20b":[7.5e-8,3e-7,null,3.75e-8],"groq/openai/gpt-oss-safeguard-20b":[7.5e-8,3e-7,null,3.7e-8],"openai/gpt-oss-safeguard-20b":[7.5e-8,3e-7,null,3.7e-8],"groq/qwen/qwen3-32b":[2.9e-7,5.9e-7,null,null],"qwen/qwen3-32b":[2.9e-7,5.9e-7,null,null],"hyperbolic/NousResearch/Hermes-3-Llama-3.1-70B":[1.2e-7,3e-7,null,null],"hyperbolic/Qwen/QwQ-32B":[2e-7,2e-7,null,null],"hyperbolic/Qwen/Qwen2.5-72B-Instruct":[1.2e-7,3e-7,null,null],"hyperbolic/Qwen/Qwen2.5-Coder-32B-Instruct":[1.2e-7,3e-7,null,null],"Qwen/Qwen2.5-Coder-32B-Instruct":[1.2e-7,3e-7,null,null],"hyperbolic/Qwen/Qwen3-235B-A22B":[0.000002,0.000002,null,null],"hyperbolic/deepseek-ai/DeepSeek-R1":[4e-7,4e-7,null,null],"hyperbolic/deepseek-ai/DeepSeek-R1-0528":[2.5e-7,2.5e-7,null,null],"hyperbolic/deepseek-ai/DeepSeek-V3":[2e-7,2e-7,null,null],"hyperbolic/deepseek-ai/DeepSeek-V3-0324":[4e-7,4e-7,null,null],"hyperbolic/meta-llama/Llama-3.2-3B-Instruct":[1.2e-7,3e-7,null,null],"hyperbolic/meta-llama/Llama-3.3-70B-Instruct":[1.2e-7,3e-7,null,null],"hyperbolic/meta-llama/Meta-Llama-3-70B-Instruct":[1.2e-7,3e-7,null,null],"hyperbolic/meta-llama/Meta-Llama-3.1-405B-Instruct":[1.2e-7,3e-7,null,null],"meta-llama/Meta-Llama-3.1-405B-Instruct":[1.2e-7,3e-7,null,null],"hyperbolic/meta-llama/Meta-Llama-3.1-70B-Instruct":[1.2e-7,3e-7,null,null],"hyperbolic/meta-llama/Meta-Llama-3.1-8B-Instruct":[1.2e-7,3e-7,null,null],"hyperbolic/moonshotai/Kimi-K2-Instruct":[0.000002,0.000002,null,null],"lambda_ai/deepseek-llama3.3-70b":[2e-7,6e-7,null,null],"deepseek-llama3.3-70b":[2e-7,6e-7,null,null],"lambda_ai/deepseek-r1-0528":[2e-7,6e-7,null,null],"deepseek-r1-0528":[2e-7,6e-7,null,null],"lambda_ai/deepseek-r1-671b":[8e-7,8e-7,null,null],"deepseek-r1-671b":[8e-7,8e-7,null,null],"lambda_ai/deepseek-v3-0324":[2e-7,6e-7,null,null],"lambda_ai/hermes3-405b":[8e-7,8e-7,null,null],"hermes3-405b":[8e-7,8e-7,null,null],"lambda_ai/hermes3-70b":[1.2e-7,3e-7,null,null],"hermes3-70b":[1.2e-7,3e-7,null,null],"lambda_ai/hermes3-8b":[2.5e-8,4e-8,null,null],"hermes3-8b":[2.5e-8,4e-8,null,null],"lambda_ai/lfm-40b":[1e-7,2e-7,null,null],"lfm-40b":[1e-7,2e-7,null,null],"lambda_ai/lfm-7b":[2.5e-8,4e-8,null,null],"lfm-7b":[2.5e-8,4e-8,null,null],"lambda_ai/llama-4-maverick-17b-128e-instruct-fp8":[5e-8,1e-7,null,null],"llama-4-maverick-17b-128e-instruct-fp8":[5e-8,1e-7,null,null],"lambda_ai/llama-4-scout-17b-16e-instruct":[5e-8,1e-7,null,null],"llama-4-scout-17b-16e-instruct":[5e-8,1e-7,null,null],"lambda_ai/llama3.1-405b-instruct-fp8":[8e-7,8e-7,null,null],"llama3.1-405b-instruct-fp8":[8e-7,8e-7,null,null],"lambda_ai/llama3.1-70b-instruct-fp8":[1.2e-7,3e-7,null,null],"llama3.1-70b-instruct-fp8":[1.2e-7,3e-7,null,null],"lambda_ai/llama3.1-8b-instruct":[2.5e-8,4e-8,null,null],"llama3.1-8b-instruct":[2.5e-8,4e-8,null,null],"lambda_ai/llama3.1-nemotron-70b-instruct-fp8":[1.2e-7,3e-7,null,null],"llama3.1-nemotron-70b-instruct-fp8":[1.2e-7,3e-7,null,null],"lambda_ai/llama3.2-11b-vision-instruct":[1.5e-8,2.5e-8,null,null],"llama3.2-11b-vision-instruct":[1.5e-8,2.5e-8,null,null],"lambda_ai/llama3.2-3b-instruct":[1.5e-8,2.5e-8,null,null],"llama3.2-3b-instruct":[1.5e-8,2.5e-8,null,null],"lambda_ai/llama3.3-70b-instruct-fp8":[1.2e-7,3e-7,null,null],"llama3.3-70b-instruct-fp8":[1.2e-7,3e-7,null,null],"lambda_ai/qwen25-coder-32b-instruct":[5e-8,1e-7,null,null],"qwen25-coder-32b-instruct":[5e-8,1e-7,null,null],"lambda_ai/qwen3-32b-fp8":[5e-8,1e-7,null,null],"qwen3-32b-fp8":[5e-8,1e-7,null,null],"minimax/MiniMax-M2.1":[3e-7,0.0000012,3.75e-7,3e-8],"MiniMax-M2.1":[3e-7,0.0000012,3.75e-7,3e-8],"minimax/MiniMax-M2.1-lightning":[3e-7,0.0000024,3.75e-7,3e-8],"MiniMax-M2.1-lightning":[3e-7,0.0000024,3.75e-7,3e-8],"minimax/MiniMax-M2.5":[3e-7,0.0000012,3.75e-7,3e-8],"MiniMax-M2.5":[3e-7,0.0000012,3.75e-7,3e-8],"minimax/MiniMax-M2.5-lightning":[3e-7,0.0000024,3.75e-7,3e-8],"MiniMax-M2.5-lightning":[3e-7,0.0000024,3.75e-7,3e-8],"minimax/MiniMax-M2":[3e-7,0.0000012,3.75e-7,3e-8],"MiniMax-M2":[3e-7,0.0000012,3.75e-7,3e-8],"mistral/codestral-2405":[0.000001,0.000003,null,null],"mistral/codestral-2508":[3e-7,9e-7,null,null],"codestral-2508":[3e-7,9e-7,null,null],"mistral/codestral-latest":[0.000001,0.000003,null,null],"mistral/codestral-mamba-latest":[2.5e-7,2.5e-7,null,null],"codestral-mamba-latest":[2.5e-7,2.5e-7,null,null],"mistral/devstral-medium-2507":[4e-7,0.000002,null,null],"devstral-medium-2507":[4e-7,0.000002,null,null],"mistral/devstral-small-2505":[1e-7,3e-7,null,null],"devstral-small-2505":[1e-7,3e-7,null,null],"mistral/devstral-small-2507":[1e-7,3e-7,null,null],"devstral-small-2507":[1e-7,3e-7,null,null],"mistral/devstral-small-latest":[1e-7,3e-7,null,null],"devstral-small-latest":[1e-7,3e-7,null,null],"mistral/labs-devstral-small-2512":[1e-7,3e-7,null,null],"labs-devstral-small-2512":[1e-7,3e-7,null,null],"mistral/devstral-latest":[4e-7,0.000002,null,null],"devstral-latest":[4e-7,0.000002,null,null],"mistral/devstral-medium-latest":[4e-7,0.000002,null,null],"devstral-medium-latest":[4e-7,0.000002,null,null],"mistral/devstral-2512":[4e-7,0.000002,null,null],"devstral-2512":[4e-7,0.000002,null,null],"mistral/magistral-medium-2506":[0.000002,0.000005,null,null],"magistral-medium-2506":[0.000002,0.000005,null,null],"mistral/magistral-medium-2509":[0.000002,0.000005,null,null],"magistral-medium-2509":[0.000002,0.000005,null,null],"mistral/magistral-medium-1-2-2509":[0.000002,0.000005,null,null],"magistral-medium-1-2-2509":[0.000002,0.000005,null,null],"mistral/magistral-medium-latest":[0.000002,0.000005,null,null],"magistral-medium-latest":[0.000002,0.000005,null,null],"mistral/magistral-small-2506":[5e-7,0.0000015,null,null],"magistral-small-2506":[5e-7,0.0000015,null,null],"mistral/magistral-small-latest":[5e-7,0.0000015,null,null],"magistral-small-latest":[5e-7,0.0000015,null,null],"mistral/magistral-small-1-2-2509":[5e-7,0.0000015,null,null],"magistral-small-1-2-2509":[5e-7,0.0000015,null,null],"mistral/mistral-large-2402":[0.000004,0.000012,null,null],"mistral/mistral-large-2407":[0.000003,0.000009,null,null],"mistral/mistral-large-2411":[0.000002,0.000006,null,null],"mistral-large-2411":[0.000002,0.000006,null,null],"mistral/mistral-large-latest":[5e-7,0.0000015,null,null],"mistral/mistral-large-3":[5e-7,0.0000015,null,null],"mistral/mistral-large-2512":[5e-7,0.0000015,null,null],"mistral-large-2512":[5e-7,0.0000015,null,null],"mistral/mistral-medium":[0.0000027,0.0000081,null,null],"mistral-medium":[0.0000027,0.0000081,null,null],"mistral/mistral-medium-2312":[0.0000027,0.0000081,null,null],"mistral-medium-2312":[0.0000027,0.0000081,null,null],"mistral/mistral-medium-2505":[4e-7,0.000002,null,null],"mistral/mistral-medium-latest":[4e-7,0.000002,null,null],"mistral-medium-latest":[4e-7,0.000002,null,null],"mistral/mistral-medium-3-1-2508":[4e-7,0.000002,null,null],"mistral-medium-3-1-2508":[4e-7,0.000002,null,null],"mistral/mistral-small":[1e-7,3e-7,null,null],"mistral/mistral-small-latest":[6e-8,1.8e-7,null,null],"mistral-small-latest":[6e-8,1.8e-7,null,null],"mistral/mistral-small-3-2-2506":[6e-8,1.8e-7,null,null],"mistral-small-3-2-2506":[6e-8,1.8e-7,null,null],"mistral/ministral-3-3b-2512":[1e-7,1e-7,null,null],"ministral-3-3b-2512":[1e-7,1e-7,null,null],"mistral/ministral-3-8b-2512":[1.5e-7,1.5e-7,null,null],"ministral-3-8b-2512":[1.5e-7,1.5e-7,null,null],"mistral/ministral-3-14b-2512":[2e-7,2e-7,null,null],"ministral-3-14b-2512":[2e-7,2e-7,null,null],"mistral/mistral-tiny":[2.5e-7,2.5e-7,null,null],"mistral-tiny":[2.5e-7,2.5e-7,null,null],"mistral/open-codestral-mamba":[2.5e-7,2.5e-7,null,null],"open-codestral-mamba":[2.5e-7,2.5e-7,null,null],"mistral/open-mistral-7b":[2.5e-7,2.5e-7,null,null],"open-mistral-7b":[2.5e-7,2.5e-7,null,null],"mistral/open-mistral-nemo":[3e-7,3e-7,null,null],"open-mistral-nemo":[3e-7,3e-7,null,null],"mistral/open-mistral-nemo-2407":[3e-7,3e-7,null,null],"open-mistral-nemo-2407":[3e-7,3e-7,null,null],"mistral/open-mixtral-8x22b":[0.000002,0.000006,null,null],"open-mixtral-8x22b":[0.000002,0.000006,null,null],"mistral/open-mixtral-8x7b":[7e-7,7e-7,null,null],"open-mixtral-8x7b":[7e-7,7e-7,null,null],"mistral/pixtral-12b-2409":[1.5e-7,1.5e-7,null,null],"pixtral-12b-2409":[1.5e-7,1.5e-7,null,null],"mistral/pixtral-large-2411":[0.000002,0.000006,null,null],"pixtral-large-2411":[0.000002,0.000006,null,null],"mistral/pixtral-large-latest":[0.000002,0.000006,null,null],"pixtral-large-latest":[0.000002,0.000006,null,null],"moonshot/kimi-k2-0711-preview":[6e-7,0.0000025,null,1.5e-7],"kimi-k2-0711-preview":[6e-7,0.0000025,null,1.5e-7],"moonshot/kimi-k2-0905-preview":[6e-7,0.0000025,null,1.5e-7],"kimi-k2-0905-preview":[6e-7,0.0000025,null,1.5e-7],"moonshot/kimi-k2-turbo-preview":[0.00000115,0.000008,null,1.5e-7],"kimi-k2-turbo-preview":[0.00000115,0.000008,null,1.5e-7],"moonshot/kimi-k2.5":[6e-7,0.000003,null,1e-7],"moonshot/kimi-k2.6":[9.5e-7,0.000004,null,1.6e-7],"kimi-k2.6":[9.5e-7,0.000004,null,1.6e-7],"moonshot/kimi-latest":[0.000002,0.000005,null,1.5e-7],"kimi-latest":[0.000002,0.000005,null,1.5e-7],"moonshot/kimi-latest-128k":[0.000002,0.000005,null,1.5e-7],"kimi-latest-128k":[0.000002,0.000005,null,1.5e-7],"moonshot/kimi-latest-32k":[0.000001,0.000003,null,1.5e-7],"kimi-latest-32k":[0.000001,0.000003,null,1.5e-7],"moonshot/kimi-latest-8k":[2e-7,0.000002,null,1.5e-7],"kimi-latest-8k":[2e-7,0.000002,null,1.5e-7],"moonshot/kimi-thinking-preview":[6e-7,0.0000025,null,1.5e-7],"kimi-thinking-preview":[6e-7,0.0000025,null,1.5e-7],"moonshot/kimi-k2-thinking":[6e-7,0.0000025,null,1.5e-7],"kimi-k2-thinking":[6e-7,0.0000025,null,1.5e-7],"moonshot/kimi-k2-thinking-turbo":[0.00000115,0.000008,null,1.5e-7],"kimi-k2-thinking-turbo":[0.00000115,0.000008,null,1.5e-7],"moonshot/moonshot-v1-128k":[0.000002,0.000005,null,null],"moonshot-v1-128k":[0.000002,0.000005,null,null],"moonshot/moonshot-v1-128k-0430":[0.000002,0.000005,null,null],"moonshot-v1-128k-0430":[0.000002,0.000005,null,null],"moonshot/moonshot-v1-128k-vision-preview":[0.000002,0.000005,null,null],"moonshot-v1-128k-vision-preview":[0.000002,0.000005,null,null],"moonshot/moonshot-v1-32k":[0.000001,0.000003,null,null],"moonshot-v1-32k":[0.000001,0.000003,null,null],"moonshot/moonshot-v1-32k-0430":[0.000001,0.000003,null,null],"moonshot-v1-32k-0430":[0.000001,0.000003,null,null],"moonshot/moonshot-v1-32k-vision-preview":[0.000001,0.000003,null,null],"moonshot-v1-32k-vision-preview":[0.000001,0.000003,null,null],"moonshot/moonshot-v1-8k":[2e-7,0.000002,null,null],"moonshot-v1-8k":[2e-7,0.000002,null,null],"moonshot/moonshot-v1-8k-0430":[2e-7,0.000002,null,null],"moonshot-v1-8k-0430":[2e-7,0.000002,null,null],"moonshot/moonshot-v1-8k-vision-preview":[2e-7,0.000002,null,null],"moonshot-v1-8k-vision-preview":[2e-7,0.000002,null,null],"moonshot/moonshot-v1-auto":[0.000002,0.000005,null,null],"moonshot-v1-auto":[0.000002,0.000005,null,null],"morph/morph-v3-fast":[8e-7,0.0000012,null,null],"morph-v3-fast":[8e-7,0.0000012,null,null],"morph/morph-v3-large":[9e-7,0.0000019,null,null],"morph-v3-large":[9e-7,0.0000019,null,null],"nscale/Qwen/QwQ-32B":[1.8e-7,2e-7,null,null],"nscale/Qwen/Qwen2.5-Coder-32B-Instruct":[6e-8,2e-7,null,null],"nscale/Qwen/Qwen2.5-Coder-3B-Instruct":[1e-8,3e-8,null,null],"Qwen/Qwen2.5-Coder-3B-Instruct":[1e-8,3e-8,null,null],"nscale/Qwen/Qwen2.5-Coder-7B-Instruct":[1e-8,3e-8,null,null],"Qwen/Qwen2.5-Coder-7B-Instruct":[1e-8,3e-8,null,null],"nscale/deepseek-ai/DeepSeek-R1-Distill-Llama-70B":[3.75e-7,3.75e-7,null,null],"nscale/deepseek-ai/DeepSeek-R1-Distill-Llama-8B":[2.5e-8,2.5e-8,null,null],"deepseek-ai/DeepSeek-R1-Distill-Llama-8B":[2.5e-8,2.5e-8,null,null],"nscale/deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B":[9e-8,9e-8,null,null],"deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B":[9e-8,9e-8,null,null],"nscale/deepseek-ai/DeepSeek-R1-Distill-Qwen-14B":[7e-8,7e-8,null,null],"deepseek-ai/DeepSeek-R1-Distill-Qwen-14B":[7e-8,7e-8,null,null],"nscale/deepseek-ai/DeepSeek-R1-Distill-Qwen-32B":[1.5e-7,1.5e-7,null,null],"nscale/deepseek-ai/DeepSeek-R1-Distill-Qwen-7B":[2e-7,2e-7,null,null],"deepseek-ai/DeepSeek-R1-Distill-Qwen-7B":[2e-7,2e-7,null,null],"nscale/meta-llama/Llama-3.1-8B-Instruct":[3e-8,3e-8,null,null],"meta-llama/Llama-3.1-8B-Instruct":[3e-8,3e-8,null,null],"nscale/meta-llama/Llama-3.3-70B-Instruct":[2e-7,2e-7,null,null],"nscale/meta-llama/Llama-4-Scout-17B-16E-Instruct":[9e-8,2.9e-7,null,null],"nscale/mistralai/mixtral-8x22b-instruct-v0.1":[6e-7,6e-7,null,null],"mistralai/mixtral-8x22b-instruct-v0.1":[6e-7,6e-7,null,null],"nebius/deepseek-ai/DeepSeek-R1":[8e-7,0.0000024,null,null],"nebius/deepseek-ai/DeepSeek-R1-0528":[8e-7,0.0000024,null,null],"nebius/deepseek-ai/DeepSeek-R1-Distill-Llama-70B":[2.5e-7,7.5e-7,null,null],"nebius/deepseek-ai/DeepSeek-V3":[5e-7,0.0000015,null,null],"nebius/deepseek-ai/DeepSeek-V3-0324":[5e-7,0.0000015,null,null],"nebius/google/gemma-3-27b-it":[6e-8,2e-7,null,null],"nebius/meta-llama/Llama-3.3-70B-Instruct":[1.3e-7,4e-7,null,null],"nebius/meta-llama/Llama-Guard-3-8B":[2e-8,6e-8,null,null],"nebius/meta-llama/Meta-Llama-3.1-8B-Instruct":[2e-8,6e-8,null,null],"nebius/meta-llama/Meta-Llama-3.1-70B-Instruct":[1.3e-7,4e-7,null,null],"nebius/meta-llama/Meta-Llama-3.1-405B-Instruct":[0.000001,0.000003,null,null],"nebius/mistralai/Mistral-Nemo-Instruct-2407":[4e-8,1.2e-7,null,null],"nebius/NousResearch/Hermes-3-Llama-3.1-405B":[0.000001,0.000003,null,null],"nebius/nvidia/Llama-3.1-Nemotron-Ultra-253B-v1":[6e-7,0.0000018,null,null],"nvidia/Llama-3.1-Nemotron-Ultra-253B-v1":[6e-7,0.0000018,null,null],"nebius/nvidia/Llama-3.3-Nemotron-Super-49B-v1":[1e-7,4e-7,null,null],"nvidia/Llama-3.3-Nemotron-Super-49B-v1":[1e-7,4e-7,null,null],"nebius/Qwen/Qwen3-235B-A22B":[2e-7,6e-7,null,null],"nebius/Qwen/Qwen3-32B":[1e-7,3e-7,null,null],"nebius/Qwen/Qwen3-30B-A3B":[1e-7,3e-7,null,null],"nebius/Qwen/Qwen3-14B":[8e-8,2.4e-7,null,null],"nebius/Qwen/Qwen3-4B":[8e-8,2.4e-7,null,null],"Qwen/Qwen3-4B":[8e-8,2.4e-7,null,null],"nebius/Qwen/QwQ-32B":[1.5e-7,4.5e-7,null,null],"nebius/Qwen/Qwen2.5-72B-Instruct":[1.3e-7,4e-7,null,null],"nebius/Qwen/Qwen2.5-32B-Instruct":[6e-8,2e-7,null,null],"Qwen/Qwen2.5-32B-Instruct":[6e-8,2e-7,null,null],"nebius/Qwen/Qwen2.5-Coder-7B":[1e-8,3e-8,null,null],"Qwen/Qwen2.5-Coder-7B":[1e-8,3e-8,null,null],"nebius/Qwen/Qwen2.5-VL-72B-Instruct":[1.3e-7,4e-7,null,null],"Qwen/Qwen2.5-VL-72B-Instruct":[1.3e-7,4e-7,null,null],"nebius/Qwen/Qwen2-VL-72B-Instruct":[1.3e-7,4e-7,null,null],"Qwen/Qwen2-VL-72B-Instruct":[1.3e-7,4e-7,null,null],"nebius/Qwen/Qwen2-VL-7B-Instruct":[2e-8,6e-8,null,null],"Qwen/Qwen2-VL-7B-Instruct":[2e-8,6e-8,null,null],"nebius/BAAI/bge-en-icl":[1e-8,0,null,null],"BAAI/bge-en-icl":[1e-8,0,null,null],"nebius/BAAI/bge-multilingual-gemma2":[1e-8,0,null,null],"BAAI/bge-multilingual-gemma2":[1e-8,0,null,null],"nebius/intfloat/e5-mistral-7b-instruct":[1e-8,0,null,null],"intfloat/e5-mistral-7b-instruct":[1e-8,0,null,null],"oci/meta.llama-3.1-405b-instruct":[0.00001068,0.00001068,null,null],"meta.llama-3.1-405b-instruct":[0.00001068,0.00001068,null,null],"oci/meta.llama-3.2-90b-vision-instruct":[0.000002,0.000002,null,null],"meta.llama-3.2-90b-vision-instruct":[0.000002,0.000002,null,null],"oci/meta.llama-3.3-70b-instruct":[7.2e-7,7.2e-7,null,null],"meta.llama-3.3-70b-instruct":[7.2e-7,7.2e-7,null,null],"oci/meta.llama-4-maverick-17b-128e-instruct-fp8":[7.2e-7,7.2e-7,null,null],"meta.llama-4-maverick-17b-128e-instruct-fp8":[7.2e-7,7.2e-7,null,null],"oci/meta.llama-4-scout-17b-16e-instruct":[7.2e-7,7.2e-7,null,null],"meta.llama-4-scout-17b-16e-instruct":[7.2e-7,7.2e-7,null,null],"oci/xai.grok-3":[0.000003,0.000015,null,null],"xai.grok-3":[0.000003,0.000015,null,null],"oci/xai.grok-3-fast":[0.000005,0.000025,null,null],"xai.grok-3-fast":[0.000005,0.000025,null,null],"oci/xai.grok-3-mini":[3e-7,5e-7,null,null],"xai.grok-3-mini":[3e-7,5e-7,null,null],"oci/xai.grok-3-mini-fast":[6e-7,0.000004,null,null],"xai.grok-3-mini-fast":[6e-7,0.000004,null,null],"oci/xai.grok-4":[0.000003,0.000015,null,null],"xai.grok-4":[0.000003,0.000015,null,null],"oci/cohere.command-latest":[0.00000156,0.00000156,null,null],"cohere.command-latest":[0.00000156,0.00000156,null,null],"oci/cohere.command-a-03-2025":[0.00000156,0.00000156,null,null],"cohere.command-a-03-2025":[0.00000156,0.00000156,null,null],"oci/cohere.command-plus-latest":[0.00000156,0.00000156,null,null],"cohere.command-plus-latest":[0.00000156,0.00000156,null,null],"oci/cohere.command-a-reasoning-08-2025":[0.00000156,0.00000156,null,null],"cohere.command-a-reasoning-08-2025":[0.00000156,0.00000156,null,null],"oci/cohere.command-a-vision-07-2025":[0.00000156,0.00000156,null,null],"cohere.command-a-vision-07-2025":[0.00000156,0.00000156,null,null],"oci/cohere.command-a-translate-08-2025":[9e-8,9e-8,null,null],"cohere.command-a-translate-08-2025":[9e-8,9e-8,null,null],"oci/cohere.command-r-08-2024":[1.5e-7,1.5e-7,null,null],"cohere.command-r-08-2024":[1.5e-7,1.5e-7,null,null],"oci/cohere.command-r-plus-08-2024":[0.00000156,0.00000156,null,null],"cohere.command-r-plus-08-2024":[0.00000156,0.00000156,null,null],"oci/meta.llama-3.2-11b-vision-instruct":[0.000002,0.000002,null,null],"meta.llama-3.2-11b-vision-instruct":[0.000002,0.000002,null,null],"oci/meta.llama-3.1-70b-instruct":[7.2e-7,7.2e-7,null,null],"meta.llama-3.1-70b-instruct":[7.2e-7,7.2e-7,null,null],"oci/meta.llama-3.3-70b-instruct-fp8-dynamic":[7.2e-7,7.2e-7,null,null],"meta.llama-3.3-70b-instruct-fp8-dynamic":[7.2e-7,7.2e-7,null,null],"oci/xai.grok-4-fast":[0.000005,0.000025,null,null],"xai.grok-4-fast":[0.000005,0.000025,null,null],"oci/xai.grok-4.1-fast":[0.000005,0.000025,null,null],"xai.grok-4.1-fast":[0.000005,0.000025,null,null],"oci/xai.grok-4.20":[0.000003,0.000015,null,null],"xai.grok-4.20":[0.000003,0.000015,null,null],"oci/xai.grok-4.20-multi-agent":[0.000003,0.000015,null,null],"xai.grok-4.20-multi-agent":[0.000003,0.000015,null,null],"oci/xai.grok-code-fast-1":[0.000005,0.000025,null,null],"xai.grok-code-fast-1":[0.000005,0.000025,null,null],"oci/google.gemini-2.5-pro":[0.00000125,0.00001,null,null],"google.gemini-2.5-pro":[0.00000125,0.00001,null,null],"oci/google.gemini-2.5-flash":[1.5e-7,6e-7,null,null],"google.gemini-2.5-flash":[1.5e-7,6e-7,null,null],"oci/google.gemini-2.5-flash-lite":[7.5e-8,3e-7,null,null],"google.gemini-2.5-flash-lite":[7.5e-8,3e-7,null,null],"oci/cohere.embed-english-v3.0":[1e-7,0,null,null],"cohere.embed-english-v3.0":[1e-7,0,null,null],"oci/cohere.embed-english-light-v3.0":[1e-7,0,null,null],"cohere.embed-english-light-v3.0":[1e-7,0,null,null],"oci/cohere.embed-multilingual-v3.0":[1e-7,0,null,null],"cohere.embed-multilingual-v3.0":[1e-7,0,null,null],"oci/cohere.embed-multilingual-light-v3.0":[1e-7,0,null,null],"cohere.embed-multilingual-light-v3.0":[1e-7,0,null,null],"oci/cohere.embed-english-image-v3.0":[1e-7,0,null,null],"cohere.embed-english-image-v3.0":[1e-7,0,null,null],"oci/cohere.embed-english-light-image-v3.0":[1e-7,0,null,null],"cohere.embed-english-light-image-v3.0":[1e-7,0,null,null],"oci/cohere.embed-multilingual-light-image-v3.0":[1e-7,0,null,null],"cohere.embed-multilingual-light-image-v3.0":[1e-7,0,null,null],"oci/cohere.embed-v4.0":[1.2e-7,0,null,null],"cohere.embed-v4.0":[1.2e-7,0,null,null],"ollama/codegeex4":[0,0,null,null],"codegeex4":[0,0,null,null],"ollama/codegemma":[0,0,null,null],"codegemma":[0,0,null,null],"ollama/codellama":[0,0,null,null],"codellama":[0,0,null,null],"ollama/deepseek-coder-v2-base":[0,0,null,null],"deepseek-coder-v2-base":[0,0,null,null],"ollama/deepseek-coder-v2-instruct":[0,0,null,null],"deepseek-coder-v2-instruct":[0,0,null,null],"ollama/deepseek-coder-v2-lite-base":[0,0,null,null],"deepseek-coder-v2-lite-base":[0,0,null,null],"ollama/deepseek-coder-v2-lite-instruct":[0,0,null,null],"deepseek-coder-v2-lite-instruct":[0,0,null,null],"ollama/deepseek-v3.1:671b-cloud":[0,0,null,null],"deepseek-v3.1:671b-cloud":[0,0,null,null],"ollama/gpt-oss:120b-cloud":[0,0,null,null],"gpt-oss:120b-cloud":[0,0,null,null],"ollama/gpt-oss:20b-cloud":[0,0,null,null],"gpt-oss:20b-cloud":[0,0,null,null],"ollama/internlm2_5-20b-chat":[0,0,null,null],"internlm2_5-20b-chat":[0,0,null,null],"ollama/llama2":[0,0,null,null],"llama2":[0,0,null,null],"ollama/llama2-uncensored":[0,0,null,null],"llama2-uncensored":[0,0,null,null],"ollama/llama2:13b":[0,0,null,null],"llama2:13b":[0,0,null,null],"ollama/llama2:70b":[0,0,null,null],"llama2:70b":[0,0,null,null],"ollama/llama2:7b":[0,0,null,null],"llama2:7b":[0,0,null,null],"ollama/llama3":[0,0,null,null],"llama3":[0,0,null,null],"ollama/llama3.1":[0,0,null,null],"llama3.1":[0,0,null,null],"ollama/llama3:70b":[0,0,null,null],"llama3:70b":[0,0,null,null],"ollama/llama3:8b":[0,0,null,null],"llama3:8b":[0,0,null,null],"ollama/mistral":[0,0,null,null],"mistral":[0,0,null,null],"ollama/mistral-7B-Instruct-v0.1":[0,0,null,null],"mistral-7B-Instruct-v0.1":[0,0,null,null],"ollama/mistral-7B-Instruct-v0.2":[0,0,null,null],"mistral-7B-Instruct-v0.2":[0,0,null,null],"ollama/mistral-large-instruct-2407":[0,0,null,null],"mistral-large-instruct-2407":[0,0,null,null],"ollama/mixtral-8x22B-Instruct-v0.1":[0,0,null,null],"mixtral-8x22B-Instruct-v0.1":[0,0,null,null],"ollama/mixtral-8x7B-Instruct-v0.1":[0,0,null,null],"mixtral-8x7B-Instruct-v0.1":[0,0,null,null],"ollama/orca-mini":[0,0,null,null],"orca-mini":[0,0,null,null],"ollama/qwen3-coder:480b-cloud":[0,0,null,null],"qwen3-coder:480b-cloud":[0,0,null,null],"ollama/vicuna":[0,0,null,null],"vicuna":[0,0,null,null],"openrouter/anthropic/claude-3-haiku":[2.5e-7,0.00000125,null,null],"anthropic/claude-3-haiku":[2.5e-7,0.00000125,null,null],"openrouter/anthropic/claude-3.5-sonnet":[0.000003,0.000015,null,null],"anthropic/claude-3.5-sonnet":[0.000003,0.000015,null,null],"openrouter/anthropic/claude-3.7-sonnet":[0.000003,0.000015,null,null],"anthropic/claude-3.7-sonnet":[0.000003,0.000015,null,null],"openrouter/anthropic/claude-opus-4":[0.000015,0.000075,0.00001875,0.0000015],"openrouter/anthropic/claude-opus-4.1":[0.000015,0.000075,0.00001875,0.0000015],"anthropic/claude-opus-4.1":[0.000015,0.000075,0.00001875,0.0000015],"openrouter/anthropic/claude-sonnet-4":[0.000003,0.000015,0.00000375,3e-7],"openrouter/anthropic/claude-sonnet-4.6":[0.000003,0.000015,0.00000375,3e-7],"anthropic/claude-sonnet-4.6":[0.000003,0.000015,0.00000375,3e-7],"openrouter/anthropic/claude-opus-4.5":[0.000005,0.000025,0.00000625,5e-7],"openrouter/anthropic/claude-opus-4.6":[0.000005,0.000025,0.00000625,5e-7],"anthropic/claude-opus-4.6":[0.000005,0.000025,0.00000625,5e-7],"openrouter/anthropic/claude-sonnet-4.5":[0.000003,0.000015,0.00000375,3e-7],"openrouter/anthropic/claude-haiku-4.5":[0.000001,0.000005,0.00000125,1e-7],"anthropic/claude-haiku-4.5":[0.000001,0.000005,0.00000125,1e-7],"openrouter/anthropic/claude-opus-4.7":[0.000005,0.000025,0.00000625,5e-7],"anthropic/claude-opus-4.7":[0.000005,0.000025,0.00000625,5e-7],"openrouter/bytedance/ui-tars-1.5-7b":[1e-7,2e-7,null,null],"bytedance/ui-tars-1.5-7b":[1e-7,2e-7,null,null],"openrouter/deepseek/deepseek-chat":[1.4e-7,2.8e-7,null,null],"openrouter/deepseek/deepseek-chat-v3-0324":[1.4e-7,2.8e-7,null,null],"deepseek/deepseek-chat-v3-0324":[1.4e-7,2.8e-7,null,null],"openrouter/deepseek/deepseek-chat-v3.1":[2e-7,8e-7,null,null],"deepseek/deepseek-chat-v3.1":[2e-7,8e-7,null,null],"openrouter/deepseek/deepseek-v3.2":[2.8e-7,4e-7,null,null],"openrouter/deepseek/deepseek-v3.2-exp":[2e-7,4e-7,null,null],"deepseek/deepseek-v3.2-exp":[2e-7,4e-7,null,null],"openrouter/deepseek/deepseek-r1":[5.5e-7,0.00000219,null,null],"openrouter/deepseek/deepseek-r1-0528":[5e-7,0.00000215,null,null],"deepseek/deepseek-r1-0528":[5e-7,0.00000215,null,null],"openrouter/google/gemini-2.0-flash-001":[1e-7,4e-7,null,null],"openrouter/google/gemini-2.5-flash":[3e-7,0.0000025,null,null],"openrouter/google/gemini-2.5-pro":[0.00000125,0.00001,null,null],"openrouter/google/gemini-3-pro-preview":[0.000002,0.000012,null,2e-7],"openrouter/google/gemini-3-flash-preview":[5e-7,0.000003,null,5e-8],"openrouter/google/gemini-3.1-flash-lite-preview":[2.5e-7,0.0000015,null,2.5e-8],"google/gemini-3.1-flash-lite-preview":[2.5e-7,0.0000015,null,2.5e-8],"openrouter/google/gemini-3.1-pro-preview":[0.000002,0.000012,null,2e-7],"google/gemini-3.1-pro-preview":[0.000002,0.000012,null,2e-7],"openrouter/gryphe/mythomax-l2-13b":[0.000001875,0.000001875,null,null],"gryphe/mythomax-l2-13b":[0.000001875,0.000001875,null,null],"openrouter/mancer/weaver":[0.000005625,0.000005625,null,null],"mancer/weaver":[0.000005625,0.000005625,null,null],"openrouter/meta-llama/llama-3-70b-instruct":[5.9e-7,7.9e-7,null,null],"meta-llama/llama-3-70b-instruct":[5.9e-7,7.9e-7,null,null],"openrouter/minimax/minimax-m2":[2.55e-7,0.00000102,null,null],"minimax/minimax-m2":[2.55e-7,0.00000102,null,null],"openrouter/mistralai/devstral-2512":[1.5e-7,6e-7,null,null],"mistralai/devstral-2512":[1.5e-7,6e-7,null,null],"openrouter/mistralai/ministral-3b-2512":[1e-7,1e-7,null,null],"mistralai/ministral-3b-2512":[1e-7,1e-7,null,null],"openrouter/mistralai/ministral-8b-2512":[1.5e-7,1.5e-7,null,null],"mistralai/ministral-8b-2512":[1.5e-7,1.5e-7,null,null],"openrouter/mistralai/ministral-14b-2512":[2e-7,2e-7,null,null],"mistralai/ministral-14b-2512":[2e-7,2e-7,null,null],"openrouter/mistralai/mistral-large-2512":[5e-7,0.0000015,null,null],"mistralai/mistral-large-2512":[5e-7,0.0000015,null,null],"openrouter/mistralai/mistral-7b-instruct":[1.3e-7,1.3e-7,null,null],"mistralai/mistral-7b-instruct":[1.3e-7,1.3e-7,null,null],"openrouter/mistralai/mistral-large":[0.000008,0.000024,null,null],"mistralai/mistral-large":[0.000008,0.000024,null,null],"openrouter/mistralai/mistral-small-3.1-24b-instruct":[1e-7,3e-7,null,null],"mistralai/mistral-small-3.1-24b-instruct":[1e-7,3e-7,null,null],"openrouter/mistralai/mistral-small-3.2-24b-instruct":[1e-7,3e-7,null,null],"mistralai/mistral-small-3.2-24b-instruct":[1e-7,3e-7,null,null],"openrouter/mistralai/mixtral-8x22b-instruct":[6.5e-7,6.5e-7,null,null],"mistralai/mixtral-8x22b-instruct":[6.5e-7,6.5e-7,null,null],"openrouter/moonshotai/kimi-k2.5":[6e-7,0.000003,null,1e-7],"moonshotai/kimi-k2.5":[6e-7,0.000003,null,1e-7],"openrouter/openai/gpt-3.5-turbo":[0.0000015,0.000002,null,null],"openai/gpt-3.5-turbo":[0.0000015,0.000002,null,null],"openrouter/openai/gpt-3.5-turbo-16k":[0.000003,0.000004,null,null],"openai/gpt-3.5-turbo-16k":[0.000003,0.000004,null,null],"openrouter/openai/gpt-4":[0.00003,0.00006,null,null],"openai/gpt-4":[0.00003,0.00006,null,null],"openrouter/openai/gpt-4.1":[0.000002,0.000008,null,5e-7],"openai/gpt-4.1":[0.000002,0.000008,null,5e-7],"openrouter/openai/gpt-4.1-mini":[4e-7,0.0000016,null,1e-7],"openai/gpt-4.1-mini":[4e-7,0.0000016,null,1e-7],"openrouter/openai/gpt-4.1-nano":[1e-7,4e-7,null,2.5e-8],"openai/gpt-4.1-nano":[1e-7,4e-7,null,2.5e-8],"openrouter/openai/gpt-4o":[0.0000025,0.00001,null,null],"openrouter/openai/gpt-4o-2024-05-13":[0.000005,0.000015,null,null],"openai/gpt-4o-2024-05-13":[0.000005,0.000015,null,null],"openrouter/openai/gpt-5-chat":[0.00000125,0.00001,null,1.25e-7],"openai/gpt-5-chat":[0.00000125,0.00001,null,1.25e-7],"openrouter/openai/gpt-5-codex":[0.00000125,0.00001,null,1.25e-7],"openai/gpt-5-codex":[0.00000125,0.00001,null,1.25e-7],"openrouter/openai/gpt-5.2-codex":[0.00000175,0.000014,null,1.75e-7],"openai/gpt-5.2-codex":[0.00000175,0.000014,null,1.75e-7],"openrouter/openai/gpt-5":[0.00000125,0.00001,null,1.25e-7],"openrouter/openai/gpt-5-mini":[2.5e-7,0.000002,null,2.5e-8],"openai/gpt-5-mini":[2.5e-7,0.000002,null,2.5e-8],"openrouter/openai/gpt-5-nano":[5e-8,4e-7,null,5e-9],"openai/gpt-5-nano":[5e-8,4e-7,null,5e-9],"openrouter/openai/gpt-5.1-codex-max":[0.00000125,0.00001,null,1.25e-7],"openai/gpt-5.1-codex-max":[0.00000125,0.00001,null,1.25e-7],"openrouter/openai/gpt-5.2":[0.00000175,0.000014,null,1.75e-7],"openrouter/openai/gpt-5.2-chat":[0.00000175,0.000014,null,1.75e-7],"openai/gpt-5.2-chat":[0.00000175,0.000014,null,1.75e-7],"openrouter/openai/gpt-5.2-pro":[0.000021,0.000168,null,null],"openai/gpt-5.2-pro":[0.000021,0.000168,null,null],"openrouter/openai/gpt-oss-120b":[1.8e-7,8e-7,null,null],"openrouter/openai/gpt-oss-20b":[2e-8,1e-7,null,null],"openrouter/openai/o1":[0.000015,0.00006,null,0.0000075],"openai/o1":[0.000015,0.00006,null,0.0000075],"openrouter/openai/o3-mini":[0.0000011,0.0000044,null,null],"openai/o3-mini":[0.0000011,0.0000044,null,null],"openrouter/openai/o3-mini-high":[0.0000011,0.0000044,null,null],"openai/o3-mini-high":[0.0000011,0.0000044,null,null],"openrouter/qwen/qwen-2.5-coder-32b-instruct":[1.8e-7,1.8e-7,null,null],"qwen/qwen-2.5-coder-32b-instruct":[1.8e-7,1.8e-7,null,null],"openrouter/qwen/qwen-vl-plus":[2.1e-7,6.3e-7,null,null],"qwen/qwen-vl-plus":[2.1e-7,6.3e-7,null,null],"openrouter/qwen/qwen3-coder":[2.2e-7,9.5e-7,null,null],"qwen/qwen3-coder":[2.2e-7,9.5e-7,null,null],"openrouter/qwen/qwen3-coder-plus":[0.000001,0.000005,null,null],"qwen/qwen3-coder-plus":[0.000001,0.000005,null,null],"openrouter/qwen/qwen3-235b-a22b-2507":[7.1e-8,1e-7,null,null],"qwen/qwen3-235b-a22b-2507":[7.1e-8,1e-7,null,null],"openrouter/qwen/qwen3-235b-a22b-thinking-2507":[1.1e-7,6e-7,null,null],"qwen/qwen3-235b-a22b-thinking-2507":[1.1e-7,6e-7,null,null],"openrouter/qwen/qwen3.5-35b-a3b":[2.5e-7,0.000002,null,null],"qwen/qwen3.5-35b-a3b":[2.5e-7,0.000002,null,null],"openrouter/qwen/qwen3.5-27b":[3e-7,0.0000024,null,null],"qwen/qwen3.5-27b":[3e-7,0.0000024,null,null],"openrouter/qwen/qwen3.5-122b-a10b":[4e-7,0.000002,null,null],"qwen/qwen3.5-122b-a10b":[4e-7,0.000002,null,null],"openrouter/qwen/qwen3.5-flash-02-23":[1e-7,4e-7,null,null],"qwen/qwen3.5-flash-02-23":[1e-7,4e-7,null,null],"openrouter/qwen/qwen3.5-plus-02-15":[4e-7,0.0000024,null,null],"qwen/qwen3.5-plus-02-15":[4e-7,0.0000024,null,null],"openrouter/qwen/qwen3.5-397b-a17b":[6e-7,0.0000036,null,null],"qwen/qwen3.5-397b-a17b":[6e-7,0.0000036,null,null],"openrouter/switchpoint/router":[8.5e-7,0.0000034,null,null],"switchpoint/router":[8.5e-7,0.0000034,null,null],"openrouter/undi95/remm-slerp-l2-13b":[0.000001875,0.000001875,null,null],"undi95/remm-slerp-l2-13b":[0.000001875,0.000001875,null,null],"openrouter/x-ai/grok-4":[0.000003,0.000015,null,null],"x-ai/grok-4":[0.000003,0.000015,null,null],"openrouter/z-ai/glm-4.6":[4e-7,0.00000175,null,null],"z-ai/glm-4.6":[4e-7,0.00000175,null,null],"openrouter/z-ai/glm-4.6:exacto":[4.5e-7,0.0000019,null,null],"z-ai/glm-4.6:exacto":[4.5e-7,0.0000019,null,null],"openrouter/xiaomi/mimo-v2-flash":[9e-8,2.9e-7,0,0],"xiaomi/mimo-v2-flash":[9e-8,2.9e-7,0,0],"openrouter/z-ai/glm-4.7":[4e-7,0.0000015,0,0],"z-ai/glm-4.7":[4e-7,0.0000015,0,0],"openrouter/z-ai/glm-4.7-flash":[7e-8,4e-7,0,0],"z-ai/glm-4.7-flash":[7e-8,4e-7,0,0],"openrouter/z-ai/glm-5":[8e-7,0.00000256,null,null],"z-ai/glm-5":[8e-7,0.00000256,null,null],"openrouter/minimax/minimax-m2.1":[2.7e-7,0.0000012,0,0],"minimax/minimax-m2.1":[2.7e-7,0.0000012,0,0],"openrouter/minimax/minimax-m2.5":[3e-7,0.0000011,null,1.5e-7],"minimax/minimax-m2.5":[3e-7,0.0000011,null,1.5e-7],"openrouter/openrouter/auto":[0,0,null,null],"openrouter/auto":[0,0,null,null],"openrouter/openrouter/free":[0,0,null,null],"openrouter/free":[0,0,null,null],"openrouter/openrouter/bodybuilder":[0,0,null,null],"openrouter/bodybuilder":[0,0,null,null],"ovhcloud/DeepSeek-R1-Distill-Llama-70B":[6.7e-7,6.7e-7,null,null],"DeepSeek-R1-Distill-Llama-70B":[6.7e-7,6.7e-7,null,null],"ovhcloud/Llama-3.1-8B-Instruct":[1e-7,1e-7,null,null],"Llama-3.1-8B-Instruct":[1e-7,1e-7,null,null],"ovhcloud/Meta-Llama-3_1-70B-Instruct":[6.7e-7,6.7e-7,null,null],"Meta-Llama-3_1-70B-Instruct":[6.7e-7,6.7e-7,null,null],"ovhcloud/Meta-Llama-3_3-70B-Instruct":[6.7e-7,6.7e-7,null,null],"Meta-Llama-3_3-70B-Instruct":[6.7e-7,6.7e-7,null,null],"ovhcloud/Mistral-7B-Instruct-v0.3":[1e-7,1e-7,null,null],"Mistral-7B-Instruct-v0.3":[1e-7,1e-7,null,null],"ovhcloud/Mistral-Nemo-Instruct-2407":[1.3e-7,1.3e-7,null,null],"Mistral-Nemo-Instruct-2407":[1.3e-7,1.3e-7,null,null],"ovhcloud/Mistral-Small-3.2-24B-Instruct-2506":[9e-8,2.8e-7,null,null],"Mistral-Small-3.2-24B-Instruct-2506":[9e-8,2.8e-7,null,null],"ovhcloud/Mixtral-8x7B-Instruct-v0.1":[6.3e-7,6.3e-7,null,null],"Mixtral-8x7B-Instruct-v0.1":[6.3e-7,6.3e-7,null,null],"ovhcloud/Qwen2.5-Coder-32B-Instruct":[8.7e-7,8.7e-7,null,null],"Qwen2.5-Coder-32B-Instruct":[8.7e-7,8.7e-7,null,null],"ovhcloud/Qwen2.5-VL-72B-Instruct":[9.1e-7,9.1e-7,null,null],"Qwen2.5-VL-72B-Instruct":[9.1e-7,9.1e-7,null,null],"ovhcloud/Qwen3-32B":[8e-8,2.3e-7,null,null],"Qwen3-32B":[8e-8,2.3e-7,null,null],"ovhcloud/gpt-oss-120b":[8e-8,4e-7,null,null],"ovhcloud/gpt-oss-20b":[4e-8,1.5e-7,null,null],"gpt-oss-20b":[4e-8,1.5e-7,null,null],"ovhcloud/llava-v1.6-mistral-7b-hf":[2.9e-7,2.9e-7,null,null],"llava-v1.6-mistral-7b-hf":[2.9e-7,2.9e-7,null,null],"ovhcloud/mamba-codestral-7B-v0.1":[1.9e-7,1.9e-7,null,null],"mamba-codestral-7B-v0.1":[1.9e-7,1.9e-7,null,null],"palm/chat-bison":[1.25e-7,1.25e-7,null,null],"chat-bison":[1.25e-7,1.25e-7,null,null],"palm/chat-bison-001":[1.25e-7,1.25e-7,null,null],"chat-bison-001":[1.25e-7,1.25e-7,null,null],"palm/text-bison":[1.25e-7,1.25e-7,null,null],"text-bison":[1.25e-7,1.25e-7,null,null],"palm/text-bison-001":[1.25e-7,1.25e-7,null,null],"text-bison-001":[1.25e-7,1.25e-7,null,null],"palm/text-bison-safety-off":[1.25e-7,1.25e-7,null,null],"text-bison-safety-off":[1.25e-7,1.25e-7,null,null],"palm/text-bison-safety-recitation-off":[1.25e-7,1.25e-7,null,null],"text-bison-safety-recitation-off":[1.25e-7,1.25e-7,null,null],"perplexity/codellama-34b-instruct":[3.5e-7,0.0000014,null,null],"codellama-34b-instruct":[3.5e-7,0.0000014,null,null],"perplexity/codellama-70b-instruct":[7e-7,0.0000028,null,null],"codellama-70b-instruct":[7e-7,0.0000028,null,null],"perplexity/llama-2-70b-chat":[7e-7,0.0000028,null,null],"llama-2-70b-chat":[7e-7,0.0000028,null,null],"perplexity/llama-3.1-70b-instruct":[0.000001,0.000001,null,null],"llama-3.1-70b-instruct":[0.000001,0.000001,null,null],"perplexity/llama-3.1-8b-instruct":[2e-7,2e-7,null,null],"llama-3.1-8b-instruct":[2e-7,2e-7,null,null],"perplexity/mistral-7b-instruct":[7e-8,2.8e-7,null,null],"mistral-7b-instruct":[7e-8,2.8e-7,null,null],"perplexity/mixtral-8x7b-instruct":[7e-8,2.8e-7,null,null],"mixtral-8x7b-instruct":[7e-8,2.8e-7,null,null],"perplexity/pplx-70b-chat":[7e-7,0.0000028,null,null],"pplx-70b-chat":[7e-7,0.0000028,null,null],"perplexity/pplx-70b-online":[0,0.0000028,null,null],"pplx-70b-online":[0,0.0000028,null,null],"perplexity/pplx-7b-chat":[7e-8,2.8e-7,null,null],"pplx-7b-chat":[7e-8,2.8e-7,null,null],"perplexity/pplx-7b-online":[0,2.8e-7,null,null],"pplx-7b-online":[0,2.8e-7,null,null],"perplexity/sonar":[0.000001,0.000001,null,null],"sonar":[0.000001,0.000001,null,null],"perplexity/sonar-deep-research":[0.000002,0.000008,null,null],"sonar-deep-research":[0.000002,0.000008,null,null],"perplexity/sonar-medium-chat":[6e-7,0.0000018,null,null],"sonar-medium-chat":[6e-7,0.0000018,null,null],"perplexity/sonar-medium-online":[0,0.0000018,null,null],"sonar-medium-online":[0,0.0000018,null,null],"perplexity/sonar-pro":[0.000003,0.000015,null,null],"sonar-pro":[0.000003,0.000015,null,null],"perplexity/sonar-reasoning":[0.000001,0.000005,null,null],"sonar-reasoning":[0.000001,0.000005,null,null],"perplexity/sonar-reasoning-pro":[0.000002,0.000008,null,null],"sonar-reasoning-pro":[0.000002,0.000008,null,null],"perplexity/sonar-small-chat":[7e-8,2.8e-7,null,null],"sonar-small-chat":[7e-8,2.8e-7,null,null],"perplexity/sonar-small-online":[0,2.8e-7,null,null],"sonar-small-online":[0,2.8e-7,null,null],"publicai/swiss-ai/apertus-8b-instruct":[0,0,null,null],"swiss-ai/apertus-8b-instruct":[0,0,null,null],"publicai/swiss-ai/apertus-70b-instruct":[0,0,null,null],"swiss-ai/apertus-70b-instruct":[0,0,null,null],"publicai/aisingapore/Gemma-SEA-LION-v4-27B-IT":[0,0,null,null],"aisingapore/Gemma-SEA-LION-v4-27B-IT":[0,0,null,null],"publicai/BSC-LT/salamandra-7b-instruct-tools-16k":[0,0,null,null],"BSC-LT/salamandra-7b-instruct-tools-16k":[0,0,null,null],"publicai/BSC-LT/ALIA-40b-instruct_Q8_0":[0,0,null,null],"BSC-LT/ALIA-40b-instruct_Q8_0":[0,0,null,null],"publicai/allenai/Olmo-3-7B-Instruct":[0,0,null,null],"allenai/Olmo-3-7B-Instruct":[0,0,null,null],"perplexity/pplx-embed-v1-0.6b":[4e-9,0,null,null],"pplx-embed-v1-0.6b":[4e-9,0,null,null],"perplexity/pplx-embed-v1-4b":[3e-8,0,null,null],"pplx-embed-v1-4b":[3e-8,0,null,null],"publicai/aisingapore/Qwen-SEA-LION-v4-32B-IT":[0,0,null,null],"aisingapore/Qwen-SEA-LION-v4-32B-IT":[0,0,null,null],"publicai/allenai/Olmo-3-7B-Think":[0,0,null,null],"allenai/Olmo-3-7B-Think":[0,0,null,null],"publicai/allenai/Olmo-3-32B-Think":[0,0,null,null],"allenai/Olmo-3-32B-Think":[0,0,null,null],"replicate/meta/llama-2-13b":[1e-7,5e-7,null,null],"meta/llama-2-13b":[1e-7,5e-7,null,null],"replicate/meta/llama-2-13b-chat":[1e-7,5e-7,null,null],"meta/llama-2-13b-chat":[1e-7,5e-7,null,null],"replicate/meta/llama-2-70b":[6.5e-7,0.00000275,null,null],"meta/llama-2-70b":[6.5e-7,0.00000275,null,null],"replicate/meta/llama-2-70b-chat":[6.5e-7,0.00000275,null,null],"meta/llama-2-70b-chat":[6.5e-7,0.00000275,null,null],"replicate/meta/llama-2-7b":[5e-8,2.5e-7,null,null],"meta/llama-2-7b":[5e-8,2.5e-7,null,null],"replicate/meta/llama-2-7b-chat":[5e-8,2.5e-7,null,null],"meta/llama-2-7b-chat":[5e-8,2.5e-7,null,null],"replicate/meta/llama-3-70b":[6.5e-7,0.00000275,null,null],"meta/llama-3-70b":[6.5e-7,0.00000275,null,null],"replicate/meta/llama-3-70b-instruct":[6.5e-7,0.00000275,null,null],"meta/llama-3-70b-instruct":[6.5e-7,0.00000275,null,null],"replicate/meta/llama-3-8b":[5e-8,2.5e-7,null,null],"meta/llama-3-8b":[5e-8,2.5e-7,null,null],"replicate/meta/llama-3-8b-instruct":[5e-8,2.5e-7,null,null],"meta/llama-3-8b-instruct":[5e-8,2.5e-7,null,null],"replicate/mistralai/mistral-7b-instruct-v0.2":[5e-8,2.5e-7,null,null],"mistralai/mistral-7b-instruct-v0.2":[5e-8,2.5e-7,null,null],"replicate/mistralai/mistral-7b-v0.1":[5e-8,2.5e-7,null,null],"mistralai/mistral-7b-v0.1":[5e-8,2.5e-7,null,null],"replicate/mistralai/mixtral-8x7b-instruct-v0.1":[3e-7,0.000001,null,null],"mistralai/mixtral-8x7b-instruct-v0.1":[3e-7,0.000001,null,null],"replicate/openai/gpt-5":[0.00000125,0.00001,null,null],"replicateopenai/gpt-oss-20b":[9e-8,3.6e-7,null,null],"replicate/anthropic/claude-4.5-haiku":[0.000001,0.000005,null,null],"anthropic/claude-4.5-haiku":[0.000001,0.000005,null,null],"replicate/ibm-granite/granite-3.3-8b-instruct":[3e-8,2.5e-7,null,null],"ibm-granite/granite-3.3-8b-instruct":[3e-8,2.5e-7,null,null],"replicate/openai/gpt-4o":[0.0000025,0.00001,null,null],"replicate/openai/o4-mini":[0.000001,0.000004,null,null],"openai/o4-mini":[0.000001,0.000004,null,null],"replicate/openai/o1-mini":[0.0000011,0.0000044,null,null],"openai/o1-mini":[0.0000011,0.0000044,null,null],"replicate/openai/o1":[0.000015,0.00006,null,null],"replicate/openai/gpt-4o-mini":[1.5e-7,6e-7,null,null],"replicate/qwen/qwen3-235b-a22b-instruct-2507":[2.64e-7,0.00000106,null,null],"qwen/qwen3-235b-a22b-instruct-2507":[2.64e-7,0.00000106,null,null],"replicate/anthropic/claude-4-sonnet":[0.000003,0.000015,null,null],"replicate/deepseek-ai/deepseek-v3":[0.00000145,0.00000145,null,null],"deepseek-ai/deepseek-v3":[0.00000145,0.00000145,null,null],"replicate/anthropic/claude-3.7-sonnet":[0.000003,0.000015,null,null],"replicate/anthropic/claude-3.5-haiku":[0.000001,0.000005,null,null],"anthropic/claude-3.5-haiku":[0.000001,0.000005,null,null],"replicate/anthropic/claude-3.5-sonnet":[0.00000375,0.00001875,null,null],"replicate/google/gemini-3-pro":[0.000002,0.000012,null,null],"google/gemini-3-pro":[0.000002,0.000012,null,null],"replicate/anthropic/claude-4.5-sonnet":[0.000003,0.000015,null,null],"anthropic/claude-4.5-sonnet":[0.000003,0.000015,null,null],"replicate/openai/gpt-4.1":[0.000002,0.000008,null,null],"replicate/openai/gpt-4.1-nano":[1e-7,4e-7,null,null],"replicate/openai/gpt-4.1-mini":[4e-7,0.0000016,null,null],"replicate/openai/gpt-5-nano":[5e-8,4e-7,null,null],"replicate/openai/gpt-5-mini":[2.5e-7,0.000002,null,null],"replicate/google/gemini-2.5-flash":[0.0000025,0.0000025,null,null],"replicate/openai/gpt-oss-120b":[1.8e-7,7.2e-7,null,null],"replicate/deepseek-ai/deepseek-v3.1":[6.72e-7,0.000002016,null,null],"deepseek-ai/deepseek-v3.1":[6.72e-7,0.000002016,null,null],"replicate/xai/grok-4":[0.0000072,0.000036,null,null],"xai/grok-4":[0.0000072,0.000036,null,null],"replicate/deepseek-ai/deepseek-r1":[0.00000375,0.00001,null,null],"deepseek-ai/deepseek-r1":[0.00000375,0.00001,null,null],"nvidia_nim/nvidia/nv-rerankqa-mistral-4b-v3":[0,0,null,null],"nvidia/nv-rerankqa-mistral-4b-v3":[0,0,null,null],"nvidia_nim/nvidia/llama-3_2-nv-rerankqa-1b-v2":[0,0,null,null],"nvidia/llama-3_2-nv-rerankqa-1b-v2":[0,0,null,null],"nvidia_nim/ranking/nvidia/llama-3.2-nv-rerankqa-1b-v2":[0,0,null,null],"ranking/nvidia/llama-3.2-nv-rerankqa-1b-v2":[0,0,null,null],"sagemaker/meta-textgeneration-llama-2-13b":[0,0,null,null],"meta-textgeneration-llama-2-13b":[0,0,null,null],"sagemaker/meta-textgeneration-llama-2-13b-f":[0,0,null,null],"meta-textgeneration-llama-2-13b-f":[0,0,null,null],"sagemaker/meta-textgeneration-llama-2-70b":[0,0,null,null],"meta-textgeneration-llama-2-70b":[0,0,null,null],"sagemaker/meta-textgeneration-llama-2-70b-b-f":[0,0,null,null],"meta-textgeneration-llama-2-70b-b-f":[0,0,null,null],"sagemaker/meta-textgeneration-llama-2-7b":[0,0,null,null],"meta-textgeneration-llama-2-7b":[0,0,null,null],"sagemaker/meta-textgeneration-llama-2-7b-f":[0,0,null,null],"meta-textgeneration-llama-2-7b-f":[0,0,null,null],"sambanova/DeepSeek-R1":[0.000005,0.000007,null,null],"DeepSeek-R1":[0.000005,0.000007,null,null],"sambanova/DeepSeek-R1-Distill-Llama-70B":[7e-7,0.0000014,null,null],"sambanova/DeepSeek-V3-0324":[0.000003,0.0000045,null,null],"DeepSeek-V3-0324":[0.000003,0.0000045,null,null],"sambanova/Llama-4-Maverick-17B-128E-Instruct":[6.3e-7,0.0000018,null,null],"Llama-4-Maverick-17B-128E-Instruct":[6.3e-7,0.0000018,null,null],"sambanova/Llama-4-Scout-17B-16E-Instruct":[4e-7,7e-7,null,null],"sambanova/Meta-Llama-3.1-405B-Instruct":[0.000005,0.00001,null,null],"sambanova/Meta-Llama-3.1-8B-Instruct":[1e-7,2e-7,null,null],"sambanova/Meta-Llama-3.2-1B-Instruct":[4e-8,8e-8,null,null],"Meta-Llama-3.2-1B-Instruct":[4e-8,8e-8,null,null],"sambanova/Meta-Llama-3.2-3B-Instruct":[8e-8,1.6e-7,null,null],"Meta-Llama-3.2-3B-Instruct":[8e-8,1.6e-7,null,null],"sambanova/Meta-Llama-3.3-70B-Instruct":[6e-7,0.0000012,null,null],"Meta-Llama-3.3-70B-Instruct":[6e-7,0.0000012,null,null],"sambanova/Meta-Llama-Guard-3-8B":[3e-7,3e-7,null,null],"Meta-Llama-Guard-3-8B":[3e-7,3e-7,null,null],"sambanova/QwQ-32B":[5e-7,0.000001,null,null],"QwQ-32B":[5e-7,0.000001,null,null],"sambanova/Qwen2-Audio-7B-Instruct":[5e-7,0.0001,null,null],"Qwen2-Audio-7B-Instruct":[5e-7,0.0001,null,null],"sambanova/Qwen3-32B":[4e-7,8e-7,null,null],"sambanova/DeepSeek-V3.1":[0.000003,0.0000045,null,null],"DeepSeek-V3.1":[0.000003,0.0000045,null,null],"sambanova/gpt-oss-120b":[0.000003,0.0000045,null,null],"text-completion-codestral/codestral-2405":[0,0,null,null],"text-completion-codestral/codestral-latest":[0,0,null,null],"together_ai/baai/bge-base-en-v1.5":[8e-9,0,null,null],"baai/bge-base-en-v1.5":[8e-9,0,null,null],"together_ai/BAAI/bge-base-en-v1.5":[8e-9,0,null,null],"BAAI/bge-base-en-v1.5":[8e-9,0,null,null],"together_ai/Qwen/Qwen3-235B-A22B-Instruct-2507-tput":[2e-7,0.000006,null,null],"Qwen/Qwen3-235B-A22B-Instruct-2507-tput":[2e-7,0.000006,null,null],"together_ai/Qwen/Qwen3-235B-A22B-Thinking-2507":[6.5e-7,0.000003,null,null],"together_ai/Qwen/Qwen3-235B-A22B-fp8-tput":[2e-7,6e-7,null,null],"Qwen/Qwen3-235B-A22B-fp8-tput":[2e-7,6e-7,null,null],"together_ai/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8":[0.000002,0.000002,null,null],"Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8":[0.000002,0.000002,null,null],"together_ai/deepseek-ai/DeepSeek-R1":[0.000003,0.000007,null,null],"together_ai/deepseek-ai/DeepSeek-R1-0528-tput":[5.5e-7,0.00000219,null,null],"deepseek-ai/DeepSeek-R1-0528-tput":[5.5e-7,0.00000219,null,null],"together_ai/deepseek-ai/DeepSeek-V3":[0.00000125,0.00000125,null,null],"together_ai/deepseek-ai/DeepSeek-V3.1":[6e-7,0.0000017,null,null],"together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo":[8.8e-7,8.8e-7,null,null],"together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo-Free":[0,0,null,null],"meta-llama/Llama-3.3-70B-Instruct-Turbo-Free":[0,0,null,null],"together_ai/meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":[2.7e-7,8.5e-7,null,null],"together_ai/meta-llama/Llama-4-Scout-17B-16E-Instruct":[1.8e-7,5.9e-7,null,null],"together_ai/meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo":[0.0000035,0.0000035,null,null],"meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo":[0.0000035,0.0000035,null,null],"together_ai/meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo":[8.8e-7,8.8e-7,null,null],"together_ai/meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo":[1.8e-7,1.8e-7,null,null],"together_ai/mistralai/Mixtral-8x7B-Instruct-v0.1":[6e-7,6e-7,null,null],"together_ai/moonshotai/Kimi-K2-Instruct":[0.000001,0.000003,null,null],"together_ai/openai/gpt-oss-120b":[1.5e-7,6e-7,null,null],"together_ai/openai/gpt-oss-20b":[5e-8,2e-7,null,null],"together_ai/zai-org/GLM-4.5-Air-FP8":[2e-7,0.0000011,null,null],"zai-org/GLM-4.5-Air-FP8":[2e-7,0.0000011,null,null],"together_ai/zai-org/GLM-4.6":[6e-7,0.0000022,null,null],"together_ai/zai-org/GLM-4.7":[4.5e-7,0.000002,null,null],"together_ai/moonshotai/Kimi-K2.5":[5e-7,0.0000028,null,null],"together_ai/moonshotai/Kimi-K2-Instruct-0905":[0.000001,0.000003,null,null],"together_ai/Qwen/Qwen3-Next-80B-A3B-Instruct":[1.5e-7,0.0000015,null,null],"together_ai/Qwen/Qwen3-Next-80B-A3B-Thinking":[1.5e-7,0.0000015,null,null],"together_ai/Qwen/Qwen3.5-397B-A17B":[6e-7,0.0000036,null,null],"Qwen/Qwen3.5-397B-A17B":[6e-7,0.0000036,null,null],"v0/v0-1.0-md":[0.000003,0.000015,null,null],"v0-1.0-md":[0.000003,0.000015,null,null],"v0/v0-1.5-lg":[0.000015,0.000075,null,null],"v0-1.5-lg":[0.000015,0.000075,null,null],"v0/v0-1.5-md":[0.000003,0.000015,null,null],"v0-1.5-md":[0.000003,0.000015,null,null],"vercel_ai_gateway/alibaba/qwen-3-14b":[8e-8,2.4e-7,null,null],"alibaba/qwen-3-14b":[8e-8,2.4e-7,null,null],"vercel_ai_gateway/alibaba/qwen-3-235b":[2e-7,6e-7,null,null],"alibaba/qwen-3-235b":[2e-7,6e-7,null,null],"vercel_ai_gateway/alibaba/qwen-3-30b":[1e-7,3e-7,null,null],"alibaba/qwen-3-30b":[1e-7,3e-7,null,null],"vercel_ai_gateway/alibaba/qwen-3-32b":[1e-7,3e-7,null,null],"alibaba/qwen-3-32b":[1e-7,3e-7,null,null],"vercel_ai_gateway/alibaba/qwen3-coder":[4e-7,0.0000016,null,null],"alibaba/qwen3-coder":[4e-7,0.0000016,null,null],"vercel_ai_gateway/amazon/nova-lite":[6e-8,2.4e-7,null,null],"amazon/nova-lite":[6e-8,2.4e-7,null,null],"vercel_ai_gateway/amazon/nova-micro":[3.5e-8,1.4e-7,null,null],"amazon/nova-micro":[3.5e-8,1.4e-7,null,null],"vercel_ai_gateway/amazon/nova-pro":[8e-7,0.0000032,null,null],"amazon/nova-pro":[8e-7,0.0000032,null,null],"vercel_ai_gateway/amazon/titan-embed-text-v2":[2e-8,0,null,null],"amazon/titan-embed-text-v2":[2e-8,0,null,null],"vercel_ai_gateway/anthropic/claude-3-haiku":[2.5e-7,0.00000125,3e-7,3e-8],"vercel_ai_gateway/anthropic/claude-3-opus":[0.000015,0.000075,0.00001875,0.0000015],"anthropic/claude-3-opus":[0.000015,0.000075,0.00001875,0.0000015],"vercel_ai_gateway/anthropic/claude-3.5-haiku":[8e-7,0.000004,0.000001,8e-8],"vercel_ai_gateway/anthropic/claude-3.5-sonnet":[0.000003,0.000015,0.00000375,3e-7],"vercel_ai_gateway/anthropic/claude-3.7-sonnet":[0.000003,0.000015,0.00000375,3e-7],"vercel_ai_gateway/anthropic/claude-4-opus":[0.000015,0.000075,0.00001875,0.0000015],"vercel_ai_gateway/anthropic/claude-4-sonnet":[0.000003,0.000015,0.00000375,3e-7],"vercel_ai_gateway/anthropic/claude-3-5-sonnet":[0.000003,0.000015,0.00000375,3e-7],"anthropic/claude-3-5-sonnet":[0.000003,0.000015,0.00000375,3e-7],"vercel_ai_gateway/anthropic/claude-3-5-sonnet-20241022":[0.000003,0.000015,0.00000375,3e-7],"anthropic/claude-3-5-sonnet-20241022":[0.000003,0.000015,0.00000375,3e-7],"vercel_ai_gateway/anthropic/claude-3-7-sonnet":[0.000003,0.000015,0.00000375,3e-7],"anthropic/claude-3-7-sonnet":[0.000003,0.000015,0.00000375,3e-7],"vercel_ai_gateway/anthropic/claude-haiku-4.5":[0.000001,0.000005,0.00000125,1e-7],"vercel_ai_gateway/anthropic/claude-opus-4":[0.000015,0.000075,0.00001875,0.0000015],"vercel_ai_gateway/anthropic/claude-opus-4.1":[0.000015,0.000075,0.00001875,0.0000015],"vercel_ai_gateway/anthropic/claude-opus-4.5":[0.000005,0.000025,0.00000625,5e-7],"vercel_ai_gateway/anthropic/claude-opus-4.6":[0.000005,0.000025,0.00000625,5e-7],"vercel_ai_gateway/anthropic/claude-sonnet-4":[0.000003,0.000015,0.00000375,3e-7],"vercel_ai_gateway/anthropic/claude-sonnet-4.5":[0.000003,0.000015,0.00000375,3e-7],"vercel_ai_gateway/cohere/command-a":[0.0000025,0.00001,null,null],"cohere/command-a":[0.0000025,0.00001,null,null],"vercel_ai_gateway/cohere/command-r":[1.5e-7,6e-7,null,null],"cohere/command-r":[1.5e-7,6e-7,null,null],"vercel_ai_gateway/cohere/command-r-plus":[0.0000025,0.00001,null,null],"cohere/command-r-plus":[0.0000025,0.00001,null,null],"vercel_ai_gateway/cohere/embed-v4.0":[1.2e-7,0,null,null],"vercel_ai_gateway/deepseek/deepseek-r1":[5.5e-7,0.00000219,null,null],"vercel_ai_gateway/deepseek/deepseek-r1-distill-llama-70b":[7.5e-7,9.9e-7,null,null],"deepseek/deepseek-r1-distill-llama-70b":[7.5e-7,9.9e-7,null,null],"vercel_ai_gateway/deepseek/deepseek-v3":[9e-7,9e-7,null,null],"vercel_ai_gateway/google/gemini-2.0-flash":[1.5e-7,6e-7,null,null],"google/gemini-2.0-flash":[1.5e-7,6e-7,null,null],"vercel_ai_gateway/google/gemini-2.0-flash-lite":[7.5e-8,3e-7,null,null],"google/gemini-2.0-flash-lite":[7.5e-8,3e-7,null,null],"vercel_ai_gateway/google/gemini-2.5-flash":[3e-7,0.0000025,null,null],"vercel_ai_gateway/google/gemini-2.5-pro":[0.0000025,0.00001,null,null],"vercel_ai_gateway/google/gemini-embedding-001":[1.5e-7,0,null,null],"google/gemini-embedding-001":[1.5e-7,0,null,null],"vercel_ai_gateway/google/gemma-2-9b":[2e-7,2e-7,null,null],"google/gemma-2-9b":[2e-7,2e-7,null,null],"vercel_ai_gateway/google/text-embedding-005":[2.5e-8,0,null,null],"google/text-embedding-005":[2.5e-8,0,null,null],"vercel_ai_gateway/google/text-multilingual-embedding-002":[2.5e-8,0,null,null],"google/text-multilingual-embedding-002":[2.5e-8,0,null,null],"vercel_ai_gateway/inception/mercury-coder-small":[2.5e-7,0.000001,null,null],"inception/mercury-coder-small":[2.5e-7,0.000001,null,null],"vercel_ai_gateway/meta/llama-3-70b":[5.9e-7,7.9e-7,null,null],"vercel_ai_gateway/meta/llama-3-8b":[5e-8,8e-8,null,null],"vercel_ai_gateway/meta/llama-3.1-70b":[7.2e-7,7.2e-7,null,null],"meta/llama-3.1-70b":[7.2e-7,7.2e-7,null,null],"vercel_ai_gateway/meta/llama-3.1-8b":[5e-8,8e-8,null,null],"meta/llama-3.1-8b":[5e-8,8e-8,null,null],"vercel_ai_gateway/meta/llama-3.2-11b":[1.6e-7,1.6e-7,null,null],"meta/llama-3.2-11b":[1.6e-7,1.6e-7,null,null],"vercel_ai_gateway/meta/llama-3.2-1b":[1e-7,1e-7,null,null],"meta/llama-3.2-1b":[1e-7,1e-7,null,null],"vercel_ai_gateway/meta/llama-3.2-3b":[1.5e-7,1.5e-7,null,null],"meta/llama-3.2-3b":[1.5e-7,1.5e-7,null,null],"vercel_ai_gateway/meta/llama-3.2-90b":[7.2e-7,7.2e-7,null,null],"meta/llama-3.2-90b":[7.2e-7,7.2e-7,null,null],"vercel_ai_gateway/meta/llama-3.3-70b":[7.2e-7,7.2e-7,null,null],"meta/llama-3.3-70b":[7.2e-7,7.2e-7,null,null],"vercel_ai_gateway/meta/llama-4-maverick":[2e-7,6e-7,null,null],"meta/llama-4-maverick":[2e-7,6e-7,null,null],"vercel_ai_gateway/meta/llama-4-scout":[1e-7,3e-7,null,null],"meta/llama-4-scout":[1e-7,3e-7,null,null],"vercel_ai_gateway/mistral/codestral":[3e-7,9e-7,null,null],"mistral/codestral":[3e-7,9e-7,null,null],"vercel_ai_gateway/mistral/codestral-embed":[1.5e-7,0,null,null],"mistral/codestral-embed":[1.5e-7,0,null,null],"vercel_ai_gateway/mistral/devstral-small":[7e-8,2.8e-7,null,null],"mistral/devstral-small":[7e-8,2.8e-7,null,null],"vercel_ai_gateway/mistral/magistral-medium":[0.000002,0.000005,null,null],"mistral/magistral-medium":[0.000002,0.000005,null,null],"vercel_ai_gateway/mistral/magistral-small":[5e-7,0.0000015,null,null],"mistral/magistral-small":[5e-7,0.0000015,null,null],"vercel_ai_gateway/mistral/ministral-3b":[4e-8,4e-8,null,null],"mistral/ministral-3b":[4e-8,4e-8,null,null],"vercel_ai_gateway/mistral/ministral-8b":[1e-7,1e-7,null,null],"mistral/ministral-8b":[1e-7,1e-7,null,null],"vercel_ai_gateway/mistral/mistral-embed":[1e-7,0,null,null],"mistral/mistral-embed":[1e-7,0,null,null],"vercel_ai_gateway/mistral/mistral-large":[0.000002,0.000006,null,null],"mistral/mistral-large":[0.000002,0.000006,null,null],"vercel_ai_gateway/mistral/mistral-saba-24b":[7.9e-7,7.9e-7,null,null],"mistral/mistral-saba-24b":[7.9e-7,7.9e-7,null,null],"vercel_ai_gateway/mistral/mistral-small":[1e-7,3e-7,null,null],"vercel_ai_gateway/mistral/mixtral-8x22b-instruct":[0.0000012,0.0000012,null,null],"mistral/mixtral-8x22b-instruct":[0.0000012,0.0000012,null,null],"vercel_ai_gateway/mistral/pixtral-12b":[1.5e-7,1.5e-7,null,null],"mistral/pixtral-12b":[1.5e-7,1.5e-7,null,null],"vercel_ai_gateway/mistral/pixtral-large":[0.000002,0.000006,null,null],"mistral/pixtral-large":[0.000002,0.000006,null,null],"vercel_ai_gateway/moonshotai/kimi-k2":[5.5e-7,0.0000022,null,null],"moonshotai/kimi-k2":[5.5e-7,0.0000022,null,null],"vercel_ai_gateway/morph/morph-v3-fast":[8e-7,0.0000012,null,null],"vercel_ai_gateway/morph/morph-v3-large":[9e-7,0.0000019,null,null],"vercel_ai_gateway/openai/gpt-3.5-turbo":[5e-7,0.0000015,null,null],"vercel_ai_gateway/openai/gpt-3.5-turbo-instruct":[0.0000015,0.000002,null,null],"openai/gpt-3.5-turbo-instruct":[0.0000015,0.000002,null,null],"vercel_ai_gateway/openai/gpt-4-turbo":[0.00001,0.00003,null,null],"openai/gpt-4-turbo":[0.00001,0.00003,null,null],"vercel_ai_gateway/openai/gpt-4.1":[0.000002,0.000008,0,5e-7],"vercel_ai_gateway/openai/gpt-4.1-mini":[4e-7,0.0000016,0,1e-7],"vercel_ai_gateway/openai/gpt-4.1-nano":[1e-7,4e-7,0,2.5e-8],"vercel_ai_gateway/openai/gpt-4o":[0.0000025,0.00001,0,0.00000125],"vercel_ai_gateway/openai/gpt-4o-mini":[1.5e-7,6e-7,0,7.5e-8],"vercel_ai_gateway/openai/o1":[0.000015,0.00006,0,0.0000075],"vercel_ai_gateway/openai/o3":[0.000002,0.000008,0,5e-7],"openai/o3":[0.000002,0.000008,0,5e-7],"vercel_ai_gateway/openai/o3-mini":[0.0000011,0.0000044,0,5.5e-7],"vercel_ai_gateway/openai/o4-mini":[0.0000011,0.0000044,0,2.75e-7],"vercel_ai_gateway/openai/text-embedding-3-large":[1.3e-7,0,null,null],"openai/text-embedding-3-large":[1.3e-7,0,null,null],"vercel_ai_gateway/openai/text-embedding-3-small":[2e-8,0,null,null],"openai/text-embedding-3-small":[2e-8,0,null,null],"vercel_ai_gateway/openai/text-embedding-ada-002":[1e-7,0,null,null],"openai/text-embedding-ada-002":[1e-7,0,null,null],"vercel_ai_gateway/perplexity/sonar":[0.000001,0.000001,null,null],"vercel_ai_gateway/perplexity/sonar-pro":[0.000003,0.000015,null,null],"vercel_ai_gateway/perplexity/sonar-reasoning":[0.000001,0.000005,null,null],"vercel_ai_gateway/perplexity/sonar-reasoning-pro":[0.000002,0.000008,null,null],"vercel_ai_gateway/vercel/v0-1.0-md":[0.000003,0.000015,null,null],"vercel/v0-1.0-md":[0.000003,0.000015,null,null],"vercel_ai_gateway/vercel/v0-1.5-md":[0.000003,0.000015,null,null],"vercel/v0-1.5-md":[0.000003,0.000015,null,null],"vercel_ai_gateway/xai/grok-2":[0.000002,0.00001,null,null],"xai/grok-2":[0.000002,0.00001,null,null],"vercel_ai_gateway/xai/grok-2-vision":[0.000002,0.00001,null,null],"xai/grok-2-vision":[0.000002,0.00001,null,null],"vercel_ai_gateway/xai/grok-3":[0.000003,0.000015,null,null],"xai/grok-3":[0.000003,0.000015,null,null],"vercel_ai_gateway/xai/grok-3-fast":[0.000005,0.000025,null,null],"xai/grok-3-fast":[0.000005,0.000025,null,null],"vercel_ai_gateway/xai/grok-3-mini":[3e-7,5e-7,null,null],"xai/grok-3-mini":[3e-7,5e-7,null,null],"vercel_ai_gateway/xai/grok-3-mini-fast":[6e-7,0.000004,null,null],"xai/grok-3-mini-fast":[6e-7,0.000004,null,null],"vercel_ai_gateway/xai/grok-4":[0.000003,0.000015,null,null],"vercel_ai_gateway/zai/glm-4.5":[6e-7,0.0000022,null,null],"zai/glm-4.5":[6e-7,0.0000022,null,null],"vercel_ai_gateway/zai/glm-4.5-air":[2e-7,0.0000011,null,null],"zai/glm-4.5-air":[2e-7,0.0000011,null,null],"vercel_ai_gateway/zai/glm-4.6":[4.5e-7,0.0000018,null,1.1e-7],"zai/glm-4.6":[4.5e-7,0.0000018,null,1.1e-7],"vertex_ai/claude-3-5-haiku":[0.000001,0.000005,null,null],"claude-3-5-haiku":[0.000001,0.000005,null,null],"vertex_ai/claude-3-5-haiku@20241022":[0.000001,0.000005,null,null],"claude-3-5-haiku@20241022":[0.000001,0.000005,null,null],"vertex_ai/claude-haiku-4-5":[0.000001,0.000005,0.00000125,1e-7],"vertex_ai/claude-haiku-4-5@20251001":[0.000001,0.000005,0.00000125,1e-7],"claude-haiku-4-5@20251001":[0.000001,0.000005,0.00000125,1e-7],"vertex_ai/claude-3-5-sonnet":[0.000003,0.000015,null,null],"claude-3-5-sonnet":[0.000003,0.000015,null,null],"vertex_ai/claude-3-5-sonnet@20240620":[0.000003,0.000015,null,null],"claude-3-5-sonnet@20240620":[0.000003,0.000015,null,null],"vertex_ai/claude-3-7-sonnet@20250219":[0.000003,0.000015,0.00000375,3e-7],"claude-3-7-sonnet@20250219":[0.000003,0.000015,0.00000375,3e-7],"vertex_ai/claude-3-haiku":[2.5e-7,0.00000125,null,null],"claude-3-haiku":[2.5e-7,0.00000125,null,null],"vertex_ai/claude-3-haiku@20240307":[2.5e-7,0.00000125,null,null],"claude-3-haiku@20240307":[2.5e-7,0.00000125,null,null],"vertex_ai/claude-3-opus":[0.000015,0.000075,null,null],"claude-3-opus":[0.000015,0.000075,null,null],"vertex_ai/claude-3-opus@20240229":[0.000015,0.000075,null,null],"claude-3-opus@20240229":[0.000015,0.000075,null,null],"vertex_ai/claude-3-sonnet":[0.000003,0.000015,null,null],"claude-3-sonnet":[0.000003,0.000015,null,null],"vertex_ai/claude-3-sonnet@20240229":[0.000003,0.000015,null,null],"claude-3-sonnet@20240229":[0.000003,0.000015,null,null],"vertex_ai/claude-opus-4":[0.000015,0.000075,0.00001875,0.0000015],"claude-opus-4":[0.000015,0.000075,0.00001875,0.0000015],"vertex_ai/claude-opus-4-1":[0.000015,0.000075,0.00001875,0.0000015],"vertex_ai/claude-opus-4-1@20250805":[0.000015,0.000075,0.00001875,0.0000015],"claude-opus-4-1@20250805":[0.000015,0.000075,0.00001875,0.0000015],"vertex_ai/claude-opus-4-5":[0.000005,0.000025,0.00000625,5e-7],"vertex_ai/claude-opus-4-5@20251101":[0.000005,0.000025,0.00000625,5e-7],"claude-opus-4-5@20251101":[0.000005,0.000025,0.00000625,5e-7],"vertex_ai/claude-opus-4-6":[0.000005,0.000025,0.00000625,5e-7],"vertex_ai/claude-opus-4-6@default":[0.000005,0.000025,0.00000625,5e-7],"claude-opus-4-6@default":[0.000005,0.000025,0.00000625,5e-7],"vertex_ai/claude-opus-4-7":[0.000005,0.000025,0.00000625,5e-7],"vertex_ai/claude-opus-4-7@default":[0.000005,0.000025,0.00000625,5e-7],"claude-opus-4-7@default":[0.000005,0.000025,0.00000625,5e-7],"vertex_ai/claude-sonnet-4-5":[0.000003,0.000015,0.00000375,3e-7],"vertex_ai/claude-sonnet-4-6":[0.000003,0.000015,0.00000375,3e-7],"vertex_ai/claude-sonnet-4-5@20250929":[0.000003,0.000015,0.00000375,3e-7],"claude-sonnet-4-5@20250929":[0.000003,0.000015,0.00000375,3e-7],"vertex_ai/claude-opus-4@20250514":[0.000015,0.000075,0.00001875,0.0000015],"claude-opus-4@20250514":[0.000015,0.000075,0.00001875,0.0000015],"vertex_ai/claude-sonnet-4":[0.000003,0.000015,0.00000375,3e-7],"claude-sonnet-4":[0.000003,0.000015,0.00000375,3e-7],"vertex_ai/claude-sonnet-4@20250514":[0.000003,0.000015,0.00000375,3e-7],"claude-sonnet-4@20250514":[0.000003,0.000015,0.00000375,3e-7],"vertex_ai/mistralai/codestral-2@001":[3e-7,9e-7,null,null],"mistralai/codestral-2@001":[3e-7,9e-7,null,null],"vertex_ai/codestral-2":[3e-7,9e-7,null,null],"codestral-2":[3e-7,9e-7,null,null],"vertex_ai/codestral-2@001":[3e-7,9e-7,null,null],"codestral-2@001":[3e-7,9e-7,null,null],"vertex_ai/mistralai/codestral-2":[3e-7,9e-7,null,null],"mistralai/codestral-2":[3e-7,9e-7,null,null],"vertex_ai/codestral-2501":[2e-7,6e-7,null,null],"codestral-2501":[2e-7,6e-7,null,null],"vertex_ai/codestral@2405":[2e-7,6e-7,null,null],"codestral@2405":[2e-7,6e-7,null,null],"vertex_ai/codestral@latest":[2e-7,6e-7,null,null],"codestral@latest":[2e-7,6e-7,null,null],"vertex_ai/deepseek-ai/deepseek-v3.1-maas":[0.00000135,0.0000054,null,null],"deepseek-ai/deepseek-v3.1-maas":[0.00000135,0.0000054,null,null],"vertex_ai/deepseek-ai/deepseek-v3.2-maas":[5.6e-7,0.00000168,null,null],"deepseek-ai/deepseek-v3.2-maas":[5.6e-7,0.00000168,null,null],"vertex_ai/deepseek-ai/deepseek-r1-0528-maas":[0.00000135,0.0000054,null,null],"deepseek-ai/deepseek-r1-0528-maas":[0.00000135,0.0000054,null,null],"vertex_ai/gemini-2.5-flash-image":[3e-7,0.0000025,null,3e-8],"vertex_ai/gemini-3-pro-image-preview":[0.000002,0.000012,null,null],"vertex_ai/gemini-3.1-flash-image-preview":[5e-7,0.000003,null,null],"vertex_ai/gemini-3.1-flash-lite-preview":[2.5e-7,0.0000015,null,2.5e-8],"vertex_ai/deep-research-pro-preview-12-2025":[0.000002,0.000012,null,null],"vertex_ai/jamba-1.5":[2e-7,4e-7,null,null],"vertex_ai/jamba-1.5-large":[0.000002,0.000008,null,null],"vertex_ai/jamba-1.5-large@001":[0.000002,0.000008,null,null],"vertex_ai/jamba-1.5-mini":[2e-7,4e-7,null,null],"vertex_ai/jamba-1.5-mini@001":[2e-7,4e-7,null,null],"vertex_ai/meta/llama-3.1-405b-instruct-maas":[0.000005,0.000016,null,null],"meta/llama-3.1-405b-instruct-maas":[0.000005,0.000016,null,null],"vertex_ai/meta/llama-3.1-70b-instruct-maas":[0,0,null,null],"meta/llama-3.1-70b-instruct-maas":[0,0,null,null],"vertex_ai/meta/llama-3.1-8b-instruct-maas":[0,0,null,null],"meta/llama-3.1-8b-instruct-maas":[0,0,null,null],"vertex_ai/meta/llama-3.2-90b-vision-instruct-maas":[0,0,null,null],"meta/llama-3.2-90b-vision-instruct-maas":[0,0,null,null],"vertex_ai/meta/llama-4-maverick-17b-128e-instruct-maas":[3.5e-7,0.00000115,null,null],"meta/llama-4-maverick-17b-128e-instruct-maas":[3.5e-7,0.00000115,null,null],"vertex_ai/meta/llama-4-maverick-17b-16e-instruct-maas":[3.5e-7,0.00000115,null,null],"meta/llama-4-maverick-17b-16e-instruct-maas":[3.5e-7,0.00000115,null,null],"vertex_ai/meta/llama-4-scout-17b-128e-instruct-maas":[2.5e-7,7e-7,null,null],"meta/llama-4-scout-17b-128e-instruct-maas":[2.5e-7,7e-7,null,null],"vertex_ai/meta/llama-4-scout-17b-16e-instruct-maas":[2.5e-7,7e-7,null,null],"meta/llama-4-scout-17b-16e-instruct-maas":[2.5e-7,7e-7,null,null],"vertex_ai/meta/llama3-405b-instruct-maas":[0,0,null,null],"meta/llama3-405b-instruct-maas":[0,0,null,null],"vertex_ai/meta/llama3-70b-instruct-maas":[0,0,null,null],"meta/llama3-70b-instruct-maas":[0,0,null,null],"vertex_ai/meta/llama3-8b-instruct-maas":[0,0,null,null],"meta/llama3-8b-instruct-maas":[0,0,null,null],"vertex_ai/minimaxai/minimax-m2-maas":[3e-7,0.0000012,null,null],"minimaxai/minimax-m2-maas":[3e-7,0.0000012,null,null],"vertex_ai/moonshotai/kimi-k2-thinking-maas":[6e-7,0.0000025,null,null],"moonshotai/kimi-k2-thinking-maas":[6e-7,0.0000025,null,null],"vertex_ai/zai-org/glm-4.7-maas":[6e-7,0.0000022,null,null],"zai-org/glm-4.7-maas":[6e-7,0.0000022,null,null],"vertex_ai/zai-org/glm-5-maas":[0.000001,0.0000032,null,1e-7],"zai-org/glm-5-maas":[0.000001,0.0000032,null,1e-7],"vertex_ai/mistral-medium-3":[4e-7,0.000002,null,null],"mistral-medium-3":[4e-7,0.000002,null,null],"vertex_ai/mistral-medium-3@001":[4e-7,0.000002,null,null],"mistral-medium-3@001":[4e-7,0.000002,null,null],"vertex_ai/mistralai/mistral-medium-3":[4e-7,0.000002,null,null],"mistralai/mistral-medium-3":[4e-7,0.000002,null,null],"vertex_ai/mistralai/mistral-medium-3@001":[4e-7,0.000002,null,null],"mistralai/mistral-medium-3@001":[4e-7,0.000002,null,null],"vertex_ai/mistral-large-2411":[0.000002,0.000006,null,null],"vertex_ai/mistral-large@2407":[0.000002,0.000006,null,null],"mistral-large@2407":[0.000002,0.000006,null,null],"vertex_ai/mistral-large@2411-001":[0.000002,0.000006,null,null],"mistral-large@2411-001":[0.000002,0.000006,null,null],"vertex_ai/mistral-large@latest":[0.000002,0.000006,null,null],"mistral-large@latest":[0.000002,0.000006,null,null],"vertex_ai/mistral-nemo@2407":[0.000003,0.000003,null,null],"mistral-nemo@2407":[0.000003,0.000003,null,null],"vertex_ai/mistral-nemo@latest":[1.5e-7,1.5e-7,null,null],"mistral-nemo@latest":[1.5e-7,1.5e-7,null,null],"vertex_ai/mistral-small-2503":[0.000001,0.000003,null,null],"vertex_ai/mistral-small-2503@001":[0.000001,0.000003,null,null],"mistral-small-2503@001":[0.000001,0.000003,null,null],"vertex_ai/deepseek-ai/deepseek-ocr-maas":[3e-7,0.0000012,null,null],"deepseek-ai/deepseek-ocr-maas":[3e-7,0.0000012,null,null],"vertex_ai/openai/gpt-oss-120b-maas":[1.5e-7,6e-7,null,null],"openai/gpt-oss-120b-maas":[1.5e-7,6e-7,null,null],"vertex_ai/openai/gpt-oss-20b-maas":[7.5e-8,3e-7,null,null],"openai/gpt-oss-20b-maas":[7.5e-8,3e-7,null,null],"vertex_ai/qwen/qwen3-235b-a22b-instruct-2507-maas":[2.5e-7,0.000001,null,null],"qwen/qwen3-235b-a22b-instruct-2507-maas":[2.5e-7,0.000001,null,null],"vertex_ai/qwen/qwen3-coder-480b-a35b-instruct-maas":[0.000001,0.000004,null,null],"qwen/qwen3-coder-480b-a35b-instruct-maas":[0.000001,0.000004,null,null],"vertex_ai/qwen/qwen3-next-80b-a3b-instruct-maas":[1.5e-7,0.0000012,null,null],"qwen/qwen3-next-80b-a3b-instruct-maas":[1.5e-7,0.0000012,null,null],"vertex_ai/qwen/qwen3-next-80b-a3b-thinking-maas":[1.5e-7,0.0000012,null,null],"qwen/qwen3-next-80b-a3b-thinking-maas":[1.5e-7,0.0000012,null,null],"voyage/rerank-2":[5e-8,0,null,null],"rerank-2":[5e-8,0,null,null],"voyage/rerank-2-lite":[2e-8,0,null,null],"rerank-2-lite":[2e-8,0,null,null],"voyage/rerank-2.5":[5e-8,0,null,null],"rerank-2.5":[5e-8,0,null,null],"voyage/rerank-2.5-lite":[2e-8,0,null,null],"rerank-2.5-lite":[2e-8,0,null,null],"voyage/voyage-2":[1e-7,0,null,null],"voyage-2":[1e-7,0,null,null],"voyage/voyage-3":[6e-8,0,null,null],"voyage-3":[6e-8,0,null,null],"voyage/voyage-3-large":[1.8e-7,0,null,null],"voyage-3-large":[1.8e-7,0,null,null],"voyage/voyage-3-lite":[2e-8,0,null,null],"voyage-3-lite":[2e-8,0,null,null],"voyage/voyage-3.5":[6e-8,0,null,null],"voyage-3.5":[6e-8,0,null,null],"voyage/voyage-3.5-lite":[2e-8,0,null,null],"voyage-3.5-lite":[2e-8,0,null,null],"voyage/voyage-code-2":[1.2e-7,0,null,null],"voyage-code-2":[1.2e-7,0,null,null],"voyage/voyage-code-3":[1.8e-7,0,null,null],"voyage-code-3":[1.8e-7,0,null,null],"voyage/voyage-context-3":[1.8e-7,0,null,null],"voyage-context-3":[1.8e-7,0,null,null],"voyage/voyage-finance-2":[1.2e-7,0,null,null],"voyage-finance-2":[1.2e-7,0,null,null],"voyage/voyage-large-2":[1.2e-7,0,null,null],"voyage-large-2":[1.2e-7,0,null,null],"voyage/voyage-law-2":[1.2e-7,0,null,null],"voyage-law-2":[1.2e-7,0,null,null],"voyage/voyage-lite-01":[1e-7,0,null,null],"voyage-lite-01":[1e-7,0,null,null],"voyage/voyage-lite-02-instruct":[1e-7,0,null,null],"voyage-lite-02-instruct":[1e-7,0,null,null],"voyage/voyage-multimodal-3":[1.2e-7,0,null,null],"voyage-multimodal-3":[1.2e-7,0,null,null],"wandb/openai/gpt-oss-120b":[0.015,0.06,null,null],"wandb/openai/gpt-oss-20b":[0.005,0.02,null,null],"wandb/zai-org/GLM-4.5":[0.055,0.2,null,null],"wandb/Qwen/Qwen3-235B-A22B-Instruct-2507":[0.01,0.01,null,null],"wandb/Qwen/Qwen3-Coder-480B-A35B-Instruct":[0.1,0.15,null,null],"wandb/Qwen/Qwen3-235B-A22B-Thinking-2507":[0.01,0.01,null,null],"wandb/moonshotai/Kimi-K2-Instruct":[6e-7,0.0000025,null,null],"wandb/moonshotai/Kimi-K2.5":[6e-7,0.000003,null,1e-7],"wandb/MiniMaxAI/MiniMax-M2.5":[3e-7,0.0000012,null,null],"wandb/meta-llama/Llama-3.1-8B-Instruct":[0.022,0.022,null,null],"wandb/deepseek-ai/DeepSeek-V3.1":[0.055,0.165,null,null],"wandb/deepseek-ai/DeepSeek-R1-0528":[0.135,0.54,null,null],"wandb/deepseek-ai/DeepSeek-V3-0324":[0.114,0.275,null,null],"wandb/meta-llama/Llama-3.3-70B-Instruct":[0.071,0.071,null,null],"wandb/meta-llama/Llama-4-Scout-17B-16E-Instruct":[0.017,0.066,null,null],"wandb/microsoft/Phi-4-mini-instruct":[0.008,0.035,null,null],"microsoft/Phi-4-mini-instruct":[0.008,0.035,null,null],"watsonx/ibm/granite-3-8b-instruct":[2e-7,2e-7,null,null],"ibm/granite-3-8b-instruct":[2e-7,2e-7,null,null],"watsonx/mistralai/mistral-large":[0.000003,0.00001,null,null],"watsonx/bigscience/mt0-xxl-13b":[0.0005,0.002,null,null],"bigscience/mt0-xxl-13b":[0.0005,0.002,null,null],"watsonx/core42/jais-13b-chat":[0.0005,0.002,null,null],"core42/jais-13b-chat":[0.0005,0.002,null,null],"watsonx/google/flan-t5-xl-3b":[6e-7,6e-7,null,null],"google/flan-t5-xl-3b":[6e-7,6e-7,null,null],"watsonx/ibm/granite-13b-chat-v2":[6e-7,6e-7,null,null],"ibm/granite-13b-chat-v2":[6e-7,6e-7,null,null],"watsonx/ibm/granite-13b-instruct-v2":[6e-7,6e-7,null,null],"ibm/granite-13b-instruct-v2":[6e-7,6e-7,null,null],"watsonx/ibm/granite-3-3-8b-instruct":[2e-7,2e-7,null,null],"ibm/granite-3-3-8b-instruct":[2e-7,2e-7,null,null],"watsonx/ibm/granite-4-h-small":[6e-8,2.5e-7,null,null],"ibm/granite-4-h-small":[6e-8,2.5e-7,null,null],"watsonx/ibm/granite-guardian-3-2-2b":[1e-7,1e-7,null,null],"ibm/granite-guardian-3-2-2b":[1e-7,1e-7,null,null],"watsonx/ibm/granite-guardian-3-3-8b":[2e-7,2e-7,null,null],"ibm/granite-guardian-3-3-8b":[2e-7,2e-7,null,null],"watsonx/ibm/granite-ttm-1024-96-r2":[3.8e-7,3.8e-7,null,null],"ibm/granite-ttm-1024-96-r2":[3.8e-7,3.8e-7,null,null],"watsonx/ibm/granite-ttm-1536-96-r2":[3.8e-7,3.8e-7,null,null],"ibm/granite-ttm-1536-96-r2":[3.8e-7,3.8e-7,null,null],"watsonx/ibm/granite-ttm-512-96-r2":[3.8e-7,3.8e-7,null,null],"ibm/granite-ttm-512-96-r2":[3.8e-7,3.8e-7,null,null],"watsonx/ibm/granite-vision-3-2-2b":[1e-7,1e-7,null,null],"ibm/granite-vision-3-2-2b":[1e-7,1e-7,null,null],"watsonx/meta-llama/llama-3-2-11b-vision-instruct":[3.5e-7,3.5e-7,null,null],"meta-llama/llama-3-2-11b-vision-instruct":[3.5e-7,3.5e-7,null,null],"watsonx/meta-llama/llama-3-2-1b-instruct":[1e-7,1e-7,null,null],"meta-llama/llama-3-2-1b-instruct":[1e-7,1e-7,null,null],"watsonx/meta-llama/llama-3-2-3b-instruct":[1.5e-7,1.5e-7,null,null],"meta-llama/llama-3-2-3b-instruct":[1.5e-7,1.5e-7,null,null],"watsonx/meta-llama/llama-3-2-90b-vision-instruct":[0.000002,0.000002,null,null],"meta-llama/llama-3-2-90b-vision-instruct":[0.000002,0.000002,null,null],"watsonx/meta-llama/llama-3-3-70b-instruct":[7.1e-7,7.1e-7,null,null],"meta-llama/llama-3-3-70b-instruct":[7.1e-7,7.1e-7,null,null],"watsonx/meta-llama/llama-4-maverick-17b":[3.5e-7,0.0000014,null,null],"meta-llama/llama-4-maverick-17b":[3.5e-7,0.0000014,null,null],"watsonx/meta-llama/llama-guard-3-11b-vision":[3.5e-7,3.5e-7,null,null],"meta-llama/llama-guard-3-11b-vision":[3.5e-7,3.5e-7,null,null],"watsonx/mistralai/mistral-medium-2505":[0.000003,0.00001,null,null],"mistralai/mistral-medium-2505":[0.000003,0.00001,null,null],"watsonx/mistralai/mistral-small-2503":[1e-7,3e-7,null,null],"mistralai/mistral-small-2503":[1e-7,3e-7,null,null],"watsonx/mistralai/mistral-small-3-1-24b-instruct-2503":[1e-7,3e-7,null,null],"mistralai/mistral-small-3-1-24b-instruct-2503":[1e-7,3e-7,null,null],"watsonx/mistralai/pixtral-12b-2409":[3.5e-7,3.5e-7,null,null],"mistralai/pixtral-12b-2409":[3.5e-7,3.5e-7,null,null],"watsonx/openai/gpt-oss-120b":[1.5e-7,6e-7,null,null],"watsonx/sdaia/allam-1-13b-instruct":[0.0000018,0.0000018,null,null],"sdaia/allam-1-13b-instruct":[0.0000018,0.0000018,null,null],"grok-2":[0.000002,0.00001,null,null],"xai/grok-2-1212":[0.000002,0.00001,null,null],"grok-2-1212":[0.000002,0.00001,null,null],"xai/grok-2-latest":[0.000002,0.00001,null,null],"grok-2-latest":[0.000002,0.00001,null,null],"grok-2-vision":[0.000002,0.00001,null,null],"xai/grok-2-vision-1212":[0.000002,0.00001,null,null],"grok-2-vision-1212":[0.000002,0.00001,null,null],"xai/grok-2-vision-latest":[0.000002,0.00001,null,null],"grok-2-vision-latest":[0.000002,0.00001,null,null],"xai/grok-3-beta":[0.000003,0.000015,null,7.5e-7],"grok-3-beta":[0.000003,0.000015,null,7.5e-7],"xai/grok-3-fast-beta":[0.000005,0.000025,null,0.00000125],"grok-3-fast-beta":[0.000005,0.000025,null,0.00000125],"xai/grok-3-fast-latest":[0.000005,0.000025,null,0.00000125],"grok-3-fast-latest":[0.000005,0.000025,null,0.00000125],"xai/grok-3-latest":[0.000003,0.000015,null,7.5e-7],"grok-3-latest":[0.000003,0.000015,null,7.5e-7],"xai/grok-3-mini-beta":[3e-7,5e-7,null,7.5e-8],"grok-3-mini-beta":[3e-7,5e-7,null,7.5e-8],"grok-3-mini-fast":[6e-7,0.000004,null,1.5e-7],"xai/grok-3-mini-fast-beta":[6e-7,0.000004,null,1.5e-7],"grok-3-mini-fast-beta":[6e-7,0.000004,null,1.5e-7],"xai/grok-3-mini-fast-latest":[6e-7,0.000004,null,1.5e-7],"grok-3-mini-fast-latest":[6e-7,0.000004,null,1.5e-7],"xai/grok-3-mini-latest":[3e-7,5e-7,null,7.5e-8],"grok-3-mini-latest":[3e-7,5e-7,null,7.5e-8],"xai/grok-4-fast-reasoning":[2e-7,5e-7,null,5e-8],"xai/grok-4-fast-non-reasoning":[2e-7,5e-7,null,5e-8],"xai/grok-4-0709":[0.000003,0.000015,null,null],"grok-4-0709":[0.000003,0.000015,null,null],"xai/grok-4-latest":[0.000003,0.000015,null,null],"grok-4-latest":[0.000003,0.000015,null,null],"xai/grok-4-1-fast":[2e-7,5e-7,null,5e-8],"grok-4-1-fast":[2e-7,5e-7,null,5e-8],"xai/grok-4-1-fast-reasoning":[2e-7,5e-7,null,5e-8],"xai/grok-4-1-fast-reasoning-latest":[2e-7,5e-7,null,5e-8],"grok-4-1-fast-reasoning-latest":[2e-7,5e-7,null,5e-8],"xai/grok-4-1-fast-non-reasoning":[2e-7,5e-7,null,5e-8],"xai/grok-4-1-fast-non-reasoning-latest":[2e-7,5e-7,null,5e-8],"grok-4-1-fast-non-reasoning-latest":[2e-7,5e-7,null,5e-8],"xai/grok-4.20-multi-agent-beta-0309":[0.000002,0.000006,null,2e-7],"grok-4.20-multi-agent-beta-0309":[0.000002,0.000006,null,2e-7],"xai/grok-4.20-beta-0309-reasoning":[0.000002,0.000006,null,2e-7],"grok-4.20-beta-0309-reasoning":[0.000002,0.000006,null,2e-7],"xai/grok-4.20-0309-reasoning":[0.000002,0.000006,null,2e-7],"grok-4.20-0309-reasoning":[0.000002,0.000006,null,2e-7],"xai/grok-4.20-beta-0309-non-reasoning":[0.000002,0.000006,null,2e-7],"grok-4.20-beta-0309-non-reasoning":[0.000002,0.000006,null,2e-7],"xai/grok-beta":[0.000005,0.000015,null,null],"grok-beta":[0.000005,0.000015,null,null],"xai/grok-code-fast":[2e-7,0.0000015,null,2e-8],"grok-code-fast":[2e-7,0.0000015,null,2e-8],"xai/grok-code-fast-1":[2e-7,0.0000015,null,2e-8],"xai/grok-code-fast-1-0825":[2e-7,0.0000015,null,2e-8],"grok-code-fast-1-0825":[2e-7,0.0000015,null,2e-8],"xai/grok-vision-beta":[0.000005,0.000015,null,null],"grok-vision-beta":[0.000005,0.000015,null,null],"zai/glm-5":[0.000001,0.0000032,0,2e-7],"glm-5":[0.000001,0.0000032,0,2e-7],"zai/glm-5-code":[0.0000012,0.000005,0,3e-7],"glm-5-code":[0.0000012,0.000005,0,3e-7],"zai/glm-4.7":[6e-7,0.0000022,0,1.1e-7],"glm-4.7":[6e-7,0.0000022,0,1.1e-7],"glm-4.6":[6e-7,0.0000022,0,1.1e-7],"glm-4.5":[6e-7,0.0000022,null,null],"zai/glm-4.5v":[6e-7,0.0000018,null,null],"glm-4.5v":[6e-7,0.0000018,null,null],"zai/glm-4.5-x":[0.0000022,0.0000089,null,null],"glm-4.5-x":[0.0000022,0.0000089,null,null],"glm-4.5-air":[2e-7,0.0000011,null,null],"zai/glm-4.5-airx":[0.0000011,0.0000045,null,null],"glm-4.5-airx":[0.0000011,0.0000045,null,null],"zai/glm-4-32b-0414-128k":[1e-7,1e-7,null,null],"glm-4-32b-0414-128k":[1e-7,1e-7,null,null],"zai/glm-4.5-flash":[0,0,null,null],"glm-4.5-flash":[0,0,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-coder-480b-a35b-instruct":[4.5e-7,0.0000018,null,null],"accounts/fireworks/models/qwen3-coder-480b-a35b-instruct":[4.5e-7,0.0000018,null,null],"fireworks_ai/accounts/fireworks/models/flux-kontext-pro":[4e-8,4e-8,null,null],"accounts/fireworks/models/flux-kontext-pro":[4e-8,4e-8,null,null],"fireworks_ai/accounts/fireworks/models/SSD-1B":[1.3e-10,1.3e-10,null,null],"accounts/fireworks/models/SSD-1B":[1.3e-10,1.3e-10,null,null],"fireworks_ai/accounts/fireworks/models/chronos-hermes-13b-v2":[2e-7,2e-7,null,null],"accounts/fireworks/models/chronos-hermes-13b-v2":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-13b":[2e-7,2e-7,null,null],"accounts/fireworks/models/code-llama-13b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-13b-instruct":[2e-7,2e-7,null,null],"accounts/fireworks/models/code-llama-13b-instruct":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-13b-python":[2e-7,2e-7,null,null],"accounts/fireworks/models/code-llama-13b-python":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-34b":[9e-7,9e-7,null,null],"accounts/fireworks/models/code-llama-34b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-34b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/code-llama-34b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-34b-python":[9e-7,9e-7,null,null],"accounts/fireworks/models/code-llama-34b-python":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-70b":[9e-7,9e-7,null,null],"accounts/fireworks/models/code-llama-70b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-70b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/code-llama-70b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-70b-python":[9e-7,9e-7,null,null],"accounts/fireworks/models/code-llama-70b-python":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/code-llama-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-7b-instruct":[2e-7,2e-7,null,null],"accounts/fireworks/models/code-llama-7b-instruct":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-7b-python":[2e-7,2e-7,null,null],"accounts/fireworks/models/code-llama-7b-python":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/code-qwen-1p5-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/code-qwen-1p5-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/codegemma-2b":[1e-7,1e-7,null,null],"accounts/fireworks/models/codegemma-2b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/codegemma-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/codegemma-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/cogito-671b-v2-p1":[0.0000012,0.0000012,null,null],"accounts/fireworks/models/cogito-671b-v2-p1":[0.0000012,0.0000012,null,null],"fireworks_ai/accounts/fireworks/models/cogito-v1-preview-llama-3b":[1e-7,1e-7,null,null],"accounts/fireworks/models/cogito-v1-preview-llama-3b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/cogito-v1-preview-llama-70b":[9e-7,9e-7,null,null],"accounts/fireworks/models/cogito-v1-preview-llama-70b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/cogito-v1-preview-llama-8b":[2e-7,2e-7,null,null],"accounts/fireworks/models/cogito-v1-preview-llama-8b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/cogito-v1-preview-qwen-14b":[2e-7,2e-7,null,null],"accounts/fireworks/models/cogito-v1-preview-qwen-14b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/cogito-v1-preview-qwen-32b":[9e-7,9e-7,null,null],"accounts/fireworks/models/cogito-v1-preview-qwen-32b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/flux-kontext-max":[8e-8,8e-8,null,null],"accounts/fireworks/models/flux-kontext-max":[8e-8,8e-8,null,null],"fireworks_ai/accounts/fireworks/models/dbrx-instruct":[0.0000012,0.0000012,null,null],"accounts/fireworks/models/dbrx-instruct":[0.0000012,0.0000012,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-coder-1b-base":[1e-7,1e-7,null,null],"accounts/fireworks/models/deepseek-coder-1b-base":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-coder-33b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/deepseek-coder-33b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-coder-7b-base":[2e-7,2e-7,null,null],"accounts/fireworks/models/deepseek-coder-7b-base":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-coder-7b-base-v1p5":[2e-7,2e-7,null,null],"accounts/fireworks/models/deepseek-coder-7b-base-v1p5":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-coder-7b-instruct-v1p5":[2e-7,2e-7,null,null],"accounts/fireworks/models/deepseek-coder-7b-instruct-v1p5":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-coder-v2-lite-base":[5e-7,5e-7,null,null],"accounts/fireworks/models/deepseek-coder-v2-lite-base":[5e-7,5e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-coder-v2-lite-instruct":[5e-7,5e-7,null,null],"accounts/fireworks/models/deepseek-coder-v2-lite-instruct":[5e-7,5e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-prover-v2":[0.0000012,0.0000012,null,null],"accounts/fireworks/models/deepseek-prover-v2":[0.0000012,0.0000012,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1-0528-distill-qwen3-8b":[2e-7,2e-7,null,null],"accounts/fireworks/models/deepseek-r1-0528-distill-qwen3-8b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1-distill-llama-70b":[9e-7,9e-7,null,null],"accounts/fireworks/models/deepseek-r1-distill-llama-70b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1-distill-llama-8b":[2e-7,2e-7,null,null],"accounts/fireworks/models/deepseek-r1-distill-llama-8b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1-distill-qwen-14b":[2e-7,2e-7,null,null],"accounts/fireworks/models/deepseek-r1-distill-qwen-14b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1-distill-qwen-1p5b":[1e-7,1e-7,null,null],"accounts/fireworks/models/deepseek-r1-distill-qwen-1p5b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1-distill-qwen-32b":[9e-7,9e-7,null,null],"accounts/fireworks/models/deepseek-r1-distill-qwen-32b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1-distill-qwen-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/deepseek-r1-distill-qwen-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-v2-lite-chat":[5e-7,5e-7,null,null],"accounts/fireworks/models/deepseek-v2-lite-chat":[5e-7,5e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-v2p5":[0.0000012,0.0000012,null,null],"accounts/fireworks/models/deepseek-v2p5":[0.0000012,0.0000012,null,null],"fireworks_ai/accounts/fireworks/models/devstral-small-2505":[9e-7,9e-7,null,null],"accounts/fireworks/models/devstral-small-2505":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/dobby-mini-unhinged-plus-llama-3-1-8b":[2e-7,2e-7,null,null],"accounts/fireworks/models/dobby-mini-unhinged-plus-llama-3-1-8b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/dobby-unhinged-llama-3-3-70b-new":[9e-7,9e-7,null,null],"accounts/fireworks/models/dobby-unhinged-llama-3-3-70b-new":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/dolphin-2-9-2-qwen2-72b":[9e-7,9e-7,null,null],"accounts/fireworks/models/dolphin-2-9-2-qwen2-72b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/dolphin-2p6-mixtral-8x7b":[5e-7,5e-7,null,null],"accounts/fireworks/models/dolphin-2p6-mixtral-8x7b":[5e-7,5e-7,null,null],"fireworks_ai/accounts/fireworks/models/ernie-4p5-21b-a3b-pt":[1e-7,1e-7,null,null],"accounts/fireworks/models/ernie-4p5-21b-a3b-pt":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/ernie-4p5-300b-a47b-pt":[1e-7,1e-7,null,null],"accounts/fireworks/models/ernie-4p5-300b-a47b-pt":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/fare-20b":[9e-7,9e-7,null,null],"accounts/fireworks/models/fare-20b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/firefunction-v1":[5e-7,5e-7,null,null],"accounts/fireworks/models/firefunction-v1":[5e-7,5e-7,null,null],"fireworks_ai/accounts/fireworks/models/firellava-13b":[2e-7,2e-7,null,null],"accounts/fireworks/models/firellava-13b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/firesearch-ocr-v6":[2e-7,2e-7,null,null],"accounts/fireworks/models/firesearch-ocr-v6":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/fireworks-asr-large":[0,0,null,null],"accounts/fireworks/models/fireworks-asr-large":[0,0,null,null],"fireworks_ai/accounts/fireworks/models/fireworks-asr-v2":[0,0,null,null],"accounts/fireworks/models/fireworks-asr-v2":[0,0,null,null],"fireworks_ai/accounts/fireworks/models/flux-1-dev":[1e-7,1e-7,null,null],"accounts/fireworks/models/flux-1-dev":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/flux-1-dev-controlnet-union":[1e-9,1e-9,null,null],"accounts/fireworks/models/flux-1-dev-controlnet-union":[1e-9,1e-9,null,null],"fireworks_ai/accounts/fireworks/models/flux-1-dev-fp8":[5e-10,5e-10,null,null],"accounts/fireworks/models/flux-1-dev-fp8":[5e-10,5e-10,null,null],"fireworks_ai/accounts/fireworks/models/flux-1-schnell":[1e-7,1e-7,null,null],"accounts/fireworks/models/flux-1-schnell":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/flux-1-schnell-fp8":[3.5e-10,3.5e-10,null,null],"accounts/fireworks/models/flux-1-schnell-fp8":[3.5e-10,3.5e-10,null,null],"fireworks_ai/accounts/fireworks/models/gemma-2b-it":[1e-7,1e-7,null,null],"accounts/fireworks/models/gemma-2b-it":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/gemma-3-27b-it":[9e-7,9e-7,null,null],"accounts/fireworks/models/gemma-3-27b-it":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/gemma-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/gemma-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/gemma-7b-it":[2e-7,2e-7,null,null],"accounts/fireworks/models/gemma-7b-it":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/gemma2-9b-it":[2e-7,2e-7,null,null],"accounts/fireworks/models/gemma2-9b-it":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/glm-4p5v":[0.0000012,0.0000012,null,null],"accounts/fireworks/models/glm-4p5v":[0.0000012,0.0000012,null,null],"fireworks_ai/accounts/fireworks/models/gpt-oss-safeguard-120b":[0.0000012,0.0000012,null,null],"accounts/fireworks/models/gpt-oss-safeguard-120b":[0.0000012,0.0000012,null,null],"fireworks_ai/accounts/fireworks/models/gpt-oss-safeguard-20b":[5e-7,5e-7,null,null],"accounts/fireworks/models/gpt-oss-safeguard-20b":[5e-7,5e-7,null,null],"fireworks_ai/accounts/fireworks/models/hermes-2-pro-mistral-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/hermes-2-pro-mistral-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/internvl3-38b":[9e-7,9e-7,null,null],"accounts/fireworks/models/internvl3-38b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/internvl3-78b":[9e-7,9e-7,null,null],"accounts/fireworks/models/internvl3-78b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/internvl3-8b":[2e-7,2e-7,null,null],"accounts/fireworks/models/internvl3-8b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/japanese-stable-diffusion-xl":[1.3e-10,1.3e-10,null,null],"accounts/fireworks/models/japanese-stable-diffusion-xl":[1.3e-10,1.3e-10,null,null],"fireworks_ai/accounts/fireworks/models/kat-coder":[9e-7,9e-7,null,null],"accounts/fireworks/models/kat-coder":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/kat-dev-32b":[9e-7,9e-7,null,null],"accounts/fireworks/models/kat-dev-32b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/kat-dev-72b-exp":[9e-7,9e-7,null,null],"accounts/fireworks/models/kat-dev-72b-exp":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-guard-2-8b":[2e-7,2e-7,null,null],"accounts/fireworks/models/llama-guard-2-8b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-guard-3-1b":[1e-7,1e-7,null,null],"accounts/fireworks/models/llama-guard-3-1b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-guard-3-8b":[2e-7,2e-7,null,null],"accounts/fireworks/models/llama-guard-3-8b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v2-13b":[2e-7,2e-7,null,null],"accounts/fireworks/models/llama-v2-13b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v2-13b-chat":[2e-7,2e-7,null,null],"accounts/fireworks/models/llama-v2-13b-chat":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v2-70b":[1e-7,1e-7,null,null],"accounts/fireworks/models/llama-v2-70b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v2-70b-chat":[9e-7,9e-7,null,null],"accounts/fireworks/models/llama-v2-70b-chat":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v2-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/llama-v2-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v2-7b-chat":[2e-7,2e-7,null,null],"accounts/fireworks/models/llama-v2-7b-chat":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3-70b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/llama-v3-70b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3-70b-instruct-hf":[9e-7,9e-7,null,null],"accounts/fireworks/models/llama-v3-70b-instruct-hf":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3-8b":[2e-7,2e-7,null,null],"accounts/fireworks/models/llama-v3-8b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3-8b-instruct-hf":[2e-7,2e-7,null,null],"accounts/fireworks/models/llama-v3-8b-instruct-hf":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p1-405b-instruct-long":[1e-7,1e-7,null,null],"accounts/fireworks/models/llama-v3p1-405b-instruct-long":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p1-70b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/llama-v3p1-70b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p1-70b-instruct-1b":[1e-7,1e-7,null,null],"accounts/fireworks/models/llama-v3p1-70b-instruct-1b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p1-nemotron-70b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/llama-v3p1-nemotron-70b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p2-1b":[1e-7,1e-7,null,null],"accounts/fireworks/models/llama-v3p2-1b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p2-3b":[1e-7,1e-7,null,null],"accounts/fireworks/models/llama-v3p2-3b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p3-70b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/llama-v3p3-70b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/llamaguard-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/llamaguard-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/llava-yi-34b":[9e-7,9e-7,null,null],"accounts/fireworks/models/llava-yi-34b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/minimax-m1-80k":[1e-7,1e-7,null,null],"accounts/fireworks/models/minimax-m1-80k":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/minimax-m2":[3e-7,0.0000012,null,null],"accounts/fireworks/models/minimax-m2":[3e-7,0.0000012,null,null],"fireworks_ai/accounts/fireworks/models/ministral-3-14b-instruct-2512":[2e-7,2e-7,null,null],"accounts/fireworks/models/ministral-3-14b-instruct-2512":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/ministral-3-3b-instruct-2512":[1e-7,1e-7,null,null],"accounts/fireworks/models/ministral-3-3b-instruct-2512":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/ministral-3-8b-instruct-2512":[2e-7,2e-7,null,null],"accounts/fireworks/models/ministral-3-8b-instruct-2512":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/mistral-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/mistral-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/mistral-7b-instruct-4k":[2e-7,2e-7,null,null],"accounts/fireworks/models/mistral-7b-instruct-4k":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/mistral-7b-instruct-v0p2":[2e-7,2e-7,null,null],"accounts/fireworks/models/mistral-7b-instruct-v0p2":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/mistral-7b-instruct-v3":[2e-7,2e-7,null,null],"accounts/fireworks/models/mistral-7b-instruct-v3":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/mistral-7b-v0p2":[2e-7,2e-7,null,null],"accounts/fireworks/models/mistral-7b-v0p2":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/mistral-large-3-fp8":[0.0000012,0.0000012,null,null],"accounts/fireworks/models/mistral-large-3-fp8":[0.0000012,0.0000012,null,null],"fireworks_ai/accounts/fireworks/models/mistral-nemo-base-2407":[2e-7,2e-7,null,null],"accounts/fireworks/models/mistral-nemo-base-2407":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/mistral-nemo-instruct-2407":[2e-7,2e-7,null,null],"accounts/fireworks/models/mistral-nemo-instruct-2407":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/mistral-small-24b-instruct-2501":[9e-7,9e-7,null,null],"accounts/fireworks/models/mistral-small-24b-instruct-2501":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/mixtral-8x22b":[0.0000012,0.0000012,null,null],"accounts/fireworks/models/mixtral-8x22b":[0.0000012,0.0000012,null,null],"fireworks_ai/accounts/fireworks/models/mixtral-8x22b-instruct":[0.0000012,0.0000012,null,null],"accounts/fireworks/models/mixtral-8x22b-instruct":[0.0000012,0.0000012,null,null],"fireworks_ai/accounts/fireworks/models/mixtral-8x7b":[5e-7,5e-7,null,null],"accounts/fireworks/models/mixtral-8x7b":[5e-7,5e-7,null,null],"fireworks_ai/accounts/fireworks/models/mixtral-8x7b-instruct":[5e-7,5e-7,null,null],"accounts/fireworks/models/mixtral-8x7b-instruct":[5e-7,5e-7,null,null],"fireworks_ai/accounts/fireworks/models/mixtral-8x7b-instruct-hf":[5e-7,5e-7,null,null],"accounts/fireworks/models/mixtral-8x7b-instruct-hf":[5e-7,5e-7,null,null],"fireworks_ai/accounts/fireworks/models/mythomax-l2-13b":[2e-7,2e-7,null,null],"accounts/fireworks/models/mythomax-l2-13b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/nemotron-nano-v2-12b-vl":[1e-7,1e-7,null,null],"accounts/fireworks/models/nemotron-nano-v2-12b-vl":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/nous-capybara-7b-v1p9":[2e-7,2e-7,null,null],"accounts/fireworks/models/nous-capybara-7b-v1p9":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/nous-hermes-2-mixtral-8x7b-dpo":[5e-7,5e-7,null,null],"accounts/fireworks/models/nous-hermes-2-mixtral-8x7b-dpo":[5e-7,5e-7,null,null],"fireworks_ai/accounts/fireworks/models/nous-hermes-2-yi-34b":[9e-7,9e-7,null,null],"accounts/fireworks/models/nous-hermes-2-yi-34b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/nous-hermes-llama2-13b":[2e-7,2e-7,null,null],"accounts/fireworks/models/nous-hermes-llama2-13b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/nous-hermes-llama2-70b":[9e-7,9e-7,null,null],"accounts/fireworks/models/nous-hermes-llama2-70b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/nous-hermes-llama2-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/nous-hermes-llama2-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/nvidia-nemotron-nano-12b-v2":[2e-7,2e-7,null,null],"accounts/fireworks/models/nvidia-nemotron-nano-12b-v2":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/nvidia-nemotron-nano-9b-v2":[2e-7,2e-7,null,null],"accounts/fireworks/models/nvidia-nemotron-nano-9b-v2":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/openchat-3p5-0106-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/openchat-3p5-0106-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/openhermes-2-mistral-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/openhermes-2-mistral-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/openhermes-2p5-mistral-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/openhermes-2p5-mistral-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/openorca-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/openorca-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/phi-2-3b":[1e-7,1e-7,null,null],"accounts/fireworks/models/phi-2-3b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/phi-3-mini-128k-instruct":[1e-7,1e-7,null,null],"accounts/fireworks/models/phi-3-mini-128k-instruct":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/phi-3-vision-128k-instruct":[2e-7,2e-7,null,null],"accounts/fireworks/models/phi-3-vision-128k-instruct":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/phind-code-llama-34b-python-v1":[9e-7,9e-7,null,null],"accounts/fireworks/models/phind-code-llama-34b-python-v1":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/phind-code-llama-34b-v1":[9e-7,9e-7,null,null],"accounts/fireworks/models/phind-code-llama-34b-v1":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/phind-code-llama-34b-v2":[9e-7,9e-7,null,null],"accounts/fireworks/models/phind-code-llama-34b-v2":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/playground-v2-1024px-aesthetic":[1.3e-10,1.3e-10,null,null],"accounts/fireworks/models/playground-v2-1024px-aesthetic":[1.3e-10,1.3e-10,null,null],"fireworks_ai/accounts/fireworks/models/playground-v2-5-1024px-aesthetic":[1.3e-10,1.3e-10,null,null],"accounts/fireworks/models/playground-v2-5-1024px-aesthetic":[1.3e-10,1.3e-10,null,null],"fireworks_ai/accounts/fireworks/models/pythia-12b":[2e-7,2e-7,null,null],"accounts/fireworks/models/pythia-12b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen-qwq-32b-preview":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen-qwq-32b-preview":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen-v2p5-14b-instruct":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen-v2p5-14b-instruct":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen-v2p5-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen-v2p5-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen1p5-72b-chat":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen1p5-72b-chat":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2-7b-instruct":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen2-7b-instruct":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2-vl-2b-instruct":[1e-7,1e-7,null,null],"accounts/fireworks/models/qwen2-vl-2b-instruct":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2-vl-72b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen2-vl-72b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2-vl-7b-instruct":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen2-vl-7b-instruct":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-0p5b-instruct":[1e-7,1e-7,null,null],"accounts/fireworks/models/qwen2p5-0p5b-instruct":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-14b":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen2p5-14b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-1p5b-instruct":[1e-7,1e-7,null,null],"accounts/fireworks/models/qwen2p5-1p5b-instruct":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-32b":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen2p5-32b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-32b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen2p5-32b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-72b":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen2p5-72b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-72b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen2p5-72b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-7b-instruct":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen2p5-7b-instruct":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-0p5b":[1e-7,1e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-0p5b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-0p5b-instruct":[1e-7,1e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-0p5b-instruct":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-14b":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-14b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-14b-instruct":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-14b-instruct":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-1p5b":[1e-7,1e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-1p5b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-1p5b-instruct":[1e-7,1e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-1p5b-instruct":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-32b":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-32b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-32b-instruct-128k":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-32b-instruct-128k":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-32b-instruct-32k-rope":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-32b-instruct-32k-rope":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-32b-instruct-64k":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-32b-instruct-64k":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-3b":[1e-7,1e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-3b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-3b-instruct":[1e-7,1e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-3b-instruct":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-7b-instruct":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-7b-instruct":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-math-72b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen2p5-math-72b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-vl-32b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen2p5-vl-32b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-vl-3b-instruct":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen2p5-vl-3b-instruct":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-vl-72b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen2p5-vl-72b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-vl-7b-instruct":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen2p5-vl-7b-instruct":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-0p6b":[1e-7,1e-7,null,null],"accounts/fireworks/models/qwen3-0p6b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-14b":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen3-14b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-1p7b":[1e-7,1e-7,null,null],"accounts/fireworks/models/qwen3-1p7b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-1p7b-fp8-draft":[1e-7,1e-7,null,null],"accounts/fireworks/models/qwen3-1p7b-fp8-draft":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-1p7b-fp8-draft-131072":[1e-7,1e-7,null,null],"accounts/fireworks/models/qwen3-1p7b-fp8-draft-131072":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-1p7b-fp8-draft-40960":[1e-7,1e-7,null,null],"accounts/fireworks/models/qwen3-1p7b-fp8-draft-40960":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-235b-a22b":[2.2e-7,8.8e-7,null,null],"accounts/fireworks/models/qwen3-235b-a22b":[2.2e-7,8.8e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-235b-a22b-instruct-2507":[2.2e-7,8.8e-7,null,null],"accounts/fireworks/models/qwen3-235b-a22b-instruct-2507":[2.2e-7,8.8e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-235b-a22b-thinking-2507":[2.2e-7,8.8e-7,null,null],"accounts/fireworks/models/qwen3-235b-a22b-thinking-2507":[2.2e-7,8.8e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-30b-a3b":[1.5e-7,6e-7,null,null],"accounts/fireworks/models/qwen3-30b-a3b":[1.5e-7,6e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-30b-a3b-instruct-2507":[5e-7,5e-7,null,null],"accounts/fireworks/models/qwen3-30b-a3b-instruct-2507":[5e-7,5e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-30b-a3b-thinking-2507":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen3-30b-a3b-thinking-2507":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-32b":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen3-32b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-4b":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen3-4b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-4b-instruct-2507":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen3-4b-instruct-2507":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-8b":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen3-8b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-coder-30b-a3b-instruct":[1.5e-7,6e-7,null,null],"accounts/fireworks/models/qwen3-coder-30b-a3b-instruct":[1.5e-7,6e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-coder-480b-instruct-bf16":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen3-coder-480b-instruct-bf16":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-embedding-0p6b":[0,0,null,null],"accounts/fireworks/models/qwen3-embedding-0p6b":[0,0,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-embedding-4b":[0,0,null,null],"accounts/fireworks/models/qwen3-embedding-4b":[0,0,null,null],"fireworks_ai/accounts/fireworks/models/":[1e-7,0,null,null],"accounts/fireworks/models/":[1e-7,0,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-next-80b-a3b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen3-next-80b-a3b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-next-80b-a3b-thinking":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen3-next-80b-a3b-thinking":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-reranker-0p6b":[0,0,null,null],"accounts/fireworks/models/qwen3-reranker-0p6b":[0,0,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-reranker-4b":[0,0,null,null],"accounts/fireworks/models/qwen3-reranker-4b":[0,0,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-reranker-8b":[0,0,null,null],"accounts/fireworks/models/qwen3-reranker-8b":[0,0,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-vl-235b-a22b-instruct":[2.2e-7,8.8e-7,null,null],"accounts/fireworks/models/qwen3-vl-235b-a22b-instruct":[2.2e-7,8.8e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-vl-235b-a22b-thinking":[2.2e-7,8.8e-7,null,null],"accounts/fireworks/models/qwen3-vl-235b-a22b-thinking":[2.2e-7,8.8e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-vl-30b-a3b-instruct":[1.5e-7,6e-7,null,null],"accounts/fireworks/models/qwen3-vl-30b-a3b-instruct":[1.5e-7,6e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-vl-30b-a3b-thinking":[1.5e-7,6e-7,null,null],"accounts/fireworks/models/qwen3-vl-30b-a3b-thinking":[1.5e-7,6e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-vl-32b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen3-vl-32b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-vl-8b-instruct":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen3-vl-8b-instruct":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwq-32b":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwq-32b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/rolm-ocr":[2e-7,2e-7,null,null],"accounts/fireworks/models/rolm-ocr":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/snorkel-mistral-7b-pairrm-dpo":[2e-7,2e-7,null,null],"accounts/fireworks/models/snorkel-mistral-7b-pairrm-dpo":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/stable-diffusion-xl-1024-v1-0":[1.3e-10,1.3e-10,null,null],"accounts/fireworks/models/stable-diffusion-xl-1024-v1-0":[1.3e-10,1.3e-10,null,null],"fireworks_ai/accounts/fireworks/models/stablecode-3b":[1e-7,1e-7,null,null],"accounts/fireworks/models/stablecode-3b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/starcoder-16b":[2e-7,2e-7,null,null],"accounts/fireworks/models/starcoder-16b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/starcoder-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/starcoder-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/starcoder2-15b":[2e-7,2e-7,null,null],"accounts/fireworks/models/starcoder2-15b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/starcoder2-3b":[1e-7,1e-7,null,null],"accounts/fireworks/models/starcoder2-3b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/starcoder2-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/starcoder2-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/toppy-m-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/toppy-m-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/whisper-v3":[0,0,null,null],"accounts/fireworks/models/whisper-v3":[0,0,null,null],"fireworks_ai/accounts/fireworks/models/whisper-v3-turbo":[0,0,null,null],"accounts/fireworks/models/whisper-v3-turbo":[0,0,null,null],"fireworks_ai/accounts/fireworks/models/yi-34b":[9e-7,9e-7,null,null],"accounts/fireworks/models/yi-34b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/yi-34b-200k-capybara":[9e-7,9e-7,null,null],"accounts/fireworks/models/yi-34b-200k-capybara":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/yi-34b-chat":[9e-7,9e-7,null,null],"accounts/fireworks/models/yi-34b-chat":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/yi-6b":[2e-7,2e-7,null,null],"accounts/fireworks/models/yi-6b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/zephyr-7b-beta":[2e-7,2e-7,null,null],"accounts/fireworks/models/zephyr-7b-beta":[2e-7,2e-7,null,null],"novita/deepseek/deepseek-v3.2":[2.69e-7,4e-7,null,1.345e-7],"novita/minimax/minimax-m2.1":[3e-7,0.0000012,null,3e-8],"novita/zai-org/glm-4.7":[6e-7,0.0000022,null,1.1e-7],"zai-org/glm-4.7":[6e-7,0.0000022,null,1.1e-7],"novita/xiaomimimo/mimo-v2-flash":[1e-7,3e-7,null,2e-8],"xiaomimimo/mimo-v2-flash":[1e-7,3e-7,null,2e-8],"novita/zai-org/autoglm-phone-9b-multilingual":[3.5e-8,1.38e-7,null,null],"zai-org/autoglm-phone-9b-multilingual":[3.5e-8,1.38e-7,null,null],"novita/moonshotai/kimi-k2-thinking":[6e-7,0.0000025,null,null],"moonshotai/kimi-k2-thinking":[6e-7,0.0000025,null,null],"novita/minimax/minimax-m2":[3e-7,0.0000012,null,3e-8],"novita/paddlepaddle/paddleocr-vl":[2e-8,2e-8,null,null],"paddlepaddle/paddleocr-vl":[2e-8,2e-8,null,null],"novita/deepseek/deepseek-v3.2-exp":[2.7e-7,4.1e-7,null,null],"novita/qwen/qwen3-vl-235b-a22b-thinking":[9.8e-7,0.00000395,null,null],"qwen/qwen3-vl-235b-a22b-thinking":[9.8e-7,0.00000395,null,null],"novita/zai-org/glm-4.6v":[3e-7,9e-7,null,5.5e-8],"zai-org/glm-4.6v":[3e-7,9e-7,null,5.5e-8],"novita/zai-org/glm-4.6":[5.5e-7,0.0000022,null,1.1e-7],"zai-org/glm-4.6":[5.5e-7,0.0000022,null,1.1e-7],"novita/kwaipilot/kat-coder-pro":[3e-7,0.0000012,null,6e-8],"kwaipilot/kat-coder-pro":[3e-7,0.0000012,null,6e-8],"novita/qwen/qwen3-next-80b-a3b-instruct":[1.5e-7,0.0000015,null,null],"qwen/qwen3-next-80b-a3b-instruct":[1.5e-7,0.0000015,null,null],"novita/qwen/qwen3-next-80b-a3b-thinking":[1.5e-7,0.0000015,null,null],"qwen/qwen3-next-80b-a3b-thinking":[1.5e-7,0.0000015,null,null],"novita/deepseek/deepseek-ocr":[3e-8,3e-8,null,null],"deepseek/deepseek-ocr":[3e-8,3e-8,null,null],"novita/deepseek/deepseek-v3.1-terminus":[2.7e-7,0.000001,null,1.35e-7],"deepseek/deepseek-v3.1-terminus":[2.7e-7,0.000001,null,1.35e-7],"novita/qwen/qwen3-vl-235b-a22b-instruct":[3e-7,0.0000015,null,null],"qwen/qwen3-vl-235b-a22b-instruct":[3e-7,0.0000015,null,null],"novita/qwen/qwen3-max":[0.00000211,0.00000845,null,null],"qwen/qwen3-max":[0.00000211,0.00000845,null,null],"novita/skywork/r1v4-lite":[2e-7,6e-7,null,null],"skywork/r1v4-lite":[2e-7,6e-7,null,null],"novita/deepseek/deepseek-v3.1":[2.7e-7,0.000001,null,1.35e-7],"deepseek/deepseek-v3.1":[2.7e-7,0.000001,null,1.35e-7],"novita/moonshotai/kimi-k2-0905":[6e-7,0.0000025,null,null],"moonshotai/kimi-k2-0905":[6e-7,0.0000025,null,null],"novita/qwen/qwen3-coder-480b-a35b-instruct":[3e-7,0.0000013,null,null],"qwen/qwen3-coder-480b-a35b-instruct":[3e-7,0.0000013,null,null],"novita/qwen/qwen3-coder-30b-a3b-instruct":[7e-8,2.7e-7,null,null],"qwen/qwen3-coder-30b-a3b-instruct":[7e-8,2.7e-7,null,null],"novita/openai/gpt-oss-120b":[5e-8,2.5e-7,null,null],"novita/moonshotai/kimi-k2-instruct":[5.7e-7,0.0000023,null,null],"moonshotai/kimi-k2-instruct":[5.7e-7,0.0000023,null,null],"novita/deepseek/deepseek-v3-0324":[2.7e-7,0.00000112,null,1.35e-7],"deepseek/deepseek-v3-0324":[2.7e-7,0.00000112,null,1.35e-7],"novita/zai-org/glm-4.5":[6e-7,0.0000022,null,1.1e-7],"zai-org/glm-4.5":[6e-7,0.0000022,null,1.1e-7],"novita/qwen/qwen3-235b-a22b-thinking-2507":[3e-7,0.000003,null,null],"novita/meta-llama/llama-3.1-8b-instruct":[2e-8,5e-8,null,null],"meta-llama/llama-3.1-8b-instruct":[2e-8,5e-8,null,null],"novita/google/gemma-3-12b-it":[5e-8,1e-7,null,null],"novita/zai-org/glm-4.5v":[6e-7,0.0000018,null,1.1e-7],"zai-org/glm-4.5v":[6e-7,0.0000018,null,1.1e-7],"novita/openai/gpt-oss-20b":[4e-8,1.5e-7,null,null],"novita/qwen/qwen3-235b-a22b-instruct-2507":[9e-8,5.8e-7,null,null],"novita/deepseek/deepseek-r1-distill-qwen-14b":[1.5e-7,1.5e-7,null,null],"deepseek/deepseek-r1-distill-qwen-14b":[1.5e-7,1.5e-7,null,null],"novita/meta-llama/llama-3.3-70b-instruct":[1.35e-7,4e-7,null,null],"meta-llama/llama-3.3-70b-instruct":[1.35e-7,4e-7,null,null],"novita/qwen/qwen-2.5-72b-instruct":[3.8e-7,4e-7,null,null],"qwen/qwen-2.5-72b-instruct":[3.8e-7,4e-7,null,null],"novita/mistralai/mistral-nemo":[4e-8,1.7e-7,null,null],"mistralai/mistral-nemo":[4e-8,1.7e-7,null,null],"novita/minimaxai/minimax-m1-80k":[5.5e-7,0.0000022,null,null],"minimaxai/minimax-m1-80k":[5.5e-7,0.0000022,null,null],"novita/deepseek/deepseek-r1-0528":[7e-7,0.0000025,null,3.5e-7],"novita/deepseek/deepseek-r1-distill-qwen-32b":[3e-7,3e-7,null,null],"deepseek/deepseek-r1-distill-qwen-32b":[3e-7,3e-7,null,null],"novita/meta-llama/llama-3-8b-instruct":[4e-8,4e-8,null,null],"meta-llama/llama-3-8b-instruct":[4e-8,4e-8,null,null],"novita/microsoft/wizardlm-2-8x22b":[6.2e-7,6.2e-7,null,null],"microsoft/wizardlm-2-8x22b":[6.2e-7,6.2e-7,null,null],"novita/deepseek/deepseek-r1-0528-qwen3-8b":[6e-8,9e-8,null,null],"deepseek/deepseek-r1-0528-qwen3-8b":[6e-8,9e-8,null,null],"novita/deepseek/deepseek-r1-distill-llama-70b":[8e-7,8e-7,null,null],"novita/meta-llama/llama-3-70b-instruct":[5.1e-7,7.4e-7,null,null],"novita/qwen/qwen3-235b-a22b-fp8":[2e-7,8e-7,null,null],"qwen/qwen3-235b-a22b-fp8":[2e-7,8e-7,null,null],"novita/meta-llama/llama-4-maverick-17b-128e-instruct-fp8":[2.7e-7,8.5e-7,null,null],"meta-llama/llama-4-maverick-17b-128e-instruct-fp8":[2.7e-7,8.5e-7,null,null],"novita/meta-llama/llama-4-scout-17b-16e-instruct":[1.8e-7,5.9e-7,null,null],"novita/nousresearch/hermes-2-pro-llama-3-8b":[1.4e-7,1.4e-7,null,null],"nousresearch/hermes-2-pro-llama-3-8b":[1.4e-7,1.4e-7,null,null],"novita/qwen/qwen2.5-vl-72b-instruct":[8e-7,8e-7,null,null],"qwen/qwen2.5-vl-72b-instruct":[8e-7,8e-7,null,null],"novita/sao10k/l3-70b-euryale-v2.1":[0.00000148,0.00000148,null,null],"sao10k/l3-70b-euryale-v2.1":[0.00000148,0.00000148,null,null],"novita/baidu/ernie-4.5-21B-a3b-thinking":[7e-8,2.8e-7,null,null],"baidu/ernie-4.5-21B-a3b-thinking":[7e-8,2.8e-7,null,null],"novita/sao10k/l3-8b-lunaris":[5e-8,5e-8,null,null],"sao10k/l3-8b-lunaris":[5e-8,5e-8,null,null],"novita/baichuan/baichuan-m2-32b":[7e-8,7e-8,null,null],"baichuan/baichuan-m2-32b":[7e-8,7e-8,null,null],"novita/baidu/ernie-4.5-vl-424b-a47b":[4.2e-7,0.00000125,null,null],"baidu/ernie-4.5-vl-424b-a47b":[4.2e-7,0.00000125,null,null],"novita/baidu/ernie-4.5-300b-a47b-paddle":[2.8e-7,0.0000011,null,null],"baidu/ernie-4.5-300b-a47b-paddle":[2.8e-7,0.0000011,null,null],"novita/deepseek/deepseek-prover-v2-671b":[7e-7,0.0000025,null,null],"deepseek/deepseek-prover-v2-671b":[7e-7,0.0000025,null,null],"novita/qwen/qwen3-32b-fp8":[1e-7,4.5e-7,null,null],"qwen/qwen3-32b-fp8":[1e-7,4.5e-7,null,null],"novita/qwen/qwen3-30b-a3b-fp8":[9e-8,4.5e-7,null,null],"qwen/qwen3-30b-a3b-fp8":[9e-8,4.5e-7,null,null],"novita/google/gemma-3-27b-it":[1.19e-7,2e-7,null,null],"novita/deepseek/deepseek-v3-turbo":[4e-7,0.0000013,null,null],"deepseek/deepseek-v3-turbo":[4e-7,0.0000013,null,null],"novita/deepseek/deepseek-r1-turbo":[7e-7,0.0000025,null,null],"deepseek/deepseek-r1-turbo":[7e-7,0.0000025,null,null],"novita/Sao10K/L3-8B-Stheno-v3.2":[5e-8,5e-8,null,null],"Sao10K/L3-8B-Stheno-v3.2":[5e-8,5e-8,null,null],"novita/gryphe/mythomax-l2-13b":[9e-8,9e-8,null,null],"novita/baidu/ernie-4.5-vl-28b-a3b-thinking":[3.9e-7,3.9e-7,null,null],"baidu/ernie-4.5-vl-28b-a3b-thinking":[3.9e-7,3.9e-7,null,null],"novita/qwen/qwen3-vl-8b-instruct":[8e-8,5e-7,null,null],"qwen/qwen3-vl-8b-instruct":[8e-8,5e-7,null,null],"novita/zai-org/glm-4.5-air":[1.3e-7,8.5e-7,null,null],"zai-org/glm-4.5-air":[1.3e-7,8.5e-7,null,null],"novita/qwen/qwen3-vl-30b-a3b-instruct":[2e-7,7e-7,null,null],"qwen/qwen3-vl-30b-a3b-instruct":[2e-7,7e-7,null,null],"novita/qwen/qwen3-vl-30b-a3b-thinking":[2e-7,0.000001,null,null],"qwen/qwen3-vl-30b-a3b-thinking":[2e-7,0.000001,null,null],"novita/qwen/qwen3-omni-30b-a3b-thinking":[2.5e-7,9.7e-7,null,null],"qwen/qwen3-omni-30b-a3b-thinking":[2.5e-7,9.7e-7,null,null],"novita/qwen/qwen3-omni-30b-a3b-instruct":[2.5e-7,9.7e-7,null,null],"qwen/qwen3-omni-30b-a3b-instruct":[2.5e-7,9.7e-7,null,null],"novita/qwen/qwen-mt-plus":[2.5e-7,7.5e-7,null,null],"qwen/qwen-mt-plus":[2.5e-7,7.5e-7,null,null],"novita/baidu/ernie-4.5-vl-28b-a3b":[1.4e-7,5.6e-7,null,null],"baidu/ernie-4.5-vl-28b-a3b":[1.4e-7,5.6e-7,null,null],"novita/baidu/ernie-4.5-21B-a3b":[7e-8,2.8e-7,null,null],"baidu/ernie-4.5-21B-a3b":[7e-8,2.8e-7,null,null],"novita/qwen/qwen3-8b-fp8":[3.5e-8,1.38e-7,null,null],"qwen/qwen3-8b-fp8":[3.5e-8,1.38e-7,null,null],"novita/qwen/qwen3-4b-fp8":[3e-8,3e-8,null,null],"qwen/qwen3-4b-fp8":[3e-8,3e-8,null,null],"novita/qwen/qwen2.5-7b-instruct":[7e-8,7e-8,null,null],"qwen/qwen2.5-7b-instruct":[7e-8,7e-8,null,null],"novita/meta-llama/llama-3.2-3b-instruct":[3e-8,5e-8,null,null],"meta-llama/llama-3.2-3b-instruct":[3e-8,5e-8,null,null],"novita/sao10k/l31-70b-euryale-v2.2":[0.00000148,0.00000148,null,null],"sao10k/l31-70b-euryale-v2.2":[0.00000148,0.00000148,null,null],"novita/qwen/qwen3-embedding-0.6b":[7e-8,0,null,null],"qwen/qwen3-embedding-0.6b":[7e-8,0,null,null],"novita/qwen/qwen3-embedding-8b":[7e-8,0,null,null],"qwen/qwen3-embedding-8b":[7e-8,0,null,null],"novita/baai/bge-m3":[1e-8,1e-8,null,null],"baai/bge-m3":[1e-8,1e-8,null,null],"novita/qwen/qwen3-reranker-8b":[5e-8,5e-8,null,null],"qwen/qwen3-reranker-8b":[5e-8,5e-8,null,null],"novita/baai/bge-reranker-v2-m3":[1e-8,1e-8,null,null],"baai/bge-reranker-v2-m3":[1e-8,1e-8,null,null],"llamagate/llama-3.1-8b":[3e-8,5e-8,null,null],"llama-3.1-8b":[3e-8,5e-8,null,null],"llamagate/llama-3.2-3b":[4e-8,8e-8,null,null],"llama-3.2-3b":[4e-8,8e-8,null,null],"llamagate/mistral-7b-v0.3":[1e-7,1.5e-7,null,null],"mistral-7b-v0.3":[1e-7,1.5e-7,null,null],"llamagate/qwen3-8b":[4e-8,1.4e-7,null,null],"qwen3-8b":[4e-8,1.4e-7,null,null],"llamagate/dolphin3-8b":[8e-8,1.5e-7,null,null],"dolphin3-8b":[8e-8,1.5e-7,null,null],"llamagate/deepseek-r1-8b":[1e-7,2e-7,null,null],"deepseek-r1-8b":[1e-7,2e-7,null,null],"llamagate/deepseek-r1-7b-qwen":[8e-8,1.5e-7,null,null],"deepseek-r1-7b-qwen":[8e-8,1.5e-7,null,null],"llamagate/openthinker-7b":[8e-8,1.5e-7,null,null],"openthinker-7b":[8e-8,1.5e-7,null,null],"llamagate/qwen2.5-coder-7b":[6e-8,1.2e-7,null,null],"qwen2.5-coder-7b":[6e-8,1.2e-7,null,null],"llamagate/deepseek-coder-6.7b":[6e-8,1.2e-7,null,null],"deepseek-coder-6.7b":[6e-8,1.2e-7,null,null],"llamagate/codellama-7b":[6e-8,1.2e-7,null,null],"codellama-7b":[6e-8,1.2e-7,null,null],"llamagate/qwen3-vl-8b":[1.5e-7,5.5e-7,null,null],"qwen3-vl-8b":[1.5e-7,5.5e-7,null,null],"llamagate/llava-7b":[1e-7,2e-7,null,null],"llava-7b":[1e-7,2e-7,null,null],"llamagate/gemma3-4b":[3e-8,8e-8,null,null],"gemma3-4b":[3e-8,8e-8,null,null],"llamagate/nomic-embed-text":[2e-8,0,null,null],"nomic-embed-text":[2e-8,0,null,null],"llamagate/qwen3-embedding-8b":[2e-8,0,null,null],"qwen3-embedding-8b":[2e-8,0,null,null],"sarvam/sarvam-m":[0,0,0,0],"sarvam-m":[0,0,0,0],"gemini/gemini-2.0-flash-exp-image-generation":[0,0,null,null],"gemini/gemini-2.0-flash-lite-001":[7.5e-8,3e-7,null,1.875e-8],"gemini/gemini-2.5-flash-native-audio-latest":[3e-7,0.0000025,null,null],"gemini/gemini-2.5-flash-native-audio-preview-09-2025":[3e-7,0.0000025,null,null],"gemini/gemini-2.5-flash-native-audio-preview-12-2025":[3e-7,0.0000025,null,null],"gemini/gemini-3.1-flash-live-preview":[7.5e-7,0.0000045,null,null],"gemini/gemini-pro-latest":[0.00000125,0.00001,null,1.25e-7],"vertex_ai/claude-sonnet-4-6@default":[0.000003,0.000015,0.00000375,3e-7],"claude-sonnet-4-6@default":[0.000003,0.000015,0.00000375,3e-7],"bedrock_mantle/openai.gpt-oss-120b":[1.5e-7,6e-7,null,null],"openai.gpt-oss-120b":[1.5e-7,6e-7,null,null],"bedrock_mantle/openai.gpt-oss-20b":[7.5e-8,3e-7,null,null],"openai.gpt-oss-20b":[7.5e-8,3e-7,null,null],"bedrock_mantle/openai.gpt-oss-safeguard-120b":[1.5e-7,6e-7,null,null],"bedrock_mantle/openai.gpt-oss-safeguard-20b":[7.5e-8,3e-7,null,null],"bedrock/us-east-1/zai.glm-5":[0.000001,0.0000032,null,null],"us-east-1/zai.glm-5":[0.000001,0.0000032,null,null],"bedrock/us-west-2/zai.glm-5":[0.000001,0.0000032,null,null],"us-west-2/zai.glm-5":[0.000001,0.0000032,null,null],"bedrock/us-gov-east-1/anthropic.claude-haiku-4-5-20251001-v1:0":[0.0000012,0.000006,0.0000015,1.2e-7],"us-gov-east-1/anthropic.claude-haiku-4-5-20251001-v1:0":[0.0000012,0.000006,0.0000015,1.2e-7],"bedrock/us-gov-west-1/anthropic.claude-haiku-4-5-20251001-v1:0":[0.0000012,0.000006,0.0000015,1.2e-7],"us-gov-west-1/anthropic.claude-haiku-4-5-20251001-v1:0":[0.0000012,0.000006,0.0000015,1.2e-7],"MiniMax-M2.7":[3e-7,0.0000012,3.75e-7,6e-8],"MiniMax-M2.7-highspeed":[6e-7,0.0000024,3.75e-7,6e-8]} \ No newline at end of file +{"ai21.j2-mid-v1":[0.0000125,0.0000125,null,null],"ai21.j2-ultra-v1":[0.0000188,0.0000188,null,null],"ai21.jamba-1-5-large-v1:0":[0.000002,0.000008,null,null],"ai21.jamba-1-5-mini-v1:0":[2e-7,4e-7,null,null],"ai21.jamba-instruct-v1:0":[5e-7,7e-7,null,null],"us.writer.palmyra-x4-v1:0":[0.0000025,0.00001,null,null],"us.writer.palmyra-x5-v1:0":[6e-7,0.000006,null,null],"writer.palmyra-x4-v1:0":[0.0000025,0.00001,null,null],"writer.palmyra-x5-v1:0":[6e-7,0.000006,null,null],"amazon.nova-lite-v1:0":[6e-8,2.4e-7,null,null],"amazon.nova-2-lite-v1:0":[3e-7,0.0000025,null,7.5e-8],"amazon.nova-2-pro-preview-20251202-v1:0":[0.0000021875,0.0000175,null,5.46875e-7],"apac.amazon.nova-2-lite-v1:0":[3.3e-7,0.00000275,null,8.25e-8],"apac.amazon.nova-2-pro-preview-20251202-v1:0":[0.0000021875,0.0000175,null,5.46875e-7],"eu.amazon.nova-2-lite-v1:0":[3.3e-7,0.00000275,null,8.25e-8],"eu.amazon.nova-2-pro-preview-20251202-v1:0":[0.0000021875,0.0000175,null,5.46875e-7],"us.amazon.nova-2-lite-v1:0":[3.3e-7,0.00000275,null,8.25e-8],"us.amazon.nova-2-pro-preview-20251202-v1:0":[0.0000021875,0.0000175,null,5.46875e-7],"amazon.nova-2-multimodal-embeddings-v1:0":[1.35e-7,0,null,null],"amazon.nova-micro-v1:0":[3.5e-8,1.4e-7,null,null],"amazon.nova-pro-v1:0":[8e-7,0.0000032,null,null],"amazon.rerank-v1:0":[0,0,null,null],"amazon.titan-embed-image-v1":[8e-7,0,null,null],"amazon.titan-embed-text-v1":[1e-7,0,null,null],"amazon.titan-embed-text-v2:0":[2e-7,0,null,null],"twelvelabs.marengo-embed-2-7-v1:0":[0.00007,0,null,null],"us.twelvelabs.marengo-embed-2-7-v1:0":[0.00007,0,null,null],"eu.twelvelabs.marengo-embed-2-7-v1:0":[0.00007,0,null,null],"amazon.titan-text-express-v1":[0.0000013,0.0000017,null,null],"amazon.titan-text-lite-v1":[3e-7,4e-7,null,null],"amazon.titan-text-premier-v1:0":[5e-7,0.0000015,null,null],"anthropic.claude-3-5-haiku-20241022-v1:0":[8e-7,0.000004,0.000001,8e-8],"anthropic.claude-haiku-4-5-20251001-v1:0":[0.000001,0.000005,0.00000125,1e-7],"anthropic.claude-haiku-4-5@20251001":[0.000001,0.000005,0.00000125,1e-7],"anthropic.claude-3-5-sonnet-20240620-v1:0":[0.000003,0.000015,0.00000375,3e-7],"anthropic.claude-3-5-sonnet-20241022-v2:0":[0.000003,0.000015,0.00000375,3e-7],"anthropic.claude-3-7-sonnet-20240620-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7],"anthropic.claude-3-7-sonnet-20250219-v1:0":[0.000003,0.000015,0.00000375,3e-7],"anthropic.claude-3-haiku-20240307-v1:0":[2.5e-7,0.00000125,3.125e-7,2.5e-8],"anthropic.claude-3-opus-20240229-v1:0":[0.000015,0.000075,0.00001875,0.0000015],"anthropic.claude-3-sonnet-20240229-v1:0":[0.000003,0.000015,0.00000375,3e-7],"anthropic.claude-instant-v1":[8e-7,0.0000024,null,null],"anthropic.claude-opus-4-1-20250805-v1:0":[0.000015,0.000075,0.00001875,0.0000015],"anthropic.claude-opus-4-20250514-v1:0":[0.000015,0.000075,0.00001875,0.0000015],"anthropic.claude-opus-4-5-20251101-v1:0":[0.000005,0.000025,0.00000625,5e-7],"anthropic.claude-opus-4-6-v1":[0.000005,0.000025,0.00000625,5e-7],"global.anthropic.claude-opus-4-6-v1":[0.000005,0.000025,0.00000625,5e-7],"us.anthropic.claude-opus-4-6-v1":[0.0000055,0.0000275,0.000006875,5.5e-7],"eu.anthropic.claude-opus-4-6-v1":[0.0000055,0.0000275,0.000006875,5.5e-7],"au.anthropic.claude-opus-4-6-v1":[0.0000055,0.0000275,0.000006875,5.5e-7],"anthropic.claude-opus-4-7":[0.000005,0.000025,0.00000625,5e-7],"anthropic.claude-mythos-preview":[0,0,null,null],"global.anthropic.claude-opus-4-7":[0.000005,0.000025,0.00000625,5e-7],"us.anthropic.claude-opus-4-7":[0.0000055,0.0000275,0.000006875,5.5e-7],"eu.anthropic.claude-opus-4-7":[0.0000055,0.0000275,0.000006875,5.5e-7],"au.anthropic.claude-opus-4-7":[0.0000055,0.0000275,0.000006875,5.5e-7],"anthropic.claude-sonnet-4-6":[0.000003,0.000015,0.00000375,3e-7],"global.anthropic.claude-sonnet-4-6":[0.000003,0.000015,0.00000375,3e-7],"us.anthropic.claude-sonnet-4-6":[0.0000033,0.0000165,0.000004125,3.3e-7],"eu.anthropic.claude-sonnet-4-6":[0.0000033,0.0000165,0.000004125,3.3e-7],"au.anthropic.claude-sonnet-4-6":[0.0000033,0.0000165,0.000004125,3.3e-7],"anthropic.claude-sonnet-4-20250514-v1:0":[0.000003,0.000015,0.00000375,3e-7],"anthropic.claude-sonnet-4-5-20250929-v1:0":[0.000003,0.000015,0.00000375,3e-7],"anthropic.claude-v1":[0.000008,0.000024,null,null],"anthropic.claude-v2:1":[0.000008,0.000024,null,null],"apac.amazon.nova-lite-v1:0":[6.3e-8,2.52e-7,null,null],"apac.amazon.nova-micro-v1:0":[3.7e-8,1.48e-7,null,null],"apac.amazon.nova-pro-v1:0":[8.4e-7,0.00000336,null,null],"apac.anthropic.claude-3-5-sonnet-20240620-v1:0":[0.000003,0.000015,0.00000375,3e-7],"apac.anthropic.claude-3-5-sonnet-20241022-v2:0":[0.000003,0.000015,0.00000375,3e-7],"apac.anthropic.claude-3-haiku-20240307-v1:0":[2.5e-7,0.00000125,3.125e-7,2.5e-8],"apac.anthropic.claude-haiku-4-5-20251001-v1:0":[0.0000011,0.0000055,0.000001375,1.1e-7],"apac.anthropic.claude-3-sonnet-20240229-v1:0":[0.000003,0.000015,0.00000375,3e-7],"apac.anthropic.claude-sonnet-4-20250514-v1:0":[0.000003,0.000015,0.00000375,3e-7],"au.anthropic.claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7],"babbage-002":[4e-7,4e-7,null,null],"chatdolphin":[5e-7,5e-7,null,null],"chatgpt-4o-latest":[0.000005,0.000015,null,null],"gpt-4o-transcribe-diarize":[0.0000025,0.00001,null,null],"claude-haiku-4-5-20251001":[0.000001,0.000005,0.00000125,1e-7],"claude-haiku-4-5":[0.000001,0.000005,0.00000125,1e-7],"claude-3-7-sonnet-20250219":[0.000003,0.000015,0.00000375,3e-7],"claude-3-haiku-20240307":[2.5e-7,0.00000125,3e-7,3e-8],"claude-3-opus-20240229":[0.000015,0.000075,0.00001875,0.0000015],"claude-4-opus-20250514":[0.000015,0.000075,0.00001875,0.0000015],"claude-4-sonnet-20250514":[0.000003,0.000015,0.00000375,3e-7],"claude-sonnet-4-5":[0.000003,0.000015,0.00000375,3e-7],"claude-sonnet-4-5-20250929":[0.000003,0.000015,0.00000375,3e-7],"claude-sonnet-4-6":[0.000003,0.000015,0.00000375,3e-7],"claude-sonnet-4-5-20250929-v1:0":[0.000003,0.000015,0.00000375,3e-7],"claude-opus-4-1":[0.000015,0.000075,0.00001875,0.0000015],"claude-opus-4-1-20250805":[0.000015,0.000075,0.00001875,0.0000015],"claude-opus-4-20250514":[0.000015,0.000075,0.00001875,0.0000015],"claude-opus-4-5-20251101":[0.000005,0.000025,0.00000625,5e-7],"claude-opus-4-5":[0.000005,0.000025,0.00000625,5e-7],"claude-opus-4-6":[0.000005,0.000025,0.00000625,5e-7],"claude-opus-4-6-20260205":[0.000005,0.000025,0.00000625,5e-7],"claude-opus-4-7":[0.000005,0.000025,0.00000625,5e-7],"claude-opus-4-7-20260416":[0.000005,0.000025,0.00000625,5e-7],"claude-sonnet-4-20250514":[0.000003,0.000015,0.00000375,3e-7],"codex-mini-latest":[0.0000015,0.000006,null,3.75e-7],"cohere.command-light-text-v14":[3e-7,6e-7,null,null],"cohere.command-r-plus-v1:0":[0.000003,0.000015,null,null],"cohere.command-r-v1:0":[5e-7,0.0000015,null,null],"cohere.command-text-v14":[0.0000015,0.000002,null,null],"cohere.embed-english-v3":[1e-7,0,null,null],"cohere.embed-multilingual-v3":[1e-7,0,null,null],"cohere.embed-v4:0":[1.2e-7,0,null,null],"cohere.rerank-v3-5:0":[0,0,null,null],"command":[0.000001,0.000002,null,null],"command-a-03-2025":[0.0000025,0.00001,null,null],"command-light":[3e-7,6e-7,null,null],"command-nightly":[0.000001,0.000002,null,null],"command-r":[1.5e-7,6e-7,null,null],"command-r-08-2024":[1.5e-7,6e-7,null,null],"command-r-plus":[0.0000025,0.00001,null,null],"command-r-plus-08-2024":[0.0000025,0.00001,null,null],"command-r7b-12-2024":[1.5e-7,3.75e-8,null,null],"computer-use-preview":[0.000003,0.000012,null,null],"deepseek-chat":[2.8e-7,4.2e-7,null,2.8e-8],"deepseek-reasoner":[2.8e-7,4.2e-7,null,2.8e-8],"davinci-002":[0.000002,0.000002,null,null],"deepseek.v3-v1:0":[5.8e-7,0.00000168,null,null],"deepseek.v3.2":[6.2e-7,0.00000185,null,null],"dolphin":[5e-7,5e-7,null,null],"deepseek-v3-2-251201":[0,0,null,null],"glm-4-7-251222":[0,0,null,null],"kimi-k2-thinking-251104":[0,0,null,null],"doubao-embedding":[0,0,null,null],"doubao-embedding-large":[0,0,null,null],"doubao-embedding-large-text-240915":[0,0,null,null],"doubao-embedding-large-text-250515":[0,0,null,null],"doubao-embedding-text-240715":[0,0,null,null],"embed-english-light-v2.0":[1e-7,0,null,null],"embed-english-light-v3.0":[1e-7,0,null,null],"embed-english-v2.0":[1e-7,0,null,null],"embed-english-v3.0":[1e-7,0,null,null],"embed-multilingual-v2.0":[1e-7,0,null,null],"embed-multilingual-v3.0":[1e-7,0,null,null],"embed-multilingual-light-v3.0":[0.0001,0,null,null],"eu.amazon.nova-lite-v1:0":[7.8e-8,3.12e-7,null,null],"eu.amazon.nova-micro-v1:0":[4.6e-8,1.84e-7,null,null],"eu.amazon.nova-pro-v1:0":[0.00000105,0.0000042,null,null],"eu.anthropic.claude-3-5-haiku-20241022-v1:0":[2.5e-7,0.00000125,3.125e-7,2.5e-8],"eu.anthropic.claude-haiku-4-5-20251001-v1:0":[0.0000011,0.0000055,0.000001375,1.1e-7],"eu.anthropic.claude-3-5-sonnet-20240620-v1:0":[0.000003,0.000015,0.00000375,3e-7],"eu.anthropic.claude-3-5-sonnet-20241022-v2:0":[0.000003,0.000015,0.00000375,3e-7],"eu.anthropic.claude-3-7-sonnet-20250219-v1:0":[0.000003,0.000015,0.00000375,3e-7],"eu.anthropic.claude-3-haiku-20240307-v1:0":[2.5e-7,0.00000125,3.125e-7,2.5e-8],"eu.anthropic.claude-3-opus-20240229-v1:0":[0.000015,0.000075,0.00001875,0.0000015],"eu.anthropic.claude-3-sonnet-20240229-v1:0":[0.000003,0.000015,0.00000375,3e-7],"eu.anthropic.claude-opus-4-1-20250805-v1:0":[0.000015,0.000075,0.00001875,0.0000015],"eu.anthropic.claude-opus-4-20250514-v1:0":[0.000015,0.000075,0.00001875,0.0000015],"eu.anthropic.claude-sonnet-4-20250514-v1:0":[0.000003,0.000015,0.00000375,3e-7],"eu.anthropic.claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7],"eu.meta.llama3-2-1b-instruct-v1:0":[1.3e-7,1.3e-7,null,null],"eu.meta.llama3-2-3b-instruct-v1:0":[1.9e-7,1.9e-7,null,null],"eu.mistral.pixtral-large-2502-v1:0":[0.000002,0.000006,null,null],"fireworks-ai-4.1b-to-16b":[2e-7,2e-7,null,null],"fireworks-ai-56b-to-176b":[0.0000012,0.0000012,null,null],"fireworks-ai-above-16b":[9e-7,9e-7,null,null],"fireworks-ai-default":[0,0,null,null],"fireworks-ai-embedding-150m-to-350m":[1.6e-8,0,null,null],"fireworks-ai-embedding-up-to-150m":[8e-9,0,null,null],"fireworks-ai-moe-up-to-56b":[5e-7,5e-7,null,null],"fireworks-ai-up-to-4b":[2e-7,2e-7,null,null],"ft:babbage-002":[0.0000016,0.0000016,null,null],"ft:davinci-002":[0.000012,0.000012,null,null],"ft:gpt-3.5-turbo":[0.000003,0.000006,null,null],"ft:gpt-3.5-turbo-0125":[0.000003,0.000006,null,null],"ft:gpt-3.5-turbo-0613":[0.000003,0.000006,null,null],"ft:gpt-3.5-turbo-1106":[0.000003,0.000006,null,null],"ft:gpt-4-0613":[0.00003,0.00006,null,null],"ft:gpt-4o-2024-08-06":[0.00000375,0.000015,null,0.000001875],"ft:gpt-4o-2024-11-20":[0.00000375,0.000015,0.000001875,null],"ft:gpt-4o-mini-2024-07-18":[3e-7,0.0000012,null,1.5e-7],"ft:gpt-4.1-2025-04-14":[0.000003,0.000012,null,7.5e-7],"ft:gpt-4.1-mini-2025-04-14":[8e-7,0.0000032,null,2e-7],"ft:gpt-4.1-nano-2025-04-14":[2e-7,8e-7,null,5e-8],"ft:o4-mini-2025-04-16":[0.000004,0.000016,null,0.000001],"gemini-2.0-flash":[1e-7,4e-7,null,2.5e-8],"gemini-2.0-flash-001":[1.5e-7,6e-7,null,3.75e-8],"gemini-2.0-flash-lite":[7.5e-8,3e-7,null,1.875e-8],"gemini-2.0-flash-lite-001":[7.5e-8,3e-7,null,1.875e-8],"gemini-2.5-flash":[3e-7,0.0000025,null,3e-8],"gemini-2.5-flash-image":[3e-7,0.0000025,null,3e-8],"gemini-3-pro-image-preview":[0.000002,0.000012,null,null],"gemini-3.1-flash-image-preview":[5e-7,0.000003,null,null],"gemini-3.1-flash-lite-preview":[2.5e-7,0.0000015,null,2.5e-8],"deep-research-pro-preview-12-2025":[0.000002,0.000012,null,null],"gemini-2.5-flash-lite":[1e-7,4e-7,null,1e-8],"gemini-2.5-flash-lite-preview-09-2025":[1e-7,4e-7,null,1e-8],"gemini-2.5-flash-preview-09-2025":[3e-7,0.0000025,null,7.5e-8],"gemini-live-2.5-flash-preview-native-audio-09-2025":[3e-7,0.000002,null,7.5e-8],"gemini-2.5-flash-lite-preview-06-17":[1e-7,4e-7,null,2.5e-8],"gemini-2.5-pro":[0.00000125,0.00001,null,1.25e-7],"gemini-3-pro-preview":[0.000002,0.000012,null,2e-7],"gemini-3.1-pro-preview":[0.000002,0.000012,null,2e-7],"gemini-3.1-pro-preview-customtools":[0.000002,0.000012,null,2e-7],"gemini-2.5-pro-preview-tts":[0.00000125,0.00001,null,1.25e-7],"gemini-robotics-er-1.5-preview":[3e-7,0.0000025,null,0],"gemini-2.5-computer-use-preview-10-2025":[0.00000125,0.00001,null,null],"gemini-embedding-001":[1.5e-7,0,null,null],"gemini-embedding-2-preview":[2e-7,0,null,null],"gemini-embedding-2":[2e-7,0,null,null],"gemini-flash-experimental":[0,0,null,null],"gemini-3-flash-preview":[5e-7,0.000003,null,5e-8],"google.gemma-3-12b-it":[9e-8,2.9e-7,null,null],"google.gemma-3-27b-it":[2.3e-7,3.8e-7,null,null],"google.gemma-3-4b-it":[4e-8,8e-8,null,null],"global.anthropic.claude-sonnet-4-5-20250929-v1:0":[0.000003,0.000015,0.00000375,3e-7],"global.anthropic.claude-sonnet-4-20250514-v1:0":[0.000003,0.000015,0.00000375,3e-7],"global.anthropic.claude-haiku-4-5-20251001-v1:0":[0.000001,0.000005,0.00000125,1e-7],"global.amazon.nova-2-lite-v1:0":[3e-7,0.0000025,null,7.5e-8],"gpt-3.5-turbo":[5e-7,0.0000015,null,null],"gpt-3.5-turbo-0125":[5e-7,0.0000015,null,null],"gpt-3.5-turbo-1106":[0.000001,0.000002,null,null],"gpt-3.5-turbo-16k":[0.000003,0.000004,null,null],"gpt-3.5-turbo-instruct":[0.0000015,0.000002,null,null],"gpt-3.5-turbo-instruct-0914":[0.0000015,0.000002,null,null],"gpt-4":[0.00003,0.00006,null,null],"gpt-4-0125-preview":[0.00001,0.00003,null,null],"gpt-4-0314":[0.00003,0.00006,null,null],"gpt-4-0613":[0.00003,0.00006,null,null],"gpt-4-1106-preview":[0.00001,0.00003,null,null],"gpt-4-turbo":[0.00001,0.00003,null,null],"gpt-4-turbo-2024-04-09":[0.00001,0.00003,null,null],"gpt-4-turbo-preview":[0.00001,0.00003,null,null],"gpt-4.1":[0.000002,0.000008,null,5e-7],"gpt-4.1-2025-04-14":[0.000002,0.000008,null,5e-7],"gpt-4.1-mini":[4e-7,0.0000016,null,1e-7],"gpt-4.1-mini-2025-04-14":[4e-7,0.0000016,null,1e-7],"gpt-4.1-nano":[1e-7,4e-7,null,2.5e-8],"gpt-4.1-nano-2025-04-14":[1e-7,4e-7,null,2.5e-8],"gpt-4o":[0.0000025,0.00001,null,0.00000125],"gpt-4o-2024-05-13":[0.000005,0.000015,null,null],"gpt-4o-2024-08-06":[0.0000025,0.00001,null,0.00000125],"gpt-4o-2024-11-20":[0.0000025,0.00001,null,0.00000125],"gpt-4o-audio-preview":[0.0000025,0.00001,null,null],"gpt-4o-audio-preview-2024-12-17":[0.0000025,0.00001,null,null],"gpt-4o-audio-preview-2025-06-03":[0.0000025,0.00001,null,null],"gpt-audio":[0.0000025,0.00001,null,null],"gpt-audio-1.5":[0.0000025,0.00001,null,null],"gpt-audio-2025-08-28":[0.0000025,0.00001,null,null],"gpt-audio-mini":[6e-7,0.0000024,null,null],"gpt-audio-mini-2025-10-06":[6e-7,0.0000024,null,null],"gpt-audio-mini-2025-12-15":[6e-7,0.0000024,null,null],"gpt-4o-mini":[1.5e-7,6e-7,null,7.5e-8],"gpt-4o-mini-2024-07-18":[1.5e-7,6e-7,null,7.5e-8],"gpt-4o-mini-audio-preview":[1.5e-7,6e-7,null,null],"gpt-4o-mini-audio-preview-2024-12-17":[1.5e-7,6e-7,null,null],"gpt-4o-mini-realtime-preview":[6e-7,0.0000024,null,3e-7],"gpt-4o-mini-realtime-preview-2024-12-17":[6e-7,0.0000024,null,3e-7],"gpt-4o-mini-search-preview":[1.5e-7,6e-7,null,7.5e-8],"gpt-4o-mini-search-preview-2025-03-11":[1.5e-7,6e-7,null,7.5e-8],"gpt-4o-mini-transcribe":[0.00000125,0.000005,null,null],"gpt-4o-mini-tts":[0.0000025,0.00001,null,null],"gpt-4o-realtime-preview":[0.000005,0.00002,null,0.0000025],"gpt-4o-realtime-preview-2024-12-17":[0.000005,0.00002,null,0.0000025],"gpt-4o-realtime-preview-2025-06-03":[0.000005,0.00002,null,0.0000025],"gpt-4o-search-preview":[0.0000025,0.00001,null,0.00000125],"gpt-4o-search-preview-2025-03-11":[0.0000025,0.00001,null,0.00000125],"gpt-4o-transcribe":[0.0000025,0.00001,null,null],"gpt-image-1.5":[0.000005,0.00001,null,0.00000125],"gpt-image-1.5-2025-12-16":[0.000005,0.00001,null,0.00000125],"gpt-image-2":[0.000005,0.00001,null,0.00000125],"gpt-image-2-2026-04-21":[0.000005,0.00001,null,0.00000125],"gpt-5":[0.00000125,0.00001,null,1.25e-7],"gpt-5.1":[0.00000125,0.00001,null,1.25e-7],"gpt-5.1-2025-11-13":[0.00000125,0.00001,null,1.25e-7],"gpt-5.1-chat-latest":[0.00000125,0.00001,null,1.25e-7],"gpt-5.2":[0.00000175,0.000014,null,1.75e-7],"gpt-5.2-2025-12-11":[0.00000175,0.000014,null,1.75e-7],"gpt-5.2-chat-latest":[0.00000175,0.000014,null,1.75e-7],"gpt-5.3-chat-latest":[0.00000175,0.000014,null,1.75e-7],"gpt-5.2-pro":[0.000021,0.000168,null,null],"gpt-5.2-pro-2025-12-11":[0.000021,0.000168,null,null],"gpt-5.5":[0.000005,0.00003,null,5e-7],"gpt-5.5-2026-04-23":[0.000005,0.00003,null,5e-7],"gpt-5.5-pro":[0.00003,0.00018,null,0.000003],"gpt-5.5-pro-2026-04-23":[0.00003,0.00018,null,0.000003],"gpt-5.4":[0.0000025,0.000015,null,2.5e-7],"gpt-5.4-2026-03-05":[0.0000025,0.000015,null,2.5e-7],"gpt-5.4-pro":[0.00003,0.00018,null,0.000003],"gpt-5.4-pro-2026-03-05":[0.00003,0.00018,null,0.000003],"gpt-5.4-mini":[7.5e-7,0.0000045,null,7.5e-8],"gpt-5.4-mini-2026-03-17":[7.5e-7,0.0000045,null,7.5e-8],"gpt-5.4-nano":[2e-7,0.00000125,null,2e-8],"gpt-5.4-nano-2026-03-17":[2e-7,0.00000125,null,2e-8],"gpt-5-pro":[0.000015,0.00012,null,null],"gpt-5-pro-2025-10-06":[0.000015,0.00012,null,null],"gpt-5-2025-08-07":[0.00000125,0.00001,null,1.25e-7],"gpt-5-chat":[0.00000125,0.00001,null,1.25e-7],"gpt-5-chat-latest":[0.00000125,0.00001,null,1.25e-7],"gpt-5-codex":[0.00000125,0.00001,null,1.25e-7],"gpt-5.1-codex":[0.00000125,0.00001,null,1.25e-7],"gpt-5.1-codex-max":[0.00000125,0.00001,null,1.25e-7],"gpt-5.1-codex-mini":[2.5e-7,0.000002,null,2.5e-8],"gpt-5.2-codex":[0.00000175,0.000014,null,1.75e-7],"gpt-5.3-codex":[0.00000175,0.000014,null,1.75e-7],"gpt-5-mini":[2.5e-7,0.000002,null,2.5e-8],"gpt-5-mini-2025-08-07":[2.5e-7,0.000002,null,2.5e-8],"gpt-5-nano":[5e-8,4e-7,null,5e-9],"gpt-5-nano-2025-08-07":[5e-8,4e-7,null,5e-9],"gpt-realtime":[0.000004,0.000016,null,4e-7],"gpt-realtime-1.5":[0.000004,0.000016,null,4e-7],"gpt-realtime-2":[0.000004,0.000016,null,4e-7],"gpt-realtime-mini":[6e-7,0.0000024,null,null],"gpt-realtime-2025-08-28":[0.000004,0.000016,null,4e-7],"j2-light":[0.000003,0.000003,null,null],"j2-mid":[0.00001,0.00001,null,null],"j2-ultra":[0.000015,0.000015,null,null],"jamba-1.5":[2e-7,4e-7,null,null],"jamba-1.5-large":[0.000002,0.000008,null,null],"jamba-1.5-large@001":[0.000002,0.000008,null,null],"jamba-1.5-mini":[2e-7,4e-7,null,null],"jamba-1.5-mini@001":[2e-7,4e-7,null,null],"jamba-large-1.6":[0.000002,0.000008,null,null],"jamba-large-1.7":[0.000002,0.000008,null,null],"jamba-mini-1.6":[2e-7,4e-7,null,null],"jamba-mini-1.7":[2e-7,4e-7,null,null],"jina-reranker-v2-base-multilingual":[1.8e-8,1.8e-8,null,null],"jp.anthropic.claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7],"jp.anthropic.claude-haiku-4-5-20251001-v1:0":[0.0000011,0.0000055,0.000001375,1.1e-7],"meta.llama2-13b-chat-v1":[7.5e-7,0.000001,null,null],"meta.llama2-70b-chat-v1":[0.00000195,0.00000256,null,null],"meta.llama3-1-405b-instruct-v1:0":[0.00000532,0.000016,null,null],"meta.llama3-1-70b-instruct-v1:0":[9.9e-7,9.9e-7,null,null],"meta.llama3-1-8b-instruct-v1:0":[2.2e-7,2.2e-7,null,null],"meta.llama3-2-11b-instruct-v1:0":[3.5e-7,3.5e-7,null,null],"meta.llama3-2-1b-instruct-v1:0":[1e-7,1e-7,null,null],"meta.llama3-2-3b-instruct-v1:0":[1.5e-7,1.5e-7,null,null],"meta.llama3-2-90b-instruct-v1:0":[0.000002,0.000002,null,null],"meta.llama3-3-70b-instruct-v1:0":[7.2e-7,7.2e-7,null,null],"meta.llama3-70b-instruct-v1:0":[0.00000265,0.0000035,null,null],"meta.llama3-8b-instruct-v1:0":[3e-7,6e-7,null,null],"meta.llama4-maverick-17b-instruct-v1:0":[2.4e-7,9.7e-7,null,null],"meta.llama4-scout-17b-instruct-v1:0":[1.7e-7,6.6e-7,null,null],"minimax.minimax-m2":[3e-7,0.0000012,null,null],"minimax.minimax-m2.1":[3e-7,0.0000012,null,null],"minimax.minimax-m2.5":[3e-7,0.0000012,null,null],"mistral.devstral-2-123b":[4e-7,0.000002,null,null],"mistral.magistral-small-2509":[5e-7,0.0000015,null,null],"mistral.ministral-3-14b-instruct":[2e-7,2e-7,null,null],"mistral.ministral-3-3b-instruct":[1e-7,1e-7,null,null],"mistral.ministral-3-8b-instruct":[1.5e-7,1.5e-7,null,null],"mistral.mistral-7b-instruct-v0:2":[1.5e-7,2e-7,null,null],"mistral.mistral-large-2402-v1:0":[0.000008,0.000024,null,null],"mistral.mistral-large-2407-v1:0":[0.000003,0.000009,null,null],"mistral.mistral-large-3-675b-instruct":[5e-7,0.0000015,null,null],"mistral.mistral-small-2402-v1:0":[0.000001,0.000003,null,null],"mistral.mixtral-8x7b-instruct-v0:1":[4.5e-7,7e-7,null,null],"mistral.voxtral-mini-3b-2507":[4e-8,4e-8,null,null],"mistral.voxtral-small-24b-2507":[1e-7,3e-7,null,null],"moonshot.kimi-k2-thinking":[6e-7,0.0000025,null,null],"moonshotai.kimi-k2.5":[6e-7,0.000003,null,null],"multimodalembedding":[8e-7,0,null,null],"multimodalembedding@001":[8e-7,0,null,null],"nvidia.nemotron-nano-12b-v2":[2e-7,6e-7,null,null],"nvidia.nemotron-nano-9b-v2":[6e-8,2.3e-7,null,null],"nvidia.nemotron-nano-3-30b":[6e-8,2.4e-7,null,null],"nvidia.nemotron-super-3-120b":[1.5e-7,6.5e-7,null,null],"o1":[0.000015,0.00006,null,0.0000075],"o1-2024-12-17":[0.000015,0.00006,null,0.0000075],"o1-pro":[0.00015,0.0006,null,null],"o1-pro-2025-03-19":[0.00015,0.0006,null,null],"o3":[0.000002,0.000008,null,5e-7],"o3-2025-04-16":[0.000002,0.000008,null,5e-7],"o3-deep-research":[0.00001,0.00004,null,0.0000025],"o3-deep-research-2025-06-26":[0.00001,0.00004,null,0.0000025],"o3-mini":[0.0000011,0.0000044,null,5.5e-7],"o3-mini-2025-01-31":[0.0000011,0.0000044,null,5.5e-7],"o3-pro":[0.00002,0.00008,null,null],"o3-pro-2025-06-10":[0.00002,0.00008,null,null],"o4-mini":[0.0000011,0.0000044,null,2.75e-7],"o4-mini-2025-04-16":[0.0000011,0.0000044,null,2.75e-7],"o4-mini-deep-research":[0.000002,0.000008,null,5e-7],"o4-mini-deep-research-2025-06-26":[0.000002,0.000008,null,5e-7],"omni-moderation-2024-09-26":[0,0,null,null],"omni-moderation-latest":[0,0,null,null],"openai.gpt-oss-120b-1:0":[1.5e-7,6e-7,null,null],"openai.gpt-oss-20b-1:0":[7e-8,3e-7,null,null],"openai.gpt-oss-safeguard-120b":[1.5e-7,6e-7,null,null],"openai.gpt-oss-safeguard-20b":[7e-8,2e-7,null,null],"qwen.qwen3-coder-480b-a35b-v1:0":[2.2e-7,0.0000018,null,null],"qwen.qwen3-235b-a22b-2507-v1:0":[2.2e-7,8.8e-7,null,null],"qwen.qwen3-coder-30b-a3b-v1:0":[1.5e-7,6e-7,null,null],"qwen.qwen3-32b-v1:0":[1.5e-7,6e-7,null,null],"qwen.qwen3-next-80b-a3b":[1.5e-7,0.0000012,null,null],"qwen.qwen3-vl-235b-a22b":[5.3e-7,0.00000266,null,null],"qwen.qwen3-coder-next":[5e-7,0.0000012,null,null],"rerank-english-v2.0":[0,0,null,null],"rerank-english-v3.0":[0,0,null,null],"rerank-multilingual-v2.0":[0,0,null,null],"rerank-multilingual-v3.0":[0,0,null,null],"rerank-v3.5":[0,0,null,null],"text-embedding-004":[1e-7,0,null,null],"text-embedding-005":[1e-7,0,null,null],"text-embedding-3-large":[1.3e-7,0,null,null],"text-embedding-3-small":[2e-8,0,null,null],"text-embedding-ada-002":[1e-7,0,null,null],"text-embedding-ada-002-v2":[1e-7,0,null,null],"text-embedding-large-exp-03-07":[1e-7,0,null,null],"text-embedding-preview-0409":[6.25e-9,0,null,null],"text-moderation-007":[0,0,null,null],"text-moderation-latest":[0,0,null,null],"text-moderation-stable":[0,0,null,null],"text-multilingual-embedding-002":[1e-7,0,null,null],"text-unicorn":[0.00001,0.000028,null,null],"text-unicorn@001":[0.00001,0.000028,null,null],"together-ai-21.1b-41b":[8e-7,8e-7,null,null],"together-ai-4.1b-8b":[2e-7,2e-7,null,null],"together-ai-41.1b-80b":[9e-7,9e-7,null,null],"together-ai-8.1b-21b":[3e-7,3e-7,null,null],"together-ai-81.1b-110b":[0.0000018,0.0000018,null,null],"together-ai-embedding-151m-to-350m":[1.6e-8,0,null,null],"together-ai-embedding-up-to-150m":[8e-9,0,null,null],"together-ai-up-to-4b":[1e-7,1e-7,null,null],"us.amazon.nova-lite-v1:0":[6e-8,2.4e-7,null,null],"us.amazon.nova-micro-v1:0":[3.5e-8,1.4e-7,null,null],"us.amazon.nova-premier-v1:0":[0.0000025,0.0000125,null,null],"us.amazon.nova-pro-v1:0":[8e-7,0.0000032,null,null],"us.anthropic.claude-3-5-haiku-20241022-v1:0":[8e-7,0.000004,0.000001,8e-8],"us.anthropic.claude-haiku-4-5-20251001-v1:0":[0.0000011,0.0000055,0.000001375,1.1e-7],"us.anthropic.claude-3-5-sonnet-20240620-v1:0":[0.000003,0.000015,0.00000375,3e-7],"us.anthropic.claude-3-5-sonnet-20241022-v2:0":[0.000003,0.000015,0.00000375,3e-7],"us.anthropic.claude-3-7-sonnet-20250219-v1:0":[0.000003,0.000015,0.00000375,3e-7],"us.anthropic.claude-3-haiku-20240307-v1:0":[2.5e-7,0.00000125,3.125e-7,2.5e-8],"us.anthropic.claude-3-opus-20240229-v1:0":[0.000015,0.000075,0.00001875,0.0000015],"us.anthropic.claude-3-sonnet-20240229-v1:0":[0.000003,0.000015,0.00000375,3e-7],"us.anthropic.claude-opus-4-1-20250805-v1:0":[0.000015,0.000075,0.00001875,0.0000015],"us.anthropic.claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7],"us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7],"au.anthropic.claude-haiku-4-5-20251001-v1:0":[0.0000011,0.0000055,0.000001375,1.1e-7],"us.anthropic.claude-opus-4-20250514-v1:0":[0.000015,0.000075,0.00001875,0.0000015],"us.anthropic.claude-opus-4-5-20251101-v1:0":[0.0000055,0.0000275,0.000006875,5.5e-7],"global.anthropic.claude-opus-4-5-20251101-v1:0":[0.000005,0.000025,0.00000625,5e-7],"eu.anthropic.claude-opus-4-5-20251101-v1:0":[0.000005,0.000025,0.00000625,5e-7],"us.anthropic.claude-sonnet-4-20250514-v1:0":[0.000003,0.000015,0.00000375,3e-7],"us.deepseek.r1-v1:0":[0.00000135,0.0000054,null,null],"us.deepseek.v3.2":[6.2e-7,0.00000185,null,null],"eu.deepseek.v3.2":[7.4e-7,0.00000222,null,null],"us.meta.llama3-1-405b-instruct-v1:0":[0.00000532,0.000016,null,null],"us.meta.llama3-1-70b-instruct-v1:0":[9.9e-7,9.9e-7,null,null],"us.meta.llama3-1-8b-instruct-v1:0":[2.2e-7,2.2e-7,null,null],"us.meta.llama3-2-11b-instruct-v1:0":[3.5e-7,3.5e-7,null,null],"us.meta.llama3-2-1b-instruct-v1:0":[1e-7,1e-7,null,null],"us.meta.llama3-2-3b-instruct-v1:0":[1.5e-7,1.5e-7,null,null],"us.meta.llama3-2-90b-instruct-v1:0":[0.000002,0.000002,null,null],"us.meta.llama3-3-70b-instruct-v1:0":[7.2e-7,7.2e-7,null,null],"us.meta.llama4-maverick-17b-instruct-v1:0":[2.4e-7,9.7e-7,null,null],"us.meta.llama4-scout-17b-instruct-v1:0":[1.7e-7,6.6e-7,null,null],"us.mistral.pixtral-large-2502-v1:0":[0.000002,0.000006,null,null],"zai.glm-4.7":[6e-7,0.0000022,null,null],"zai.glm-5":[0.000001,0.0000032,null,null],"zai.glm-4.7-flash":[7e-8,4e-7,null,null],"gpt-4o-mini-tts-2025-03-20":[0.0000025,0.00001,null,null],"gpt-4o-mini-tts-2025-12-15":[0.0000025,0.00001,null,null],"gpt-4o-mini-transcribe-2025-03-20":[0.00000125,0.000005,null,null],"gpt-4o-mini-transcribe-2025-12-15":[0.00000125,0.000005,null,null],"gpt-5-search-api":[0.00000125,0.00001,null,1.25e-7],"gpt-5-search-api-2025-10-14":[0.00000125,0.00001,null,1.25e-7],"gpt-realtime-mini-2025-10-06":[6e-7,0.0000024,null,6e-8],"gpt-realtime-mini-2025-12-15":[6e-7,0.0000024,null,6e-8],"gemini-2.0-flash-exp-image-generation":[0,0,null,null],"gemini-2.5-flash-native-audio-latest":[3e-7,0.0000025,null,null],"gemini-2.5-flash-native-audio-preview-09-2025":[3e-7,0.0000025,null,null],"gemini-2.5-flash-native-audio-preview-12-2025":[3e-7,0.0000025,null,null],"gemini-3.1-flash-live-preview":[7.5e-7,0.0000045,null,null],"gemini-2.5-flash-preview-tts":[3e-7,0.0000025,null,null],"gemini-flash-latest":[3e-7,0.0000025,null,3e-8],"gemini-flash-lite-latest":[1e-7,4e-7,null,1e-8],"gemini-pro-latest":[0.00000125,0.00001,null,1.25e-7],"gemini-exp-1206":[3e-7,0.0000025,null,3e-8],"anyscale/HuggingFaceH4/zephyr-7b-beta":[1.5e-7,1.5e-7,null,null],"HuggingFaceH4/zephyr-7b-beta":[1.5e-7,1.5e-7,null,null],"anyscale/codellama/CodeLlama-34b-Instruct-hf":[0.000001,0.000001,null,null],"codellama/CodeLlama-34b-Instruct-hf":[0.000001,0.000001,null,null],"anyscale/codellama/CodeLlama-70b-Instruct-hf":[0.000001,0.000001,null,null],"codellama/CodeLlama-70b-Instruct-hf":[0.000001,0.000001,null,null],"anyscale/google/gemma-7b-it":[1.5e-7,1.5e-7,null,null],"google/gemma-7b-it":[1.5e-7,1.5e-7,null,null],"anyscale/meta-llama/Llama-2-13b-chat-hf":[2.5e-7,2.5e-7,null,null],"meta-llama/Llama-2-13b-chat-hf":[2.5e-7,2.5e-7,null,null],"anyscale/meta-llama/Llama-2-70b-chat-hf":[0.000001,0.000001,null,null],"meta-llama/Llama-2-70b-chat-hf":[0.000001,0.000001,null,null],"anyscale/meta-llama/Llama-2-7b-chat-hf":[1.5e-7,1.5e-7,null,null],"meta-llama/Llama-2-7b-chat-hf":[1.5e-7,1.5e-7,null,null],"anyscale/meta-llama/Meta-Llama-3-70B-Instruct":[0.000001,0.000001,null,null],"meta-llama/Meta-Llama-3-70B-Instruct":[0.000001,0.000001,null,null],"anyscale/meta-llama/Meta-Llama-3-8B-Instruct":[1.5e-7,1.5e-7,null,null],"meta-llama/Meta-Llama-3-8B-Instruct":[1.5e-7,1.5e-7,null,null],"anyscale/mistralai/Mistral-7B-Instruct-v0.1":[1.5e-7,1.5e-7,null,null],"mistralai/Mistral-7B-Instruct-v0.1":[1.5e-7,1.5e-7,null,null],"anyscale/mistralai/Mixtral-8x22B-Instruct-v0.1":[9e-7,9e-7,null,null],"mistralai/Mixtral-8x22B-Instruct-v0.1":[9e-7,9e-7,null,null],"anyscale/mistralai/Mixtral-8x7B-Instruct-v0.1":[1.5e-7,1.5e-7,null,null],"mistralai/Mixtral-8x7B-Instruct-v0.1":[1.5e-7,1.5e-7,null,null],"azure/ada":[1e-7,0,null,null],"ada":[1e-7,0,null,null],"azure/codex-mini":[0.0000015,0.000006,null,3.75e-7],"codex-mini":[0.0000015,0.000006,null,3.75e-7],"azure/command-r-plus":[0.000003,0.000015,null,null],"azure_ai/claude-haiku-4-5":[0.000001,0.000005,0.00000125,1e-7],"azure_ai/claude-opus-4-5":[0.000005,0.000025,0.00000625,5e-7],"azure_ai/claude-opus-4-6":[0.000005,0.000025,0.00000625,5e-7],"azure_ai/claude-opus-4-7":[0.000005,0.000025,0.00000625,5e-7],"azure_ai/claude-opus-4-1":[0.000015,0.000075,0.00001875,0.0000015],"azure_ai/claude-sonnet-4-5":[0.000003,0.000015,0.00000375,3e-7],"azure_ai/claude-sonnet-4-6":[0.000003,0.000015,0.00000375,3e-7],"azure/computer-use-preview":[0.000003,0.000012,null,null],"azure_ai/gpt-oss-120b":[1.5e-7,6e-7,null,null],"gpt-oss-120b":[1.5e-7,6e-7,null,null],"azure_ai/model_router":[1.4e-7,0,null,null],"model_router":[1.4e-7,0,null,null],"azure/eu/gpt-4o-2024-08-06":[0.00000275,0.000011,null,0.000001375],"eu/gpt-4o-2024-08-06":[0.00000275,0.000011,null,0.000001375],"azure/eu/gpt-4o-2024-11-20":[0.00000275,0.000011,0.00000138,null],"eu/gpt-4o-2024-11-20":[0.00000275,0.000011,0.00000138,null],"azure/eu/gpt-4o-mini-2024-07-18":[1.65e-7,6.6e-7,null,8.3e-8],"eu/gpt-4o-mini-2024-07-18":[1.65e-7,6.6e-7,null,8.3e-8],"azure/eu/gpt-4o-mini-realtime-preview-2024-12-17":[6.6e-7,0.00000264,null,3.3e-7],"eu/gpt-4o-mini-realtime-preview-2024-12-17":[6.6e-7,0.00000264,null,3.3e-7],"azure/eu/gpt-4o-realtime-preview-2024-10-01":[0.0000055,0.000022,null,0.00000275],"eu/gpt-4o-realtime-preview-2024-10-01":[0.0000055,0.000022,null,0.00000275],"azure/eu/gpt-4o-realtime-preview-2024-12-17":[0.0000055,0.000022,null,0.00000275],"eu/gpt-4o-realtime-preview-2024-12-17":[0.0000055,0.000022,null,0.00000275],"azure/eu/gpt-5-2025-08-07":[0.000001375,0.000011,null,1.375e-7],"eu/gpt-5-2025-08-07":[0.000001375,0.000011,null,1.375e-7],"azure/eu/gpt-5-mini-2025-08-07":[2.75e-7,0.0000022,null,2.75e-8],"eu/gpt-5-mini-2025-08-07":[2.75e-7,0.0000022,null,2.75e-8],"azure/eu/gpt-5.1":[0.00000138,0.000011,null,1.4e-7],"eu/gpt-5.1":[0.00000138,0.000011,null,1.4e-7],"azure/eu/gpt-5.1-chat":[0.00000138,0.000011,null,1.4e-7],"eu/gpt-5.1-chat":[0.00000138,0.000011,null,1.4e-7],"azure/eu/gpt-5.1-codex":[0.00000138,0.000011,null,1.4e-7],"eu/gpt-5.1-codex":[0.00000138,0.000011,null,1.4e-7],"azure/eu/gpt-5.1-codex-mini":[2.75e-7,0.0000022,null,2.8e-8],"eu/gpt-5.1-codex-mini":[2.75e-7,0.0000022,null,2.8e-8],"azure/eu/gpt-5-nano-2025-08-07":[5.5e-8,4.4e-7,null,5.5e-9],"eu/gpt-5-nano-2025-08-07":[5.5e-8,4.4e-7,null,5.5e-9],"azure/eu/o1-2024-12-17":[0.0000165,0.000066,null,0.00000825],"eu/o1-2024-12-17":[0.0000165,0.000066,null,0.00000825],"azure/eu/o1-mini-2024-09-12":[0.00000121,0.00000484,null,6.05e-7],"eu/o1-mini-2024-09-12":[0.00000121,0.00000484,null,6.05e-7],"azure/eu/o1-preview-2024-09-12":[0.0000165,0.000066,null,0.00000825],"eu/o1-preview-2024-09-12":[0.0000165,0.000066,null,0.00000825],"azure/eu/o3-mini-2025-01-31":[0.00000121,0.00000484,null,6.05e-7],"eu/o3-mini-2025-01-31":[0.00000121,0.00000484,null,6.05e-7],"azure/global-standard/gpt-4o-2024-08-06":[0.0000025,0.00001,null,0.00000125],"global-standard/gpt-4o-2024-08-06":[0.0000025,0.00001,null,0.00000125],"azure/global-standard/gpt-4o-2024-11-20":[0.0000025,0.00001,null,0.00000125],"global-standard/gpt-4o-2024-11-20":[0.0000025,0.00001,null,0.00000125],"azure/global-standard/gpt-4o-mini":[1.5e-7,6e-7,null,null],"global-standard/gpt-4o-mini":[1.5e-7,6e-7,null,null],"azure/global/gpt-4o-2024-08-06":[0.0000025,0.00001,null,0.00000125],"global/gpt-4o-2024-08-06":[0.0000025,0.00001,null,0.00000125],"azure/global/gpt-4o-2024-11-20":[0.0000025,0.00001,null,0.00000125],"global/gpt-4o-2024-11-20":[0.0000025,0.00001,null,0.00000125],"azure/global/gpt-5.1":[0.00000125,0.00001,null,1.25e-7],"global/gpt-5.1":[0.00000125,0.00001,null,1.25e-7],"azure/global/gpt-5.1-chat":[0.00000125,0.00001,null,1.25e-7],"global/gpt-5.1-chat":[0.00000125,0.00001,null,1.25e-7],"azure/global/gpt-5.1-codex":[0.00000125,0.00001,null,1.25e-7],"global/gpt-5.1-codex":[0.00000125,0.00001,null,1.25e-7],"azure/global/gpt-5.1-codex-mini":[2.5e-7,0.000002,null,2.5e-8],"global/gpt-5.1-codex-mini":[2.5e-7,0.000002,null,2.5e-8],"azure/gpt-3.5-turbo":[5e-7,0.0000015,null,null],"azure/gpt-3.5-turbo-0125":[5e-7,0.0000015,null,null],"azure/gpt-3.5-turbo-instruct-0914":[0.0000015,0.000002,null,null],"azure/gpt-35-turbo":[5e-7,0.0000015,null,null],"gpt-35-turbo":[5e-7,0.0000015,null,null],"azure/gpt-35-turbo-0125":[5e-7,0.0000015,null,null],"gpt-35-turbo-0125":[5e-7,0.0000015,null,null],"azure/gpt-35-turbo-1106":[0.000001,0.000002,null,null],"gpt-35-turbo-1106":[0.000001,0.000002,null,null],"azure/gpt-35-turbo-16k":[0.000003,0.000004,null,null],"gpt-35-turbo-16k":[0.000003,0.000004,null,null],"azure/gpt-35-turbo-16k-0613":[0.000003,0.000004,null,null],"gpt-35-turbo-16k-0613":[0.000003,0.000004,null,null],"azure/gpt-35-turbo-instruct":[0.0000015,0.000002,null,null],"gpt-35-turbo-instruct":[0.0000015,0.000002,null,null],"azure/gpt-35-turbo-instruct-0914":[0.0000015,0.000002,null,null],"gpt-35-turbo-instruct-0914":[0.0000015,0.000002,null,null],"azure/gpt-4":[0.00003,0.00006,null,null],"azure/gpt-4-0125-preview":[0.00001,0.00003,null,null],"azure/gpt-4-0613":[0.00003,0.00006,null,null],"azure/gpt-4-1106-preview":[0.00001,0.00003,null,null],"azure/gpt-4-32k":[0.00006,0.00012,null,null],"gpt-4-32k":[0.00006,0.00012,null,null],"azure/gpt-4-32k-0613":[0.00006,0.00012,null,null],"gpt-4-32k-0613":[0.00006,0.00012,null,null],"azure/gpt-4-turbo":[0.00001,0.00003,null,null],"azure/gpt-4-turbo-2024-04-09":[0.00001,0.00003,null,null],"azure/gpt-4-turbo-vision-preview":[0.00001,0.00003,null,null],"gpt-4-turbo-vision-preview":[0.00001,0.00003,null,null],"azure/gpt-4.1":[0.000002,0.000008,null,5e-7],"azure/gpt-4.1-2025-04-14":[0.000002,0.000008,null,5e-7],"azure/gpt-4.1-mini":[4e-7,0.0000016,null,1e-7],"azure/gpt-4.1-mini-2025-04-14":[4e-7,0.0000016,null,1e-7],"azure/gpt-4.1-nano":[1e-7,4e-7,null,2.5e-8],"azure/gpt-4.1-nano-2025-04-14":[1e-7,4e-7,null,2.5e-8],"azure/gpt-4.5-preview":[0.000075,0.00015,null,0.0000375],"gpt-4.5-preview":[0.000075,0.00015,null,0.0000375],"azure/gpt-4o":[0.0000025,0.00001,null,0.00000125],"azure/gpt-4o-2024-05-13":[0.000005,0.000015,null,null],"azure/gpt-4o-2024-08-06":[0.0000025,0.00001,null,0.00000125],"azure/gpt-4o-2024-11-20":[0.00000275,0.000011,null,0.00000125],"azure/gpt-audio-2025-08-28":[0.0000025,0.00001,null,null],"azure/gpt-audio-1.5-2026-02-23":[0.0000025,0.00001,null,null],"gpt-audio-1.5-2026-02-23":[0.0000025,0.00001,null,null],"azure/gpt-audio-mini-2025-10-06":[6e-7,0.0000024,null,null],"azure/gpt-4o-audio-preview-2024-12-17":[0.0000025,0.00001,null,null],"azure/gpt-4o-mini":[1.65e-7,6.6e-7,null,7.5e-8],"azure/gpt-4o-mini-2024-07-18":[1.65e-7,6.6e-7,null,7.5e-8],"azure/gpt-4o-mini-audio-preview-2024-12-17":[0.0000025,0.00001,null,null],"azure/gpt-4o-mini-realtime-preview-2024-12-17":[6e-7,0.0000024,null,3e-7],"azure/gpt-realtime-2025-08-28":[0.000004,0.000016,null,0.000004],"azure/gpt-realtime-1.5-2026-02-23":[0.000004,0.000016,null,0.000004],"gpt-realtime-1.5-2026-02-23":[0.000004,0.000016,null,0.000004],"azure/gpt-realtime-mini-2025-10-06":[6e-7,0.0000024,null,6e-8],"azure/gpt-4o-mini-transcribe":[0.00000125,0.000005,null,null],"azure/gpt-4o-mini-tts":[0.0000025,0.00001,null,null],"azure/gpt-4o-realtime-preview-2024-10-01":[0.000005,0.00002,null,0.0000025],"gpt-4o-realtime-preview-2024-10-01":[0.000005,0.00002,null,0.0000025],"azure/gpt-4o-realtime-preview-2024-12-17":[0.000005,0.00002,null,0.0000025],"azure/gpt-4o-transcribe":[0.0000025,0.00001,null,null],"azure/gpt-4o-transcribe-diarize":[0.0000025,0.00001,null,null],"azure/gpt-5.1-2025-11-13":[0.00000125,0.00001,null,1.25e-7],"azure/gpt-5.1-chat-2025-11-13":[0.00000125,0.00001,null,1.25e-7],"gpt-5.1-chat-2025-11-13":[0.00000125,0.00001,null,1.25e-7],"azure/gpt-5.1-codex-2025-11-13":[0.00000125,0.00001,null,1.25e-7],"gpt-5.1-codex-2025-11-13":[0.00000125,0.00001,null,1.25e-7],"azure/gpt-5.1-codex-mini-2025-11-13":[2.5e-7,0.000002,null,2.5e-8],"gpt-5.1-codex-mini-2025-11-13":[2.5e-7,0.000002,null,2.5e-8],"azure/gpt-5":[0.00000125,0.00001,null,1.25e-7],"azure/gpt-5-2025-08-07":[0.00000125,0.00001,null,1.25e-7],"azure/gpt-5-chat":[0.00000125,0.00001,null,1.25e-7],"azure/gpt-5-chat-latest":[0.00000125,0.00001,null,1.25e-7],"azure/gpt-5-codex":[0.00000125,0.00001,null,1.25e-7],"azure/gpt-5-mini":[2.5e-7,0.000002,null,2.5e-8],"azure/gpt-5-mini-2025-08-07":[2.5e-7,0.000002,null,2.5e-8],"azure/gpt-5-nano":[5e-8,4e-7,null,5e-9],"azure/gpt-5-nano-2025-08-07":[5e-8,4e-7,null,5e-9],"azure/gpt-5-pro":[0.000015,0.00012,null,null],"azure/gpt-5.1":[0.00000125,0.00001,null,1.25e-7],"azure/gpt-5.1-chat":[0.00000125,0.00001,null,1.25e-7],"gpt-5.1-chat":[0.00000125,0.00001,null,1.25e-7],"azure/gpt-5.1-codex":[0.00000125,0.00001,null,1.25e-7],"azure/gpt-5.1-codex-max":[0.00000125,0.00001,null,1.25e-7],"azure/gpt-5.1-codex-mini":[2.5e-7,0.000002,null,2.5e-8],"azure/gpt-5.2":[0.00000175,0.000014,null,1.75e-7],"azure/gpt-5.2-2025-12-11":[0.00000175,0.000014,null,1.75e-7],"azure/gpt-5.2-chat":[0.00000175,0.000014,null,1.75e-7],"gpt-5.2-chat":[0.00000175,0.000014,null,1.75e-7],"azure/gpt-5.2-chat-2025-12-11":[0.00000175,0.000014,null,1.75e-7],"gpt-5.2-chat-2025-12-11":[0.00000175,0.000014,null,1.75e-7],"azure/gpt-5.2-codex":[0.00000175,0.000014,null,1.75e-7],"azure/gpt-5.3-chat":[0.00000175,0.000014,null,1.75e-7],"gpt-5.3-chat":[0.00000175,0.000014,null,1.75e-7],"azure/gpt-5.3-codex":[0.00000175,0.000014,null,1.75e-7],"azure/gpt-5.2-pro":[0.000021,0.000168,null,null],"azure/gpt-5.2-pro-2025-12-11":[0.000021,0.000168,null,null],"azure/gpt-5.4":[0.0000025,0.000015,null,2.5e-7],"azure/gpt-5.4-2026-03-05":[0.0000025,0.000015,null,2.5e-7],"azure/gpt-5.4-pro":[0.00003,0.00018,null,0.000003],"azure/gpt-5.4-pro-2026-03-05":[0.00003,0.00018,null,0.000003],"azure/gpt-5.5":[0.000005,0.00003,null,5e-7],"azure/gpt-5.5-2026-04-23":[0.000005,0.00003,null,5e-7],"azure/gpt-5.5-pro":[0.00003,0.00018,null,0.000003],"azure/gpt-5.5-pro-2026-04-23":[0.00003,0.00018,null,0.000003],"azure/gpt-5.4-mini":[7.5e-7,0.0000045,null,7.5e-8],"azure/gpt-5.4-mini-2026-03-17":[7.5e-7,0.0000045,null,7.5e-8],"azure/gpt-5.4-nano":[2e-7,0.00000125,null,2e-8],"azure/gpt-5.4-nano-2026-03-17":[2e-7,0.00000125,null,2e-8],"azure/gpt-image-2":[0.000005,0.00001,null,0.00000125],"azure/gpt-image-2-2026-04-21":[0.000005,0.00001,null,0.00000125],"azure/mistral-large-2402":[0.000008,0.000024,null,null],"mistral-large-2402":[0.000008,0.000024,null,null],"azure/mistral-large-latest":[0.000008,0.000024,null,null],"mistral-large-latest":[0.000008,0.000024,null,null],"azure/o1":[0.000015,0.00006,null,0.0000075],"azure/o1-2024-12-17":[0.000015,0.00006,null,0.0000075],"azure/o1-mini":[0.00000121,0.00000484,null,6.05e-7],"o1-mini":[0.00000121,0.00000484,null,6.05e-7],"azure/o1-mini-2024-09-12":[0.0000011,0.0000044,null,5.5e-7],"o1-mini-2024-09-12":[0.0000011,0.0000044,null,5.5e-7],"azure/o1-preview":[0.000015,0.00006,null,0.0000075],"o1-preview":[0.000015,0.00006,null,0.0000075],"azure/o1-preview-2024-09-12":[0.000015,0.00006,null,0.0000075],"o1-preview-2024-09-12":[0.000015,0.00006,null,0.0000075],"azure/o3":[0.000002,0.000008,null,5e-7],"azure/o3-2025-04-16":[0.000002,0.000008,null,5e-7],"azure/o3-deep-research":[0.00001,0.00004,null,0.0000025],"azure/o3-mini":[0.0000011,0.0000044,null,5.5e-7],"azure/o3-mini-2025-01-31":[0.0000011,0.0000044,null,5.5e-7],"azure/o3-pro":[0.00002,0.00008,null,null],"azure/o3-pro-2025-06-10":[0.00002,0.00008,null,null],"azure/o4-mini":[0.0000011,0.0000044,null,2.75e-7],"azure/o4-mini-2025-04-16":[0.0000011,0.0000044,null,2.75e-7],"azure/text-embedding-3-large":[1.3e-7,0,null,null],"azure/text-embedding-3-small":[2e-8,0,null,null],"azure/text-embedding-ada-002":[1e-7,0,null,null],"azure/us/gpt-4.1-2025-04-14":[0.0000022,0.0000088,null,5.5e-7],"us/gpt-4.1-2025-04-14":[0.0000022,0.0000088,null,5.5e-7],"azure/us/gpt-4.1-mini-2025-04-14":[4.4e-7,0.00000176,null,1.1e-7],"us/gpt-4.1-mini-2025-04-14":[4.4e-7,0.00000176,null,1.1e-7],"azure/us/gpt-4.1-nano-2025-04-14":[1.1e-7,4.4e-7,null,2.5e-8],"us/gpt-4.1-nano-2025-04-14":[1.1e-7,4.4e-7,null,2.5e-8],"azure/us/gpt-4o-2024-08-06":[0.00000275,0.000011,null,0.000001375],"us/gpt-4o-2024-08-06":[0.00000275,0.000011,null,0.000001375],"azure/us/gpt-4o-2024-11-20":[0.00000275,0.000011,0.00000138,null],"us/gpt-4o-2024-11-20":[0.00000275,0.000011,0.00000138,null],"azure/us/gpt-4o-mini-2024-07-18":[1.65e-7,6.6e-7,null,8.3e-8],"us/gpt-4o-mini-2024-07-18":[1.65e-7,6.6e-7,null,8.3e-8],"azure/us/gpt-4o-mini-realtime-preview-2024-12-17":[6.6e-7,0.00000264,null,3.3e-7],"us/gpt-4o-mini-realtime-preview-2024-12-17":[6.6e-7,0.00000264,null,3.3e-7],"azure/us/gpt-4o-realtime-preview-2024-10-01":[0.0000055,0.000022,null,0.00000275],"us/gpt-4o-realtime-preview-2024-10-01":[0.0000055,0.000022,null,0.00000275],"azure/us/gpt-4o-realtime-preview-2024-12-17":[0.0000055,0.000022,null,0.00000275],"us/gpt-4o-realtime-preview-2024-12-17":[0.0000055,0.000022,null,0.00000275],"azure/us/gpt-5-2025-08-07":[0.000001375,0.000011,null,1.375e-7],"us/gpt-5-2025-08-07":[0.000001375,0.000011,null,1.375e-7],"azure/us/gpt-5-mini-2025-08-07":[2.75e-7,0.0000022,null,2.75e-8],"us/gpt-5-mini-2025-08-07":[2.75e-7,0.0000022,null,2.75e-8],"azure/us/gpt-5-nano-2025-08-07":[5.5e-8,4.4e-7,null,5.5e-9],"us/gpt-5-nano-2025-08-07":[5.5e-8,4.4e-7,null,5.5e-9],"azure/us/gpt-5.1":[0.00000138,0.000011,null,1.4e-7],"us/gpt-5.1":[0.00000138,0.000011,null,1.4e-7],"azure/us/gpt-5.1-chat":[0.00000138,0.000011,null,1.4e-7],"us/gpt-5.1-chat":[0.00000138,0.000011,null,1.4e-7],"azure/us/gpt-5.1-codex":[0.00000138,0.000011,null,1.4e-7],"us/gpt-5.1-codex":[0.00000138,0.000011,null,1.4e-7],"azure/us/gpt-5.1-codex-mini":[2.75e-7,0.0000022,null,2.8e-8],"us/gpt-5.1-codex-mini":[2.75e-7,0.0000022,null,2.8e-8],"azure/us/o1-2024-12-17":[0.0000165,0.000066,null,0.00000825],"us/o1-2024-12-17":[0.0000165,0.000066,null,0.00000825],"azure/us/o1-mini-2024-09-12":[0.00000121,0.00000484,null,6.05e-7],"us/o1-mini-2024-09-12":[0.00000121,0.00000484,null,6.05e-7],"azure/us/o1-preview-2024-09-12":[0.0000165,0.000066,null,0.00000825],"us/o1-preview-2024-09-12":[0.0000165,0.000066,null,0.00000825],"azure/us/o3-2025-04-16":[0.0000022,0.0000088,null,5.5e-7],"us/o3-2025-04-16":[0.0000022,0.0000088,null,5.5e-7],"azure/us/o3-mini-2025-01-31":[0.00000121,0.00000484,null,6.05e-7],"us/o3-mini-2025-01-31":[0.00000121,0.00000484,null,6.05e-7],"azure/us/o4-mini-2025-04-16":[0.00000121,0.00000484,null,3.1e-7],"us/o4-mini-2025-04-16":[0.00000121,0.00000484,null,3.1e-7],"azure_ai/Cohere-embed-v3-english":[1e-7,0,null,null],"Cohere-embed-v3-english":[1e-7,0,null,null],"azure_ai/Cohere-embed-v3-multilingual":[1e-7,0,null,null],"Cohere-embed-v3-multilingual":[1e-7,0,null,null],"azure_ai/Llama-3.2-11B-Vision-Instruct":[3.7e-7,3.7e-7,null,null],"Llama-3.2-11B-Vision-Instruct":[3.7e-7,3.7e-7,null,null],"azure_ai/Llama-3.2-90B-Vision-Instruct":[0.00000204,0.00000204,null,null],"Llama-3.2-90B-Vision-Instruct":[0.00000204,0.00000204,null,null],"azure_ai/Llama-3.3-70B-Instruct":[7.1e-7,7.1e-7,null,null],"Llama-3.3-70B-Instruct":[7.1e-7,7.1e-7,null,null],"azure_ai/Llama-4-Maverick-17B-128E-Instruct-FP8":[0.00000141,3.5e-7,null,null],"Llama-4-Maverick-17B-128E-Instruct-FP8":[0.00000141,3.5e-7,null,null],"azure_ai/Llama-4-Scout-17B-16E-Instruct":[2e-7,7.8e-7,null,null],"Llama-4-Scout-17B-16E-Instruct":[2e-7,7.8e-7,null,null],"azure_ai/Meta-Llama-3-70B-Instruct":[0.0000011,3.7e-7,null,null],"Meta-Llama-3-70B-Instruct":[0.0000011,3.7e-7,null,null],"azure_ai/Meta-Llama-3.1-405B-Instruct":[0.00000533,0.000016,null,null],"Meta-Llama-3.1-405B-Instruct":[0.00000533,0.000016,null,null],"azure_ai/Meta-Llama-3.1-70B-Instruct":[0.00000268,0.00000354,null,null],"Meta-Llama-3.1-70B-Instruct":[0.00000268,0.00000354,null,null],"azure_ai/Meta-Llama-3.1-8B-Instruct":[3e-7,6.1e-7,null,null],"Meta-Llama-3.1-8B-Instruct":[3e-7,6.1e-7,null,null],"azure_ai/Phi-3-medium-128k-instruct":[1.7e-7,6.8e-7,null,null],"Phi-3-medium-128k-instruct":[1.7e-7,6.8e-7,null,null],"azure_ai/Phi-3-medium-4k-instruct":[1.7e-7,6.8e-7,null,null],"Phi-3-medium-4k-instruct":[1.7e-7,6.8e-7,null,null],"azure_ai/Phi-3-mini-128k-instruct":[1.3e-7,5.2e-7,null,null],"Phi-3-mini-128k-instruct":[1.3e-7,5.2e-7,null,null],"azure_ai/Phi-3-mini-4k-instruct":[1.3e-7,5.2e-7,null,null],"Phi-3-mini-4k-instruct":[1.3e-7,5.2e-7,null,null],"azure_ai/Phi-3-small-128k-instruct":[1.5e-7,6e-7,null,null],"Phi-3-small-128k-instruct":[1.5e-7,6e-7,null,null],"azure_ai/Phi-3-small-8k-instruct":[1.5e-7,6e-7,null,null],"Phi-3-small-8k-instruct":[1.5e-7,6e-7,null,null],"azure_ai/Phi-3.5-MoE-instruct":[1.6e-7,6.4e-7,null,null],"Phi-3.5-MoE-instruct":[1.6e-7,6.4e-7,null,null],"azure_ai/Phi-3.5-mini-instruct":[1.3e-7,5.2e-7,null,null],"Phi-3.5-mini-instruct":[1.3e-7,5.2e-7,null,null],"azure_ai/Phi-3.5-vision-instruct":[1.3e-7,5.2e-7,null,null],"Phi-3.5-vision-instruct":[1.3e-7,5.2e-7,null,null],"azure_ai/Phi-4":[1.25e-7,5e-7,null,null],"Phi-4":[1.25e-7,5e-7,null,null],"azure_ai/Phi-4-mini-instruct":[7.5e-8,3e-7,null,null],"Phi-4-mini-instruct":[7.5e-8,3e-7,null,null],"azure_ai/Phi-4-multimodal-instruct":[8e-8,3.2e-7,null,null],"Phi-4-multimodal-instruct":[8e-8,3.2e-7,null,null],"azure_ai/Phi-4-mini-reasoning":[8e-8,3.2e-7,null,null],"Phi-4-mini-reasoning":[8e-8,3.2e-7,null,null],"azure_ai/Phi-4-reasoning":[1.25e-7,5e-7,null,null],"Phi-4-reasoning":[1.25e-7,5e-7,null,null],"azure_ai/MAI-DS-R1":[0.00000135,0.0000054,null,null],"MAI-DS-R1":[0.00000135,0.0000054,null,null],"azure_ai/cohere-rerank-v3-english":[0,0,null,null],"cohere-rerank-v3-english":[0,0,null,null],"azure_ai/cohere-rerank-v3-multilingual":[0,0,null,null],"cohere-rerank-v3-multilingual":[0,0,null,null],"azure_ai/cohere-rerank-v3.5":[0,0,null,null],"cohere-rerank-v3.5":[0,0,null,null],"azure_ai/cohere-rerank-v4.0-pro":[0,0,null,null],"cohere-rerank-v4.0-pro":[0,0,null,null],"azure_ai/cohere-rerank-v4.0-fast":[0,0,null,null],"cohere-rerank-v4.0-fast":[0,0,null,null],"azure_ai/deepseek-v3.2":[5.8e-7,0.00000168,null,null],"deepseek-v3.2":[5.8e-7,0.00000168,null,null],"azure_ai/deepseek-v3.2-speciale":[5.8e-7,0.00000168,null,null],"deepseek-v3.2-speciale":[5.8e-7,0.00000168,null,null],"azure_ai/deepseek-r1":[0.00000135,0.0000054,null,null],"deepseek-r1":[0.00000135,0.0000054,null,null],"azure_ai/deepseek-v3":[0.00000114,0.00000456,null,null],"deepseek-v3":[0.00000114,0.00000456,null,null],"azure_ai/deepseek-v3-0324":[0.00000114,0.00000456,null,null],"deepseek-v3-0324":[0.00000114,0.00000456,null,null],"azure_ai/embed-v-4-0":[1.2e-7,0,null,null],"embed-v-4-0":[1.2e-7,0,null,null],"azure_ai/global/grok-3":[0.000003,0.000015,null,null],"global/grok-3":[0.000003,0.000015,null,null],"azure_ai/global/grok-3-mini":[2.5e-7,0.00000127,null,null],"global/grok-3-mini":[2.5e-7,0.00000127,null,null],"azure_ai/grok-3":[0.000003,0.000015,null,null],"grok-3":[0.000003,0.000015,null,null],"azure_ai/grok-3-mini":[2.5e-7,0.00000127,null,null],"grok-3-mini":[2.5e-7,0.00000127,null,null],"azure_ai/grok-4":[0.000003,0.000015,null,null],"grok-4":[0.000003,0.000015,null,null],"azure_ai/grok-4-fast-non-reasoning":[2e-7,5e-7,null,null],"grok-4-fast-non-reasoning":[2e-7,5e-7,null,null],"azure_ai/grok-4-fast-reasoning":[2e-7,5e-7,null,null],"grok-4-fast-reasoning":[2e-7,5e-7,null,null],"azure_ai/grok-4-1-fast-non-reasoning":[2e-7,5e-7,null,null],"grok-4-1-fast-non-reasoning":[2e-7,5e-7,null,null],"azure_ai/grok-4-1-fast-reasoning":[2e-7,5e-7,null,null],"grok-4-1-fast-reasoning":[2e-7,5e-7,null,null],"azure_ai/grok-code-fast-1":[2e-7,0.0000015,null,null],"grok-code-fast-1":[2e-7,0.0000015,null,null],"azure_ai/jais-30b-chat":[0.0032,0.00971,null,null],"jais-30b-chat":[0.0032,0.00971,null,null],"azure_ai/jamba-instruct":[5e-7,7e-7,null,null],"jamba-instruct":[5e-7,7e-7,null,null],"azure_ai/kimi-k2.5":[6e-7,0.000003,null,null],"kimi-k2.5":[6e-7,0.000003,null,null],"azure_ai/ministral-3b":[4e-8,4e-8,null,null],"ministral-3b":[4e-8,4e-8,null,null],"azure_ai/mistral-large":[0.000004,0.000012,null,null],"mistral-large":[0.000004,0.000012,null,null],"azure_ai/mistral-large-2407":[0.000002,0.000006,null,null],"mistral-large-2407":[0.000002,0.000006,null,null],"azure_ai/mistral-large-latest":[0.000002,0.000006,null,null],"azure_ai/mistral-large-3":[5e-7,0.0000015,null,null],"mistral-large-3":[5e-7,0.0000015,null,null],"azure_ai/mistral-medium-2505":[4e-7,0.000002,null,null],"mistral-medium-2505":[4e-7,0.000002,null,null],"azure_ai/mistral-nemo":[1.5e-7,1.5e-7,null,null],"mistral-nemo":[1.5e-7,1.5e-7,null,null],"azure_ai/mistral-small":[0.000001,0.000003,null,null],"mistral-small":[0.000001,0.000003,null,null],"azure_ai/mistral-small-2503":[1e-7,3e-7,null,null],"mistral-small-2503":[1e-7,3e-7,null,null],"bedrock/ap-northeast-1/anthropic.claude-instant-v1":[0.00000223,0.00000755,null,null],"ap-northeast-1/anthropic.claude-instant-v1":[0.00000223,0.00000755,null,null],"bedrock/ap-northeast-1/anthropic.claude-v1":[0.000008,0.000024,null,null],"ap-northeast-1/anthropic.claude-v1":[0.000008,0.000024,null,null],"bedrock/ap-northeast-1/anthropic.claude-v2:1":[0.000008,0.000024,null,null],"ap-northeast-1/anthropic.claude-v2:1":[0.000008,0.000024,null,null],"bedrock/ap-northeast-1/deepseek.v3.2":[7.4e-7,0.00000222,null,null],"ap-northeast-1/deepseek.v3.2":[7.4e-7,0.00000222,null,null],"bedrock/ap-northeast-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"ap-northeast-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"bedrock/ap-northeast-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"ap-northeast-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"bedrock/ap-northeast-1/moonshotai.kimi-k2-thinking":[7.3e-7,0.00000303,null,null],"ap-northeast-1/moonshotai.kimi-k2-thinking":[7.3e-7,0.00000303,null,null],"bedrock/ap-northeast-1/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null],"ap-northeast-1/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null],"bedrock/ap-northeast-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null],"ap-northeast-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null],"bedrock/moonshotai.kimi-k2-thinking":[7.3e-7,0.00000303,null,null],"moonshotai.kimi-k2-thinking":[7.3e-7,0.00000303,null,null],"bedrock/moonshotai.kimi-k2.5":[6e-7,0.00000303,null,null],"bedrock/ap-south-1/meta.llama3-70b-instruct-v1:0":[0.00000318,0.0000042,null,null],"ap-south-1/meta.llama3-70b-instruct-v1:0":[0.00000318,0.0000042,null,null],"bedrock/ap-south-1/meta.llama3-8b-instruct-v1:0":[3.6e-7,7.2e-7,null,null],"ap-south-1/meta.llama3-8b-instruct-v1:0":[3.6e-7,7.2e-7,null,null],"bedrock/ap-south-1/deepseek.v3.2":[7.4e-7,0.00000222,null,null],"ap-south-1/deepseek.v3.2":[7.4e-7,0.00000222,null,null],"bedrock/ap-south-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"ap-south-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"bedrock/ap-south-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"ap-south-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"bedrock/ap-south-1/moonshotai.kimi-k2-thinking":[7.1e-7,0.00000294,null,null],"ap-south-1/moonshotai.kimi-k2-thinking":[7.1e-7,0.00000294,null,null],"bedrock/ap-south-1/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null],"ap-south-1/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null],"bedrock/ap-south-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null],"ap-south-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null],"bedrock/ap-southeast-2/minimax.minimax-m2.5":[3.09e-7,0.000001236,null,null],"ap-southeast-2/minimax.minimax-m2.5":[3.09e-7,0.000001236,null,null],"bedrock/ap-southeast-3/deepseek.v3.2":[7.4e-7,0.00000222,null,null],"ap-southeast-3/deepseek.v3.2":[7.4e-7,0.00000222,null,null],"bedrock/ap-southeast-3/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"ap-southeast-3/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"bedrock/ap-southeast-3/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"ap-southeast-3/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"bedrock/ap-southeast-3/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null],"ap-southeast-3/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null],"bedrock/ap-southeast-3/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null],"ap-southeast-3/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null],"bedrock/ca-central-1/meta.llama3-70b-instruct-v1:0":[0.00000305,0.00000403,null,null],"ca-central-1/meta.llama3-70b-instruct-v1:0":[0.00000305,0.00000403,null,null],"bedrock/ca-central-1/meta.llama3-8b-instruct-v1:0":[3.5e-7,6.9e-7,null,null],"ca-central-1/meta.llama3-8b-instruct-v1:0":[3.5e-7,6.9e-7,null,null],"bedrock/eu-north-1/deepseek.v3.2":[7.4e-7,0.00000222,null,null],"eu-north-1/deepseek.v3.2":[7.4e-7,0.00000222,null,null],"bedrock/eu-north-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"eu-north-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"bedrock/eu-north-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"eu-north-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"bedrock/eu-north-1/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null],"eu-north-1/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null],"bedrock/eu-central-1/anthropic.claude-instant-v1":[0.00000248,0.00000838,null,null],"eu-central-1/anthropic.claude-instant-v1":[0.00000248,0.00000838,null,null],"bedrock/eu-central-1/anthropic.claude-v1":[0.000008,0.000024,null,null],"eu-central-1/anthropic.claude-v1":[0.000008,0.000024,null,null],"bedrock/eu-central-1/anthropic.claude-v2:1":[0.000008,0.000024,null,null],"eu-central-1/anthropic.claude-v2:1":[0.000008,0.000024,null,null],"bedrock/eu-central-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"eu-central-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"bedrock/eu-central-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"eu-central-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"bedrock/eu-central-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null],"eu-central-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null],"bedrock/eu-west-1/meta.llama3-70b-instruct-v1:0":[0.00000286,0.00000378,null,null],"eu-west-1/meta.llama3-70b-instruct-v1:0":[0.00000286,0.00000378,null,null],"bedrock/eu-west-1/meta.llama3-8b-instruct-v1:0":[3.2e-7,6.5e-7,null,null],"eu-west-1/meta.llama3-8b-instruct-v1:0":[3.2e-7,6.5e-7,null,null],"bedrock/eu-west-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"eu-west-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"bedrock/eu-west-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"eu-west-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"bedrock/eu-west-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null],"eu-west-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null],"bedrock/eu-west-2/meta.llama3-70b-instruct-v1:0":[0.00000345,0.00000455,null,null],"eu-west-2/meta.llama3-70b-instruct-v1:0":[0.00000345,0.00000455,null,null],"bedrock/eu-west-2/meta.llama3-8b-instruct-v1:0":[3.9e-7,7.8e-7,null,null],"eu-west-2/meta.llama3-8b-instruct-v1:0":[3.9e-7,7.8e-7,null,null],"bedrock/eu-west-2/minimax.minimax-m2.1":[4.7e-7,0.00000186,null,null],"eu-west-2/minimax.minimax-m2.1":[4.7e-7,0.00000186,null,null],"bedrock/eu-west-2/minimax.minimax-m2.5":[4.7e-7,0.00000186,null,null],"eu-west-2/minimax.minimax-m2.5":[4.7e-7,0.00000186,null,null],"bedrock/eu-west-2/qwen.qwen3-coder-next":[7.8e-7,0.00000186,null,null],"eu-west-2/qwen.qwen3-coder-next":[7.8e-7,0.00000186,null,null],"bedrock/eu-west-3/mistral.mistral-7b-instruct-v0:2":[2e-7,2.6e-7,null,null],"eu-west-3/mistral.mistral-7b-instruct-v0:2":[2e-7,2.6e-7,null,null],"bedrock/eu-west-3/mistral.mistral-large-2402-v1:0":[0.0000104,0.0000312,null,null],"eu-west-3/mistral.mistral-large-2402-v1:0":[0.0000104,0.0000312,null,null],"bedrock/eu-west-3/mistral.mixtral-8x7b-instruct-v0:1":[5.9e-7,9.1e-7,null,null],"eu-west-3/mistral.mixtral-8x7b-instruct-v0:1":[5.9e-7,9.1e-7,null,null],"bedrock/eu-south-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"eu-south-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"bedrock/eu-south-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"eu-south-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"bedrock/eu-south-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null],"eu-south-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null],"bedrock/invoke/anthropic.claude-3-5-sonnet-20240620-v1:0":[0.000003,0.000015,0.00000375,3e-7],"invoke/anthropic.claude-3-5-sonnet-20240620-v1:0":[0.000003,0.000015,0.00000375,3e-7],"bedrock/sa-east-1/meta.llama3-70b-instruct-v1:0":[0.00000445,0.00000588,null,null],"sa-east-1/meta.llama3-70b-instruct-v1:0":[0.00000445,0.00000588,null,null],"bedrock/sa-east-1/meta.llama3-8b-instruct-v1:0":[5e-7,0.00000101,null,null],"sa-east-1/meta.llama3-8b-instruct-v1:0":[5e-7,0.00000101,null,null],"bedrock/sa-east-1/deepseek.v3.2":[7.4e-7,0.00000222,null,null],"sa-east-1/deepseek.v3.2":[7.4e-7,0.00000222,null,null],"bedrock/sa-east-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"sa-east-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null],"bedrock/sa-east-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"sa-east-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null],"bedrock/sa-east-1/moonshotai.kimi-k2-thinking":[7.3e-7,0.00000303,null,null],"sa-east-1/moonshotai.kimi-k2-thinking":[7.3e-7,0.00000303,null,null],"bedrock/sa-east-1/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null],"sa-east-1/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null],"bedrock/sa-east-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null],"sa-east-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null],"bedrock/us-east-1/anthropic.claude-instant-v1":[8e-7,0.0000024,null,null],"us-east-1/anthropic.claude-instant-v1":[8e-7,0.0000024,null,null],"bedrock/us-east-1/anthropic.claude-v1":[0.000008,0.000024,null,null],"us-east-1/anthropic.claude-v1":[0.000008,0.000024,null,null],"bedrock/us-east-1/anthropic.claude-v2:1":[0.000008,0.000024,null,null],"us-east-1/anthropic.claude-v2:1":[0.000008,0.000024,null,null],"bedrock/us-east-1/meta.llama3-70b-instruct-v1:0":[0.00000265,0.0000035,null,null],"us-east-1/meta.llama3-70b-instruct-v1:0":[0.00000265,0.0000035,null,null],"bedrock/us-east-1/meta.llama3-8b-instruct-v1:0":[3e-7,6e-7,null,null],"us-east-1/meta.llama3-8b-instruct-v1:0":[3e-7,6e-7,null,null],"bedrock/us-east-1/mistral.mistral-7b-instruct-v0:2":[1.5e-7,2e-7,null,null],"us-east-1/mistral.mistral-7b-instruct-v0:2":[1.5e-7,2e-7,null,null],"bedrock/us-east-1/mistral.mistral-large-2402-v1:0":[0.000008,0.000024,null,null],"us-east-1/mistral.mistral-large-2402-v1:0":[0.000008,0.000024,null,null],"bedrock/us-east-1/mistral.mixtral-8x7b-instruct-v0:1":[4.5e-7,7e-7,null,null],"us-east-1/mistral.mixtral-8x7b-instruct-v0:1":[4.5e-7,7e-7,null,null],"bedrock/us-east-1/deepseek.v3.2":[6.2e-7,0.00000185,null,null],"us-east-1/deepseek.v3.2":[6.2e-7,0.00000185,null,null],"bedrock/us-east-1/minimax.minimax-m2.1":[3e-7,0.0000012,null,null],"us-east-1/minimax.minimax-m2.1":[3e-7,0.0000012,null,null],"bedrock/us-east-1/minimax.minimax-m2.5":[3e-7,0.0000012,null,null],"us-east-1/minimax.minimax-m2.5":[3e-7,0.0000012,null,null],"bedrock/us-east-1/moonshotai.kimi-k2-thinking":[6e-7,0.0000025,null,null],"us-east-1/moonshotai.kimi-k2-thinking":[6e-7,0.0000025,null,null],"bedrock/us-east-1/moonshotai.kimi-k2.5":[6e-7,0.000003,null,null],"us-east-1/moonshotai.kimi-k2.5":[6e-7,0.000003,null,null],"bedrock/us-east-1/qwen.qwen3-coder-next":[5e-7,0.0000012,null,null],"us-east-1/qwen.qwen3-coder-next":[5e-7,0.0000012,null,null],"bedrock/us-east-2/deepseek.v3.2":[6.2e-7,0.00000185,null,null],"us-east-2/deepseek.v3.2":[6.2e-7,0.00000185,null,null],"bedrock/us-east-2/minimax.minimax-m2.1":[3e-7,0.0000012,null,null],"us-east-2/minimax.minimax-m2.1":[3e-7,0.0000012,null,null],"bedrock/us-east-2/minimax.minimax-m2.5":[3e-7,0.0000012,null,null],"us-east-2/minimax.minimax-m2.5":[3e-7,0.0000012,null,null],"bedrock/us-east-2/moonshotai.kimi-k2-thinking":[6e-7,0.0000025,null,null],"us-east-2/moonshotai.kimi-k2-thinking":[6e-7,0.0000025,null,null],"bedrock/us-east-2/moonshotai.kimi-k2.5":[6e-7,0.000003,null,null],"us-east-2/moonshotai.kimi-k2.5":[6e-7,0.000003,null,null],"bedrock/us-east-2/qwen.qwen3-coder-next":[5e-7,0.0000012,null,null],"us-east-2/qwen.qwen3-coder-next":[5e-7,0.0000012,null,null],"bedrock/us-gov-east-1/amazon.nova-pro-v1:0":[9.6e-7,0.00000384,null,null],"us-gov-east-1/amazon.nova-pro-v1:0":[9.6e-7,0.00000384,null,null],"bedrock/us-gov-east-1/amazon.titan-embed-text-v1":[1e-7,0,null,null],"us-gov-east-1/amazon.titan-embed-text-v1":[1e-7,0,null,null],"bedrock/us-gov-east-1/amazon.titan-embed-text-v2:0":[2e-7,0,null,null],"us-gov-east-1/amazon.titan-embed-text-v2:0":[2e-7,0,null,null],"bedrock/us-gov-east-1/amazon.titan-text-express-v1":[0.0000013,0.0000017,null,null],"us-gov-east-1/amazon.titan-text-express-v1":[0.0000013,0.0000017,null,null],"bedrock/us-gov-east-1/amazon.titan-text-lite-v1":[3e-7,4e-7,null,null],"us-gov-east-1/amazon.titan-text-lite-v1":[3e-7,4e-7,null,null],"bedrock/us-gov-east-1/amazon.titan-text-premier-v1:0":[5e-7,0.0000015,null,null],"us-gov-east-1/amazon.titan-text-premier-v1:0":[5e-7,0.0000015,null,null],"bedrock/us-gov-east-1/anthropic.claude-3-5-sonnet-20240620-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7],"us-gov-east-1/anthropic.claude-3-5-sonnet-20240620-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7],"bedrock/us-gov-east-1/anthropic.claude-3-haiku-20240307-v1:0":[3e-7,0.0000015,3.75e-7,3e-8],"us-gov-east-1/anthropic.claude-3-haiku-20240307-v1:0":[3e-7,0.0000015,3.75e-7,3e-8],"bedrock/us-gov-east-1/anthropic.claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7],"us-gov-east-1/anthropic.claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7],"bedrock/us-gov-east-1/claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7],"us-gov-east-1/claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7],"bedrock/us-gov-east-1/meta.llama3-70b-instruct-v1:0":[0.00000265,0.0000035,null,null],"us-gov-east-1/meta.llama3-70b-instruct-v1:0":[0.00000265,0.0000035,null,null],"bedrock/us-gov-east-1/meta.llama3-8b-instruct-v1:0":[3e-7,0.00000265,null,null],"us-gov-east-1/meta.llama3-8b-instruct-v1:0":[3e-7,0.00000265,null,null],"bedrock/us-gov-west-1/amazon.nova-pro-v1:0":[9.6e-7,0.00000384,null,null],"us-gov-west-1/amazon.nova-pro-v1:0":[9.6e-7,0.00000384,null,null],"bedrock/us-gov-west-1/amazon.titan-embed-text-v1":[1e-7,0,null,null],"us-gov-west-1/amazon.titan-embed-text-v1":[1e-7,0,null,null],"bedrock/us-gov-west-1/amazon.titan-embed-text-v2:0":[2e-7,0,null,null],"us-gov-west-1/amazon.titan-embed-text-v2:0":[2e-7,0,null,null],"bedrock/us-gov-west-1/amazon.titan-text-express-v1":[0.0000013,0.0000017,null,null],"us-gov-west-1/amazon.titan-text-express-v1":[0.0000013,0.0000017,null,null],"bedrock/us-gov-west-1/amazon.titan-text-lite-v1":[3e-7,4e-7,null,null],"us-gov-west-1/amazon.titan-text-lite-v1":[3e-7,4e-7,null,null],"bedrock/us-gov-west-1/amazon.titan-text-premier-v1:0":[5e-7,0.0000015,null,null],"us-gov-west-1/amazon.titan-text-premier-v1:0":[5e-7,0.0000015,null,null],"bedrock/us-gov-west-1/anthropic.claude-3-7-sonnet-20250219-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7],"us-gov-west-1/anthropic.claude-3-7-sonnet-20250219-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7],"bedrock/us-gov-west-1/anthropic.claude-3-5-sonnet-20240620-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7],"us-gov-west-1/anthropic.claude-3-5-sonnet-20240620-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7],"bedrock/us-gov-west-1/anthropic.claude-3-haiku-20240307-v1:0":[3e-7,0.0000015,3.75e-7,3e-8],"us-gov-west-1/anthropic.claude-3-haiku-20240307-v1:0":[3e-7,0.0000015,3.75e-7,3e-8],"bedrock/us-gov-west-1/anthropic.claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7],"us-gov-west-1/anthropic.claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7],"bedrock/us-gov-west-1/claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7],"us-gov-west-1/claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7],"bedrock/us-gov-west-1/meta.llama3-70b-instruct-v1:0":[0.00000265,0.0000035,null,null],"us-gov-west-1/meta.llama3-70b-instruct-v1:0":[0.00000265,0.0000035,null,null],"bedrock/us-gov-west-1/meta.llama3-8b-instruct-v1:0":[3e-7,0.00000265,null,null],"us-gov-west-1/meta.llama3-8b-instruct-v1:0":[3e-7,0.00000265,null,null],"bedrock/us-west-1/meta.llama3-70b-instruct-v1:0":[0.00000265,0.0000035,null,null],"us-west-1/meta.llama3-70b-instruct-v1:0":[0.00000265,0.0000035,null,null],"bedrock/us-west-1/meta.llama3-8b-instruct-v1:0":[3e-7,6e-7,null,null],"us-west-1/meta.llama3-8b-instruct-v1:0":[3e-7,6e-7,null,null],"bedrock/us-west-2/anthropic.claude-instant-v1":[8e-7,0.0000024,null,null],"us-west-2/anthropic.claude-instant-v1":[8e-7,0.0000024,null,null],"bedrock/us-west-2/anthropic.claude-v1":[0.000008,0.000024,null,null],"us-west-2/anthropic.claude-v1":[0.000008,0.000024,null,null],"bedrock/us-west-2/anthropic.claude-v2:1":[0.000008,0.000024,null,null],"us-west-2/anthropic.claude-v2:1":[0.000008,0.000024,null,null],"bedrock/us-west-2/mistral.mistral-7b-instruct-v0:2":[1.5e-7,2e-7,null,null],"us-west-2/mistral.mistral-7b-instruct-v0:2":[1.5e-7,2e-7,null,null],"bedrock/us-west-2/mistral.mistral-large-2402-v1:0":[0.000008,0.000024,null,null],"us-west-2/mistral.mistral-large-2402-v1:0":[0.000008,0.000024,null,null],"bedrock/us-west-2/mistral.mixtral-8x7b-instruct-v0:1":[4.5e-7,7e-7,null,null],"us-west-2/mistral.mixtral-8x7b-instruct-v0:1":[4.5e-7,7e-7,null,null],"bedrock/us-west-2/deepseek.v3.2":[6.2e-7,0.00000185,null,null],"us-west-2/deepseek.v3.2":[6.2e-7,0.00000185,null,null],"bedrock/us-west-2/minimax.minimax-m2.1":[3e-7,0.0000012,null,null],"us-west-2/minimax.minimax-m2.1":[3e-7,0.0000012,null,null],"bedrock/us-west-2/minimax.minimax-m2.5":[3e-7,0.0000012,null,null],"us-west-2/minimax.minimax-m2.5":[3e-7,0.0000012,null,null],"bedrock/us-west-2/moonshotai.kimi-k2-thinking":[6e-7,0.0000025,null,null],"us-west-2/moonshotai.kimi-k2-thinking":[6e-7,0.0000025,null,null],"bedrock/us-west-2/moonshotai.kimi-k2.5":[6e-7,0.000003,null,null],"us-west-2/moonshotai.kimi-k2.5":[6e-7,0.000003,null,null],"bedrock/us-west-2/qwen.qwen3-coder-next":[5e-7,0.0000012,null,null],"us-west-2/qwen.qwen3-coder-next":[5e-7,0.0000012,null,null],"bedrock/us.anthropic.claude-3-5-haiku-20241022-v1:0":[8e-7,0.000004,0.000001,8e-8],"cerebras/llama-3.3-70b":[8.5e-7,0.0000012,null,null],"llama-3.3-70b":[8.5e-7,0.0000012,null,null],"cerebras/llama3.1-70b":[6e-7,6e-7,null,null],"llama3.1-70b":[6e-7,6e-7,null,null],"cerebras/llama3.1-8b":[1e-7,1e-7,null,null],"llama3.1-8b":[1e-7,1e-7,null,null],"cerebras/gpt-oss-120b":[3.5e-7,7.5e-7,null,null],"cerebras/qwen-3-32b":[4e-7,8e-7,null,null],"qwen-3-32b":[4e-7,8e-7,null,null],"cerebras/zai-glm-4.6":[0.00000225,0.00000275,null,null],"zai-glm-4.6":[0.00000225,0.00000275,null,null],"cerebras/zai-glm-4.7":[0.00000225,0.00000275,null,null],"zai-glm-4.7":[0.00000225,0.00000275,null,null],"cloudflare/@cf/meta/llama-2-7b-chat-fp16":[0.000001923,0.000001923,null,null],"@cf/meta/llama-2-7b-chat-fp16":[0.000001923,0.000001923,null,null],"cloudflare/@cf/meta/llama-2-7b-chat-int8":[0.000001923,0.000001923,null,null],"@cf/meta/llama-2-7b-chat-int8":[0.000001923,0.000001923,null,null],"cloudflare/@cf/mistral/mistral-7b-instruct-v0.1":[0.000001923,0.000001923,null,null],"@cf/mistral/mistral-7b-instruct-v0.1":[0.000001923,0.000001923,null,null],"cloudflare/@hf/thebloke/codellama-7b-instruct-awq":[0.000001923,0.000001923,null,null],"@hf/thebloke/codellama-7b-instruct-awq":[0.000001923,0.000001923,null,null],"codestral/codestral-2405":[0,0,null,null],"codestral-2405":[0,0,null,null],"codestral/codestral-latest":[0,0,null,null],"codestral-latest":[0,0,null,null],"cohere/embed-v4.0":[1.2e-7,0,null,null],"embed-v4.0":[1.2e-7,0,null,null],"dashscope/qwen-coder":[3e-7,0.0000015,null,null],"qwen-coder":[3e-7,0.0000015,null,null],"dashscope/qwen-max":[0.0000016,0.0000064,null,null],"qwen-max":[0.0000016,0.0000064,null,null],"dashscope/qwen-plus":[4e-7,0.0000012,null,null],"qwen-plus":[4e-7,0.0000012,null,null],"dashscope/qwen-plus-2025-01-25":[4e-7,0.0000012,null,null],"qwen-plus-2025-01-25":[4e-7,0.0000012,null,null],"dashscope/qwen-plus-2025-04-28":[4e-7,0.0000012,null,null],"qwen-plus-2025-04-28":[4e-7,0.0000012,null,null],"dashscope/qwen-plus-2025-07-14":[4e-7,0.0000012,null,null],"qwen-plus-2025-07-14":[4e-7,0.0000012,null,null],"dashscope/qwen-turbo":[5e-8,2e-7,null,null],"qwen-turbo":[5e-8,2e-7,null,null],"dashscope/qwen-turbo-2024-11-01":[5e-8,2e-7,null,null],"qwen-turbo-2024-11-01":[5e-8,2e-7,null,null],"dashscope/qwen-turbo-2025-04-28":[5e-8,2e-7,null,null],"qwen-turbo-2025-04-28":[5e-8,2e-7,null,null],"dashscope/qwen-turbo-latest":[5e-8,2e-7,null,null],"qwen-turbo-latest":[5e-8,2e-7,null,null],"dashscope/qwen3-next-80b-a3b-instruct":[1.5e-7,0.0000012,null,null],"qwen3-next-80b-a3b-instruct":[1.5e-7,0.0000012,null,null],"dashscope/qwen3-next-80b-a3b-thinking":[1.5e-7,0.0000012,null,null],"qwen3-next-80b-a3b-thinking":[1.5e-7,0.0000012,null,null],"dashscope/qwen3-vl-235b-a22b-instruct":[4e-7,0.0000016,null,null],"qwen3-vl-235b-a22b-instruct":[4e-7,0.0000016,null,null],"dashscope/qwen3-vl-235b-a22b-thinking":[4e-7,0.000004,null,null],"qwen3-vl-235b-a22b-thinking":[4e-7,0.000004,null,null],"dashscope/qwen3-vl-32b-instruct":[1.6e-7,6.4e-7,null,null],"qwen3-vl-32b-instruct":[1.6e-7,6.4e-7,null,null],"dashscope/qwen3-vl-32b-thinking":[1.6e-7,0.00000287,null,null],"qwen3-vl-32b-thinking":[1.6e-7,0.00000287,null,null],"dashscope/qwq-plus":[8e-7,0.0000024,null,null],"qwq-plus":[8e-7,0.0000024,null,null],"databricks/databricks-bge-large-en":[1.0003e-7,0,null,null],"databricks-bge-large-en":[1.0003e-7,0,null,null],"databricks/databricks-claude-3-7-sonnet":[0.0000029999900000000002,0.000015000020000000002,null,null],"databricks-claude-3-7-sonnet":[0.0000029999900000000002,0.000015000020000000002,null,null],"databricks/databricks-claude-haiku-4-5":[0.00000100002,0.00000500003,null,null],"databricks-claude-haiku-4-5":[0.00000100002,0.00000500003,null,null],"databricks/databricks-claude-opus-4":[0.000015000020000000002,0.00007500003000000001,null,null],"databricks-claude-opus-4":[0.000015000020000000002,0.00007500003000000001,null,null],"databricks/databricks-claude-opus-4-1":[0.000015000020000000002,0.00007500003000000001,null,null],"databricks-claude-opus-4-1":[0.000015000020000000002,0.00007500003000000001,null,null],"databricks/databricks-claude-opus-4-5":[0.00000500003,0.000025000010000000002,null,null],"databricks-claude-opus-4-5":[0.00000500003,0.000025000010000000002,null,null],"databricks/databricks-claude-sonnet-4":[0.0000029999900000000002,0.000015000020000000002,null,null],"databricks-claude-sonnet-4":[0.0000029999900000000002,0.000015000020000000002,null,null],"databricks/databricks-claude-sonnet-4-1":[0.0000029999900000000002,0.000015000020000000002,null,null],"databricks-claude-sonnet-4-1":[0.0000029999900000000002,0.000015000020000000002,null,null],"databricks/databricks-claude-sonnet-4-5":[0.0000029999900000000002,0.000015000020000000002,null,null],"databricks-claude-sonnet-4-5":[0.0000029999900000000002,0.000015000020000000002,null,null],"databricks/databricks-gemini-2-5-flash":[3.0001999999999996e-7,0.00000249998,null,null],"databricks-gemini-2-5-flash":[3.0001999999999996e-7,0.00000249998,null,null],"databricks/databricks-gemini-2-5-pro":[0.00000124999,0.000009999990000000002,null,null],"databricks-gemini-2-5-pro":[0.00000124999,0.000009999990000000002,null,null],"databricks/databricks-gemma-3-12b":[1.5000999999999998e-7,5.0001e-7,null,null],"databricks-gemma-3-12b":[1.5000999999999998e-7,5.0001e-7,null,null],"databricks/databricks-gpt-5":[0.00000124999,0.000009999990000000002,null,null],"databricks-gpt-5":[0.00000124999,0.000009999990000000002,null,null],"databricks/databricks-gpt-5-1":[0.00000124999,0.000009999990000000002,null,null],"databricks-gpt-5-1":[0.00000124999,0.000009999990000000002,null,null],"databricks/databricks-gpt-5-mini":[2.4997000000000006e-7,0.0000019999700000000004,null,null],"databricks-gpt-5-mini":[2.4997000000000006e-7,0.0000019999700000000004,null,null],"databricks/databricks-gpt-5-nano":[4.998e-8,3.9998000000000007e-7,null,null],"databricks-gpt-5-nano":[4.998e-8,3.9998000000000007e-7,null,null],"databricks/databricks-gpt-oss-120b":[1.5000999999999998e-7,5.9997e-7,null,null],"databricks-gpt-oss-120b":[1.5000999999999998e-7,5.9997e-7,null,null],"databricks/databricks-gpt-oss-20b":[7e-8,3.0001999999999996e-7,null,null],"databricks-gpt-oss-20b":[7e-8,3.0001999999999996e-7,null,null],"databricks/databricks-gte-large-en":[1.2999000000000001e-7,0,null,null],"databricks-gte-large-en":[1.2999000000000001e-7,0,null,null],"databricks/databricks-llama-2-70b-chat":[5.0001e-7,0.0000015000300000000002,null,null],"databricks-llama-2-70b-chat":[5.0001e-7,0.0000015000300000000002,null,null],"databricks/databricks-llama-4-maverick":[5.0001e-7,0.0000015000300000000002,null,null],"databricks-llama-4-maverick":[5.0001e-7,0.0000015000300000000002,null,null],"databricks/databricks-meta-llama-3-1-405b-instruct":[0.00000500003,0.000015000020000000002,null,null],"databricks-meta-llama-3-1-405b-instruct":[0.00000500003,0.000015000020000000002,null,null],"databricks/databricks-meta-llama-3-1-8b-instruct":[1.5000999999999998e-7,4.5003000000000007e-7,null,null],"databricks-meta-llama-3-1-8b-instruct":[1.5000999999999998e-7,4.5003000000000007e-7,null,null],"databricks/databricks-meta-llama-3-3-70b-instruct":[5.0001e-7,0.0000015000300000000002,null,null],"databricks-meta-llama-3-3-70b-instruct":[5.0001e-7,0.0000015000300000000002,null,null],"databricks/databricks-meta-llama-3-70b-instruct":[0.00000100002,0.0000029999900000000002,null,null],"databricks-meta-llama-3-70b-instruct":[0.00000100002,0.0000029999900000000002,null,null],"databricks/databricks-mixtral-8x7b-instruct":[5.0001e-7,0.00000100002,null,null],"databricks-mixtral-8x7b-instruct":[5.0001e-7,0.00000100002,null,null],"databricks/databricks-mpt-30b-instruct":[0.00000100002,0.00000100002,null,null],"databricks-mpt-30b-instruct":[0.00000100002,0.00000100002,null,null],"databricks/databricks-mpt-7b-instruct":[5.0001e-7,0,null,null],"databricks-mpt-7b-instruct":[5.0001e-7,0,null,null],"deepinfra/Gryphe/MythoMax-L2-13b":[8e-8,9e-8,null,null],"Gryphe/MythoMax-L2-13b":[8e-8,9e-8,null,null],"deepinfra/NousResearch/Hermes-3-Llama-3.1-405B":[0.000001,0.000001,null,null],"NousResearch/Hermes-3-Llama-3.1-405B":[0.000001,0.000001,null,null],"deepinfra/NousResearch/Hermes-3-Llama-3.1-70B":[3e-7,3e-7,null,null],"NousResearch/Hermes-3-Llama-3.1-70B":[3e-7,3e-7,null,null],"deepinfra/Qwen/QwQ-32B":[1.5e-7,4e-7,null,null],"Qwen/QwQ-32B":[1.5e-7,4e-7,null,null],"deepinfra/Qwen/Qwen2.5-72B-Instruct":[1.2e-7,3.9e-7,null,null],"Qwen/Qwen2.5-72B-Instruct":[1.2e-7,3.9e-7,null,null],"deepinfra/Qwen/Qwen2.5-7B-Instruct":[4e-8,1e-7,null,null],"Qwen/Qwen2.5-7B-Instruct":[4e-8,1e-7,null,null],"deepinfra/Qwen/Qwen2.5-VL-32B-Instruct":[2e-7,6e-7,null,null],"Qwen/Qwen2.5-VL-32B-Instruct":[2e-7,6e-7,null,null],"deepinfra/Qwen/Qwen3-14B":[6e-8,2.4e-7,null,null],"Qwen/Qwen3-14B":[6e-8,2.4e-7,null,null],"deepinfra/Qwen/Qwen3-235B-A22B":[1.8e-7,5.4e-7,null,null],"Qwen/Qwen3-235B-A22B":[1.8e-7,5.4e-7,null,null],"deepinfra/Qwen/Qwen3-235B-A22B-Instruct-2507":[9e-8,6e-7,null,null],"Qwen/Qwen3-235B-A22B-Instruct-2507":[9e-8,6e-7,null,null],"deepinfra/Qwen/Qwen3-235B-A22B-Thinking-2507":[3e-7,0.0000029,null,null],"Qwen/Qwen3-235B-A22B-Thinking-2507":[3e-7,0.0000029,null,null],"deepinfra/Qwen/Qwen3-30B-A3B":[8e-8,2.9e-7,null,null],"Qwen/Qwen3-30B-A3B":[8e-8,2.9e-7,null,null],"deepinfra/Qwen/Qwen3-32B":[1e-7,2.8e-7,null,null],"Qwen/Qwen3-32B":[1e-7,2.8e-7,null,null],"deepinfra/Qwen/Qwen3-Coder-480B-A35B-Instruct":[4e-7,0.0000016,null,null],"Qwen/Qwen3-Coder-480B-A35B-Instruct":[4e-7,0.0000016,null,null],"deepinfra/Qwen/Qwen3-Coder-480B-A35B-Instruct-Turbo":[2.9e-7,0.0000012,null,null],"Qwen/Qwen3-Coder-480B-A35B-Instruct-Turbo":[2.9e-7,0.0000012,null,null],"deepinfra/Qwen/Qwen3-Next-80B-A3B-Instruct":[1.4e-7,0.0000014,null,null],"Qwen/Qwen3-Next-80B-A3B-Instruct":[1.4e-7,0.0000014,null,null],"deepinfra/Qwen/Qwen3-Next-80B-A3B-Thinking":[1.4e-7,0.0000014,null,null],"Qwen/Qwen3-Next-80B-A3B-Thinking":[1.4e-7,0.0000014,null,null],"deepinfra/Sao10K/L3-8B-Lunaris-v1-Turbo":[4e-8,5e-8,null,null],"Sao10K/L3-8B-Lunaris-v1-Turbo":[4e-8,5e-8,null,null],"deepinfra/Sao10K/L3.1-70B-Euryale-v2.2":[6.5e-7,7.5e-7,null,null],"Sao10K/L3.1-70B-Euryale-v2.2":[6.5e-7,7.5e-7,null,null],"deepinfra/Sao10K/L3.3-70B-Euryale-v2.3":[6.5e-7,7.5e-7,null,null],"Sao10K/L3.3-70B-Euryale-v2.3":[6.5e-7,7.5e-7,null,null],"deepinfra/allenai/olmOCR-7B-0725-FP8":[2.7e-7,0.0000015,null,null],"allenai/olmOCR-7B-0725-FP8":[2.7e-7,0.0000015,null,null],"deepinfra/anthropic/claude-3-7-sonnet-latest":[0.0000033,0.0000165,null,3.3e-7],"anthropic/claude-3-7-sonnet-latest":[0.0000033,0.0000165,null,3.3e-7],"deepinfra/anthropic/claude-4-opus":[0.0000165,0.0000825,null,null],"anthropic/claude-4-opus":[0.0000165,0.0000825,null,null],"deepinfra/anthropic/claude-4-sonnet":[0.0000033,0.0000165,null,null],"anthropic/claude-4-sonnet":[0.0000033,0.0000165,null,null],"deepinfra/deepseek-ai/DeepSeek-R1":[7e-7,0.0000024,null,null],"deepseek-ai/DeepSeek-R1":[7e-7,0.0000024,null,null],"deepinfra/deepseek-ai/DeepSeek-R1-0528":[5e-7,0.00000215,null,4e-7],"deepseek-ai/DeepSeek-R1-0528":[5e-7,0.00000215,null,4e-7],"deepinfra/deepseek-ai/DeepSeek-R1-0528-Turbo":[0.000001,0.000003,null,null],"deepseek-ai/DeepSeek-R1-0528-Turbo":[0.000001,0.000003,null,null],"deepinfra/deepseek-ai/DeepSeek-R1-Distill-Llama-70B":[2e-7,6e-7,null,null],"deepseek-ai/DeepSeek-R1-Distill-Llama-70B":[2e-7,6e-7,null,null],"deepinfra/deepseek-ai/DeepSeek-R1-Distill-Qwen-32B":[2.7e-7,2.7e-7,null,null],"deepseek-ai/DeepSeek-R1-Distill-Qwen-32B":[2.7e-7,2.7e-7,null,null],"deepinfra/deepseek-ai/DeepSeek-R1-Turbo":[0.000001,0.000003,null,null],"deepseek-ai/DeepSeek-R1-Turbo":[0.000001,0.000003,null,null],"deepinfra/deepseek-ai/DeepSeek-V3":[3.8e-7,8.9e-7,null,null],"deepseek-ai/DeepSeek-V3":[3.8e-7,8.9e-7,null,null],"deepinfra/deepseek-ai/DeepSeek-V3-0324":[2.5e-7,8.8e-7,null,null],"deepseek-ai/DeepSeek-V3-0324":[2.5e-7,8.8e-7,null,null],"deepinfra/deepseek-ai/DeepSeek-V3.1":[2.7e-7,0.000001,null,2.16e-7],"deepseek-ai/DeepSeek-V3.1":[2.7e-7,0.000001,null,2.16e-7],"deepinfra/deepseek-ai/DeepSeek-V3.1-Terminus":[2.7e-7,0.000001,null,2.16e-7],"deepseek-ai/DeepSeek-V3.1-Terminus":[2.7e-7,0.000001,null,2.16e-7],"deepinfra/google/gemini-2.0-flash-001":[1e-7,4e-7,null,null],"google/gemini-2.0-flash-001":[1e-7,4e-7,null,null],"deepinfra/google/gemini-2.5-flash":[3e-7,0.0000025,null,null],"google/gemini-2.5-flash":[3e-7,0.0000025,null,null],"deepinfra/google/gemini-2.5-pro":[0.00000125,0.00001,null,null],"google/gemini-2.5-pro":[0.00000125,0.00001,null,null],"deepinfra/google/gemma-3-12b-it":[5e-8,1e-7,null,null],"google/gemma-3-12b-it":[5e-8,1e-7,null,null],"deepinfra/google/gemma-3-27b-it":[9e-8,1.6e-7,null,null],"google/gemma-3-27b-it":[9e-8,1.6e-7,null,null],"deepinfra/google/gemma-3-4b-it":[4e-8,8e-8,null,null],"google/gemma-3-4b-it":[4e-8,8e-8,null,null],"deepinfra/meta-llama/Llama-3.2-11B-Vision-Instruct":[4.9e-8,4.9e-8,null,null],"meta-llama/Llama-3.2-11B-Vision-Instruct":[4.9e-8,4.9e-8,null,null],"deepinfra/meta-llama/Llama-3.2-3B-Instruct":[2e-8,2e-8,null,null],"meta-llama/Llama-3.2-3B-Instruct":[2e-8,2e-8,null,null],"deepinfra/meta-llama/Llama-3.3-70B-Instruct":[2.3e-7,4e-7,null,null],"meta-llama/Llama-3.3-70B-Instruct":[2.3e-7,4e-7,null,null],"deepinfra/meta-llama/Llama-3.3-70B-Instruct-Turbo":[1.3e-7,3.9e-7,null,null],"meta-llama/Llama-3.3-70B-Instruct-Turbo":[1.3e-7,3.9e-7,null,null],"deepinfra/meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":[1.5e-7,6e-7,null,null],"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":[1.5e-7,6e-7,null,null],"deepinfra/meta-llama/Llama-4-Scout-17B-16E-Instruct":[8e-8,3e-7,null,null],"meta-llama/Llama-4-Scout-17B-16E-Instruct":[8e-8,3e-7,null,null],"deepinfra/meta-llama/Llama-Guard-3-8B":[5.5e-8,5.5e-8,null,null],"meta-llama/Llama-Guard-3-8B":[5.5e-8,5.5e-8,null,null],"deepinfra/meta-llama/Llama-Guard-4-12B":[1.8e-7,1.8e-7,null,null],"meta-llama/Llama-Guard-4-12B":[1.8e-7,1.8e-7,null,null],"deepinfra/meta-llama/Meta-Llama-3-8B-Instruct":[3e-8,6e-8,null,null],"deepinfra/meta-llama/Meta-Llama-3.1-70B-Instruct":[4e-7,4e-7,null,null],"meta-llama/Meta-Llama-3.1-70B-Instruct":[4e-7,4e-7,null,null],"deepinfra/meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo":[1e-7,2.8e-7,null,null],"meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo":[1e-7,2.8e-7,null,null],"deepinfra/meta-llama/Meta-Llama-3.1-8B-Instruct":[3e-8,5e-8,null,null],"meta-llama/Meta-Llama-3.1-8B-Instruct":[3e-8,5e-8,null,null],"deepinfra/meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo":[2e-8,3e-8,null,null],"meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo":[2e-8,3e-8,null,null],"deepinfra/microsoft/WizardLM-2-8x22B":[4.8e-7,4.8e-7,null,null],"microsoft/WizardLM-2-8x22B":[4.8e-7,4.8e-7,null,null],"deepinfra/microsoft/phi-4":[7e-8,1.4e-7,null,null],"microsoft/phi-4":[7e-8,1.4e-7,null,null],"deepinfra/mistralai/Mistral-Nemo-Instruct-2407":[2e-8,4e-8,null,null],"mistralai/Mistral-Nemo-Instruct-2407":[2e-8,4e-8,null,null],"deepinfra/mistralai/Mistral-Small-24B-Instruct-2501":[5e-8,8e-8,null,null],"mistralai/Mistral-Small-24B-Instruct-2501":[5e-8,8e-8,null,null],"deepinfra/mistralai/Mistral-Small-3.2-24B-Instruct-2506":[7.5e-8,2e-7,null,null],"mistralai/Mistral-Small-3.2-24B-Instruct-2506":[7.5e-8,2e-7,null,null],"deepinfra/mistralai/Mixtral-8x7B-Instruct-v0.1":[4e-7,4e-7,null,null],"deepinfra/moonshotai/Kimi-K2-Instruct":[5e-7,0.000002,null,null],"moonshotai/Kimi-K2-Instruct":[5e-7,0.000002,null,null],"deepinfra/moonshotai/Kimi-K2-Instruct-0905":[5e-7,0.000002,null,4e-7],"moonshotai/Kimi-K2-Instruct-0905":[5e-7,0.000002,null,4e-7],"deepinfra/nvidia/Llama-3.1-Nemotron-70B-Instruct":[6e-7,6e-7,null,null],"nvidia/Llama-3.1-Nemotron-70B-Instruct":[6e-7,6e-7,null,null],"deepinfra/nvidia/Llama-3.3-Nemotron-Super-49B-v1.5":[1e-7,4e-7,null,null],"nvidia/Llama-3.3-Nemotron-Super-49B-v1.5":[1e-7,4e-7,null,null],"deepinfra/nvidia/NVIDIA-Nemotron-Nano-9B-v2":[4e-8,1.6e-7,null,null],"nvidia/NVIDIA-Nemotron-Nano-9B-v2":[4e-8,1.6e-7,null,null],"deepinfra/openai/gpt-oss-120b":[5e-8,4.5e-7,null,null],"openai/gpt-oss-120b":[5e-8,4.5e-7,null,null],"deepinfra/openai/gpt-oss-20b":[4e-8,1.5e-7,null,null],"openai/gpt-oss-20b":[4e-8,1.5e-7,null,null],"deepinfra/zai-org/GLM-4.5":[4e-7,0.0000016,null,null],"zai-org/GLM-4.5":[4e-7,0.0000016,null,null],"deepseek/deepseek-chat":[2.8e-7,4.2e-7,0,2.8e-8],"deepseek/deepseek-coder":[1.4e-7,2.8e-7,null,null],"deepseek-coder":[1.4e-7,2.8e-7,null,null],"deepseek/deepseek-r1":[5.5e-7,0.00000219,null,null],"deepseek/deepseek-reasoner":[2.8e-7,4.2e-7,null,2.8e-8],"deepseek/deepseek-v3":[2.7e-7,0.0000011,0,7e-8],"deepseek/deepseek-v3.2":[2.8e-7,4e-7,null,null],"fireworks_ai/WhereIsAI/UAE-Large-V1":[1.6e-8,0,null,null],"WhereIsAI/UAE-Large-V1":[1.6e-8,0,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-coder-v2-instruct":[0.0000012,0.0000012,null,null],"accounts/fireworks/models/deepseek-coder-v2-instruct":[0.0000012,0.0000012,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1":[0.000003,0.000008,null,null],"accounts/fireworks/models/deepseek-r1":[0.000003,0.000008,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1-0528":[0.000003,0.000008,null,null],"accounts/fireworks/models/deepseek-r1-0528":[0.000003,0.000008,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1-basic":[5.5e-7,0.00000219,null,null],"accounts/fireworks/models/deepseek-r1-basic":[5.5e-7,0.00000219,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-v3":[9e-7,9e-7,null,null],"accounts/fireworks/models/deepseek-v3":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-v3-0324":[9e-7,9e-7,null,null],"accounts/fireworks/models/deepseek-v3-0324":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-v3p1":[5.6e-7,0.00000168,null,null],"accounts/fireworks/models/deepseek-v3p1":[5.6e-7,0.00000168,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-v3p1-terminus":[5.6e-7,0.00000168,null,null],"accounts/fireworks/models/deepseek-v3p1-terminus":[5.6e-7,0.00000168,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-v3p2":[5.6e-7,0.00000168,null,null],"accounts/fireworks/models/deepseek-v3p2":[5.6e-7,0.00000168,null,null],"fireworks_ai/accounts/fireworks/models/firefunction-v2":[9e-7,9e-7,null,null],"accounts/fireworks/models/firefunction-v2":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/glm-4p5":[5.5e-7,0.00000219,null,null],"accounts/fireworks/models/glm-4p5":[5.5e-7,0.00000219,null,null],"fireworks_ai/accounts/fireworks/models/glm-4p5-air":[2.2e-7,8.8e-7,null,null],"accounts/fireworks/models/glm-4p5-air":[2.2e-7,8.8e-7,null,null],"fireworks_ai/accounts/fireworks/models/glm-4p6":[5.5e-7,0.00000219,null,null],"accounts/fireworks/models/glm-4p6":[5.5e-7,0.00000219,null,null],"fireworks_ai/accounts/fireworks/models/glm-4p7":[6e-7,0.0000022,null,3e-7],"accounts/fireworks/models/glm-4p7":[6e-7,0.0000022,null,3e-7],"fireworks_ai/accounts/fireworks/models/gpt-oss-120b":[1.5e-7,6e-7,null,null],"accounts/fireworks/models/gpt-oss-120b":[1.5e-7,6e-7,null,null],"fireworks_ai/accounts/fireworks/models/gpt-oss-20b":[5e-8,2e-7,null,null],"accounts/fireworks/models/gpt-oss-20b":[5e-8,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/kimi-k2-instruct":[6e-7,0.0000025,null,null],"accounts/fireworks/models/kimi-k2-instruct":[6e-7,0.0000025,null,null],"fireworks_ai/accounts/fireworks/models/kimi-k2-instruct-0905":[6e-7,0.0000025,null,null],"accounts/fireworks/models/kimi-k2-instruct-0905":[6e-7,0.0000025,null,null],"fireworks_ai/accounts/fireworks/models/kimi-k2-thinking":[6e-7,0.0000025,null,null],"accounts/fireworks/models/kimi-k2-thinking":[6e-7,0.0000025,null,null],"fireworks_ai/accounts/fireworks/models/kimi-k2p5":[6e-7,0.000003,null,1e-7],"accounts/fireworks/models/kimi-k2p5":[6e-7,0.000003,null,1e-7],"fireworks_ai/accounts/fireworks/models/llama-v3p1-405b-instruct":[0.000003,0.000003,null,null],"accounts/fireworks/models/llama-v3p1-405b-instruct":[0.000003,0.000003,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p1-8b-instruct":[1e-7,1e-7,null,null],"accounts/fireworks/models/llama-v3p1-8b-instruct":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p2-11b-vision-instruct":[2e-7,2e-7,null,null],"accounts/fireworks/models/llama-v3p2-11b-vision-instruct":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p2-1b-instruct":[1e-7,1e-7,null,null],"accounts/fireworks/models/llama-v3p2-1b-instruct":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p2-3b-instruct":[1e-7,1e-7,null,null],"accounts/fireworks/models/llama-v3p2-3b-instruct":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p2-90b-vision-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/llama-v3p2-90b-vision-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama4-maverick-instruct-basic":[2.2e-7,8.8e-7,null,null],"accounts/fireworks/models/llama4-maverick-instruct-basic":[2.2e-7,8.8e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama4-scout-instruct-basic":[1.5e-7,6e-7,null,null],"accounts/fireworks/models/llama4-scout-instruct-basic":[1.5e-7,6e-7,null,null],"fireworks_ai/accounts/fireworks/models/minimax-m2p1":[3e-7,0.0000012,null,3e-8],"accounts/fireworks/models/minimax-m2p1":[3e-7,0.0000012,null,3e-8],"fireworks_ai/accounts/fireworks/models/mixtral-8x22b-instruct-hf":[0.0000012,0.0000012,null,null],"accounts/fireworks/models/mixtral-8x22b-instruct-hf":[0.0000012,0.0000012,null,null],"fireworks_ai/accounts/fireworks/models/qwen2-72b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen2-72b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-32b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-32b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/yi-large":[0.000003,0.000003,null,null],"accounts/fireworks/models/yi-large":[0.000003,0.000003,null,null],"fireworks_ai/glm-4p7":[6e-7,0.0000022,null,3e-7],"glm-4p7":[6e-7,0.0000022,null,3e-7],"fireworks_ai/kimi-k2p5":[6e-7,0.000003,null,1e-7],"kimi-k2p5":[6e-7,0.000003,null,1e-7],"fireworks_ai/minimax-m2p1":[3e-7,0.0000012,null,3e-8],"minimax-m2p1":[3e-7,0.0000012,null,3e-8],"fireworks_ai/nomic-ai/nomic-embed-text-v1":[8e-9,0,null,null],"nomic-ai/nomic-embed-text-v1":[8e-9,0,null,null],"fireworks_ai/nomic-ai/nomic-embed-text-v1.5":[8e-9,0,null,null],"nomic-ai/nomic-embed-text-v1.5":[8e-9,0,null,null],"fireworks_ai/thenlper/gte-base":[8e-9,0,null,null],"thenlper/gte-base":[8e-9,0,null,null],"fireworks_ai/thenlper/gte-large":[1.6e-8,0,null,null],"thenlper/gte-large":[1.6e-8,0,null,null],"friendliai/meta-llama-3.1-70b-instruct":[6e-7,6e-7,null,null],"meta-llama-3.1-70b-instruct":[6e-7,6e-7,null,null],"friendliai/meta-llama-3.1-8b-instruct":[1e-7,1e-7,null,null],"meta-llama-3.1-8b-instruct":[1e-7,1e-7,null,null],"gemini/gemini-live-2.5-flash-preview-native-audio-09-2025":[3e-7,0.000002,null,7.5e-8],"vertex_ai/gemini-3-pro-preview":[0.000002,0.000012,null,2e-7],"vertex_ai/gemini-3-flash-preview":[5e-7,0.000003,null,5e-8],"vertex_ai/gemini-3.1-pro-preview":[0.000002,0.000012,null,2e-7],"vertex_ai/gemini-3.1-pro-preview-customtools":[0.000002,0.000012,null,2e-7],"gemini/gemini-robotics-er-1.5-preview":[3e-7,0.0000025,null,0],"vertex_ai/gemini-embedding-2-preview":[2e-7,0,null,null],"vertex_ai/gemini-embedding-2":[2e-7,0,null,null],"gemini/gemini-embedding-001":[1.5e-7,0,null,null],"gemini/gemini-embedding-2-preview":[2e-7,0,null,null],"gemini/gemini-embedding-2":[2e-7,0,null,null],"gemini/gemini-1.5-flash":[7.5e-8,0,null,null],"gemini-1.5-flash":[7.5e-8,0,null,null],"gemini/gemini-2.0-flash":[1e-7,4e-7,null,2.5e-8],"gemini/gemini-2.0-flash-001":[1e-7,4e-7,null,2.5e-8],"gemini/gemini-2.0-flash-lite":[7.5e-8,3e-7,null,1.875e-8],"gemini/gemini-2.5-flash":[3e-7,0.0000025,null,3e-8],"gemini/gemini-2.5-flash-image":[3e-7,0.0000025,null,3e-8],"gemini/gemini-3-pro-image-preview":[0.000002,0.000012,null,null],"gemini/gemini-3.1-flash-image-preview":[2.5e-7,0.0000015,null,null],"gemini/deep-research-pro-preview-12-2025":[0.000002,0.000012,null,null],"gemini/gemini-2.5-flash-lite":[1e-7,4e-7,null,1e-8],"gemini/gemini-2.5-flash-lite-preview-09-2025":[1e-7,4e-7,null,1e-8],"gemini/gemini-2.5-flash-preview-09-2025":[3e-7,0.0000025,null,7.5e-8],"gemini/gemini-flash-latest":[3e-7,0.0000025,null,7.5e-8],"gemini/gemini-flash-lite-latest":[1e-7,4e-7,null,2.5e-8],"gemini/gemini-2.5-flash-lite-preview-06-17":[1e-7,4e-7,null,2.5e-8],"gemini/gemini-2.5-flash-preview-tts":[3e-7,0.0000025,null,null],"gemini/gemini-2.5-pro":[0.00000125,0.00001,null,1.25e-7],"gemini/gemini-2.5-computer-use-preview-10-2025":[0.00000125,0.00001,null,null],"gemini/gemini-3-pro-preview":[0.000002,0.000012,null,2e-7],"gemini/gemini-3.1-flash-lite-preview":[2.5e-7,0.0000015,null,2.5e-8],"gemini/gemini-3-flash-preview":[5e-7,0.000003,null,5e-8],"gemini/gemini-3.1-pro-preview":[0.000002,0.000012,null,2e-7],"gemini/gemini-3.1-pro-preview-customtools":[0.000002,0.000012,null,2e-7],"gemini/gemini-2.5-pro-preview-tts":[0.00000125,0.00001,null,1.25e-7],"gemini/gemini-exp-1114":[0,0,null,null],"gemini-exp-1114":[0,0,null,null],"gemini/gemini-exp-1206":[0,0,null,null],"gemini/gemini-gemma-2-27b-it":[3.5e-7,0.00000105,null,null],"gemini-gemma-2-27b-it":[3.5e-7,0.00000105,null,null],"gemini/gemini-gemma-2-9b-it":[3.5e-7,0.00000105,null,null],"gemini-gemma-2-9b-it":[3.5e-7,0.00000105,null,null],"gemini/gemma-3-27b-it":[0,0,null,null],"gemma-3-27b-it":[0,0,null,null],"gemini/learnlm-1.5-pro-experimental":[0,0,null,null],"learnlm-1.5-pro-experimental":[0,0,null,null],"gemini/lyria-3-clip-preview":[0,0,null,null],"lyria-3-clip-preview":[0,0,null,null],"gemini/lyria-3-pro-preview":[0,0,null,null],"lyria-3-pro-preview":[0,0,null,null],"gigachat/GigaChat-2-Lite":[0,0,null,null],"GigaChat-2-Lite":[0,0,null,null],"gigachat/GigaChat-2-Max":[0,0,null,null],"GigaChat-2-Max":[0,0,null,null],"gigachat/GigaChat-2-Pro":[0,0,null,null],"GigaChat-2-Pro":[0,0,null,null],"gigachat/Embeddings":[0,0,null,null],"Embeddings":[0,0,null,null],"gigachat/Embeddings-2":[0,0,null,null],"Embeddings-2":[0,0,null,null],"gigachat/EmbeddingsGigaR":[0,0,null,null],"EmbeddingsGigaR":[0,0,null,null],"gmi/anthropic/claude-opus-4.5":[0.000005,0.000025,null,null],"anthropic/claude-opus-4.5":[0.000005,0.000025,null,null],"gmi/anthropic/claude-sonnet-4.5":[0.000003,0.000015,null,null],"anthropic/claude-sonnet-4.5":[0.000003,0.000015,null,null],"gmi/anthropic/claude-sonnet-4":[0.000003,0.000015,null,null],"anthropic/claude-sonnet-4":[0.000003,0.000015,null,null],"gmi/anthropic/claude-opus-4":[0.000015,0.000075,null,null],"anthropic/claude-opus-4":[0.000015,0.000075,null,null],"gmi/openai/gpt-5.2":[0.00000175,0.000014,null,null],"openai/gpt-5.2":[0.00000175,0.000014,null,null],"gmi/openai/gpt-5.1":[0.00000125,0.00001,null,null],"openai/gpt-5.1":[0.00000125,0.00001,null,null],"gmi/openai/gpt-5":[0.00000125,0.00001,null,null],"openai/gpt-5":[0.00000125,0.00001,null,null],"gmi/openai/gpt-4o":[0.0000025,0.00001,null,null],"openai/gpt-4o":[0.0000025,0.00001,null,null],"gmi/openai/gpt-4o-mini":[1.5e-7,6e-7,null,null],"openai/gpt-4o-mini":[1.5e-7,6e-7,null,null],"gmi/deepseek-ai/DeepSeek-V3.2":[2.8e-7,4e-7,null,null],"deepseek-ai/DeepSeek-V3.2":[2.8e-7,4e-7,null,null],"gmi/deepseek-ai/DeepSeek-V3-0324":[2.8e-7,8.8e-7,null,null],"gmi/google/gemini-3-pro-preview":[0.000002,0.000012,null,null],"google/gemini-3-pro-preview":[0.000002,0.000012,null,null],"gmi/google/gemini-3-flash-preview":[5e-7,0.000003,null,null],"google/gemini-3-flash-preview":[5e-7,0.000003,null,null],"gmi/moonshotai/Kimi-K2-Thinking":[8e-7,0.0000012,null,null],"moonshotai/Kimi-K2-Thinking":[8e-7,0.0000012,null,null],"gmi/MiniMaxAI/MiniMax-M2.1":[3e-7,0.0000012,null,null],"MiniMaxAI/MiniMax-M2.1":[3e-7,0.0000012,null,null],"baseten/MiniMaxAI/MiniMax-M2.5":[3e-7,0.0000012,null,null],"MiniMaxAI/MiniMax-M2.5":[3e-7,0.0000012,null,null],"baseten/nvidia/Nemotron-120B-A12B":[3e-7,7.5e-7,null,null],"nvidia/Nemotron-120B-A12B":[3e-7,7.5e-7,null,null],"baseten/zai-org/GLM-5":[9.5e-7,0.00000315,null,null],"zai-org/GLM-5":[9.5e-7,0.00000315,null,null],"baseten/zai-org/GLM-4.7":[6e-7,0.0000022,null,null],"zai-org/GLM-4.7":[6e-7,0.0000022,null,null],"baseten/zai-org/GLM-4.6":[6e-7,0.0000022,null,null],"zai-org/GLM-4.6":[6e-7,0.0000022,null,null],"baseten/moonshotai/Kimi-K2.5":[6e-7,0.000003,null,null],"moonshotai/Kimi-K2.5":[6e-7,0.000003,null,null],"baseten/moonshotai/Kimi-K2-Thinking":[6e-7,0.0000025,null,null],"baseten/moonshotai/Kimi-K2-Instruct-0905":[6e-7,0.0000025,null,null],"baseten/openai/gpt-oss-120b":[1e-7,5e-7,null,null],"baseten/deepseek-ai/DeepSeek-V3.1":[5e-7,0.0000015,null,null],"baseten/deepseek-ai/DeepSeek-V3-0324":[7.7e-7,7.7e-7,null,null],"gmi/Qwen/Qwen3-VL-235B-A22B-Instruct-FP8":[3e-7,0.0000014,null,null],"Qwen/Qwen3-VL-235B-A22B-Instruct-FP8":[3e-7,0.0000014,null,null],"gmi/zai-org/GLM-4.7-FP8":[4e-7,0.000002,null,null],"zai-org/GLM-4.7-FP8":[4e-7,0.000002,null,null],"gradient_ai/anthropic-claude-3-opus":[0.000015,0.000075,null,null],"anthropic-claude-3-opus":[0.000015,0.000075,null,null],"gradient_ai/anthropic-claude-3.5-haiku":[8e-7,0.000004,null,null],"anthropic-claude-3.5-haiku":[8e-7,0.000004,null,null],"gradient_ai/anthropic-claude-3.5-sonnet":[0.000003,0.000015,null,null],"anthropic-claude-3.5-sonnet":[0.000003,0.000015,null,null],"gradient_ai/anthropic-claude-3.7-sonnet":[0.000003,0.000015,null,null],"anthropic-claude-3.7-sonnet":[0.000003,0.000015,null,null],"gradient_ai/deepseek-r1-distill-llama-70b":[9.9e-7,9.9e-7,null,null],"deepseek-r1-distill-llama-70b":[9.9e-7,9.9e-7,null,null],"gradient_ai/llama3-8b-instruct":[2e-7,2e-7,null,null],"llama3-8b-instruct":[2e-7,2e-7,null,null],"gradient_ai/llama3.3-70b-instruct":[6.5e-7,6.5e-7,null,null],"llama3.3-70b-instruct":[6.5e-7,6.5e-7,null,null],"gradient_ai/mistral-nemo-instruct-2407":[3e-7,3e-7,null,null],"mistral-nemo-instruct-2407":[3e-7,3e-7,null,null],"gradient_ai/openai-o3":[0.000002,0.000008,null,null],"openai-o3":[0.000002,0.000008,null,null],"gradient_ai/openai-o3-mini":[0.0000011,0.0000044,null,null],"openai-o3-mini":[0.0000011,0.0000044,null,null],"lemonade/Qwen3-Coder-30B-A3B-Instruct-GGUF":[0,0,null,null],"Qwen3-Coder-30B-A3B-Instruct-GGUF":[0,0,null,null],"lemonade/gpt-oss-20b-mxfp4-GGUF":[0,0,null,null],"gpt-oss-20b-mxfp4-GGUF":[0,0,null,null],"lemonade/gpt-oss-120b-mxfp-GGUF":[0,0,null,null],"gpt-oss-120b-mxfp-GGUF":[0,0,null,null],"lemonade/Gemma-3-4b-it-GGUF":[0,0,null,null],"Gemma-3-4b-it-GGUF":[0,0,null,null],"lemonade/Qwen3-4B-Instruct-2507-GGUF":[0,0,null,null],"Qwen3-4B-Instruct-2507-GGUF":[0,0,null,null],"amazon-nova/nova-micro-v1":[3.5e-8,1.4e-7,null,null],"nova-micro-v1":[3.5e-8,1.4e-7,null,null],"amazon-nova/nova-lite-v1":[6e-8,2.4e-7,null,null],"nova-lite-v1":[6e-8,2.4e-7,null,null],"amazon-nova/nova-premier-v1":[0.0000025,0.0000125,null,null],"nova-premier-v1":[0.0000025,0.0000125,null,null],"amazon-nova/nova-pro-v1":[8e-7,0.0000032,null,null],"nova-pro-v1":[8e-7,0.0000032,null,null],"groq/llama-3.1-8b-instant":[5e-8,8e-8,null,null],"llama-3.1-8b-instant":[5e-8,8e-8,null,null],"groq/llama-3.3-70b-versatile":[5.9e-7,7.9e-7,null,null],"llama-3.3-70b-versatile":[5.9e-7,7.9e-7,null,null],"groq/gemma-7b-it":[5e-8,8e-8,null,null],"gemma-7b-it":[5e-8,8e-8,null,null],"groq/meta-llama/llama-guard-4-12b":[2e-7,2e-7,null,null],"meta-llama/llama-guard-4-12b":[2e-7,2e-7,null,null],"groq/meta-llama/llama-4-maverick-17b-128e-instruct":[2e-7,6e-7,null,null],"meta-llama/llama-4-maverick-17b-128e-instruct":[2e-7,6e-7,null,null],"groq/meta-llama/llama-4-scout-17b-16e-instruct":[1.1e-7,3.4e-7,null,null],"meta-llama/llama-4-scout-17b-16e-instruct":[1.1e-7,3.4e-7,null,null],"groq/moonshotai/kimi-k2-instruct-0905":[0.000001,0.000003,null,5e-7],"moonshotai/kimi-k2-instruct-0905":[0.000001,0.000003,null,5e-7],"groq/openai/gpt-oss-120b":[1.5e-7,6e-7,null,7.5e-8],"groq/openai/gpt-oss-20b":[7.5e-8,3e-7,null,3.75e-8],"groq/openai/gpt-oss-safeguard-20b":[7.5e-8,3e-7,null,3.7e-8],"openai/gpt-oss-safeguard-20b":[7.5e-8,3e-7,null,3.7e-8],"groq/qwen/qwen3-32b":[2.9e-7,5.9e-7,null,null],"qwen/qwen3-32b":[2.9e-7,5.9e-7,null,null],"hyperbolic/NousResearch/Hermes-3-Llama-3.1-70B":[1.2e-7,3e-7,null,null],"hyperbolic/Qwen/QwQ-32B":[2e-7,2e-7,null,null],"hyperbolic/Qwen/Qwen2.5-72B-Instruct":[1.2e-7,3e-7,null,null],"hyperbolic/Qwen/Qwen2.5-Coder-32B-Instruct":[1.2e-7,3e-7,null,null],"Qwen/Qwen2.5-Coder-32B-Instruct":[1.2e-7,3e-7,null,null],"hyperbolic/Qwen/Qwen3-235B-A22B":[0.000002,0.000002,null,null],"hyperbolic/deepseek-ai/DeepSeek-R1":[4e-7,4e-7,null,null],"hyperbolic/deepseek-ai/DeepSeek-R1-0528":[2.5e-7,2.5e-7,null,null],"hyperbolic/deepseek-ai/DeepSeek-V3":[2e-7,2e-7,null,null],"hyperbolic/deepseek-ai/DeepSeek-V3-0324":[4e-7,4e-7,null,null],"hyperbolic/meta-llama/Llama-3.2-3B-Instruct":[1.2e-7,3e-7,null,null],"hyperbolic/meta-llama/Llama-3.3-70B-Instruct":[1.2e-7,3e-7,null,null],"hyperbolic/meta-llama/Meta-Llama-3-70B-Instruct":[1.2e-7,3e-7,null,null],"hyperbolic/meta-llama/Meta-Llama-3.1-405B-Instruct":[1.2e-7,3e-7,null,null],"meta-llama/Meta-Llama-3.1-405B-Instruct":[1.2e-7,3e-7,null,null],"hyperbolic/meta-llama/Meta-Llama-3.1-70B-Instruct":[1.2e-7,3e-7,null,null],"hyperbolic/meta-llama/Meta-Llama-3.1-8B-Instruct":[1.2e-7,3e-7,null,null],"hyperbolic/moonshotai/Kimi-K2-Instruct":[0.000002,0.000002,null,null],"crusoe/deepseek-ai/DeepSeek-R1-0528":[0.000003,0.000007,null,null],"crusoe/deepseek-ai/DeepSeek-V3-0324":[0.0000015,0.0000015,null,null],"crusoe/google/gemma-3-12b-it":[1e-7,1e-7,null,null],"crusoe/meta-llama/Llama-3.3-70B-Instruct":[2e-7,2e-7,null,null],"crusoe/moonshotai/Kimi-K2-Thinking":[0.0000025,0.0000025,null,null],"crusoe/openai/gpt-oss-120b":[8e-7,8e-7,null,null],"crusoe/Qwen/Qwen3-235B-A22B-Instruct-2507":[0.000003,0.000003,null,null],"lambda_ai/deepseek-llama3.3-70b":[2e-7,6e-7,null,null],"deepseek-llama3.3-70b":[2e-7,6e-7,null,null],"lambda_ai/deepseek-r1-0528":[2e-7,6e-7,null,null],"deepseek-r1-0528":[2e-7,6e-7,null,null],"lambda_ai/deepseek-r1-671b":[8e-7,8e-7,null,null],"deepseek-r1-671b":[8e-7,8e-7,null,null],"lambda_ai/deepseek-v3-0324":[2e-7,6e-7,null,null],"lambda_ai/hermes3-405b":[8e-7,8e-7,null,null],"hermes3-405b":[8e-7,8e-7,null,null],"lambda_ai/hermes3-70b":[1.2e-7,3e-7,null,null],"hermes3-70b":[1.2e-7,3e-7,null,null],"lambda_ai/hermes3-8b":[2.5e-8,4e-8,null,null],"hermes3-8b":[2.5e-8,4e-8,null,null],"lambda_ai/lfm-40b":[1e-7,2e-7,null,null],"lfm-40b":[1e-7,2e-7,null,null],"lambda_ai/lfm-7b":[2.5e-8,4e-8,null,null],"lfm-7b":[2.5e-8,4e-8,null,null],"lambda_ai/llama-4-maverick-17b-128e-instruct-fp8":[5e-8,1e-7,null,null],"llama-4-maverick-17b-128e-instruct-fp8":[5e-8,1e-7,null,null],"lambda_ai/llama-4-scout-17b-16e-instruct":[5e-8,1e-7,null,null],"llama-4-scout-17b-16e-instruct":[5e-8,1e-7,null,null],"lambda_ai/llama3.1-405b-instruct-fp8":[8e-7,8e-7,null,null],"llama3.1-405b-instruct-fp8":[8e-7,8e-7,null,null],"lambda_ai/llama3.1-70b-instruct-fp8":[1.2e-7,3e-7,null,null],"llama3.1-70b-instruct-fp8":[1.2e-7,3e-7,null,null],"lambda_ai/llama3.1-8b-instruct":[2.5e-8,4e-8,null,null],"llama3.1-8b-instruct":[2.5e-8,4e-8,null,null],"lambda_ai/llama3.1-nemotron-70b-instruct-fp8":[1.2e-7,3e-7,null,null],"llama3.1-nemotron-70b-instruct-fp8":[1.2e-7,3e-7,null,null],"lambda_ai/llama3.2-11b-vision-instruct":[1.5e-8,2.5e-8,null,null],"llama3.2-11b-vision-instruct":[1.5e-8,2.5e-8,null,null],"lambda_ai/llama3.2-3b-instruct":[1.5e-8,2.5e-8,null,null],"llama3.2-3b-instruct":[1.5e-8,2.5e-8,null,null],"lambda_ai/llama3.3-70b-instruct-fp8":[1.2e-7,3e-7,null,null],"llama3.3-70b-instruct-fp8":[1.2e-7,3e-7,null,null],"lambda_ai/qwen25-coder-32b-instruct":[5e-8,1e-7,null,null],"qwen25-coder-32b-instruct":[5e-8,1e-7,null,null],"lambda_ai/qwen3-32b-fp8":[5e-8,1e-7,null,null],"qwen3-32b-fp8":[5e-8,1e-7,null,null],"minimax/MiniMax-M2.1":[3e-7,0.0000012,3.75e-7,3e-8],"MiniMax-M2.1":[3e-7,0.0000012,3.75e-7,3e-8],"minimax/MiniMax-M2.1-lightning":[3e-7,0.0000024,3.75e-7,3e-8],"MiniMax-M2.1-lightning":[3e-7,0.0000024,3.75e-7,3e-8],"minimax/MiniMax-M2.5":[3e-7,0.0000012,3.75e-7,3e-8],"MiniMax-M2.5":[3e-7,0.0000012,3.75e-7,3e-8],"minimax/MiniMax-M2.5-lightning":[3e-7,0.0000024,3.75e-7,3e-8],"MiniMax-M2.5-lightning":[3e-7,0.0000024,3.75e-7,3e-8],"minimax/MiniMax-M2":[3e-7,0.0000012,3.75e-7,3e-8],"MiniMax-M2":[3e-7,0.0000012,3.75e-7,3e-8],"mistral/codestral-2405":[0.000001,0.000003,null,null],"mistral/codestral-2508":[3e-7,9e-7,null,null],"codestral-2508":[3e-7,9e-7,null,null],"mistral/codestral-latest":[0.000001,0.000003,null,null],"mistral/codestral-mamba-latest":[2.5e-7,2.5e-7,null,null],"codestral-mamba-latest":[2.5e-7,2.5e-7,null,null],"mistral/devstral-medium-2507":[4e-7,0.000002,null,null],"devstral-medium-2507":[4e-7,0.000002,null,null],"mistral/devstral-small-2505":[1e-7,3e-7,null,null],"devstral-small-2505":[1e-7,3e-7,null,null],"mistral/devstral-small-2507":[1e-7,3e-7,null,null],"devstral-small-2507":[1e-7,3e-7,null,null],"mistral/devstral-small-latest":[1e-7,3e-7,null,null],"devstral-small-latest":[1e-7,3e-7,null,null],"mistral/labs-devstral-small-2512":[1e-7,3e-7,null,null],"labs-devstral-small-2512":[1e-7,3e-7,null,null],"mistral/devstral-latest":[4e-7,0.000002,null,null],"devstral-latest":[4e-7,0.000002,null,null],"mistral/devstral-medium-latest":[4e-7,0.000002,null,null],"devstral-medium-latest":[4e-7,0.000002,null,null],"mistral/devstral-2512":[4e-7,0.000002,null,null],"devstral-2512":[4e-7,0.000002,null,null],"mistral/magistral-medium-2506":[0.000002,0.000005,null,null],"magistral-medium-2506":[0.000002,0.000005,null,null],"mistral/magistral-medium-2509":[0.000002,0.000005,null,null],"magistral-medium-2509":[0.000002,0.000005,null,null],"mistral/magistral-medium-1-2-2509":[0.000002,0.000005,null,null],"magistral-medium-1-2-2509":[0.000002,0.000005,null,null],"mistral/magistral-medium-latest":[0.000002,0.000005,null,null],"magistral-medium-latest":[0.000002,0.000005,null,null],"mistral/magistral-small-2506":[5e-7,0.0000015,null,null],"magistral-small-2506":[5e-7,0.0000015,null,null],"mistral/magistral-small-latest":[5e-7,0.0000015,null,null],"magistral-small-latest":[5e-7,0.0000015,null,null],"mistral/magistral-small-1-2-2509":[5e-7,0.0000015,null,null],"magistral-small-1-2-2509":[5e-7,0.0000015,null,null],"mistral/mistral-large-2402":[0.000004,0.000012,null,null],"mistral/mistral-large-2407":[0.000003,0.000009,null,null],"mistral/mistral-large-2411":[0.000002,0.000006,null,null],"mistral-large-2411":[0.000002,0.000006,null,null],"mistral/mistral-large-latest":[5e-7,0.0000015,null,null],"mistral/mistral-large-3":[5e-7,0.0000015,null,null],"mistral/mistral-large-2512":[5e-7,0.0000015,null,null],"mistral-large-2512":[5e-7,0.0000015,null,null],"mistral/mistral-medium":[0.0000027,0.0000081,null,null],"mistral-medium":[0.0000027,0.0000081,null,null],"mistral/mistral-medium-2312":[0.0000027,0.0000081,null,null],"mistral-medium-2312":[0.0000027,0.0000081,null,null],"mistral/mistral-medium-2505":[4e-7,0.000002,null,null],"mistral/mistral-medium-latest":[4e-7,0.000002,null,null],"mistral-medium-latest":[4e-7,0.000002,null,null],"mistral/mistral-medium-3-1-2508":[4e-7,0.000002,null,null],"mistral-medium-3-1-2508":[4e-7,0.000002,null,null],"mistral/mistral-small":[1e-7,3e-7,null,null],"mistral/mistral-small-latest":[6e-8,1.8e-7,null,null],"mistral-small-latest":[6e-8,1.8e-7,null,null],"mistral/mistral-small-3-2-2506":[6e-8,1.8e-7,null,null],"mistral-small-3-2-2506":[6e-8,1.8e-7,null,null],"mistral/ministral-3-3b-2512":[1e-7,1e-7,null,null],"ministral-3-3b-2512":[1e-7,1e-7,null,null],"mistral/ministral-3-8b-2512":[1.5e-7,1.5e-7,null,null],"ministral-3-8b-2512":[1.5e-7,1.5e-7,null,null],"mistral/ministral-3-14b-2512":[2e-7,2e-7,null,null],"ministral-3-14b-2512":[2e-7,2e-7,null,null],"mistral/mistral-tiny":[2.5e-7,2.5e-7,null,null],"mistral-tiny":[2.5e-7,2.5e-7,null,null],"mistral/open-codestral-mamba":[2.5e-7,2.5e-7,null,null],"open-codestral-mamba":[2.5e-7,2.5e-7,null,null],"mistral/open-mistral-7b":[2.5e-7,2.5e-7,null,null],"open-mistral-7b":[2.5e-7,2.5e-7,null,null],"mistral/open-mistral-nemo":[3e-7,3e-7,null,null],"open-mistral-nemo":[3e-7,3e-7,null,null],"mistral/open-mistral-nemo-2407":[3e-7,3e-7,null,null],"open-mistral-nemo-2407":[3e-7,3e-7,null,null],"mistral/open-mixtral-8x22b":[0.000002,0.000006,null,null],"open-mixtral-8x22b":[0.000002,0.000006,null,null],"mistral/open-mixtral-8x7b":[7e-7,7e-7,null,null],"open-mixtral-8x7b":[7e-7,7e-7,null,null],"mistral/pixtral-12b-2409":[1.5e-7,1.5e-7,null,null],"pixtral-12b-2409":[1.5e-7,1.5e-7,null,null],"mistral/pixtral-large-2411":[0.000002,0.000006,null,null],"pixtral-large-2411":[0.000002,0.000006,null,null],"mistral/pixtral-large-latest":[0.000002,0.000006,null,null],"pixtral-large-latest":[0.000002,0.000006,null,null],"moonshot/kimi-k2-0711-preview":[6e-7,0.0000025,null,1.5e-7],"kimi-k2-0711-preview":[6e-7,0.0000025,null,1.5e-7],"moonshot/kimi-k2-0905-preview":[6e-7,0.0000025,null,1.5e-7],"kimi-k2-0905-preview":[6e-7,0.0000025,null,1.5e-7],"moonshot/kimi-k2-turbo-preview":[0.00000115,0.000008,null,1.5e-7],"kimi-k2-turbo-preview":[0.00000115,0.000008,null,1.5e-7],"moonshot/kimi-k2.5":[6e-7,0.000003,null,1e-7],"moonshot/kimi-k2.6":[9.5e-7,0.000004,null,1.6e-7],"kimi-k2.6":[9.5e-7,0.000004,null,1.6e-7],"moonshot/kimi-latest":[0.000002,0.000005,null,1.5e-7],"kimi-latest":[0.000002,0.000005,null,1.5e-7],"moonshot/kimi-latest-128k":[0.000002,0.000005,null,1.5e-7],"kimi-latest-128k":[0.000002,0.000005,null,1.5e-7],"moonshot/kimi-latest-32k":[0.000001,0.000003,null,1.5e-7],"kimi-latest-32k":[0.000001,0.000003,null,1.5e-7],"moonshot/kimi-latest-8k":[2e-7,0.000002,null,1.5e-7],"kimi-latest-8k":[2e-7,0.000002,null,1.5e-7],"moonshot/kimi-thinking-preview":[6e-7,0.0000025,null,1.5e-7],"kimi-thinking-preview":[6e-7,0.0000025,null,1.5e-7],"moonshot/kimi-k2-thinking":[6e-7,0.0000025,null,1.5e-7],"kimi-k2-thinking":[6e-7,0.0000025,null,1.5e-7],"moonshot/kimi-k2-thinking-turbo":[0.00000115,0.000008,null,1.5e-7],"kimi-k2-thinking-turbo":[0.00000115,0.000008,null,1.5e-7],"moonshot/moonshot-v1-128k":[0.000002,0.000005,null,null],"moonshot-v1-128k":[0.000002,0.000005,null,null],"moonshot/moonshot-v1-128k-0430":[0.000002,0.000005,null,null],"moonshot-v1-128k-0430":[0.000002,0.000005,null,null],"moonshot/moonshot-v1-128k-vision-preview":[0.000002,0.000005,null,null],"moonshot-v1-128k-vision-preview":[0.000002,0.000005,null,null],"moonshot/moonshot-v1-32k":[0.000001,0.000003,null,null],"moonshot-v1-32k":[0.000001,0.000003,null,null],"moonshot/moonshot-v1-32k-0430":[0.000001,0.000003,null,null],"moonshot-v1-32k-0430":[0.000001,0.000003,null,null],"moonshot/moonshot-v1-32k-vision-preview":[0.000001,0.000003,null,null],"moonshot-v1-32k-vision-preview":[0.000001,0.000003,null,null],"moonshot/moonshot-v1-8k":[2e-7,0.000002,null,null],"moonshot-v1-8k":[2e-7,0.000002,null,null],"moonshot/moonshot-v1-8k-0430":[2e-7,0.000002,null,null],"moonshot-v1-8k-0430":[2e-7,0.000002,null,null],"moonshot/moonshot-v1-8k-vision-preview":[2e-7,0.000002,null,null],"moonshot-v1-8k-vision-preview":[2e-7,0.000002,null,null],"moonshot/moonshot-v1-auto":[0.000002,0.000005,null,null],"moonshot-v1-auto":[0.000002,0.000005,null,null],"morph/morph-v3-fast":[8e-7,0.0000012,null,null],"morph-v3-fast":[8e-7,0.0000012,null,null],"morph/morph-v3-large":[9e-7,0.0000019,null,null],"morph-v3-large":[9e-7,0.0000019,null,null],"nscale/Qwen/QwQ-32B":[1.8e-7,2e-7,null,null],"nscale/Qwen/Qwen2.5-Coder-32B-Instruct":[6e-8,2e-7,null,null],"nscale/Qwen/Qwen2.5-Coder-3B-Instruct":[1e-8,3e-8,null,null],"Qwen/Qwen2.5-Coder-3B-Instruct":[1e-8,3e-8,null,null],"nscale/Qwen/Qwen2.5-Coder-7B-Instruct":[1e-8,3e-8,null,null],"Qwen/Qwen2.5-Coder-7B-Instruct":[1e-8,3e-8,null,null],"nscale/deepseek-ai/DeepSeek-R1-Distill-Llama-70B":[3.75e-7,3.75e-7,null,null],"nscale/deepseek-ai/DeepSeek-R1-Distill-Llama-8B":[2.5e-8,2.5e-8,null,null],"deepseek-ai/DeepSeek-R1-Distill-Llama-8B":[2.5e-8,2.5e-8,null,null],"nscale/deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B":[9e-8,9e-8,null,null],"deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B":[9e-8,9e-8,null,null],"nscale/deepseek-ai/DeepSeek-R1-Distill-Qwen-14B":[7e-8,7e-8,null,null],"deepseek-ai/DeepSeek-R1-Distill-Qwen-14B":[7e-8,7e-8,null,null],"nscale/deepseek-ai/DeepSeek-R1-Distill-Qwen-32B":[1.5e-7,1.5e-7,null,null],"nscale/deepseek-ai/DeepSeek-R1-Distill-Qwen-7B":[2e-7,2e-7,null,null],"deepseek-ai/DeepSeek-R1-Distill-Qwen-7B":[2e-7,2e-7,null,null],"nscale/meta-llama/Llama-3.1-8B-Instruct":[3e-8,3e-8,null,null],"meta-llama/Llama-3.1-8B-Instruct":[3e-8,3e-8,null,null],"nscale/meta-llama/Llama-3.3-70B-Instruct":[2e-7,2e-7,null,null],"nscale/meta-llama/Llama-4-Scout-17B-16E-Instruct":[9e-8,2.9e-7,null,null],"nscale/mistralai/mixtral-8x22b-instruct-v0.1":[6e-7,6e-7,null,null],"mistralai/mixtral-8x22b-instruct-v0.1":[6e-7,6e-7,null,null],"nebius/deepseek-ai/DeepSeek-R1":[8e-7,0.0000024,null,null],"nebius/deepseek-ai/DeepSeek-R1-0528":[8e-7,0.0000024,null,null],"nebius/deepseek-ai/DeepSeek-R1-Distill-Llama-70B":[2.5e-7,7.5e-7,null,null],"nebius/deepseek-ai/DeepSeek-V3":[5e-7,0.0000015,null,null],"nebius/deepseek-ai/DeepSeek-V3-0324":[5e-7,0.0000015,null,null],"nebius/google/gemma-3-27b-it":[6e-8,2e-7,null,null],"nebius/meta-llama/Llama-3.3-70B-Instruct":[1.3e-7,4e-7,null,null],"nebius/meta-llama/Llama-Guard-3-8B":[2e-8,6e-8,null,null],"nebius/meta-llama/Meta-Llama-3.1-8B-Instruct":[2e-8,6e-8,null,null],"nebius/meta-llama/Meta-Llama-3.1-70B-Instruct":[1.3e-7,4e-7,null,null],"nebius/meta-llama/Meta-Llama-3.1-405B-Instruct":[0.000001,0.000003,null,null],"nebius/mistralai/Mistral-Nemo-Instruct-2407":[4e-8,1.2e-7,null,null],"nebius/NousResearch/Hermes-3-Llama-3.1-405B":[0.000001,0.000003,null,null],"nebius/nvidia/Llama-3.1-Nemotron-Ultra-253B-v1":[6e-7,0.0000018,null,null],"nvidia/Llama-3.1-Nemotron-Ultra-253B-v1":[6e-7,0.0000018,null,null],"nebius/nvidia/Llama-3.3-Nemotron-Super-49B-v1":[1e-7,4e-7,null,null],"nvidia/Llama-3.3-Nemotron-Super-49B-v1":[1e-7,4e-7,null,null],"nebius/Qwen/Qwen3-235B-A22B":[2e-7,6e-7,null,null],"nebius/Qwen/Qwen3-32B":[1e-7,3e-7,null,null],"nebius/Qwen/Qwen3-30B-A3B":[1e-7,3e-7,null,null],"nebius/Qwen/Qwen3-14B":[8e-8,2.4e-7,null,null],"nebius/Qwen/Qwen3-4B":[8e-8,2.4e-7,null,null],"Qwen/Qwen3-4B":[8e-8,2.4e-7,null,null],"nebius/Qwen/QwQ-32B":[1.5e-7,4.5e-7,null,null],"nebius/Qwen/Qwen2.5-72B-Instruct":[1.3e-7,4e-7,null,null],"nebius/Qwen/Qwen2.5-32B-Instruct":[6e-8,2e-7,null,null],"Qwen/Qwen2.5-32B-Instruct":[6e-8,2e-7,null,null],"nebius/Qwen/Qwen2.5-Coder-7B":[1e-8,3e-8,null,null],"Qwen/Qwen2.5-Coder-7B":[1e-8,3e-8,null,null],"nebius/Qwen/Qwen2.5-VL-72B-Instruct":[1.3e-7,4e-7,null,null],"Qwen/Qwen2.5-VL-72B-Instruct":[1.3e-7,4e-7,null,null],"nebius/Qwen/Qwen2-VL-72B-Instruct":[1.3e-7,4e-7,null,null],"Qwen/Qwen2-VL-72B-Instruct":[1.3e-7,4e-7,null,null],"nebius/Qwen/Qwen2-VL-7B-Instruct":[2e-8,6e-8,null,null],"Qwen/Qwen2-VL-7B-Instruct":[2e-8,6e-8,null,null],"nebius/BAAI/bge-en-icl":[1e-8,0,null,null],"BAAI/bge-en-icl":[1e-8,0,null,null],"nebius/BAAI/bge-multilingual-gemma2":[1e-8,0,null,null],"BAAI/bge-multilingual-gemma2":[1e-8,0,null,null],"nebius/intfloat/e5-mistral-7b-instruct":[1e-8,0,null,null],"intfloat/e5-mistral-7b-instruct":[1e-8,0,null,null],"oci/meta.llama-3.1-405b-instruct":[0.00001068,0.00001068,null,null],"meta.llama-3.1-405b-instruct":[0.00001068,0.00001068,null,null],"oci/meta.llama-3.2-90b-vision-instruct":[0.000002,0.000002,null,null],"meta.llama-3.2-90b-vision-instruct":[0.000002,0.000002,null,null],"oci/meta.llama-3.3-70b-instruct":[7.2e-7,7.2e-7,null,null],"meta.llama-3.3-70b-instruct":[7.2e-7,7.2e-7,null,null],"oci/meta.llama-4-maverick-17b-128e-instruct-fp8":[7.2e-7,7.2e-7,null,null],"meta.llama-4-maverick-17b-128e-instruct-fp8":[7.2e-7,7.2e-7,null,null],"oci/meta.llama-4-scout-17b-16e-instruct":[7.2e-7,7.2e-7,null,null],"meta.llama-4-scout-17b-16e-instruct":[7.2e-7,7.2e-7,null,null],"oci/xai.grok-3":[0.000003,0.000015,null,null],"xai.grok-3":[0.000003,0.000015,null,null],"oci/xai.grok-3-fast":[0.000005,0.000025,null,null],"xai.grok-3-fast":[0.000005,0.000025,null,null],"oci/xai.grok-3-mini":[3e-7,5e-7,null,null],"xai.grok-3-mini":[3e-7,5e-7,null,null],"oci/xai.grok-3-mini-fast":[6e-7,0.000004,null,null],"xai.grok-3-mini-fast":[6e-7,0.000004,null,null],"oci/xai.grok-4":[0.000003,0.000015,null,null],"xai.grok-4":[0.000003,0.000015,null,null],"oci/cohere.command-latest":[0.00000156,0.00000156,null,null],"cohere.command-latest":[0.00000156,0.00000156,null,null],"oci/cohere.command-a-03-2025":[0.00000156,0.00000156,null,null],"cohere.command-a-03-2025":[0.00000156,0.00000156,null,null],"oci/cohere.command-plus-latest":[0.00000156,0.00000156,null,null],"cohere.command-plus-latest":[0.00000156,0.00000156,null,null],"oci/cohere.command-a-reasoning-08-2025":[0.00000156,0.00000156,null,null],"cohere.command-a-reasoning-08-2025":[0.00000156,0.00000156,null,null],"oci/cohere.command-a-vision-07-2025":[0.00000156,0.00000156,null,null],"cohere.command-a-vision-07-2025":[0.00000156,0.00000156,null,null],"oci/cohere.command-a-translate-08-2025":[9e-8,9e-8,null,null],"cohere.command-a-translate-08-2025":[9e-8,9e-8,null,null],"oci/cohere.command-r-08-2024":[1.5e-7,1.5e-7,null,null],"cohere.command-r-08-2024":[1.5e-7,1.5e-7,null,null],"oci/cohere.command-r-plus-08-2024":[0.00000156,0.00000156,null,null],"cohere.command-r-plus-08-2024":[0.00000156,0.00000156,null,null],"oci/meta.llama-3.2-11b-vision-instruct":[0.000002,0.000002,null,null],"meta.llama-3.2-11b-vision-instruct":[0.000002,0.000002,null,null],"oci/meta.llama-3.1-70b-instruct":[7.2e-7,7.2e-7,null,null],"meta.llama-3.1-70b-instruct":[7.2e-7,7.2e-7,null,null],"oci/meta.llama-3.3-70b-instruct-fp8-dynamic":[7.2e-7,7.2e-7,null,null],"meta.llama-3.3-70b-instruct-fp8-dynamic":[7.2e-7,7.2e-7,null,null],"oci/xai.grok-4-fast":[0.000005,0.000025,null,null],"xai.grok-4-fast":[0.000005,0.000025,null,null],"oci/xai.grok-4.1-fast":[0.000005,0.000025,null,null],"xai.grok-4.1-fast":[0.000005,0.000025,null,null],"oci/xai.grok-4.20":[0.000003,0.000015,null,null],"xai.grok-4.20":[0.000003,0.000015,null,null],"oci/xai.grok-4.20-multi-agent":[0.000003,0.000015,null,null],"xai.grok-4.20-multi-agent":[0.000003,0.000015,null,null],"oci/xai.grok-code-fast-1":[0.000005,0.000025,null,null],"xai.grok-code-fast-1":[0.000005,0.000025,null,null],"oci/google.gemini-2.5-pro":[0.00000125,0.00001,null,null],"google.gemini-2.5-pro":[0.00000125,0.00001,null,null],"oci/google.gemini-2.5-flash":[1.5e-7,6e-7,null,null],"google.gemini-2.5-flash":[1.5e-7,6e-7,null,null],"oci/google.gemini-2.5-flash-lite":[7.5e-8,3e-7,null,null],"google.gemini-2.5-flash-lite":[7.5e-8,3e-7,null,null],"oci/cohere.embed-english-v3.0":[1e-7,0,null,null],"cohere.embed-english-v3.0":[1e-7,0,null,null],"oci/cohere.embed-english-light-v3.0":[1e-7,0,null,null],"cohere.embed-english-light-v3.0":[1e-7,0,null,null],"oci/cohere.embed-multilingual-v3.0":[1e-7,0,null,null],"cohere.embed-multilingual-v3.0":[1e-7,0,null,null],"oci/cohere.embed-multilingual-light-v3.0":[1e-7,0,null,null],"cohere.embed-multilingual-light-v3.0":[1e-7,0,null,null],"oci/cohere.embed-english-image-v3.0":[1e-7,0,null,null],"cohere.embed-english-image-v3.0":[1e-7,0,null,null],"oci/cohere.embed-english-light-image-v3.0":[1e-7,0,null,null],"cohere.embed-english-light-image-v3.0":[1e-7,0,null,null],"oci/cohere.embed-multilingual-light-image-v3.0":[1e-7,0,null,null],"cohere.embed-multilingual-light-image-v3.0":[1e-7,0,null,null],"oci/cohere.embed-v4.0":[1.2e-7,0,null,null],"cohere.embed-v4.0":[1.2e-7,0,null,null],"ollama/codegeex4":[0,0,null,null],"codegeex4":[0,0,null,null],"ollama/codegemma":[0,0,null,null],"codegemma":[0,0,null,null],"ollama/codellama":[0,0,null,null],"codellama":[0,0,null,null],"ollama/deepseek-coder-v2-base":[0,0,null,null],"deepseek-coder-v2-base":[0,0,null,null],"ollama/deepseek-coder-v2-instruct":[0,0,null,null],"deepseek-coder-v2-instruct":[0,0,null,null],"ollama/deepseek-coder-v2-lite-base":[0,0,null,null],"deepseek-coder-v2-lite-base":[0,0,null,null],"ollama/deepseek-coder-v2-lite-instruct":[0,0,null,null],"deepseek-coder-v2-lite-instruct":[0,0,null,null],"ollama/deepseek-v3.1:671b-cloud":[0,0,null,null],"deepseek-v3.1:671b-cloud":[0,0,null,null],"ollama/gpt-oss:120b-cloud":[0,0,null,null],"gpt-oss:120b-cloud":[0,0,null,null],"ollama/gpt-oss:20b-cloud":[0,0,null,null],"gpt-oss:20b-cloud":[0,0,null,null],"ollama/internlm2_5-20b-chat":[0,0,null,null],"internlm2_5-20b-chat":[0,0,null,null],"ollama/llama2":[0,0,null,null],"llama2":[0,0,null,null],"ollama/llama2-uncensored":[0,0,null,null],"llama2-uncensored":[0,0,null,null],"ollama/llama2:13b":[0,0,null,null],"llama2:13b":[0,0,null,null],"ollama/llama2:70b":[0,0,null,null],"llama2:70b":[0,0,null,null],"ollama/llama2:7b":[0,0,null,null],"llama2:7b":[0,0,null,null],"ollama/llama3":[0,0,null,null],"llama3":[0,0,null,null],"ollama/llama3.1":[0,0,null,null],"llama3.1":[0,0,null,null],"ollama/llama3:70b":[0,0,null,null],"llama3:70b":[0,0,null,null],"ollama/llama3:8b":[0,0,null,null],"llama3:8b":[0,0,null,null],"ollama/mistral":[0,0,null,null],"mistral":[0,0,null,null],"ollama/mistral-7B-Instruct-v0.1":[0,0,null,null],"mistral-7B-Instruct-v0.1":[0,0,null,null],"ollama/mistral-7B-Instruct-v0.2":[0,0,null,null],"mistral-7B-Instruct-v0.2":[0,0,null,null],"ollama/mistral-large-instruct-2407":[0,0,null,null],"mistral-large-instruct-2407":[0,0,null,null],"ollama/mixtral-8x22B-Instruct-v0.1":[0,0,null,null],"mixtral-8x22B-Instruct-v0.1":[0,0,null,null],"ollama/mixtral-8x7B-Instruct-v0.1":[0,0,null,null],"mixtral-8x7B-Instruct-v0.1":[0,0,null,null],"ollama/orca-mini":[0,0,null,null],"orca-mini":[0,0,null,null],"ollama/qwen3-coder:480b-cloud":[0,0,null,null],"qwen3-coder:480b-cloud":[0,0,null,null],"ollama/vicuna":[0,0,null,null],"vicuna":[0,0,null,null],"openrouter/anthropic/claude-3-haiku":[2.5e-7,0.00000125,null,null],"anthropic/claude-3-haiku":[2.5e-7,0.00000125,null,null],"openrouter/anthropic/claude-3.5-sonnet":[0.000003,0.000015,null,null],"anthropic/claude-3.5-sonnet":[0.000003,0.000015,null,null],"openrouter/anthropic/claude-3.7-sonnet":[0.000003,0.000015,null,null],"anthropic/claude-3.7-sonnet":[0.000003,0.000015,null,null],"openrouter/anthropic/claude-opus-4":[0.000015,0.000075,0.00001875,0.0000015],"openrouter/anthropic/claude-opus-4.1":[0.000015,0.000075,0.00001875,0.0000015],"anthropic/claude-opus-4.1":[0.000015,0.000075,0.00001875,0.0000015],"openrouter/anthropic/claude-sonnet-4":[0.000003,0.000015,0.00000375,3e-7],"openrouter/anthropic/claude-sonnet-4.6":[0.000003,0.000015,0.00000375,3e-7],"anthropic/claude-sonnet-4.6":[0.000003,0.000015,0.00000375,3e-7],"openrouter/anthropic/claude-opus-4.5":[0.000005,0.000025,0.00000625,5e-7],"openrouter/anthropic/claude-opus-4.6":[0.000005,0.000025,0.00000625,5e-7],"anthropic/claude-opus-4.6":[0.000005,0.000025,0.00000625,5e-7],"openrouter/anthropic/claude-sonnet-4.5":[0.000003,0.000015,0.00000375,3e-7],"openrouter/anthropic/claude-haiku-4.5":[0.000001,0.000005,0.00000125,1e-7],"anthropic/claude-haiku-4.5":[0.000001,0.000005,0.00000125,1e-7],"openrouter/anthropic/claude-opus-4.7":[0.000005,0.000025,0.00000625,5e-7],"anthropic/claude-opus-4.7":[0.000005,0.000025,0.00000625,5e-7],"openrouter/bytedance/ui-tars-1.5-7b":[1e-7,2e-7,null,null],"bytedance/ui-tars-1.5-7b":[1e-7,2e-7,null,null],"openrouter/deepseek/deepseek-chat":[1.4e-7,2.8e-7,null,null],"openrouter/deepseek/deepseek-chat-v3-0324":[1.4e-7,2.8e-7,null,null],"deepseek/deepseek-chat-v3-0324":[1.4e-7,2.8e-7,null,null],"openrouter/deepseek/deepseek-chat-v3.1":[2e-7,8e-7,null,null],"deepseek/deepseek-chat-v3.1":[2e-7,8e-7,null,null],"openrouter/deepseek/deepseek-v3.2":[2.8e-7,4e-7,null,null],"openrouter/deepseek/deepseek-v3.2-exp":[2e-7,4e-7,null,null],"deepseek/deepseek-v3.2-exp":[2e-7,4e-7,null,null],"openrouter/deepseek/deepseek-r1":[5.5e-7,0.00000219,null,null],"openrouter/deepseek/deepseek-r1-0528":[5e-7,0.00000215,null,null],"deepseek/deepseek-r1-0528":[5e-7,0.00000215,null,null],"openrouter/google/gemini-2.0-flash-001":[1e-7,4e-7,null,null],"openrouter/google/gemini-2.5-flash":[3e-7,0.0000025,null,null],"openrouter/google/gemini-2.5-pro":[0.00000125,0.00001,null,null],"openrouter/google/gemini-3-pro-preview":[0.000002,0.000012,null,2e-7],"openrouter/google/gemini-3-flash-preview":[5e-7,0.000003,null,5e-8],"openrouter/google/gemini-3.1-flash-lite-preview":[2.5e-7,0.0000015,null,2.5e-8],"google/gemini-3.1-flash-lite-preview":[2.5e-7,0.0000015,null,2.5e-8],"openrouter/google/gemini-3.1-pro-preview":[0.000002,0.000012,null,2e-7],"google/gemini-3.1-pro-preview":[0.000002,0.000012,null,2e-7],"openrouter/gryphe/mythomax-l2-13b":[0.000001875,0.000001875,null,null],"gryphe/mythomax-l2-13b":[0.000001875,0.000001875,null,null],"openrouter/mancer/weaver":[0.000005625,0.000005625,null,null],"mancer/weaver":[0.000005625,0.000005625,null,null],"openrouter/meta-llama/llama-3-70b-instruct":[5.9e-7,7.9e-7,null,null],"meta-llama/llama-3-70b-instruct":[5.9e-7,7.9e-7,null,null],"openrouter/minimax/minimax-m2":[2.55e-7,0.00000102,null,null],"minimax/minimax-m2":[2.55e-7,0.00000102,null,null],"openrouter/mistralai/devstral-2512":[1.5e-7,6e-7,null,null],"mistralai/devstral-2512":[1.5e-7,6e-7,null,null],"openrouter/mistralai/ministral-3b-2512":[1e-7,1e-7,null,null],"mistralai/ministral-3b-2512":[1e-7,1e-7,null,null],"openrouter/mistralai/ministral-8b-2512":[1.5e-7,1.5e-7,null,null],"mistralai/ministral-8b-2512":[1.5e-7,1.5e-7,null,null],"openrouter/mistralai/ministral-14b-2512":[2e-7,2e-7,null,null],"mistralai/ministral-14b-2512":[2e-7,2e-7,null,null],"openrouter/mistralai/mistral-large-2512":[5e-7,0.0000015,null,null],"mistralai/mistral-large-2512":[5e-7,0.0000015,null,null],"openrouter/mistralai/mistral-7b-instruct":[1.3e-7,1.3e-7,null,null],"mistralai/mistral-7b-instruct":[1.3e-7,1.3e-7,null,null],"openrouter/mistralai/mistral-large":[0.000008,0.000024,null,null],"mistralai/mistral-large":[0.000008,0.000024,null,null],"openrouter/mistralai/mistral-small-3.1-24b-instruct":[1e-7,3e-7,null,null],"mistralai/mistral-small-3.1-24b-instruct":[1e-7,3e-7,null,null],"openrouter/mistralai/mistral-small-3.2-24b-instruct":[1e-7,3e-7,null,null],"mistralai/mistral-small-3.2-24b-instruct":[1e-7,3e-7,null,null],"openrouter/mistralai/mixtral-8x22b-instruct":[6.5e-7,6.5e-7,null,null],"mistralai/mixtral-8x22b-instruct":[6.5e-7,6.5e-7,null,null],"openrouter/moonshotai/kimi-k2.5":[6e-7,0.000003,null,1e-7],"moonshotai/kimi-k2.5":[6e-7,0.000003,null,1e-7],"openrouter/openai/gpt-3.5-turbo":[0.0000015,0.000002,null,null],"openai/gpt-3.5-turbo":[0.0000015,0.000002,null,null],"openrouter/openai/gpt-3.5-turbo-16k":[0.000003,0.000004,null,null],"openai/gpt-3.5-turbo-16k":[0.000003,0.000004,null,null],"openrouter/openai/gpt-4":[0.00003,0.00006,null,null],"openai/gpt-4":[0.00003,0.00006,null,null],"openrouter/openai/gpt-4.1":[0.000002,0.000008,null,5e-7],"openai/gpt-4.1":[0.000002,0.000008,null,5e-7],"openrouter/openai/gpt-4.1-mini":[4e-7,0.0000016,null,1e-7],"openai/gpt-4.1-mini":[4e-7,0.0000016,null,1e-7],"openrouter/openai/gpt-4.1-nano":[1e-7,4e-7,null,2.5e-8],"openai/gpt-4.1-nano":[1e-7,4e-7,null,2.5e-8],"openrouter/openai/gpt-4o":[0.0000025,0.00001,null,null],"openrouter/openai/gpt-4o-2024-05-13":[0.000005,0.000015,null,null],"openai/gpt-4o-2024-05-13":[0.000005,0.000015,null,null],"openrouter/openai/gpt-5-chat":[0.00000125,0.00001,null,1.25e-7],"openai/gpt-5-chat":[0.00000125,0.00001,null,1.25e-7],"openrouter/openai/gpt-5-codex":[0.00000125,0.00001,null,1.25e-7],"openai/gpt-5-codex":[0.00000125,0.00001,null,1.25e-7],"openrouter/openai/gpt-5.2-codex":[0.00000175,0.000014,null,1.75e-7],"openai/gpt-5.2-codex":[0.00000175,0.000014,null,1.75e-7],"openrouter/openai/gpt-5":[0.00000125,0.00001,null,1.25e-7],"openrouter/openai/gpt-5-mini":[2.5e-7,0.000002,null,2.5e-8],"openai/gpt-5-mini":[2.5e-7,0.000002,null,2.5e-8],"openrouter/openai/gpt-5-nano":[5e-8,4e-7,null,5e-9],"openai/gpt-5-nano":[5e-8,4e-7,null,5e-9],"openrouter/openai/gpt-5.1-codex-max":[0.00000125,0.00001,null,1.25e-7],"openai/gpt-5.1-codex-max":[0.00000125,0.00001,null,1.25e-7],"openrouter/openai/gpt-5.2":[0.00000175,0.000014,null,1.75e-7],"openrouter/openai/gpt-5.2-chat":[0.00000175,0.000014,null,1.75e-7],"openai/gpt-5.2-chat":[0.00000175,0.000014,null,1.75e-7],"openrouter/openai/gpt-5.2-pro":[0.000021,0.000168,null,null],"openai/gpt-5.2-pro":[0.000021,0.000168,null,null],"openrouter/openai/gpt-oss-120b":[1.8e-7,8e-7,null,null],"openrouter/openai/gpt-oss-20b":[2e-8,1e-7,null,null],"openrouter/openai/o1":[0.000015,0.00006,null,0.0000075],"openai/o1":[0.000015,0.00006,null,0.0000075],"openrouter/openai/o3-mini":[0.0000011,0.0000044,null,null],"openai/o3-mini":[0.0000011,0.0000044,null,null],"openrouter/openai/o3-mini-high":[0.0000011,0.0000044,null,null],"openai/o3-mini-high":[0.0000011,0.0000044,null,null],"openrouter/qwen/qwen-2.5-coder-32b-instruct":[1.8e-7,1.8e-7,null,null],"qwen/qwen-2.5-coder-32b-instruct":[1.8e-7,1.8e-7,null,null],"openrouter/qwen/qwen-vl-plus":[2.1e-7,6.3e-7,null,null],"qwen/qwen-vl-plus":[2.1e-7,6.3e-7,null,null],"openrouter/qwen/qwen3-coder":[2.2e-7,9.5e-7,null,null],"qwen/qwen3-coder":[2.2e-7,9.5e-7,null,null],"openrouter/qwen/qwen3-coder-plus":[0.000001,0.000005,null,null],"qwen/qwen3-coder-plus":[0.000001,0.000005,null,null],"openrouter/qwen/qwen3-235b-a22b-2507":[7.1e-8,1e-7,null,null],"qwen/qwen3-235b-a22b-2507":[7.1e-8,1e-7,null,null],"openrouter/qwen/qwen3-235b-a22b-thinking-2507":[1.1e-7,6e-7,null,null],"qwen/qwen3-235b-a22b-thinking-2507":[1.1e-7,6e-7,null,null],"openrouter/qwen/qwen3.6-plus":[3.25e-7,0.00000195,null,null],"qwen/qwen3.6-plus":[3.25e-7,0.00000195,null,null],"openrouter/qwen/qwen3.5-35b-a3b":[2.5e-7,0.000002,null,null],"qwen/qwen3.5-35b-a3b":[2.5e-7,0.000002,null,null],"openrouter/qwen/qwen3.5-27b":[3e-7,0.0000024,null,null],"qwen/qwen3.5-27b":[3e-7,0.0000024,null,null],"openrouter/qwen/qwen3.5-122b-a10b":[4e-7,0.000002,null,null],"qwen/qwen3.5-122b-a10b":[4e-7,0.000002,null,null],"openrouter/qwen/qwen3.5-flash-02-23":[1e-7,4e-7,null,null],"qwen/qwen3.5-flash-02-23":[1e-7,4e-7,null,null],"openrouter/qwen/qwen3.5-plus-02-15":[4e-7,0.0000024,null,null],"qwen/qwen3.5-plus-02-15":[4e-7,0.0000024,null,null],"openrouter/qwen/qwen3.5-397b-a17b":[6e-7,0.0000036,null,null],"qwen/qwen3.5-397b-a17b":[6e-7,0.0000036,null,null],"openrouter/switchpoint/router":[8.5e-7,0.0000034,null,null],"switchpoint/router":[8.5e-7,0.0000034,null,null],"openrouter/undi95/remm-slerp-l2-13b":[0.000001875,0.000001875,null,null],"undi95/remm-slerp-l2-13b":[0.000001875,0.000001875,null,null],"openrouter/x-ai/grok-4":[0.000003,0.000015,null,null],"x-ai/grok-4":[0.000003,0.000015,null,null],"openrouter/z-ai/glm-4.6":[4e-7,0.00000175,null,null],"z-ai/glm-4.6":[4e-7,0.00000175,null,null],"openrouter/z-ai/glm-4.6:exacto":[4.5e-7,0.0000019,null,null],"z-ai/glm-4.6:exacto":[4.5e-7,0.0000019,null,null],"openrouter/xiaomi/mimo-v2-flash":[9e-8,2.9e-7,0,0],"xiaomi/mimo-v2-flash":[9e-8,2.9e-7,0,0],"openrouter/z-ai/glm-4.7":[4e-7,0.0000015,0,0],"z-ai/glm-4.7":[4e-7,0.0000015,0,0],"openrouter/z-ai/glm-4.7-flash":[7e-8,4e-7,0,0],"z-ai/glm-4.7-flash":[7e-8,4e-7,0,0],"openrouter/z-ai/glm-5":[8e-7,0.00000256,null,null],"z-ai/glm-5":[8e-7,0.00000256,null,null],"openrouter/minimax/minimax-m2.1":[2.7e-7,0.0000012,0,0],"minimax/minimax-m2.1":[2.7e-7,0.0000012,0,0],"openrouter/minimax/minimax-m2.5":[3e-7,0.0000011,null,1.5e-7],"minimax/minimax-m2.5":[3e-7,0.0000011,null,1.5e-7],"openrouter/openrouter/auto":[0,0,null,null],"openrouter/auto":[0,0,null,null],"openrouter/openrouter/free":[0,0,null,null],"openrouter/free":[0,0,null,null],"openrouter/openrouter/bodybuilder":[0,0,null,null],"openrouter/bodybuilder":[0,0,null,null],"ovhcloud/DeepSeek-R1-Distill-Llama-70B":[6.7e-7,6.7e-7,null,null],"DeepSeek-R1-Distill-Llama-70B":[6.7e-7,6.7e-7,null,null],"ovhcloud/Llama-3.1-8B-Instruct":[1e-7,1e-7,null,null],"Llama-3.1-8B-Instruct":[1e-7,1e-7,null,null],"ovhcloud/Meta-Llama-3_1-70B-Instruct":[6.7e-7,6.7e-7,null,null],"Meta-Llama-3_1-70B-Instruct":[6.7e-7,6.7e-7,null,null],"ovhcloud/Meta-Llama-3_3-70B-Instruct":[6.7e-7,6.7e-7,null,null],"Meta-Llama-3_3-70B-Instruct":[6.7e-7,6.7e-7,null,null],"ovhcloud/Mistral-7B-Instruct-v0.3":[1e-7,1e-7,null,null],"Mistral-7B-Instruct-v0.3":[1e-7,1e-7,null,null],"ovhcloud/Mistral-Nemo-Instruct-2407":[1.3e-7,1.3e-7,null,null],"Mistral-Nemo-Instruct-2407":[1.3e-7,1.3e-7,null,null],"ovhcloud/Mistral-Small-3.2-24B-Instruct-2506":[9e-8,2.8e-7,null,null],"Mistral-Small-3.2-24B-Instruct-2506":[9e-8,2.8e-7,null,null],"ovhcloud/Mixtral-8x7B-Instruct-v0.1":[6.3e-7,6.3e-7,null,null],"Mixtral-8x7B-Instruct-v0.1":[6.3e-7,6.3e-7,null,null],"ovhcloud/Qwen2.5-Coder-32B-Instruct":[8.7e-7,8.7e-7,null,null],"Qwen2.5-Coder-32B-Instruct":[8.7e-7,8.7e-7,null,null],"ovhcloud/Qwen2.5-VL-72B-Instruct":[9.1e-7,9.1e-7,null,null],"Qwen2.5-VL-72B-Instruct":[9.1e-7,9.1e-7,null,null],"ovhcloud/Qwen3-32B":[8e-8,2.3e-7,null,null],"Qwen3-32B":[8e-8,2.3e-7,null,null],"ovhcloud/gpt-oss-120b":[8e-8,4e-7,null,null],"ovhcloud/gpt-oss-20b":[4e-8,1.5e-7,null,null],"gpt-oss-20b":[4e-8,1.5e-7,null,null],"ovhcloud/llava-v1.6-mistral-7b-hf":[2.9e-7,2.9e-7,null,null],"llava-v1.6-mistral-7b-hf":[2.9e-7,2.9e-7,null,null],"ovhcloud/mamba-codestral-7B-v0.1":[1.9e-7,1.9e-7,null,null],"mamba-codestral-7B-v0.1":[1.9e-7,1.9e-7,null,null],"palm/chat-bison":[1.25e-7,1.25e-7,null,null],"chat-bison":[1.25e-7,1.25e-7,null,null],"palm/chat-bison-001":[1.25e-7,1.25e-7,null,null],"chat-bison-001":[1.25e-7,1.25e-7,null,null],"palm/text-bison":[1.25e-7,1.25e-7,null,null],"text-bison":[1.25e-7,1.25e-7,null,null],"palm/text-bison-001":[1.25e-7,1.25e-7,null,null],"text-bison-001":[1.25e-7,1.25e-7,null,null],"palm/text-bison-safety-off":[1.25e-7,1.25e-7,null,null],"text-bison-safety-off":[1.25e-7,1.25e-7,null,null],"palm/text-bison-safety-recitation-off":[1.25e-7,1.25e-7,null,null],"text-bison-safety-recitation-off":[1.25e-7,1.25e-7,null,null],"perplexity/codellama-34b-instruct":[3.5e-7,0.0000014,null,null],"codellama-34b-instruct":[3.5e-7,0.0000014,null,null],"perplexity/codellama-70b-instruct":[7e-7,0.0000028,null,null],"codellama-70b-instruct":[7e-7,0.0000028,null,null],"perplexity/llama-2-70b-chat":[7e-7,0.0000028,null,null],"llama-2-70b-chat":[7e-7,0.0000028,null,null],"perplexity/llama-3.1-70b-instruct":[0.000001,0.000001,null,null],"llama-3.1-70b-instruct":[0.000001,0.000001,null,null],"perplexity/llama-3.1-8b-instruct":[2e-7,2e-7,null,null],"llama-3.1-8b-instruct":[2e-7,2e-7,null,null],"perplexity/mistral-7b-instruct":[7e-8,2.8e-7,null,null],"mistral-7b-instruct":[7e-8,2.8e-7,null,null],"perplexity/mixtral-8x7b-instruct":[7e-8,2.8e-7,null,null],"mixtral-8x7b-instruct":[7e-8,2.8e-7,null,null],"perplexity/pplx-70b-chat":[7e-7,0.0000028,null,null],"pplx-70b-chat":[7e-7,0.0000028,null,null],"perplexity/pplx-70b-online":[0,0.0000028,null,null],"pplx-70b-online":[0,0.0000028,null,null],"perplexity/pplx-7b-chat":[7e-8,2.8e-7,null,null],"pplx-7b-chat":[7e-8,2.8e-7,null,null],"perplexity/pplx-7b-online":[0,2.8e-7,null,null],"pplx-7b-online":[0,2.8e-7,null,null],"perplexity/sonar":[0.000001,0.000001,null,null],"sonar":[0.000001,0.000001,null,null],"perplexity/sonar-deep-research":[0.000002,0.000008,null,null],"sonar-deep-research":[0.000002,0.000008,null,null],"perplexity/sonar-medium-chat":[6e-7,0.0000018,null,null],"sonar-medium-chat":[6e-7,0.0000018,null,null],"perplexity/sonar-medium-online":[0,0.0000018,null,null],"sonar-medium-online":[0,0.0000018,null,null],"perplexity/sonar-pro":[0.000003,0.000015,null,null],"sonar-pro":[0.000003,0.000015,null,null],"perplexity/sonar-reasoning":[0.000001,0.000005,null,null],"sonar-reasoning":[0.000001,0.000005,null,null],"perplexity/sonar-reasoning-pro":[0.000002,0.000008,null,null],"sonar-reasoning-pro":[0.000002,0.000008,null,null],"perplexity/sonar-small-chat":[7e-8,2.8e-7,null,null],"sonar-small-chat":[7e-8,2.8e-7,null,null],"perplexity/sonar-small-online":[0,2.8e-7,null,null],"sonar-small-online":[0,2.8e-7,null,null],"publicai/swiss-ai/apertus-8b-instruct":[0,0,null,null],"swiss-ai/apertus-8b-instruct":[0,0,null,null],"publicai/swiss-ai/apertus-70b-instruct":[0,0,null,null],"swiss-ai/apertus-70b-instruct":[0,0,null,null],"publicai/aisingapore/Gemma-SEA-LION-v4-27B-IT":[0,0,null,null],"aisingapore/Gemma-SEA-LION-v4-27B-IT":[0,0,null,null],"publicai/BSC-LT/salamandra-7b-instruct-tools-16k":[0,0,null,null],"BSC-LT/salamandra-7b-instruct-tools-16k":[0,0,null,null],"publicai/BSC-LT/ALIA-40b-instruct_Q8_0":[0,0,null,null],"BSC-LT/ALIA-40b-instruct_Q8_0":[0,0,null,null],"publicai/allenai/Olmo-3-7B-Instruct":[0,0,null,null],"allenai/Olmo-3-7B-Instruct":[0,0,null,null],"perplexity/pplx-embed-v1-0.6b":[4e-9,0,null,null],"pplx-embed-v1-0.6b":[4e-9,0,null,null],"perplexity/pplx-embed-v1-4b":[3e-8,0,null,null],"pplx-embed-v1-4b":[3e-8,0,null,null],"publicai/aisingapore/Qwen-SEA-LION-v4-32B-IT":[0,0,null,null],"aisingapore/Qwen-SEA-LION-v4-32B-IT":[0,0,null,null],"publicai/allenai/Olmo-3-7B-Think":[0,0,null,null],"allenai/Olmo-3-7B-Think":[0,0,null,null],"publicai/allenai/Olmo-3-32B-Think":[0,0,null,null],"allenai/Olmo-3-32B-Think":[0,0,null,null],"replicate/meta/llama-2-13b":[1e-7,5e-7,null,null],"meta/llama-2-13b":[1e-7,5e-7,null,null],"replicate/meta/llama-2-13b-chat":[1e-7,5e-7,null,null],"meta/llama-2-13b-chat":[1e-7,5e-7,null,null],"replicate/meta/llama-2-70b":[6.5e-7,0.00000275,null,null],"meta/llama-2-70b":[6.5e-7,0.00000275,null,null],"replicate/meta/llama-2-70b-chat":[6.5e-7,0.00000275,null,null],"meta/llama-2-70b-chat":[6.5e-7,0.00000275,null,null],"replicate/meta/llama-2-7b":[5e-8,2.5e-7,null,null],"meta/llama-2-7b":[5e-8,2.5e-7,null,null],"replicate/meta/llama-2-7b-chat":[5e-8,2.5e-7,null,null],"meta/llama-2-7b-chat":[5e-8,2.5e-7,null,null],"replicate/meta/llama-3-70b":[6.5e-7,0.00000275,null,null],"meta/llama-3-70b":[6.5e-7,0.00000275,null,null],"replicate/meta/llama-3-70b-instruct":[6.5e-7,0.00000275,null,null],"meta/llama-3-70b-instruct":[6.5e-7,0.00000275,null,null],"replicate/meta/llama-3-8b":[5e-8,2.5e-7,null,null],"meta/llama-3-8b":[5e-8,2.5e-7,null,null],"replicate/meta/llama-3-8b-instruct":[5e-8,2.5e-7,null,null],"meta/llama-3-8b-instruct":[5e-8,2.5e-7,null,null],"replicate/mistralai/mistral-7b-instruct-v0.2":[5e-8,2.5e-7,null,null],"mistralai/mistral-7b-instruct-v0.2":[5e-8,2.5e-7,null,null],"replicate/mistralai/mistral-7b-v0.1":[5e-8,2.5e-7,null,null],"mistralai/mistral-7b-v0.1":[5e-8,2.5e-7,null,null],"replicate/mistralai/mixtral-8x7b-instruct-v0.1":[3e-7,0.000001,null,null],"mistralai/mixtral-8x7b-instruct-v0.1":[3e-7,0.000001,null,null],"replicate/openai/gpt-5":[0.00000125,0.00001,null,null],"replicateopenai/gpt-oss-20b":[9e-8,3.6e-7,null,null],"replicate/anthropic/claude-4.5-haiku":[0.000001,0.000005,null,null],"anthropic/claude-4.5-haiku":[0.000001,0.000005,null,null],"replicate/ibm-granite/granite-3.3-8b-instruct":[3e-8,2.5e-7,null,null],"ibm-granite/granite-3.3-8b-instruct":[3e-8,2.5e-7,null,null],"replicate/openai/gpt-4o":[0.0000025,0.00001,null,null],"replicate/openai/o4-mini":[0.000001,0.000004,null,null],"openai/o4-mini":[0.000001,0.000004,null,null],"replicate/openai/o1-mini":[0.0000011,0.0000044,null,null],"openai/o1-mini":[0.0000011,0.0000044,null,null],"replicate/openai/o1":[0.000015,0.00006,null,null],"replicate/openai/gpt-4o-mini":[1.5e-7,6e-7,null,null],"replicate/qwen/qwen3-235b-a22b-instruct-2507":[2.64e-7,0.00000106,null,null],"qwen/qwen3-235b-a22b-instruct-2507":[2.64e-7,0.00000106,null,null],"replicate/anthropic/claude-4-sonnet":[0.000003,0.000015,null,null],"replicate/deepseek-ai/deepseek-v3":[0.00000145,0.00000145,null,null],"deepseek-ai/deepseek-v3":[0.00000145,0.00000145,null,null],"replicate/anthropic/claude-3.7-sonnet":[0.000003,0.000015,null,null],"replicate/anthropic/claude-3.5-haiku":[0.000001,0.000005,null,null],"anthropic/claude-3.5-haiku":[0.000001,0.000005,null,null],"replicate/anthropic/claude-3.5-sonnet":[0.00000375,0.00001875,null,null],"replicate/google/gemini-3-pro":[0.000002,0.000012,null,null],"google/gemini-3-pro":[0.000002,0.000012,null,null],"replicate/anthropic/claude-4.5-sonnet":[0.000003,0.000015,null,null],"anthropic/claude-4.5-sonnet":[0.000003,0.000015,null,null],"replicate/openai/gpt-4.1":[0.000002,0.000008,null,null],"replicate/openai/gpt-4.1-nano":[1e-7,4e-7,null,null],"replicate/openai/gpt-4.1-mini":[4e-7,0.0000016,null,null],"replicate/openai/gpt-5-nano":[5e-8,4e-7,null,null],"replicate/openai/gpt-5-mini":[2.5e-7,0.000002,null,null],"replicate/google/gemini-2.5-flash":[0.0000025,0.0000025,null,null],"replicate/openai/gpt-oss-120b":[1.8e-7,7.2e-7,null,null],"replicate/deepseek-ai/deepseek-v3.1":[6.72e-7,0.000002016,null,null],"deepseek-ai/deepseek-v3.1":[6.72e-7,0.000002016,null,null],"replicate/xai/grok-4":[0.0000072,0.000036,null,null],"xai/grok-4":[0.0000072,0.000036,null,null],"replicate/deepseek-ai/deepseek-r1":[0.00000375,0.00001,null,null],"deepseek-ai/deepseek-r1":[0.00000375,0.00001,null,null],"nvidia_nim/nvidia/nv-rerankqa-mistral-4b-v3":[0,0,null,null],"nvidia/nv-rerankqa-mistral-4b-v3":[0,0,null,null],"nvidia_nim/nvidia/llama-3_2-nv-rerankqa-1b-v2":[0,0,null,null],"nvidia/llama-3_2-nv-rerankqa-1b-v2":[0,0,null,null],"nvidia_nim/ranking/nvidia/llama-3.2-nv-rerankqa-1b-v2":[0,0,null,null],"ranking/nvidia/llama-3.2-nv-rerankqa-1b-v2":[0,0,null,null],"sagemaker/meta-textgeneration-llama-2-13b":[0,0,null,null],"meta-textgeneration-llama-2-13b":[0,0,null,null],"sagemaker/meta-textgeneration-llama-2-13b-f":[0,0,null,null],"meta-textgeneration-llama-2-13b-f":[0,0,null,null],"sagemaker/meta-textgeneration-llama-2-70b":[0,0,null,null],"meta-textgeneration-llama-2-70b":[0,0,null,null],"sagemaker/meta-textgeneration-llama-2-70b-b-f":[0,0,null,null],"meta-textgeneration-llama-2-70b-b-f":[0,0,null,null],"sagemaker/meta-textgeneration-llama-2-7b":[0,0,null,null],"meta-textgeneration-llama-2-7b":[0,0,null,null],"sagemaker/meta-textgeneration-llama-2-7b-f":[0,0,null,null],"meta-textgeneration-llama-2-7b-f":[0,0,null,null],"sambanova/MiniMax-M2.7":[3e-7,0.0000012,null,null],"MiniMax-M2.7":[3e-7,0.0000012,3.75e-7,6e-8],"sambanova/DeepSeek-R1":[0.000005,0.000007,null,null],"DeepSeek-R1":[0.000005,0.000007,null,null],"sambanova/DeepSeek-R1-Distill-Llama-70B":[7e-7,0.0000014,null,null],"sambanova/DeepSeek-V3-0324":[0.000003,0.0000045,null,null],"DeepSeek-V3-0324":[0.000003,0.0000045,null,null],"sambanova/Llama-4-Maverick-17B-128E-Instruct":[6.3e-7,0.0000018,null,null],"Llama-4-Maverick-17B-128E-Instruct":[6.3e-7,0.0000018,null,null],"sambanova/Llama-4-Scout-17B-16E-Instruct":[4e-7,7e-7,null,null],"sambanova/Meta-Llama-3.1-405B-Instruct":[0.000005,0.00001,null,null],"sambanova/Meta-Llama-3.1-8B-Instruct":[1e-7,2e-7,null,null],"sambanova/Meta-Llama-3.2-1B-Instruct":[4e-8,8e-8,null,null],"Meta-Llama-3.2-1B-Instruct":[4e-8,8e-8,null,null],"sambanova/Meta-Llama-3.2-3B-Instruct":[8e-8,1.6e-7,null,null],"Meta-Llama-3.2-3B-Instruct":[8e-8,1.6e-7,null,null],"sambanova/Meta-Llama-3.3-70B-Instruct":[6e-7,0.0000012,null,null],"Meta-Llama-3.3-70B-Instruct":[6e-7,0.0000012,null,null],"sambanova/Meta-Llama-Guard-3-8B":[3e-7,3e-7,null,null],"Meta-Llama-Guard-3-8B":[3e-7,3e-7,null,null],"sambanova/QwQ-32B":[5e-7,0.000001,null,null],"QwQ-32B":[5e-7,0.000001,null,null],"sambanova/Qwen2-Audio-7B-Instruct":[5e-7,0.0001,null,null],"Qwen2-Audio-7B-Instruct":[5e-7,0.0001,null,null],"sambanova/Qwen3-32B":[4e-7,8e-7,null,null],"sambanova/DeepSeek-V3.1":[0.000003,0.0000045,null,null],"DeepSeek-V3.1":[0.000003,0.0000045,null,null],"sambanova/gpt-oss-120b":[0.000003,0.0000045,null,null],"text-completion-codestral/codestral-2405":[0,0,null,null],"text-completion-codestral/codestral-latest":[0,0,null,null],"together_ai/baai/bge-base-en-v1.5":[8e-9,0,null,null],"baai/bge-base-en-v1.5":[8e-9,0,null,null],"together_ai/BAAI/bge-base-en-v1.5":[8e-9,0,null,null],"BAAI/bge-base-en-v1.5":[8e-9,0,null,null],"together_ai/Qwen/Qwen3-235B-A22B-Instruct-2507-tput":[2e-7,0.000006,null,null],"Qwen/Qwen3-235B-A22B-Instruct-2507-tput":[2e-7,0.000006,null,null],"together_ai/Qwen/Qwen3-235B-A22B-Thinking-2507":[6.5e-7,0.000003,null,null],"together_ai/Qwen/Qwen3-235B-A22B-fp8-tput":[2e-7,6e-7,null,null],"Qwen/Qwen3-235B-A22B-fp8-tput":[2e-7,6e-7,null,null],"together_ai/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8":[0.000002,0.000002,null,null],"Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8":[0.000002,0.000002,null,null],"together_ai/deepseek-ai/DeepSeek-R1":[0.000003,0.000007,null,null],"together_ai/deepseek-ai/DeepSeek-R1-0528-tput":[5.5e-7,0.00000219,null,null],"deepseek-ai/DeepSeek-R1-0528-tput":[5.5e-7,0.00000219,null,null],"together_ai/deepseek-ai/DeepSeek-V3":[0.00000125,0.00000125,null,null],"together_ai/deepseek-ai/DeepSeek-V3.1":[6e-7,0.0000017,null,null],"together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo":[8.8e-7,8.8e-7,null,null],"together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo-Free":[0,0,null,null],"meta-llama/Llama-3.3-70B-Instruct-Turbo-Free":[0,0,null,null],"together_ai/meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":[2.7e-7,8.5e-7,null,null],"together_ai/meta-llama/Llama-4-Scout-17B-16E-Instruct":[1.8e-7,5.9e-7,null,null],"together_ai/meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo":[0.0000035,0.0000035,null,null],"meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo":[0.0000035,0.0000035,null,null],"together_ai/meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo":[8.8e-7,8.8e-7,null,null],"together_ai/meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo":[1.8e-7,1.8e-7,null,null],"together_ai/mistralai/Mixtral-8x7B-Instruct-v0.1":[6e-7,6e-7,null,null],"together_ai/moonshotai/Kimi-K2-Instruct":[0.000001,0.000003,null,null],"together_ai/openai/gpt-oss-120b":[1.5e-7,6e-7,null,null],"together_ai/openai/gpt-oss-20b":[5e-8,2e-7,null,null],"together_ai/zai-org/GLM-4.5-Air-FP8":[2e-7,0.0000011,null,null],"zai-org/GLM-4.5-Air-FP8":[2e-7,0.0000011,null,null],"together_ai/zai-org/GLM-4.6":[6e-7,0.0000022,null,null],"together_ai/zai-org/GLM-4.7":[4.5e-7,0.000002,null,null],"together_ai/moonshotai/Kimi-K2.5":[5e-7,0.0000028,null,null],"together_ai/moonshotai/Kimi-K2-Instruct-0905":[0.000001,0.000003,null,null],"together_ai/Qwen/Qwen3-Next-80B-A3B-Instruct":[1.5e-7,0.0000015,null,null],"together_ai/Qwen/Qwen3-Next-80B-A3B-Thinking":[1.5e-7,0.0000015,null,null],"together_ai/Qwen/Qwen3.5-397B-A17B":[6e-7,0.0000036,null,null],"Qwen/Qwen3.5-397B-A17B":[6e-7,0.0000036,null,null],"v0/v0-1.0-md":[0.000003,0.000015,null,null],"v0-1.0-md":[0.000003,0.000015,null,null],"v0/v0-1.5-lg":[0.000015,0.000075,null,null],"v0-1.5-lg":[0.000015,0.000075,null,null],"v0/v0-1.5-md":[0.000003,0.000015,null,null],"v0-1.5-md":[0.000003,0.000015,null,null],"vercel_ai_gateway/alibaba/qwen-3-14b":[8e-8,2.4e-7,null,null],"alibaba/qwen-3-14b":[8e-8,2.4e-7,null,null],"vercel_ai_gateway/alibaba/qwen-3-235b":[2e-7,6e-7,null,null],"alibaba/qwen-3-235b":[2e-7,6e-7,null,null],"vercel_ai_gateway/alibaba/qwen-3-30b":[1e-7,3e-7,null,null],"alibaba/qwen-3-30b":[1e-7,3e-7,null,null],"vercel_ai_gateway/alibaba/qwen-3-32b":[1e-7,3e-7,null,null],"alibaba/qwen-3-32b":[1e-7,3e-7,null,null],"vercel_ai_gateway/alibaba/qwen3-coder":[4e-7,0.0000016,null,null],"alibaba/qwen3-coder":[4e-7,0.0000016,null,null],"vercel_ai_gateway/amazon/nova-lite":[6e-8,2.4e-7,null,null],"amazon/nova-lite":[6e-8,2.4e-7,null,null],"vercel_ai_gateway/amazon/nova-micro":[3.5e-8,1.4e-7,null,null],"amazon/nova-micro":[3.5e-8,1.4e-7,null,null],"vercel_ai_gateway/amazon/nova-pro":[8e-7,0.0000032,null,null],"amazon/nova-pro":[8e-7,0.0000032,null,null],"vercel_ai_gateway/amazon/titan-embed-text-v2":[2e-8,0,null,null],"amazon/titan-embed-text-v2":[2e-8,0,null,null],"vercel_ai_gateway/anthropic/claude-3-haiku":[2.5e-7,0.00000125,3e-7,3e-8],"vercel_ai_gateway/anthropic/claude-3-opus":[0.000015,0.000075,0.00001875,0.0000015],"anthropic/claude-3-opus":[0.000015,0.000075,0.00001875,0.0000015],"vercel_ai_gateway/anthropic/claude-3.5-haiku":[8e-7,0.000004,0.000001,8e-8],"vercel_ai_gateway/anthropic/claude-3.5-sonnet":[0.000003,0.000015,0.00000375,3e-7],"vercel_ai_gateway/anthropic/claude-3.7-sonnet":[0.000003,0.000015,0.00000375,3e-7],"vercel_ai_gateway/anthropic/claude-4-opus":[0.000015,0.000075,0.00001875,0.0000015],"vercel_ai_gateway/anthropic/claude-4-sonnet":[0.000003,0.000015,0.00000375,3e-7],"vercel_ai_gateway/anthropic/claude-3-5-sonnet":[0.000003,0.000015,0.00000375,3e-7],"anthropic/claude-3-5-sonnet":[0.000003,0.000015,0.00000375,3e-7],"vercel_ai_gateway/anthropic/claude-3-5-sonnet-20241022":[0.000003,0.000015,0.00000375,3e-7],"anthropic/claude-3-5-sonnet-20241022":[0.000003,0.000015,0.00000375,3e-7],"vercel_ai_gateway/anthropic/claude-3-7-sonnet":[0.000003,0.000015,0.00000375,3e-7],"anthropic/claude-3-7-sonnet":[0.000003,0.000015,0.00000375,3e-7],"vercel_ai_gateway/anthropic/claude-haiku-4.5":[0.000001,0.000005,0.00000125,1e-7],"vercel_ai_gateway/anthropic/claude-opus-4":[0.000015,0.000075,0.00001875,0.0000015],"vercel_ai_gateway/anthropic/claude-opus-4.1":[0.000015,0.000075,0.00001875,0.0000015],"vercel_ai_gateway/anthropic/claude-opus-4.5":[0.000005,0.000025,0.00000625,5e-7],"vercel_ai_gateway/anthropic/claude-opus-4.6":[0.000005,0.000025,0.00000625,5e-7],"vercel_ai_gateway/anthropic/claude-sonnet-4":[0.000003,0.000015,0.00000375,3e-7],"vercel_ai_gateway/anthropic/claude-sonnet-4.5":[0.000003,0.000015,0.00000375,3e-7],"vercel_ai_gateway/cohere/command-a":[0.0000025,0.00001,null,null],"cohere/command-a":[0.0000025,0.00001,null,null],"vercel_ai_gateway/cohere/command-r":[1.5e-7,6e-7,null,null],"cohere/command-r":[1.5e-7,6e-7,null,null],"vercel_ai_gateway/cohere/command-r-plus":[0.0000025,0.00001,null,null],"cohere/command-r-plus":[0.0000025,0.00001,null,null],"vercel_ai_gateway/cohere/embed-v4.0":[1.2e-7,0,null,null],"vercel_ai_gateway/deepseek/deepseek-r1":[5.5e-7,0.00000219,null,null],"vercel_ai_gateway/deepseek/deepseek-r1-distill-llama-70b":[7.5e-7,9.9e-7,null,null],"deepseek/deepseek-r1-distill-llama-70b":[7.5e-7,9.9e-7,null,null],"vercel_ai_gateway/deepseek/deepseek-v3":[9e-7,9e-7,null,null],"vercel_ai_gateway/google/gemini-2.0-flash":[1.5e-7,6e-7,null,null],"google/gemini-2.0-flash":[1.5e-7,6e-7,null,null],"vercel_ai_gateway/google/gemini-2.0-flash-lite":[7.5e-8,3e-7,null,null],"google/gemini-2.0-flash-lite":[7.5e-8,3e-7,null,null],"vercel_ai_gateway/google/gemini-2.5-flash":[3e-7,0.0000025,null,null],"vercel_ai_gateway/google/gemini-2.5-pro":[0.0000025,0.00001,null,null],"vercel_ai_gateway/google/gemini-embedding-001":[1.5e-7,0,null,null],"google/gemini-embedding-001":[1.5e-7,0,null,null],"vercel_ai_gateway/google/gemma-2-9b":[2e-7,2e-7,null,null],"google/gemma-2-9b":[2e-7,2e-7,null,null],"vercel_ai_gateway/google/text-embedding-005":[2.5e-8,0,null,null],"google/text-embedding-005":[2.5e-8,0,null,null],"vercel_ai_gateway/google/text-multilingual-embedding-002":[2.5e-8,0,null,null],"google/text-multilingual-embedding-002":[2.5e-8,0,null,null],"vercel_ai_gateway/inception/mercury-coder-small":[2.5e-7,0.000001,null,null],"inception/mercury-coder-small":[2.5e-7,0.000001,null,null],"vercel_ai_gateway/meta/llama-3-70b":[5.9e-7,7.9e-7,null,null],"vercel_ai_gateway/meta/llama-3-8b":[5e-8,8e-8,null,null],"vercel_ai_gateway/meta/llama-3.1-70b":[7.2e-7,7.2e-7,null,null],"meta/llama-3.1-70b":[7.2e-7,7.2e-7,null,null],"vercel_ai_gateway/meta/llama-3.1-8b":[5e-8,8e-8,null,null],"meta/llama-3.1-8b":[5e-8,8e-8,null,null],"vercel_ai_gateway/meta/llama-3.2-11b":[1.6e-7,1.6e-7,null,null],"meta/llama-3.2-11b":[1.6e-7,1.6e-7,null,null],"vercel_ai_gateway/meta/llama-3.2-1b":[1e-7,1e-7,null,null],"meta/llama-3.2-1b":[1e-7,1e-7,null,null],"vercel_ai_gateway/meta/llama-3.2-3b":[1.5e-7,1.5e-7,null,null],"meta/llama-3.2-3b":[1.5e-7,1.5e-7,null,null],"vercel_ai_gateway/meta/llama-3.2-90b":[7.2e-7,7.2e-7,null,null],"meta/llama-3.2-90b":[7.2e-7,7.2e-7,null,null],"vercel_ai_gateway/meta/llama-3.3-70b":[7.2e-7,7.2e-7,null,null],"meta/llama-3.3-70b":[7.2e-7,7.2e-7,null,null],"vercel_ai_gateway/meta/llama-4-maverick":[2e-7,6e-7,null,null],"meta/llama-4-maverick":[2e-7,6e-7,null,null],"vercel_ai_gateway/meta/llama-4-scout":[1e-7,3e-7,null,null],"meta/llama-4-scout":[1e-7,3e-7,null,null],"vercel_ai_gateway/mistral/codestral":[3e-7,9e-7,null,null],"mistral/codestral":[3e-7,9e-7,null,null],"vercel_ai_gateway/mistral/codestral-embed":[1.5e-7,0,null,null],"mistral/codestral-embed":[1.5e-7,0,null,null],"vercel_ai_gateway/mistral/devstral-small":[7e-8,2.8e-7,null,null],"mistral/devstral-small":[7e-8,2.8e-7,null,null],"vercel_ai_gateway/mistral/magistral-medium":[0.000002,0.000005,null,null],"mistral/magistral-medium":[0.000002,0.000005,null,null],"vercel_ai_gateway/mistral/magistral-small":[5e-7,0.0000015,null,null],"mistral/magistral-small":[5e-7,0.0000015,null,null],"vercel_ai_gateway/mistral/ministral-3b":[4e-8,4e-8,null,null],"mistral/ministral-3b":[4e-8,4e-8,null,null],"vercel_ai_gateway/mistral/ministral-8b":[1e-7,1e-7,null,null],"mistral/ministral-8b":[1e-7,1e-7,null,null],"vercel_ai_gateway/mistral/mistral-embed":[1e-7,0,null,null],"mistral/mistral-embed":[1e-7,0,null,null],"vercel_ai_gateway/mistral/mistral-large":[0.000002,0.000006,null,null],"mistral/mistral-large":[0.000002,0.000006,null,null],"vercel_ai_gateway/mistral/mistral-saba-24b":[7.9e-7,7.9e-7,null,null],"mistral/mistral-saba-24b":[7.9e-7,7.9e-7,null,null],"vercel_ai_gateway/mistral/mistral-small":[1e-7,3e-7,null,null],"vercel_ai_gateway/mistral/mixtral-8x22b-instruct":[0.0000012,0.0000012,null,null],"mistral/mixtral-8x22b-instruct":[0.0000012,0.0000012,null,null],"vercel_ai_gateway/mistral/pixtral-12b":[1.5e-7,1.5e-7,null,null],"mistral/pixtral-12b":[1.5e-7,1.5e-7,null,null],"vercel_ai_gateway/mistral/pixtral-large":[0.000002,0.000006,null,null],"mistral/pixtral-large":[0.000002,0.000006,null,null],"vercel_ai_gateway/moonshotai/kimi-k2":[5.5e-7,0.0000022,null,null],"moonshotai/kimi-k2":[5.5e-7,0.0000022,null,null],"vercel_ai_gateway/morph/morph-v3-fast":[8e-7,0.0000012,null,null],"vercel_ai_gateway/morph/morph-v3-large":[9e-7,0.0000019,null,null],"vercel_ai_gateway/openai/gpt-3.5-turbo":[5e-7,0.0000015,null,null],"vercel_ai_gateway/openai/gpt-3.5-turbo-instruct":[0.0000015,0.000002,null,null],"openai/gpt-3.5-turbo-instruct":[0.0000015,0.000002,null,null],"vercel_ai_gateway/openai/gpt-4-turbo":[0.00001,0.00003,null,null],"openai/gpt-4-turbo":[0.00001,0.00003,null,null],"vercel_ai_gateway/openai/gpt-4.1":[0.000002,0.000008,0,5e-7],"vercel_ai_gateway/openai/gpt-4.1-mini":[4e-7,0.0000016,0,1e-7],"vercel_ai_gateway/openai/gpt-4.1-nano":[1e-7,4e-7,0,2.5e-8],"vercel_ai_gateway/openai/gpt-4o":[0.0000025,0.00001,0,0.00000125],"vercel_ai_gateway/openai/gpt-4o-mini":[1.5e-7,6e-7,0,7.5e-8],"vercel_ai_gateway/openai/o1":[0.000015,0.00006,0,0.0000075],"vercel_ai_gateway/openai/o3":[0.000002,0.000008,0,5e-7],"openai/o3":[0.000002,0.000008,0,5e-7],"vercel_ai_gateway/openai/o3-mini":[0.0000011,0.0000044,0,5.5e-7],"vercel_ai_gateway/openai/o4-mini":[0.0000011,0.0000044,0,2.75e-7],"vercel_ai_gateway/openai/text-embedding-3-large":[1.3e-7,0,null,null],"openai/text-embedding-3-large":[1.3e-7,0,null,null],"vercel_ai_gateway/openai/text-embedding-3-small":[2e-8,0,null,null],"openai/text-embedding-3-small":[2e-8,0,null,null],"vercel_ai_gateway/openai/text-embedding-ada-002":[1e-7,0,null,null],"openai/text-embedding-ada-002":[1e-7,0,null,null],"vercel_ai_gateway/perplexity/sonar":[0.000001,0.000001,null,null],"vercel_ai_gateway/perplexity/sonar-pro":[0.000003,0.000015,null,null],"vercel_ai_gateway/perplexity/sonar-reasoning":[0.000001,0.000005,null,null],"vercel_ai_gateway/perplexity/sonar-reasoning-pro":[0.000002,0.000008,null,null],"vercel_ai_gateway/vercel/v0-1.0-md":[0.000003,0.000015,null,null],"vercel/v0-1.0-md":[0.000003,0.000015,null,null],"vercel_ai_gateway/vercel/v0-1.5-md":[0.000003,0.000015,null,null],"vercel/v0-1.5-md":[0.000003,0.000015,null,null],"vercel_ai_gateway/xai/grok-2":[0.000002,0.00001,null,null],"xai/grok-2":[0.000002,0.00001,null,null],"vercel_ai_gateway/xai/grok-2-vision":[0.000002,0.00001,null,null],"xai/grok-2-vision":[0.000002,0.00001,null,null],"vercel_ai_gateway/xai/grok-3":[0.000003,0.000015,null,null],"xai/grok-3":[0.000003,0.000015,null,null],"vercel_ai_gateway/xai/grok-3-fast":[0.000005,0.000025,null,null],"xai/grok-3-fast":[0.000005,0.000025,null,null],"vercel_ai_gateway/xai/grok-3-mini":[3e-7,5e-7,null,null],"xai/grok-3-mini":[3e-7,5e-7,null,null],"vercel_ai_gateway/xai/grok-3-mini-fast":[6e-7,0.000004,null,null],"xai/grok-3-mini-fast":[6e-7,0.000004,null,null],"vercel_ai_gateway/xai/grok-4":[0.000003,0.000015,null,null],"vercel_ai_gateway/zai/glm-4.5":[6e-7,0.0000022,null,null],"zai/glm-4.5":[6e-7,0.0000022,null,null],"vercel_ai_gateway/zai/glm-4.5-air":[2e-7,0.0000011,null,null],"zai/glm-4.5-air":[2e-7,0.0000011,null,null],"vercel_ai_gateway/zai/glm-4.6":[4.5e-7,0.0000018,null,1.1e-7],"zai/glm-4.6":[4.5e-7,0.0000018,null,1.1e-7],"vertex_ai/claude-3-5-haiku":[0.000001,0.000005,null,null],"claude-3-5-haiku":[0.000001,0.000005,null,null],"vertex_ai/claude-3-5-haiku@20241022":[0.000001,0.000005,null,null],"claude-3-5-haiku@20241022":[0.000001,0.000005,null,null],"vertex_ai/claude-haiku-4-5":[0.000001,0.000005,0.00000125,1e-7],"vertex_ai/claude-haiku-4-5@20251001":[0.000001,0.000005,0.00000125,1e-7],"claude-haiku-4-5@20251001":[0.000001,0.000005,0.00000125,1e-7],"vertex_ai/claude-3-5-sonnet":[0.000003,0.000015,null,null],"claude-3-5-sonnet":[0.000003,0.000015,null,null],"vertex_ai/claude-3-5-sonnet@20240620":[0.000003,0.000015,null,null],"claude-3-5-sonnet@20240620":[0.000003,0.000015,null,null],"vertex_ai/claude-3-7-sonnet@20250219":[0.000003,0.000015,0.00000375,3e-7],"claude-3-7-sonnet@20250219":[0.000003,0.000015,0.00000375,3e-7],"vertex_ai/claude-3-haiku":[2.5e-7,0.00000125,null,null],"claude-3-haiku":[2.5e-7,0.00000125,null,null],"vertex_ai/claude-3-haiku@20240307":[2.5e-7,0.00000125,null,null],"claude-3-haiku@20240307":[2.5e-7,0.00000125,null,null],"vertex_ai/claude-3-opus":[0.000015,0.000075,null,null],"claude-3-opus":[0.000015,0.000075,null,null],"vertex_ai/claude-3-opus@20240229":[0.000015,0.000075,null,null],"claude-3-opus@20240229":[0.000015,0.000075,null,null],"vertex_ai/claude-3-sonnet":[0.000003,0.000015,null,null],"claude-3-sonnet":[0.000003,0.000015,null,null],"vertex_ai/claude-3-sonnet@20240229":[0.000003,0.000015,null,null],"claude-3-sonnet@20240229":[0.000003,0.000015,null,null],"vertex_ai/claude-opus-4":[0.000015,0.000075,0.00001875,0.0000015],"claude-opus-4":[0.000015,0.000075,0.00001875,0.0000015],"vertex_ai/claude-opus-4-1":[0.000015,0.000075,0.00001875,0.0000015],"vertex_ai/claude-opus-4-1@20250805":[0.000015,0.000075,0.00001875,0.0000015],"claude-opus-4-1@20250805":[0.000015,0.000075,0.00001875,0.0000015],"vertex_ai/claude-opus-4-5":[0.000005,0.000025,0.00000625,5e-7],"vertex_ai/claude-opus-4-5@20251101":[0.000005,0.000025,0.00000625,5e-7],"claude-opus-4-5@20251101":[0.000005,0.000025,0.00000625,5e-7],"vertex_ai/claude-opus-4-6":[0.000005,0.000025,0.00000625,5e-7],"vertex_ai/claude-opus-4-6@default":[0.000005,0.000025,0.00000625,5e-7],"claude-opus-4-6@default":[0.000005,0.000025,0.00000625,5e-7],"vertex_ai/claude-opus-4-7":[0.000005,0.000025,0.00000625,5e-7],"vertex_ai/claude-opus-4-7@default":[0.000005,0.000025,0.00000625,5e-7],"claude-opus-4-7@default":[0.000005,0.000025,0.00000625,5e-7],"vertex_ai/claude-sonnet-4-5":[0.000003,0.000015,0.00000375,3e-7],"vertex_ai/claude-sonnet-4-6":[0.000003,0.000015,0.00000375,3e-7],"vertex_ai/claude-sonnet-4-5@20250929":[0.000003,0.000015,0.00000375,3e-7],"claude-sonnet-4-5@20250929":[0.000003,0.000015,0.00000375,3e-7],"vertex_ai/claude-opus-4@20250514":[0.000015,0.000075,0.00001875,0.0000015],"claude-opus-4@20250514":[0.000015,0.000075,0.00001875,0.0000015],"vertex_ai/claude-sonnet-4":[0.000003,0.000015,0.00000375,3e-7],"claude-sonnet-4":[0.000003,0.000015,0.00000375,3e-7],"vertex_ai/claude-sonnet-4@20250514":[0.000003,0.000015,0.00000375,3e-7],"claude-sonnet-4@20250514":[0.000003,0.000015,0.00000375,3e-7],"vertex_ai/mistralai/codestral-2@001":[3e-7,9e-7,null,null],"mistralai/codestral-2@001":[3e-7,9e-7,null,null],"vertex_ai/codestral-2":[3e-7,9e-7,null,null],"codestral-2":[3e-7,9e-7,null,null],"vertex_ai/codestral-2@001":[3e-7,9e-7,null,null],"codestral-2@001":[3e-7,9e-7,null,null],"vertex_ai/mistralai/codestral-2":[3e-7,9e-7,null,null],"mistralai/codestral-2":[3e-7,9e-7,null,null],"vertex_ai/codestral-2501":[2e-7,6e-7,null,null],"codestral-2501":[2e-7,6e-7,null,null],"vertex_ai/codestral@2405":[2e-7,6e-7,null,null],"codestral@2405":[2e-7,6e-7,null,null],"vertex_ai/codestral@latest":[2e-7,6e-7,null,null],"codestral@latest":[2e-7,6e-7,null,null],"vertex_ai/deepseek-ai/deepseek-v3.1-maas":[0.00000135,0.0000054,null,null],"deepseek-ai/deepseek-v3.1-maas":[0.00000135,0.0000054,null,null],"vertex_ai/deepseek-ai/deepseek-v3.2-maas":[5.6e-7,0.00000168,null,null],"deepseek-ai/deepseek-v3.2-maas":[5.6e-7,0.00000168,null,null],"vertex_ai/deepseek-ai/deepseek-r1-0528-maas":[0.00000135,0.0000054,null,null],"deepseek-ai/deepseek-r1-0528-maas":[0.00000135,0.0000054,null,null],"vertex_ai/gemini-2.5-flash-image":[3e-7,0.0000025,null,3e-8],"vertex_ai/gemini-3-pro-image-preview":[0.000002,0.000012,null,null],"vertex_ai/gemini-3.1-flash-image-preview":[5e-7,0.000003,null,null],"vertex_ai/gemini-3.1-flash-lite-preview":[2.5e-7,0.0000015,null,2.5e-8],"vertex_ai/deep-research-pro-preview-12-2025":[0.000002,0.000012,null,null],"vertex_ai/jamba-1.5":[2e-7,4e-7,null,null],"vertex_ai/jamba-1.5-large":[0.000002,0.000008,null,null],"vertex_ai/jamba-1.5-large@001":[0.000002,0.000008,null,null],"vertex_ai/jamba-1.5-mini":[2e-7,4e-7,null,null],"vertex_ai/jamba-1.5-mini@001":[2e-7,4e-7,null,null],"vertex_ai/meta/llama-3.1-405b-instruct-maas":[0.000005,0.000016,null,null],"meta/llama-3.1-405b-instruct-maas":[0.000005,0.000016,null,null],"vertex_ai/meta/llama-3.1-70b-instruct-maas":[0,0,null,null],"meta/llama-3.1-70b-instruct-maas":[0,0,null,null],"vertex_ai/meta/llama-3.1-8b-instruct-maas":[0,0,null,null],"meta/llama-3.1-8b-instruct-maas":[0,0,null,null],"vertex_ai/meta/llama-3.2-90b-vision-instruct-maas":[0,0,null,null],"meta/llama-3.2-90b-vision-instruct-maas":[0,0,null,null],"vertex_ai/meta/llama-4-maverick-17b-128e-instruct-maas":[3.5e-7,0.00000115,null,null],"meta/llama-4-maverick-17b-128e-instruct-maas":[3.5e-7,0.00000115,null,null],"vertex_ai/meta/llama-4-maverick-17b-16e-instruct-maas":[3.5e-7,0.00000115,null,null],"meta/llama-4-maverick-17b-16e-instruct-maas":[3.5e-7,0.00000115,null,null],"vertex_ai/meta/llama-4-scout-17b-128e-instruct-maas":[2.5e-7,7e-7,null,null],"meta/llama-4-scout-17b-128e-instruct-maas":[2.5e-7,7e-7,null,null],"vertex_ai/meta/llama-4-scout-17b-16e-instruct-maas":[2.5e-7,7e-7,null,null],"meta/llama-4-scout-17b-16e-instruct-maas":[2.5e-7,7e-7,null,null],"vertex_ai/meta/llama3-405b-instruct-maas":[0,0,null,null],"meta/llama3-405b-instruct-maas":[0,0,null,null],"vertex_ai/meta/llama3-70b-instruct-maas":[0,0,null,null],"meta/llama3-70b-instruct-maas":[0,0,null,null],"vertex_ai/meta/llama3-8b-instruct-maas":[0,0,null,null],"meta/llama3-8b-instruct-maas":[0,0,null,null],"vertex_ai/minimaxai/minimax-m2-maas":[3e-7,0.0000012,null,null],"minimaxai/minimax-m2-maas":[3e-7,0.0000012,null,null],"vertex_ai/moonshotai/kimi-k2-thinking-maas":[6e-7,0.0000025,null,null],"moonshotai/kimi-k2-thinking-maas":[6e-7,0.0000025,null,null],"vertex_ai/zai-org/glm-4.7-maas":[6e-7,0.0000022,null,null],"zai-org/glm-4.7-maas":[6e-7,0.0000022,null,null],"vertex_ai/zai-org/glm-5-maas":[0.000001,0.0000032,null,1e-7],"zai-org/glm-5-maas":[0.000001,0.0000032,null,1e-7],"vertex_ai/mistral-medium-3":[4e-7,0.000002,null,null],"mistral-medium-3":[4e-7,0.000002,null,null],"vertex_ai/mistral-medium-3@001":[4e-7,0.000002,null,null],"mistral-medium-3@001":[4e-7,0.000002,null,null],"vertex_ai/mistralai/mistral-medium-3":[4e-7,0.000002,null,null],"mistralai/mistral-medium-3":[4e-7,0.000002,null,null],"vertex_ai/mistralai/mistral-medium-3@001":[4e-7,0.000002,null,null],"mistralai/mistral-medium-3@001":[4e-7,0.000002,null,null],"vertex_ai/mistral-large-2411":[0.000002,0.000006,null,null],"vertex_ai/mistral-large@2407":[0.000002,0.000006,null,null],"mistral-large@2407":[0.000002,0.000006,null,null],"vertex_ai/mistral-large@2411-001":[0.000002,0.000006,null,null],"mistral-large@2411-001":[0.000002,0.000006,null,null],"vertex_ai/mistral-large@latest":[0.000002,0.000006,null,null],"mistral-large@latest":[0.000002,0.000006,null,null],"vertex_ai/mistral-nemo@2407":[0.000003,0.000003,null,null],"mistral-nemo@2407":[0.000003,0.000003,null,null],"vertex_ai/mistral-nemo@latest":[1.5e-7,1.5e-7,null,null],"mistral-nemo@latest":[1.5e-7,1.5e-7,null,null],"vertex_ai/mistral-small-2503":[0.000001,0.000003,null,null],"vertex_ai/mistral-small-2503@001":[0.000001,0.000003,null,null],"mistral-small-2503@001":[0.000001,0.000003,null,null],"vertex_ai/deepseek-ai/deepseek-ocr-maas":[3e-7,0.0000012,null,null],"deepseek-ai/deepseek-ocr-maas":[3e-7,0.0000012,null,null],"vertex_ai/openai/gpt-oss-120b-maas":[1.5e-7,6e-7,null,null],"openai/gpt-oss-120b-maas":[1.5e-7,6e-7,null,null],"vertex_ai/openai/gpt-oss-20b-maas":[7.5e-8,3e-7,null,null],"openai/gpt-oss-20b-maas":[7.5e-8,3e-7,null,null],"vertex_ai/xai/grok-4.1-fast-non-reasoning":[2e-7,5e-7,null,5e-8],"xai/grok-4.1-fast-non-reasoning":[2e-7,5e-7,null,5e-8],"vertex_ai/xai/grok-4.1-fast-reasoning":[2e-7,5e-7,null,5e-8],"xai/grok-4.1-fast-reasoning":[2e-7,5e-7,null,5e-8],"vertex_ai/xai/grok-4.20-non-reasoning":[0.000002,0.000006,null,2e-7],"xai/grok-4.20-non-reasoning":[0.000002,0.000006,null,2e-7],"vertex_ai/xai/grok-4.20-reasoning":[0.000002,0.000006,null,2e-7],"xai/grok-4.20-reasoning":[0.000002,0.000006,null,2e-7],"vertex_ai/qwen/qwen3-235b-a22b-instruct-2507-maas":[2.5e-7,0.000001,null,null],"qwen/qwen3-235b-a22b-instruct-2507-maas":[2.5e-7,0.000001,null,null],"vertex_ai/qwen/qwen3-coder-480b-a35b-instruct-maas":[0.000001,0.000004,null,null],"qwen/qwen3-coder-480b-a35b-instruct-maas":[0.000001,0.000004,null,null],"vertex_ai/qwen/qwen3-next-80b-a3b-instruct-maas":[1.5e-7,0.0000012,null,null],"qwen/qwen3-next-80b-a3b-instruct-maas":[1.5e-7,0.0000012,null,null],"vertex_ai/qwen/qwen3-next-80b-a3b-thinking-maas":[1.5e-7,0.0000012,null,null],"qwen/qwen3-next-80b-a3b-thinking-maas":[1.5e-7,0.0000012,null,null],"voyage/rerank-2":[5e-8,0,null,null],"rerank-2":[5e-8,0,null,null],"voyage/rerank-2-lite":[2e-8,0,null,null],"rerank-2-lite":[2e-8,0,null,null],"voyage/rerank-2.5":[5e-8,0,null,null],"rerank-2.5":[5e-8,0,null,null],"voyage/rerank-2.5-lite":[2e-8,0,null,null],"rerank-2.5-lite":[2e-8,0,null,null],"voyage/voyage-2":[1e-7,0,null,null],"voyage-2":[1e-7,0,null,null],"voyage/voyage-3":[6e-8,0,null,null],"voyage-3":[6e-8,0,null,null],"voyage/voyage-3-large":[1.8e-7,0,null,null],"voyage-3-large":[1.8e-7,0,null,null],"voyage/voyage-3-lite":[2e-8,0,null,null],"voyage-3-lite":[2e-8,0,null,null],"voyage/voyage-3.5":[6e-8,0,null,null],"voyage-3.5":[6e-8,0,null,null],"voyage/voyage-3.5-lite":[2e-8,0,null,null],"voyage-3.5-lite":[2e-8,0,null,null],"voyage/voyage-code-2":[1.2e-7,0,null,null],"voyage-code-2":[1.2e-7,0,null,null],"voyage/voyage-code-3":[1.8e-7,0,null,null],"voyage-code-3":[1.8e-7,0,null,null],"voyage/voyage-context-3":[1.8e-7,0,null,null],"voyage-context-3":[1.8e-7,0,null,null],"voyage/voyage-finance-2":[1.2e-7,0,null,null],"voyage-finance-2":[1.2e-7,0,null,null],"voyage/voyage-large-2":[1.2e-7,0,null,null],"voyage-large-2":[1.2e-7,0,null,null],"voyage/voyage-law-2":[1.2e-7,0,null,null],"voyage-law-2":[1.2e-7,0,null,null],"voyage/voyage-lite-01":[1e-7,0,null,null],"voyage-lite-01":[1e-7,0,null,null],"voyage/voyage-lite-02-instruct":[1e-7,0,null,null],"voyage-lite-02-instruct":[1e-7,0,null,null],"voyage/voyage-multimodal-3":[1.2e-7,0,null,null],"voyage-multimodal-3":[1.2e-7,0,null,null],"wandb/openai/gpt-oss-120b":[0.015,0.06,null,null],"wandb/openai/gpt-oss-20b":[0.005,0.02,null,null],"wandb/zai-org/GLM-4.5":[0.055,0.2,null,null],"wandb/Qwen/Qwen3-235B-A22B-Instruct-2507":[0.01,0.01,null,null],"wandb/Qwen/Qwen3-Coder-480B-A35B-Instruct":[0.1,0.15,null,null],"wandb/Qwen/Qwen3-235B-A22B-Thinking-2507":[0.01,0.01,null,null],"wandb/moonshotai/Kimi-K2-Instruct":[6e-7,0.0000025,null,null],"wandb/moonshotai/Kimi-K2.5":[6e-7,0.000003,null,1e-7],"wandb/MiniMaxAI/MiniMax-M2.5":[3e-7,0.0000012,null,null],"wandb/meta-llama/Llama-3.1-8B-Instruct":[0.022,0.022,null,null],"wandb/deepseek-ai/DeepSeek-V3.1":[0.055,0.165,null,null],"wandb/deepseek-ai/DeepSeek-R1-0528":[0.135,0.54,null,null],"wandb/deepseek-ai/DeepSeek-V3-0324":[0.114,0.275,null,null],"wandb/meta-llama/Llama-3.3-70B-Instruct":[0.071,0.071,null,null],"wandb/meta-llama/Llama-4-Scout-17B-16E-Instruct":[0.017,0.066,null,null],"wandb/microsoft/Phi-4-mini-instruct":[0.008,0.035,null,null],"microsoft/Phi-4-mini-instruct":[0.008,0.035,null,null],"watsonx/ibm/granite-3-8b-instruct":[2e-7,2e-7,null,null],"ibm/granite-3-8b-instruct":[2e-7,2e-7,null,null],"watsonx/mistralai/mistral-large":[0.000003,0.00001,null,null],"watsonx/bigscience/mt0-xxl-13b":[0.0005,0.002,null,null],"bigscience/mt0-xxl-13b":[0.0005,0.002,null,null],"watsonx/core42/jais-13b-chat":[0.0005,0.002,null,null],"core42/jais-13b-chat":[0.0005,0.002,null,null],"watsonx/google/flan-t5-xl-3b":[6e-7,6e-7,null,null],"google/flan-t5-xl-3b":[6e-7,6e-7,null,null],"watsonx/ibm/granite-13b-chat-v2":[6e-7,6e-7,null,null],"ibm/granite-13b-chat-v2":[6e-7,6e-7,null,null],"watsonx/ibm/granite-13b-instruct-v2":[6e-7,6e-7,null,null],"ibm/granite-13b-instruct-v2":[6e-7,6e-7,null,null],"watsonx/ibm/granite-3-3-8b-instruct":[2e-7,2e-7,null,null],"ibm/granite-3-3-8b-instruct":[2e-7,2e-7,null,null],"watsonx/ibm/granite-4-h-small":[6e-8,2.5e-7,null,null],"ibm/granite-4-h-small":[6e-8,2.5e-7,null,null],"watsonx/ibm/granite-guardian-3-2-2b":[1e-7,1e-7,null,null],"ibm/granite-guardian-3-2-2b":[1e-7,1e-7,null,null],"watsonx/ibm/granite-guardian-3-3-8b":[2e-7,2e-7,null,null],"ibm/granite-guardian-3-3-8b":[2e-7,2e-7,null,null],"watsonx/ibm/granite-ttm-1024-96-r2":[3.8e-7,3.8e-7,null,null],"ibm/granite-ttm-1024-96-r2":[3.8e-7,3.8e-7,null,null],"watsonx/ibm/granite-ttm-1536-96-r2":[3.8e-7,3.8e-7,null,null],"ibm/granite-ttm-1536-96-r2":[3.8e-7,3.8e-7,null,null],"watsonx/ibm/granite-ttm-512-96-r2":[3.8e-7,3.8e-7,null,null],"ibm/granite-ttm-512-96-r2":[3.8e-7,3.8e-7,null,null],"watsonx/ibm/granite-vision-3-2-2b":[1e-7,1e-7,null,null],"ibm/granite-vision-3-2-2b":[1e-7,1e-7,null,null],"watsonx/meta-llama/llama-3-2-11b-vision-instruct":[3.5e-7,3.5e-7,null,null],"meta-llama/llama-3-2-11b-vision-instruct":[3.5e-7,3.5e-7,null,null],"watsonx/meta-llama/llama-3-2-1b-instruct":[1e-7,1e-7,null,null],"meta-llama/llama-3-2-1b-instruct":[1e-7,1e-7,null,null],"watsonx/meta-llama/llama-3-2-3b-instruct":[1.5e-7,1.5e-7,null,null],"meta-llama/llama-3-2-3b-instruct":[1.5e-7,1.5e-7,null,null],"watsonx/meta-llama/llama-3-2-90b-vision-instruct":[0.000002,0.000002,null,null],"meta-llama/llama-3-2-90b-vision-instruct":[0.000002,0.000002,null,null],"watsonx/meta-llama/llama-3-3-70b-instruct":[7.1e-7,7.1e-7,null,null],"meta-llama/llama-3-3-70b-instruct":[7.1e-7,7.1e-7,null,null],"watsonx/meta-llama/llama-4-maverick-17b":[3.5e-7,0.0000014,null,null],"meta-llama/llama-4-maverick-17b":[3.5e-7,0.0000014,null,null],"watsonx/meta-llama/llama-guard-3-11b-vision":[3.5e-7,3.5e-7,null,null],"meta-llama/llama-guard-3-11b-vision":[3.5e-7,3.5e-7,null,null],"watsonx/mistralai/mistral-medium-2505":[0.000003,0.00001,null,null],"mistralai/mistral-medium-2505":[0.000003,0.00001,null,null],"watsonx/mistralai/mistral-small-2503":[1e-7,3e-7,null,null],"mistralai/mistral-small-2503":[1e-7,3e-7,null,null],"watsonx/mistralai/mistral-small-3-1-24b-instruct-2503":[1e-7,3e-7,null,null],"mistralai/mistral-small-3-1-24b-instruct-2503":[1e-7,3e-7,null,null],"watsonx/mistralai/pixtral-12b-2409":[3.5e-7,3.5e-7,null,null],"mistralai/pixtral-12b-2409":[3.5e-7,3.5e-7,null,null],"watsonx/openai/gpt-oss-120b":[1.5e-7,6e-7,null,null],"watsonx/sdaia/allam-1-13b-instruct":[0.0000018,0.0000018,null,null],"sdaia/allam-1-13b-instruct":[0.0000018,0.0000018,null,null],"grok-2":[0.000002,0.00001,null,null],"xai/grok-2-1212":[0.000002,0.00001,null,null],"grok-2-1212":[0.000002,0.00001,null,null],"xai/grok-2-latest":[0.000002,0.00001,null,null],"grok-2-latest":[0.000002,0.00001,null,null],"grok-2-vision":[0.000002,0.00001,null,null],"xai/grok-2-vision-1212":[0.000002,0.00001,null,null],"grok-2-vision-1212":[0.000002,0.00001,null,null],"xai/grok-2-vision-latest":[0.000002,0.00001,null,null],"grok-2-vision-latest":[0.000002,0.00001,null,null],"xai/grok-3-beta":[0.000003,0.000015,null,7.5e-7],"grok-3-beta":[0.000003,0.000015,null,7.5e-7],"xai/grok-3-fast-beta":[0.000005,0.000025,null,0.00000125],"grok-3-fast-beta":[0.000005,0.000025,null,0.00000125],"xai/grok-3-fast-latest":[0.000005,0.000025,null,0.00000125],"grok-3-fast-latest":[0.000005,0.000025,null,0.00000125],"xai/grok-3-latest":[0.000003,0.000015,null,7.5e-7],"grok-3-latest":[0.000003,0.000015,null,7.5e-7],"xai/grok-3-mini-beta":[3e-7,5e-7,null,7.5e-8],"grok-3-mini-beta":[3e-7,5e-7,null,7.5e-8],"grok-3-mini-fast":[6e-7,0.000004,null,1.5e-7],"xai/grok-3-mini-fast-beta":[6e-7,0.000004,null,1.5e-7],"grok-3-mini-fast-beta":[6e-7,0.000004,null,1.5e-7],"xai/grok-3-mini-fast-latest":[6e-7,0.000004,null,1.5e-7],"grok-3-mini-fast-latest":[6e-7,0.000004,null,1.5e-7],"xai/grok-3-mini-latest":[3e-7,5e-7,null,7.5e-8],"grok-3-mini-latest":[3e-7,5e-7,null,7.5e-8],"xai/grok-4-fast-reasoning":[2e-7,5e-7,null,5e-8],"xai/grok-4-fast-non-reasoning":[2e-7,5e-7,null,5e-8],"xai/grok-4-0709":[0.000003,0.000015,null,null],"grok-4-0709":[0.000003,0.000015,null,null],"xai/grok-4-latest":[0.000003,0.000015,null,null],"grok-4-latest":[0.000003,0.000015,null,null],"xai/grok-4-1-fast":[2e-7,5e-7,null,5e-8],"grok-4-1-fast":[2e-7,5e-7,null,5e-8],"xai/grok-4-1-fast-reasoning":[2e-7,5e-7,null,5e-8],"xai/grok-4-1-fast-reasoning-latest":[2e-7,5e-7,null,5e-8],"grok-4-1-fast-reasoning-latest":[2e-7,5e-7,null,5e-8],"xai/grok-4-1-fast-non-reasoning":[2e-7,5e-7,null,5e-8],"xai/grok-4-1-fast-non-reasoning-latest":[2e-7,5e-7,null,5e-8],"grok-4-1-fast-non-reasoning-latest":[2e-7,5e-7,null,5e-8],"xai/grok-4.20-multi-agent-beta-0309":[0.000002,0.000006,null,2e-7],"grok-4.20-multi-agent-beta-0309":[0.000002,0.000006,null,2e-7],"xai/grok-4.20-beta-0309-reasoning":[0.000002,0.000006,null,2e-7],"grok-4.20-beta-0309-reasoning":[0.000002,0.000006,null,2e-7],"xai/grok-4.20-0309-reasoning":[0.000002,0.000006,null,2e-7],"grok-4.20-0309-reasoning":[0.000002,0.000006,null,2e-7],"xai/grok-4.20-beta-0309-non-reasoning":[0.000002,0.000006,null,2e-7],"grok-4.20-beta-0309-non-reasoning":[0.000002,0.000006,null,2e-7],"xai/grok-4.3":[0.00000125,0.0000025,null,2e-7],"grok-4.3":[0.00000125,0.0000025,null,2e-7],"xai/grok-4.3-latest":[0.00000125,0.0000025,null,2e-7],"grok-4.3-latest":[0.00000125,0.0000025,null,2e-7],"xai/grok-beta":[0.000005,0.000015,null,null],"grok-beta":[0.000005,0.000015,null,null],"xai/grok-code-fast":[2e-7,0.0000015,null,2e-8],"grok-code-fast":[2e-7,0.0000015,null,2e-8],"xai/grok-code-fast-1":[2e-7,0.0000015,null,2e-8],"xai/grok-code-fast-1-0825":[2e-7,0.0000015,null,2e-8],"grok-code-fast-1-0825":[2e-7,0.0000015,null,2e-8],"xai/grok-vision-beta":[0.000005,0.000015,null,null],"grok-vision-beta":[0.000005,0.000015,null,null],"zai/glm-5":[0.000001,0.0000032,0,2e-7],"glm-5":[0.000001,0.0000032,0,2e-7],"zai/glm-5-code":[0.0000012,0.000005,0,3e-7],"glm-5-code":[0.0000012,0.000005,0,3e-7],"zai/glm-4.7":[6e-7,0.0000022,0,1.1e-7],"glm-4.7":[6e-7,0.0000022,0,1.1e-7],"glm-4.6":[6e-7,0.0000022,0,1.1e-7],"glm-4.5":[6e-7,0.0000022,null,null],"zai/glm-4.5v":[6e-7,0.0000018,null,null],"glm-4.5v":[6e-7,0.0000018,null,null],"zai/glm-4.5-x":[0.0000022,0.0000089,null,null],"glm-4.5-x":[0.0000022,0.0000089,null,null],"glm-4.5-air":[2e-7,0.0000011,null,null],"zai/glm-4.5-airx":[0.0000011,0.0000045,null,null],"glm-4.5-airx":[0.0000011,0.0000045,null,null],"zai/glm-4-32b-0414-128k":[1e-7,1e-7,null,null],"glm-4-32b-0414-128k":[1e-7,1e-7,null,null],"zai/glm-4.5-flash":[0,0,null,null],"glm-4.5-flash":[0,0,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-coder-480b-a35b-instruct":[4.5e-7,0.0000018,null,null],"accounts/fireworks/models/qwen3-coder-480b-a35b-instruct":[4.5e-7,0.0000018,null,null],"fireworks_ai/accounts/fireworks/models/flux-kontext-pro":[4e-8,4e-8,null,null],"accounts/fireworks/models/flux-kontext-pro":[4e-8,4e-8,null,null],"fireworks_ai/accounts/fireworks/models/SSD-1B":[1.3e-10,1.3e-10,null,null],"accounts/fireworks/models/SSD-1B":[1.3e-10,1.3e-10,null,null],"fireworks_ai/accounts/fireworks/models/chronos-hermes-13b-v2":[2e-7,2e-7,null,null],"accounts/fireworks/models/chronos-hermes-13b-v2":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-13b":[2e-7,2e-7,null,null],"accounts/fireworks/models/code-llama-13b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-13b-instruct":[2e-7,2e-7,null,null],"accounts/fireworks/models/code-llama-13b-instruct":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-13b-python":[2e-7,2e-7,null,null],"accounts/fireworks/models/code-llama-13b-python":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-34b":[9e-7,9e-7,null,null],"accounts/fireworks/models/code-llama-34b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-34b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/code-llama-34b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-34b-python":[9e-7,9e-7,null,null],"accounts/fireworks/models/code-llama-34b-python":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-70b":[9e-7,9e-7,null,null],"accounts/fireworks/models/code-llama-70b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-70b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/code-llama-70b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-70b-python":[9e-7,9e-7,null,null],"accounts/fireworks/models/code-llama-70b-python":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/code-llama-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-7b-instruct":[2e-7,2e-7,null,null],"accounts/fireworks/models/code-llama-7b-instruct":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-7b-python":[2e-7,2e-7,null,null],"accounts/fireworks/models/code-llama-7b-python":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/code-qwen-1p5-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/code-qwen-1p5-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/codegemma-2b":[1e-7,1e-7,null,null],"accounts/fireworks/models/codegemma-2b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/codegemma-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/codegemma-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/cogito-671b-v2-p1":[0.0000012,0.0000012,null,null],"accounts/fireworks/models/cogito-671b-v2-p1":[0.0000012,0.0000012,null,null],"fireworks_ai/accounts/fireworks/models/cogito-v1-preview-llama-3b":[1e-7,1e-7,null,null],"accounts/fireworks/models/cogito-v1-preview-llama-3b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/cogito-v1-preview-llama-70b":[9e-7,9e-7,null,null],"accounts/fireworks/models/cogito-v1-preview-llama-70b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/cogito-v1-preview-llama-8b":[2e-7,2e-7,null,null],"accounts/fireworks/models/cogito-v1-preview-llama-8b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/cogito-v1-preview-qwen-14b":[2e-7,2e-7,null,null],"accounts/fireworks/models/cogito-v1-preview-qwen-14b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/cogito-v1-preview-qwen-32b":[9e-7,9e-7,null,null],"accounts/fireworks/models/cogito-v1-preview-qwen-32b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/flux-kontext-max":[8e-8,8e-8,null,null],"accounts/fireworks/models/flux-kontext-max":[8e-8,8e-8,null,null],"fireworks_ai/accounts/fireworks/models/dbrx-instruct":[0.0000012,0.0000012,null,null],"accounts/fireworks/models/dbrx-instruct":[0.0000012,0.0000012,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-coder-1b-base":[1e-7,1e-7,null,null],"accounts/fireworks/models/deepseek-coder-1b-base":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-coder-33b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/deepseek-coder-33b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-coder-7b-base":[2e-7,2e-7,null,null],"accounts/fireworks/models/deepseek-coder-7b-base":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-coder-7b-base-v1p5":[2e-7,2e-7,null,null],"accounts/fireworks/models/deepseek-coder-7b-base-v1p5":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-coder-7b-instruct-v1p5":[2e-7,2e-7,null,null],"accounts/fireworks/models/deepseek-coder-7b-instruct-v1p5":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-coder-v2-lite-base":[5e-7,5e-7,null,null],"accounts/fireworks/models/deepseek-coder-v2-lite-base":[5e-7,5e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-coder-v2-lite-instruct":[5e-7,5e-7,null,null],"accounts/fireworks/models/deepseek-coder-v2-lite-instruct":[5e-7,5e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-prover-v2":[0.0000012,0.0000012,null,null],"accounts/fireworks/models/deepseek-prover-v2":[0.0000012,0.0000012,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1-0528-distill-qwen3-8b":[2e-7,2e-7,null,null],"accounts/fireworks/models/deepseek-r1-0528-distill-qwen3-8b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1-distill-llama-70b":[9e-7,9e-7,null,null],"accounts/fireworks/models/deepseek-r1-distill-llama-70b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1-distill-llama-8b":[2e-7,2e-7,null,null],"accounts/fireworks/models/deepseek-r1-distill-llama-8b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1-distill-qwen-14b":[2e-7,2e-7,null,null],"accounts/fireworks/models/deepseek-r1-distill-qwen-14b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1-distill-qwen-1p5b":[1e-7,1e-7,null,null],"accounts/fireworks/models/deepseek-r1-distill-qwen-1p5b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1-distill-qwen-32b":[9e-7,9e-7,null,null],"accounts/fireworks/models/deepseek-r1-distill-qwen-32b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1-distill-qwen-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/deepseek-r1-distill-qwen-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-v2-lite-chat":[5e-7,5e-7,null,null],"accounts/fireworks/models/deepseek-v2-lite-chat":[5e-7,5e-7,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-v2p5":[0.0000012,0.0000012,null,null],"accounts/fireworks/models/deepseek-v2p5":[0.0000012,0.0000012,null,null],"fireworks_ai/accounts/fireworks/models/devstral-small-2505":[9e-7,9e-7,null,null],"accounts/fireworks/models/devstral-small-2505":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/dobby-mini-unhinged-plus-llama-3-1-8b":[2e-7,2e-7,null,null],"accounts/fireworks/models/dobby-mini-unhinged-plus-llama-3-1-8b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/dobby-unhinged-llama-3-3-70b-new":[9e-7,9e-7,null,null],"accounts/fireworks/models/dobby-unhinged-llama-3-3-70b-new":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/dolphin-2-9-2-qwen2-72b":[9e-7,9e-7,null,null],"accounts/fireworks/models/dolphin-2-9-2-qwen2-72b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/dolphin-2p6-mixtral-8x7b":[5e-7,5e-7,null,null],"accounts/fireworks/models/dolphin-2p6-mixtral-8x7b":[5e-7,5e-7,null,null],"fireworks_ai/accounts/fireworks/models/ernie-4p5-21b-a3b-pt":[1e-7,1e-7,null,null],"accounts/fireworks/models/ernie-4p5-21b-a3b-pt":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/ernie-4p5-300b-a47b-pt":[1e-7,1e-7,null,null],"accounts/fireworks/models/ernie-4p5-300b-a47b-pt":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/fare-20b":[9e-7,9e-7,null,null],"accounts/fireworks/models/fare-20b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/firefunction-v1":[5e-7,5e-7,null,null],"accounts/fireworks/models/firefunction-v1":[5e-7,5e-7,null,null],"fireworks_ai/accounts/fireworks/models/firellava-13b":[2e-7,2e-7,null,null],"accounts/fireworks/models/firellava-13b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/firesearch-ocr-v6":[2e-7,2e-7,null,null],"accounts/fireworks/models/firesearch-ocr-v6":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/fireworks-asr-large":[0,0,null,null],"accounts/fireworks/models/fireworks-asr-large":[0,0,null,null],"fireworks_ai/accounts/fireworks/models/fireworks-asr-v2":[0,0,null,null],"accounts/fireworks/models/fireworks-asr-v2":[0,0,null,null],"fireworks_ai/accounts/fireworks/models/flux-1-dev":[1e-7,1e-7,null,null],"accounts/fireworks/models/flux-1-dev":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/flux-1-dev-controlnet-union":[1e-9,1e-9,null,null],"accounts/fireworks/models/flux-1-dev-controlnet-union":[1e-9,1e-9,null,null],"fireworks_ai/accounts/fireworks/models/flux-1-dev-fp8":[5e-10,5e-10,null,null],"accounts/fireworks/models/flux-1-dev-fp8":[5e-10,5e-10,null,null],"fireworks_ai/accounts/fireworks/models/flux-1-schnell":[1e-7,1e-7,null,null],"accounts/fireworks/models/flux-1-schnell":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/flux-1-schnell-fp8":[3.5e-10,3.5e-10,null,null],"accounts/fireworks/models/flux-1-schnell-fp8":[3.5e-10,3.5e-10,null,null],"fireworks_ai/accounts/fireworks/models/gemma-2b-it":[1e-7,1e-7,null,null],"accounts/fireworks/models/gemma-2b-it":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/gemma-3-27b-it":[9e-7,9e-7,null,null],"accounts/fireworks/models/gemma-3-27b-it":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/gemma-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/gemma-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/gemma-7b-it":[2e-7,2e-7,null,null],"accounts/fireworks/models/gemma-7b-it":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/gemma2-9b-it":[2e-7,2e-7,null,null],"accounts/fireworks/models/gemma2-9b-it":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/glm-4p5v":[0.0000012,0.0000012,null,null],"accounts/fireworks/models/glm-4p5v":[0.0000012,0.0000012,null,null],"fireworks_ai/accounts/fireworks/models/gpt-oss-safeguard-120b":[0.0000012,0.0000012,null,null],"accounts/fireworks/models/gpt-oss-safeguard-120b":[0.0000012,0.0000012,null,null],"fireworks_ai/accounts/fireworks/models/gpt-oss-safeguard-20b":[5e-7,5e-7,null,null],"accounts/fireworks/models/gpt-oss-safeguard-20b":[5e-7,5e-7,null,null],"fireworks_ai/accounts/fireworks/models/hermes-2-pro-mistral-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/hermes-2-pro-mistral-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/internvl3-38b":[9e-7,9e-7,null,null],"accounts/fireworks/models/internvl3-38b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/internvl3-78b":[9e-7,9e-7,null,null],"accounts/fireworks/models/internvl3-78b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/internvl3-8b":[2e-7,2e-7,null,null],"accounts/fireworks/models/internvl3-8b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/japanese-stable-diffusion-xl":[1.3e-10,1.3e-10,null,null],"accounts/fireworks/models/japanese-stable-diffusion-xl":[1.3e-10,1.3e-10,null,null],"fireworks_ai/accounts/fireworks/models/kat-coder":[9e-7,9e-7,null,null],"accounts/fireworks/models/kat-coder":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/kat-dev-32b":[9e-7,9e-7,null,null],"accounts/fireworks/models/kat-dev-32b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/kat-dev-72b-exp":[9e-7,9e-7,null,null],"accounts/fireworks/models/kat-dev-72b-exp":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-guard-2-8b":[2e-7,2e-7,null,null],"accounts/fireworks/models/llama-guard-2-8b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-guard-3-1b":[1e-7,1e-7,null,null],"accounts/fireworks/models/llama-guard-3-1b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-guard-3-8b":[2e-7,2e-7,null,null],"accounts/fireworks/models/llama-guard-3-8b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v2-13b":[2e-7,2e-7,null,null],"accounts/fireworks/models/llama-v2-13b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v2-13b-chat":[2e-7,2e-7,null,null],"accounts/fireworks/models/llama-v2-13b-chat":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v2-70b":[1e-7,1e-7,null,null],"accounts/fireworks/models/llama-v2-70b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v2-70b-chat":[9e-7,9e-7,null,null],"accounts/fireworks/models/llama-v2-70b-chat":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v2-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/llama-v2-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v2-7b-chat":[2e-7,2e-7,null,null],"accounts/fireworks/models/llama-v2-7b-chat":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3-70b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/llama-v3-70b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3-70b-instruct-hf":[9e-7,9e-7,null,null],"accounts/fireworks/models/llama-v3-70b-instruct-hf":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3-8b":[2e-7,2e-7,null,null],"accounts/fireworks/models/llama-v3-8b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3-8b-instruct-hf":[2e-7,2e-7,null,null],"accounts/fireworks/models/llama-v3-8b-instruct-hf":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p1-405b-instruct-long":[1e-7,1e-7,null,null],"accounts/fireworks/models/llama-v3p1-405b-instruct-long":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p1-70b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/llama-v3p1-70b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p1-70b-instruct-1b":[1e-7,1e-7,null,null],"accounts/fireworks/models/llama-v3p1-70b-instruct-1b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p1-nemotron-70b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/llama-v3p1-nemotron-70b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p2-1b":[1e-7,1e-7,null,null],"accounts/fireworks/models/llama-v3p2-1b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p2-3b":[1e-7,1e-7,null,null],"accounts/fireworks/models/llama-v3p2-3b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p3-70b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/llama-v3p3-70b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/llamaguard-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/llamaguard-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/llava-yi-34b":[9e-7,9e-7,null,null],"accounts/fireworks/models/llava-yi-34b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/minimax-m1-80k":[1e-7,1e-7,null,null],"accounts/fireworks/models/minimax-m1-80k":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/minimax-m2":[3e-7,0.0000012,null,null],"accounts/fireworks/models/minimax-m2":[3e-7,0.0000012,null,null],"fireworks_ai/accounts/fireworks/models/ministral-3-14b-instruct-2512":[2e-7,2e-7,null,null],"accounts/fireworks/models/ministral-3-14b-instruct-2512":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/ministral-3-3b-instruct-2512":[1e-7,1e-7,null,null],"accounts/fireworks/models/ministral-3-3b-instruct-2512":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/ministral-3-8b-instruct-2512":[2e-7,2e-7,null,null],"accounts/fireworks/models/ministral-3-8b-instruct-2512":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/mistral-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/mistral-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/mistral-7b-instruct-4k":[2e-7,2e-7,null,null],"accounts/fireworks/models/mistral-7b-instruct-4k":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/mistral-7b-instruct-v0p2":[2e-7,2e-7,null,null],"accounts/fireworks/models/mistral-7b-instruct-v0p2":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/mistral-7b-instruct-v3":[2e-7,2e-7,null,null],"accounts/fireworks/models/mistral-7b-instruct-v3":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/mistral-7b-v0p2":[2e-7,2e-7,null,null],"accounts/fireworks/models/mistral-7b-v0p2":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/mistral-large-3-fp8":[0.0000012,0.0000012,null,null],"accounts/fireworks/models/mistral-large-3-fp8":[0.0000012,0.0000012,null,null],"fireworks_ai/accounts/fireworks/models/mistral-nemo-base-2407":[2e-7,2e-7,null,null],"accounts/fireworks/models/mistral-nemo-base-2407":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/mistral-nemo-instruct-2407":[2e-7,2e-7,null,null],"accounts/fireworks/models/mistral-nemo-instruct-2407":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/mistral-small-24b-instruct-2501":[9e-7,9e-7,null,null],"accounts/fireworks/models/mistral-small-24b-instruct-2501":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/mixtral-8x22b":[0.0000012,0.0000012,null,null],"accounts/fireworks/models/mixtral-8x22b":[0.0000012,0.0000012,null,null],"fireworks_ai/accounts/fireworks/models/mixtral-8x22b-instruct":[0.0000012,0.0000012,null,null],"accounts/fireworks/models/mixtral-8x22b-instruct":[0.0000012,0.0000012,null,null],"fireworks_ai/accounts/fireworks/models/mixtral-8x7b":[5e-7,5e-7,null,null],"accounts/fireworks/models/mixtral-8x7b":[5e-7,5e-7,null,null],"fireworks_ai/accounts/fireworks/models/mixtral-8x7b-instruct":[5e-7,5e-7,null,null],"accounts/fireworks/models/mixtral-8x7b-instruct":[5e-7,5e-7,null,null],"fireworks_ai/accounts/fireworks/models/mixtral-8x7b-instruct-hf":[5e-7,5e-7,null,null],"accounts/fireworks/models/mixtral-8x7b-instruct-hf":[5e-7,5e-7,null,null],"fireworks_ai/accounts/fireworks/models/mythomax-l2-13b":[2e-7,2e-7,null,null],"accounts/fireworks/models/mythomax-l2-13b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/nemotron-nano-v2-12b-vl":[1e-7,1e-7,null,null],"accounts/fireworks/models/nemotron-nano-v2-12b-vl":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/nous-capybara-7b-v1p9":[2e-7,2e-7,null,null],"accounts/fireworks/models/nous-capybara-7b-v1p9":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/nous-hermes-2-mixtral-8x7b-dpo":[5e-7,5e-7,null,null],"accounts/fireworks/models/nous-hermes-2-mixtral-8x7b-dpo":[5e-7,5e-7,null,null],"fireworks_ai/accounts/fireworks/models/nous-hermes-2-yi-34b":[9e-7,9e-7,null,null],"accounts/fireworks/models/nous-hermes-2-yi-34b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/nous-hermes-llama2-13b":[2e-7,2e-7,null,null],"accounts/fireworks/models/nous-hermes-llama2-13b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/nous-hermes-llama2-70b":[9e-7,9e-7,null,null],"accounts/fireworks/models/nous-hermes-llama2-70b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/nous-hermes-llama2-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/nous-hermes-llama2-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/nvidia-nemotron-nano-12b-v2":[2e-7,2e-7,null,null],"accounts/fireworks/models/nvidia-nemotron-nano-12b-v2":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/nvidia-nemotron-nano-9b-v2":[2e-7,2e-7,null,null],"accounts/fireworks/models/nvidia-nemotron-nano-9b-v2":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/openchat-3p5-0106-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/openchat-3p5-0106-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/openhermes-2-mistral-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/openhermes-2-mistral-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/openhermes-2p5-mistral-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/openhermes-2p5-mistral-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/openorca-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/openorca-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/phi-2-3b":[1e-7,1e-7,null,null],"accounts/fireworks/models/phi-2-3b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/phi-3-mini-128k-instruct":[1e-7,1e-7,null,null],"accounts/fireworks/models/phi-3-mini-128k-instruct":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/phi-3-vision-128k-instruct":[2e-7,2e-7,null,null],"accounts/fireworks/models/phi-3-vision-128k-instruct":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/phind-code-llama-34b-python-v1":[9e-7,9e-7,null,null],"accounts/fireworks/models/phind-code-llama-34b-python-v1":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/phind-code-llama-34b-v1":[9e-7,9e-7,null,null],"accounts/fireworks/models/phind-code-llama-34b-v1":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/phind-code-llama-34b-v2":[9e-7,9e-7,null,null],"accounts/fireworks/models/phind-code-llama-34b-v2":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/playground-v2-1024px-aesthetic":[1.3e-10,1.3e-10,null,null],"accounts/fireworks/models/playground-v2-1024px-aesthetic":[1.3e-10,1.3e-10,null,null],"fireworks_ai/accounts/fireworks/models/playground-v2-5-1024px-aesthetic":[1.3e-10,1.3e-10,null,null],"accounts/fireworks/models/playground-v2-5-1024px-aesthetic":[1.3e-10,1.3e-10,null,null],"fireworks_ai/accounts/fireworks/models/pythia-12b":[2e-7,2e-7,null,null],"accounts/fireworks/models/pythia-12b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen-qwq-32b-preview":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen-qwq-32b-preview":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen-v2p5-14b-instruct":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen-v2p5-14b-instruct":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen-v2p5-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen-v2p5-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen1p5-72b-chat":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen1p5-72b-chat":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2-7b-instruct":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen2-7b-instruct":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2-vl-2b-instruct":[1e-7,1e-7,null,null],"accounts/fireworks/models/qwen2-vl-2b-instruct":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2-vl-72b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen2-vl-72b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2-vl-7b-instruct":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen2-vl-7b-instruct":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-0p5b-instruct":[1e-7,1e-7,null,null],"accounts/fireworks/models/qwen2p5-0p5b-instruct":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-14b":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen2p5-14b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-1p5b-instruct":[1e-7,1e-7,null,null],"accounts/fireworks/models/qwen2p5-1p5b-instruct":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-32b":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen2p5-32b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-32b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen2p5-32b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-72b":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen2p5-72b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-72b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen2p5-72b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-7b-instruct":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen2p5-7b-instruct":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-0p5b":[1e-7,1e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-0p5b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-0p5b-instruct":[1e-7,1e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-0p5b-instruct":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-14b":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-14b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-14b-instruct":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-14b-instruct":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-1p5b":[1e-7,1e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-1p5b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-1p5b-instruct":[1e-7,1e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-1p5b-instruct":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-32b":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-32b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-32b-instruct-128k":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-32b-instruct-128k":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-32b-instruct-32k-rope":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-32b-instruct-32k-rope":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-32b-instruct-64k":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-32b-instruct-64k":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-3b":[1e-7,1e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-3b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-3b-instruct":[1e-7,1e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-3b-instruct":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-7b-instruct":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen2p5-coder-7b-instruct":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-math-72b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen2p5-math-72b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-vl-32b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen2p5-vl-32b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-vl-3b-instruct":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen2p5-vl-3b-instruct":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-vl-72b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen2p5-vl-72b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-vl-7b-instruct":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen2p5-vl-7b-instruct":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-0p6b":[1e-7,1e-7,null,null],"accounts/fireworks/models/qwen3-0p6b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-14b":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen3-14b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-1p7b":[1e-7,1e-7,null,null],"accounts/fireworks/models/qwen3-1p7b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-1p7b-fp8-draft":[1e-7,1e-7,null,null],"accounts/fireworks/models/qwen3-1p7b-fp8-draft":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-1p7b-fp8-draft-131072":[1e-7,1e-7,null,null],"accounts/fireworks/models/qwen3-1p7b-fp8-draft-131072":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-1p7b-fp8-draft-40960":[1e-7,1e-7,null,null],"accounts/fireworks/models/qwen3-1p7b-fp8-draft-40960":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-235b-a22b":[2.2e-7,8.8e-7,null,null],"accounts/fireworks/models/qwen3-235b-a22b":[2.2e-7,8.8e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-235b-a22b-instruct-2507":[2.2e-7,8.8e-7,null,null],"accounts/fireworks/models/qwen3-235b-a22b-instruct-2507":[2.2e-7,8.8e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-235b-a22b-thinking-2507":[2.2e-7,8.8e-7,null,null],"accounts/fireworks/models/qwen3-235b-a22b-thinking-2507":[2.2e-7,8.8e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-30b-a3b":[1.5e-7,6e-7,null,null],"accounts/fireworks/models/qwen3-30b-a3b":[1.5e-7,6e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-30b-a3b-instruct-2507":[5e-7,5e-7,null,null],"accounts/fireworks/models/qwen3-30b-a3b-instruct-2507":[5e-7,5e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-30b-a3b-thinking-2507":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen3-30b-a3b-thinking-2507":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-32b":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen3-32b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-4b":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen3-4b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-4b-instruct-2507":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen3-4b-instruct-2507":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-8b":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen3-8b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-coder-30b-a3b-instruct":[1.5e-7,6e-7,null,null],"accounts/fireworks/models/qwen3-coder-30b-a3b-instruct":[1.5e-7,6e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-coder-480b-instruct-bf16":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen3-coder-480b-instruct-bf16":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-embedding-0p6b":[0,0,null,null],"accounts/fireworks/models/qwen3-embedding-0p6b":[0,0,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-embedding-4b":[0,0,null,null],"accounts/fireworks/models/qwen3-embedding-4b":[0,0,null,null],"fireworks_ai/accounts/fireworks/models/":[1e-7,0,null,null],"accounts/fireworks/models/":[1e-7,0,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-next-80b-a3b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen3-next-80b-a3b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-next-80b-a3b-thinking":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen3-next-80b-a3b-thinking":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-reranker-0p6b":[0,0,null,null],"accounts/fireworks/models/qwen3-reranker-0p6b":[0,0,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-reranker-4b":[0,0,null,null],"accounts/fireworks/models/qwen3-reranker-4b":[0,0,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-reranker-8b":[0,0,null,null],"accounts/fireworks/models/qwen3-reranker-8b":[0,0,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-vl-235b-a22b-instruct":[2.2e-7,8.8e-7,null,null],"accounts/fireworks/models/qwen3-vl-235b-a22b-instruct":[2.2e-7,8.8e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-vl-235b-a22b-thinking":[2.2e-7,8.8e-7,null,null],"accounts/fireworks/models/qwen3-vl-235b-a22b-thinking":[2.2e-7,8.8e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-vl-30b-a3b-instruct":[1.5e-7,6e-7,null,null],"accounts/fireworks/models/qwen3-vl-30b-a3b-instruct":[1.5e-7,6e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-vl-30b-a3b-thinking":[1.5e-7,6e-7,null,null],"accounts/fireworks/models/qwen3-vl-30b-a3b-thinking":[1.5e-7,6e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-vl-32b-instruct":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwen3-vl-32b-instruct":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-vl-8b-instruct":[2e-7,2e-7,null,null],"accounts/fireworks/models/qwen3-vl-8b-instruct":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/qwq-32b":[9e-7,9e-7,null,null],"accounts/fireworks/models/qwq-32b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/rolm-ocr":[2e-7,2e-7,null,null],"accounts/fireworks/models/rolm-ocr":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/snorkel-mistral-7b-pairrm-dpo":[2e-7,2e-7,null,null],"accounts/fireworks/models/snorkel-mistral-7b-pairrm-dpo":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/stable-diffusion-xl-1024-v1-0":[1.3e-10,1.3e-10,null,null],"accounts/fireworks/models/stable-diffusion-xl-1024-v1-0":[1.3e-10,1.3e-10,null,null],"fireworks_ai/accounts/fireworks/models/stablecode-3b":[1e-7,1e-7,null,null],"accounts/fireworks/models/stablecode-3b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/starcoder-16b":[2e-7,2e-7,null,null],"accounts/fireworks/models/starcoder-16b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/starcoder-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/starcoder-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/starcoder2-15b":[2e-7,2e-7,null,null],"accounts/fireworks/models/starcoder2-15b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/starcoder2-3b":[1e-7,1e-7,null,null],"accounts/fireworks/models/starcoder2-3b":[1e-7,1e-7,null,null],"fireworks_ai/accounts/fireworks/models/starcoder2-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/starcoder2-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/toppy-m-7b":[2e-7,2e-7,null,null],"accounts/fireworks/models/toppy-m-7b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/whisper-v3":[0,0,null,null],"accounts/fireworks/models/whisper-v3":[0,0,null,null],"fireworks_ai/accounts/fireworks/models/whisper-v3-turbo":[0,0,null,null],"accounts/fireworks/models/whisper-v3-turbo":[0,0,null,null],"fireworks_ai/accounts/fireworks/models/yi-34b":[9e-7,9e-7,null,null],"accounts/fireworks/models/yi-34b":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/yi-34b-200k-capybara":[9e-7,9e-7,null,null],"accounts/fireworks/models/yi-34b-200k-capybara":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/yi-34b-chat":[9e-7,9e-7,null,null],"accounts/fireworks/models/yi-34b-chat":[9e-7,9e-7,null,null],"fireworks_ai/accounts/fireworks/models/yi-6b":[2e-7,2e-7,null,null],"accounts/fireworks/models/yi-6b":[2e-7,2e-7,null,null],"fireworks_ai/accounts/fireworks/models/zephyr-7b-beta":[2e-7,2e-7,null,null],"accounts/fireworks/models/zephyr-7b-beta":[2e-7,2e-7,null,null],"novita/deepseek/deepseek-v3.2":[2.69e-7,4e-7,null,1.345e-7],"novita/minimax/minimax-m2.1":[3e-7,0.0000012,null,3e-8],"novita/zai-org/glm-4.7":[6e-7,0.0000022,null,1.1e-7],"zai-org/glm-4.7":[6e-7,0.0000022,null,1.1e-7],"novita/xiaomimimo/mimo-v2-flash":[1e-7,3e-7,null,2e-8],"xiaomimimo/mimo-v2-flash":[1e-7,3e-7,null,2e-8],"novita/zai-org/autoglm-phone-9b-multilingual":[3.5e-8,1.38e-7,null,null],"zai-org/autoglm-phone-9b-multilingual":[3.5e-8,1.38e-7,null,null],"novita/moonshotai/kimi-k2-thinking":[6e-7,0.0000025,null,null],"moonshotai/kimi-k2-thinking":[6e-7,0.0000025,null,null],"novita/minimax/minimax-m2":[3e-7,0.0000012,null,3e-8],"novita/paddlepaddle/paddleocr-vl":[2e-8,2e-8,null,null],"paddlepaddle/paddleocr-vl":[2e-8,2e-8,null,null],"novita/deepseek/deepseek-v3.2-exp":[2.7e-7,4.1e-7,null,null],"novita/qwen/qwen3-vl-235b-a22b-thinking":[9.8e-7,0.00000395,null,null],"qwen/qwen3-vl-235b-a22b-thinking":[9.8e-7,0.00000395,null,null],"novita/zai-org/glm-4.6v":[3e-7,9e-7,null,5.5e-8],"zai-org/glm-4.6v":[3e-7,9e-7,null,5.5e-8],"novita/zai-org/glm-4.6":[5.5e-7,0.0000022,null,1.1e-7],"zai-org/glm-4.6":[5.5e-7,0.0000022,null,1.1e-7],"novita/kwaipilot/kat-coder-pro":[3e-7,0.0000012,null,6e-8],"kwaipilot/kat-coder-pro":[3e-7,0.0000012,null,6e-8],"novita/qwen/qwen3-next-80b-a3b-instruct":[1.5e-7,0.0000015,null,null],"qwen/qwen3-next-80b-a3b-instruct":[1.5e-7,0.0000015,null,null],"novita/qwen/qwen3-next-80b-a3b-thinking":[1.5e-7,0.0000015,null,null],"qwen/qwen3-next-80b-a3b-thinking":[1.5e-7,0.0000015,null,null],"novita/deepseek/deepseek-ocr":[3e-8,3e-8,null,null],"deepseek/deepseek-ocr":[3e-8,3e-8,null,null],"novita/deepseek/deepseek-v3.1-terminus":[2.7e-7,0.000001,null,1.35e-7],"deepseek/deepseek-v3.1-terminus":[2.7e-7,0.000001,null,1.35e-7],"novita/qwen/qwen3-vl-235b-a22b-instruct":[3e-7,0.0000015,null,null],"qwen/qwen3-vl-235b-a22b-instruct":[3e-7,0.0000015,null,null],"novita/qwen/qwen3-max":[0.00000211,0.00000845,null,null],"qwen/qwen3-max":[0.00000211,0.00000845,null,null],"novita/skywork/r1v4-lite":[2e-7,6e-7,null,null],"skywork/r1v4-lite":[2e-7,6e-7,null,null],"novita/deepseek/deepseek-v3.1":[2.7e-7,0.000001,null,1.35e-7],"deepseek/deepseek-v3.1":[2.7e-7,0.000001,null,1.35e-7],"novita/moonshotai/kimi-k2-0905":[6e-7,0.0000025,null,null],"moonshotai/kimi-k2-0905":[6e-7,0.0000025,null,null],"novita/qwen/qwen3-coder-480b-a35b-instruct":[3e-7,0.0000013,null,null],"qwen/qwen3-coder-480b-a35b-instruct":[3e-7,0.0000013,null,null],"novita/qwen/qwen3-coder-30b-a3b-instruct":[7e-8,2.7e-7,null,null],"qwen/qwen3-coder-30b-a3b-instruct":[7e-8,2.7e-7,null,null],"novita/openai/gpt-oss-120b":[5e-8,2.5e-7,null,null],"novita/moonshotai/kimi-k2-instruct":[5.7e-7,0.0000023,null,null],"moonshotai/kimi-k2-instruct":[5.7e-7,0.0000023,null,null],"novita/deepseek/deepseek-v3-0324":[2.7e-7,0.00000112,null,1.35e-7],"deepseek/deepseek-v3-0324":[2.7e-7,0.00000112,null,1.35e-7],"novita/zai-org/glm-4.5":[6e-7,0.0000022,null,1.1e-7],"zai-org/glm-4.5":[6e-7,0.0000022,null,1.1e-7],"novita/qwen/qwen3-235b-a22b-thinking-2507":[3e-7,0.000003,null,null],"novita/meta-llama/llama-3.1-8b-instruct":[2e-8,5e-8,null,null],"meta-llama/llama-3.1-8b-instruct":[2e-8,5e-8,null,null],"novita/google/gemma-3-12b-it":[5e-8,1e-7,null,null],"novita/zai-org/glm-4.5v":[6e-7,0.0000018,null,1.1e-7],"zai-org/glm-4.5v":[6e-7,0.0000018,null,1.1e-7],"novita/openai/gpt-oss-20b":[4e-8,1.5e-7,null,null],"novita/qwen/qwen3-235b-a22b-instruct-2507":[9e-8,5.8e-7,null,null],"novita/deepseek/deepseek-r1-distill-qwen-14b":[1.5e-7,1.5e-7,null,null],"deepseek/deepseek-r1-distill-qwen-14b":[1.5e-7,1.5e-7,null,null],"novita/meta-llama/llama-3.3-70b-instruct":[1.35e-7,4e-7,null,null],"meta-llama/llama-3.3-70b-instruct":[1.35e-7,4e-7,null,null],"novita/qwen/qwen-2.5-72b-instruct":[3.8e-7,4e-7,null,null],"qwen/qwen-2.5-72b-instruct":[3.8e-7,4e-7,null,null],"novita/mistralai/mistral-nemo":[4e-8,1.7e-7,null,null],"mistralai/mistral-nemo":[4e-8,1.7e-7,null,null],"novita/minimaxai/minimax-m1-80k":[5.5e-7,0.0000022,null,null],"minimaxai/minimax-m1-80k":[5.5e-7,0.0000022,null,null],"novita/deepseek/deepseek-r1-0528":[7e-7,0.0000025,null,3.5e-7],"novita/deepseek/deepseek-r1-distill-qwen-32b":[3e-7,3e-7,null,null],"deepseek/deepseek-r1-distill-qwen-32b":[3e-7,3e-7,null,null],"novita/meta-llama/llama-3-8b-instruct":[4e-8,4e-8,null,null],"meta-llama/llama-3-8b-instruct":[4e-8,4e-8,null,null],"novita/microsoft/wizardlm-2-8x22b":[6.2e-7,6.2e-7,null,null],"microsoft/wizardlm-2-8x22b":[6.2e-7,6.2e-7,null,null],"novita/deepseek/deepseek-r1-0528-qwen3-8b":[6e-8,9e-8,null,null],"deepseek/deepseek-r1-0528-qwen3-8b":[6e-8,9e-8,null,null],"novita/deepseek/deepseek-r1-distill-llama-70b":[8e-7,8e-7,null,null],"novita/meta-llama/llama-3-70b-instruct":[5.1e-7,7.4e-7,null,null],"novita/qwen/qwen3-235b-a22b-fp8":[2e-7,8e-7,null,null],"qwen/qwen3-235b-a22b-fp8":[2e-7,8e-7,null,null],"novita/meta-llama/llama-4-maverick-17b-128e-instruct-fp8":[2.7e-7,8.5e-7,null,null],"meta-llama/llama-4-maverick-17b-128e-instruct-fp8":[2.7e-7,8.5e-7,null,null],"novita/meta-llama/llama-4-scout-17b-16e-instruct":[1.8e-7,5.9e-7,null,null],"novita/nousresearch/hermes-2-pro-llama-3-8b":[1.4e-7,1.4e-7,null,null],"nousresearch/hermes-2-pro-llama-3-8b":[1.4e-7,1.4e-7,null,null],"novita/qwen/qwen2.5-vl-72b-instruct":[8e-7,8e-7,null,null],"qwen/qwen2.5-vl-72b-instruct":[8e-7,8e-7,null,null],"novita/sao10k/l3-70b-euryale-v2.1":[0.00000148,0.00000148,null,null],"sao10k/l3-70b-euryale-v2.1":[0.00000148,0.00000148,null,null],"novita/baidu/ernie-4.5-21B-a3b-thinking":[7e-8,2.8e-7,null,null],"baidu/ernie-4.5-21B-a3b-thinking":[7e-8,2.8e-7,null,null],"novita/sao10k/l3-8b-lunaris":[5e-8,5e-8,null,null],"sao10k/l3-8b-lunaris":[5e-8,5e-8,null,null],"novita/baichuan/baichuan-m2-32b":[7e-8,7e-8,null,null],"baichuan/baichuan-m2-32b":[7e-8,7e-8,null,null],"novita/baidu/ernie-4.5-vl-424b-a47b":[4.2e-7,0.00000125,null,null],"baidu/ernie-4.5-vl-424b-a47b":[4.2e-7,0.00000125,null,null],"novita/baidu/ernie-4.5-300b-a47b-paddle":[2.8e-7,0.0000011,null,null],"baidu/ernie-4.5-300b-a47b-paddle":[2.8e-7,0.0000011,null,null],"novita/deepseek/deepseek-prover-v2-671b":[7e-7,0.0000025,null,null],"deepseek/deepseek-prover-v2-671b":[7e-7,0.0000025,null,null],"novita/qwen/qwen3-32b-fp8":[1e-7,4.5e-7,null,null],"qwen/qwen3-32b-fp8":[1e-7,4.5e-7,null,null],"novita/qwen/qwen3-30b-a3b-fp8":[9e-8,4.5e-7,null,null],"qwen/qwen3-30b-a3b-fp8":[9e-8,4.5e-7,null,null],"novita/google/gemma-3-27b-it":[1.19e-7,2e-7,null,null],"novita/deepseek/deepseek-v3-turbo":[4e-7,0.0000013,null,null],"deepseek/deepseek-v3-turbo":[4e-7,0.0000013,null,null],"novita/deepseek/deepseek-r1-turbo":[7e-7,0.0000025,null,null],"deepseek/deepseek-r1-turbo":[7e-7,0.0000025,null,null],"novita/Sao10K/L3-8B-Stheno-v3.2":[5e-8,5e-8,null,null],"Sao10K/L3-8B-Stheno-v3.2":[5e-8,5e-8,null,null],"novita/gryphe/mythomax-l2-13b":[9e-8,9e-8,null,null],"novita/baidu/ernie-4.5-vl-28b-a3b-thinking":[3.9e-7,3.9e-7,null,null],"baidu/ernie-4.5-vl-28b-a3b-thinking":[3.9e-7,3.9e-7,null,null],"novita/qwen/qwen3-vl-8b-instruct":[8e-8,5e-7,null,null],"qwen/qwen3-vl-8b-instruct":[8e-8,5e-7,null,null],"novita/zai-org/glm-4.5-air":[1.3e-7,8.5e-7,null,null],"zai-org/glm-4.5-air":[1.3e-7,8.5e-7,null,null],"novita/qwen/qwen3-vl-30b-a3b-instruct":[2e-7,7e-7,null,null],"qwen/qwen3-vl-30b-a3b-instruct":[2e-7,7e-7,null,null],"novita/qwen/qwen3-vl-30b-a3b-thinking":[2e-7,0.000001,null,null],"qwen/qwen3-vl-30b-a3b-thinking":[2e-7,0.000001,null,null],"novita/qwen/qwen3-omni-30b-a3b-thinking":[2.5e-7,9.7e-7,null,null],"qwen/qwen3-omni-30b-a3b-thinking":[2.5e-7,9.7e-7,null,null],"novita/qwen/qwen3-omni-30b-a3b-instruct":[2.5e-7,9.7e-7,null,null],"qwen/qwen3-omni-30b-a3b-instruct":[2.5e-7,9.7e-7,null,null],"novita/qwen/qwen-mt-plus":[2.5e-7,7.5e-7,null,null],"qwen/qwen-mt-plus":[2.5e-7,7.5e-7,null,null],"novita/baidu/ernie-4.5-vl-28b-a3b":[1.4e-7,5.6e-7,null,null],"baidu/ernie-4.5-vl-28b-a3b":[1.4e-7,5.6e-7,null,null],"novita/baidu/ernie-4.5-21B-a3b":[7e-8,2.8e-7,null,null],"baidu/ernie-4.5-21B-a3b":[7e-8,2.8e-7,null,null],"novita/qwen/qwen3-8b-fp8":[3.5e-8,1.38e-7,null,null],"qwen/qwen3-8b-fp8":[3.5e-8,1.38e-7,null,null],"novita/qwen/qwen3-4b-fp8":[3e-8,3e-8,null,null],"qwen/qwen3-4b-fp8":[3e-8,3e-8,null,null],"novita/qwen/qwen2.5-7b-instruct":[7e-8,7e-8,null,null],"qwen/qwen2.5-7b-instruct":[7e-8,7e-8,null,null],"novita/meta-llama/llama-3.2-3b-instruct":[3e-8,5e-8,null,null],"meta-llama/llama-3.2-3b-instruct":[3e-8,5e-8,null,null],"novita/sao10k/l31-70b-euryale-v2.2":[0.00000148,0.00000148,null,null],"sao10k/l31-70b-euryale-v2.2":[0.00000148,0.00000148,null,null],"novita/qwen/qwen3-embedding-0.6b":[7e-8,0,null,null],"qwen/qwen3-embedding-0.6b":[7e-8,0,null,null],"novita/qwen/qwen3-embedding-8b":[7e-8,0,null,null],"qwen/qwen3-embedding-8b":[7e-8,0,null,null],"novita/baai/bge-m3":[1e-8,1e-8,null,null],"baai/bge-m3":[1e-8,1e-8,null,null],"novita/qwen/qwen3-reranker-8b":[5e-8,5e-8,null,null],"qwen/qwen3-reranker-8b":[5e-8,5e-8,null,null],"novita/baai/bge-reranker-v2-m3":[1e-8,1e-8,null,null],"baai/bge-reranker-v2-m3":[1e-8,1e-8,null,null],"llamagate/llama-3.1-8b":[3e-8,5e-8,null,null],"llama-3.1-8b":[3e-8,5e-8,null,null],"llamagate/llama-3.2-3b":[4e-8,8e-8,null,null],"llama-3.2-3b":[4e-8,8e-8,null,null],"llamagate/mistral-7b-v0.3":[1e-7,1.5e-7,null,null],"mistral-7b-v0.3":[1e-7,1.5e-7,null,null],"llamagate/qwen3-8b":[4e-8,1.4e-7,null,null],"qwen3-8b":[4e-8,1.4e-7,null,null],"llamagate/dolphin3-8b":[8e-8,1.5e-7,null,null],"dolphin3-8b":[8e-8,1.5e-7,null,null],"llamagate/deepseek-r1-8b":[1e-7,2e-7,null,null],"deepseek-r1-8b":[1e-7,2e-7,null,null],"llamagate/deepseek-r1-7b-qwen":[8e-8,1.5e-7,null,null],"deepseek-r1-7b-qwen":[8e-8,1.5e-7,null,null],"llamagate/openthinker-7b":[8e-8,1.5e-7,null,null],"openthinker-7b":[8e-8,1.5e-7,null,null],"llamagate/qwen2.5-coder-7b":[6e-8,1.2e-7,null,null],"qwen2.5-coder-7b":[6e-8,1.2e-7,null,null],"llamagate/deepseek-coder-6.7b":[6e-8,1.2e-7,null,null],"deepseek-coder-6.7b":[6e-8,1.2e-7,null,null],"llamagate/codellama-7b":[6e-8,1.2e-7,null,null],"codellama-7b":[6e-8,1.2e-7,null,null],"llamagate/qwen3-vl-8b":[1.5e-7,5.5e-7,null,null],"qwen3-vl-8b":[1.5e-7,5.5e-7,null,null],"llamagate/llava-7b":[1e-7,2e-7,null,null],"llava-7b":[1e-7,2e-7,null,null],"llamagate/gemma3-4b":[3e-8,8e-8,null,null],"gemma3-4b":[3e-8,8e-8,null,null],"llamagate/nomic-embed-text":[2e-8,0,null,null],"nomic-embed-text":[2e-8,0,null,null],"llamagate/qwen3-embedding-8b":[2e-8,0,null,null],"qwen3-embedding-8b":[2e-8,0,null,null],"sarvam/sarvam-m":[0,0,0,0],"sarvam-m":[0,0,0,0],"gemini/gemini-2.0-flash-exp-image-generation":[0,0,null,null],"gemini/gemini-2.0-flash-lite-001":[7.5e-8,3e-7,null,1.875e-8],"gemini/gemini-2.5-flash-native-audio-latest":[3e-7,0.0000025,null,null],"gemini/gemini-2.5-flash-native-audio-preview-09-2025":[3e-7,0.0000025,null,null],"gemini/gemini-2.5-flash-native-audio-preview-12-2025":[3e-7,0.0000025,null,null],"gemini/gemini-3.1-flash-live-preview":[7.5e-7,0.0000045,null,null],"gemini/gemini-pro-latest":[0.00000125,0.00001,null,1.25e-7],"vertex_ai/claude-sonnet-4-6@default":[0.000003,0.000015,0.00000375,3e-7],"claude-sonnet-4-6@default":[0.000003,0.000015,0.00000375,3e-7],"bedrock_mantle/openai.gpt-oss-120b":[1.5e-7,6e-7,null,null],"openai.gpt-oss-120b":[1.5e-7,6e-7,null,null],"bedrock_mantle/openai.gpt-oss-20b":[7.5e-8,3e-7,null,null],"openai.gpt-oss-20b":[7.5e-8,3e-7,null,null],"bedrock_mantle/openai.gpt-oss-safeguard-120b":[1.5e-7,6e-7,null,null],"bedrock_mantle/openai.gpt-oss-safeguard-20b":[7.5e-8,3e-7,null,null],"bedrock/us-east-1/zai.glm-5":[0.000001,0.0000032,null,null],"us-east-1/zai.glm-5":[0.000001,0.0000032,null,null],"bedrock/us-west-2/zai.glm-5":[0.000001,0.0000032,null,null],"us-west-2/zai.glm-5":[0.000001,0.0000032,null,null],"bedrock/us-gov-east-1/anthropic.claude-haiku-4-5-20251001-v1:0":[0.0000012,0.000006,0.0000015,1.2e-7],"us-gov-east-1/anthropic.claude-haiku-4-5-20251001-v1:0":[0.0000012,0.000006,0.0000015,1.2e-7],"bedrock/us-gov-west-1/anthropic.claude-haiku-4-5-20251001-v1:0":[0.0000012,0.000006,0.0000015,1.2e-7],"us-gov-west-1/anthropic.claude-haiku-4-5-20251001-v1:0":[0.0000012,0.000006,0.0000015,1.2e-7],"MiniMax-M2.7-highspeed":[6e-7,0.0000024,3.75e-7,6e-8]} \ No newline at end of file diff --git a/src/export.ts b/src/export.ts index 4e1afc1..70b669c 100644 --- a/src/export.ts +++ b/src/export.ts @@ -1,9 +1,10 @@ -import { writeFile, mkdir, readdir, stat, rm } from 'fs/promises' +import { writeFile, mkdir, readdir, open, stat, rm } from 'fs/promises' import { dirname, join, resolve } from 'path' import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js' -import { getCurrency, convertCost } from './currency.js' +import { getCurrency, convertCost, roundForActiveCurrency } from './currency.js' import { dateKey } from './day-aggregator.js' +import { aggregateModelEfficiency } from './model-efficiency.js' function escCsv(s: string): string { const sanitized = /^[\t\r=+\-@]/.test(s) ? `'${s}` : s @@ -69,7 +70,7 @@ function buildDailyRows(projects: ProjectSummary[], period: string): Row[] { return Object.entries(daily).sort().map(([date, d]) => ({ Period: period, Date: date, - [`Cost (${code})`]: round2(convertCost(d.cost)), + [`Cost (${code})`]: roundForActiveCurrency(convertCost(d.cost)), 'API Calls': d.calls, Sessions: d.sessions.size, 'Input Tokens': d.input, @@ -97,7 +98,7 @@ function buildActivityRows(projects: ProjectSummary[], period: string): Row[] { .map(([cat, d]) => ({ Period: period, Activity: CATEGORY_LABELS[cat as TaskCategory] ?? cat, - [`Cost (${code})`]: round2(convertCost(d.cost)), + [`Cost (${code})`]: roundForActiveCurrency(convertCost(d.cost)), 'Share (%)': pct(d.cost, totalCost), Turns: d.turns, })) @@ -105,6 +106,7 @@ function buildActivityRows(projects: ProjectSummary[], period: string): Row[] { function buildModelRows(projects: ProjectSummary[], period: string): Row[] { const modelTotals: Record = {} + const modelEfficiency = aggregateModelEfficiency(projects) for (const project of projects) { for (const session of project.sessions) { for (const [model, d] of Object.entries(session.modelBreakdown)) { @@ -123,17 +125,26 @@ function buildModelRows(projects: ProjectSummary[], period: string): Row[] { return Object.entries(modelTotals) .filter(([name]) => name !== '') .sort(([, a], [, b]) => b.cost - a.cost) - .map(([model, d]) => ({ - Period: period, - Model: model, - [`Cost (${code})`]: round2(convertCost(d.cost)), - 'Share (%)': pct(d.cost, totalCost), - 'API Calls': d.calls, - 'Input Tokens': d.input, - 'Output Tokens': d.output, - 'Cache Read Tokens': d.cacheRead, - 'Cache Write Tokens': d.cacheWrite, - })) + .map(([model, d]) => { + const efficiency = modelEfficiency.get(model) + return { + Period: period, + Model: model, + [`Cost (${code})`]: roundForActiveCurrency(convertCost(d.cost)), + 'Share (%)': pct(d.cost, totalCost), + 'API Calls': d.calls, + 'Edit Turns': efficiency?.editTurns ?? 0, + 'One-shot Rate (%)': efficiency?.oneShotRate ?? '', + 'Retries/Edit': efficiency?.retriesPerEdit ?? '', + [`Cost/Edit (${code})`]: efficiency?.costPerEditUSD !== null && efficiency?.costPerEditUSD !== undefined + ? roundForActiveCurrency(convertCost(efficiency.costPerEditUSD)) + : '', + 'Input Tokens': d.input, + 'Output Tokens': d.output, + 'Cache Read Tokens': d.cacheRead, + 'Cache Write Tokens': d.cacheWrite, + } + }) } function buildToolRows(projects: ProjectSummary[]): Row[] { @@ -182,8 +193,8 @@ function buildProjectRows(projects: ProjectSummary[]): Row[] { .sort((a, b) => b.totalCostUSD - a.totalCostUSD) .map(p => ({ Project: p.projectPath, - [`Cost (${code})`]: round2(convertCost(p.totalCostUSD)), - [`Avg/Session (${code})`]: p.sessions.length > 0 ? round2(convertCost(p.totalCostUSD / p.sessions.length)) : '', + [`Cost (${code})`]: roundForActiveCurrency(convertCost(p.totalCostUSD)), + [`Avg/Session (${code})`]: p.sessions.length > 0 ? roundForActiveCurrency(convertCost(p.totalCostUSD / p.sessions.length)) : '', 'Share (%)': pct(p.totalCostUSD, total), 'API Calls': p.totalApiCalls, Sessions: p.sessions.length, @@ -199,7 +210,7 @@ function buildSessionRows(projects: ProjectSummary[]): Row[] { Project: p.projectPath, 'Session ID': s.sessionId, 'Started At': s.firstTimestamp ?? '', - [`Cost (${code})`]: round2(convertCost(s.totalCostUSD)), + [`Cost (${code})`]: roundForActiveCurrency(convertCost(s.totalCostUSD)), 'API Calls': s.apiCalls, Turns: s.turns.length, }) @@ -222,7 +233,7 @@ function buildSummaryRows(periods: PeriodExport[]): Row[] { const projectCount = p.projects.filter(proj => proj.totalCostUSD > 0).length return { Period: p.label, - [`Cost (${code})`]: round2(convertCost(cost)), + [`Cost (${code})`]: roundForActiveCurrency(convertCost(cost)), 'API Calls': calls, Sessions: sessions, Projects: projectCount, @@ -247,10 +258,10 @@ function buildReadme(periods: PeriodExport[]): string { ' daily.csv Day-by-day breakdown, Period column distinguishes the window.', ' activity.csv Time spent per task category (Coding, Debugging, Exploration, etc.).', ' models.csv Spend per model with token totals and cache usage.', - ' projects.csv Spend per project folder (30-day window).', - ' sessions.csv One row per session (30-day window) with session IDs and costs.', - ' tools.csv Tool invocations and share (30-day window).', - ' shell-commands.csv Shell commands executed via Bash tool (30-day window).', + ' projects.csv Spend per project folder for the selected detail period.', + ' sessions.csv One row per session for the selected detail period.', + ' tools.csv Tool invocations and share for the selected detail period.', + ' shell-commands.csv Shell commands executed via Bash tool for the selected detail period.', '', 'Notes', '-----', @@ -346,6 +357,33 @@ export async function exportJson(periods: PeriodExport[], outputPath: string): P } const target = resolve(outputPath.toLowerCase().endsWith('.json') ? outputPath : `${outputPath}.json`) + // Refuse to overwrite an existing file that wasn't produced by codeburn + // export. CSV path has the same guard via the .codeburn-export marker; JSON + // was missing it, so a stray `-o ~/important.json` would silently clobber. + const existing = await stat(target).catch(() => null) + if (existing?.isFile()) { + // Read just the first 4KB to look for the schema marker. The schema key + // is the first field in the JSON object so a partial read is enough; + // loading the whole file (potentially gigabytes) into memory could OOM + // on Node's ~512MB string limit. + const fh = await open(target, 'r') + try { + const buf = Buffer.alloc(4096) + const { bytesRead } = await fh.read(buf, 0, buf.length, 0) + const head = buf.toString('utf-8', 0, bytesRead) + if (!head.includes('"schema": "codeburn.export.v')) { + throw new Error( + `Refusing to overwrite ${target}: file does not look like a codeburn export. ` + + `Delete it manually or pick a different -o path.` + ) + } + } finally { + await fh.close() + } + } + if (existing?.isDirectory()) { + throw new Error(`Refusing to overwrite directory at ${target}. Pass a file path instead.`) + } await mkdir(dirname(target), { recursive: true }) await writeFile(target, JSON.stringify(data, null, 2), 'utf-8') return target diff --git a/src/format.ts b/src/format.ts index ee44619..826c04c 100644 --- a/src/format.ts +++ b/src/format.ts @@ -8,9 +8,13 @@ import { formatCost } from './currency.js' export { formatCost } export function formatTokens(n: number): string { + // Guard against Infinity / NaN / negatives that would otherwise leak into + // the UI as "Infinity" or "NaN" strings when an upstream calculation glitches. + if (!Number.isFinite(n)) return '?' + if (n < 0) return '0' if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K` - return n.toString() + return Math.round(n).toString() } /// Returns YYYY-MM-DD for the given date in the process-local timezone. Cheaper than shelling diff --git a/src/fs-utils.ts b/src/fs-utils.ts index 823a630..cc46939 100644 --- a/src/fs-utils.ts +++ b/src/fs-utils.ts @@ -1,12 +1,18 @@ import { readFile, stat } from 'fs/promises' import { readFileSync, statSync, createReadStream } from 'fs' -import { createInterface } from 'readline' -// Hard cap well below V8's 512 MB string limit even with split('\n') doubling. -// Stream threshold chosen as empirical breakeven between readFile+split peak -// memory and createReadStream+readline overhead for typical session files. +// Hard cap well below V8's 512 MB string limit. Callers that need line-by-line +// processing should use readSessionLines(), which avoids materializing the +// whole file and can return large lines as Buffers. export const MAX_SESSION_FILE_BYTES = 128 * 1024 * 1024 -export const STREAM_THRESHOLD_BYTES = 8 * 1024 * 1024 +export const LARGE_STREAM_LINE_BYTES = 32 * 1024 + +// Line-by-line streaming has bounded memory (one line at a time) and is not +// constrained by V8's string limit, so it can safely handle multi-GB session +// files. The cap here is purely a sanity check against pathological inputs; +// real Codex sessions for heavy users have been observed at 250+ MB and will +// continue to grow as context windows expand. +export const MAX_STREAM_SESSION_FILE_BYTES = 2 * 1024 * 1024 * 1024 function verbose(): boolean { return process.env.CODEBURN_VERBOSE === '1' @@ -16,14 +22,6 @@ function warn(msg: string): void { if (verbose()) process.stderr.write(`codeburn: ${msg}\n`) } -async function readViaStream(filePath: string): Promise { - const chunks: string[] = [] - const stream = createReadStream(filePath, { encoding: 'utf-8' }) - const rl = createInterface({ input: stream, crlfDelay: Infinity }) - for await (const line of rl) chunks.push(line) - return chunks.join('\n') -} - export async function readSessionFile(filePath: string): Promise { let size: number try { @@ -39,7 +37,6 @@ export async function readSessionFile(filePath: string): Promise } try { - if (size >= STREAM_THRESHOLD_BYTES) return await readViaStream(filePath) return await readFile(filePath, 'utf-8') } catch (err) { warn(`read failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`) @@ -69,7 +66,29 @@ export function readSessionFileSync(filePath: string): string | null { } } -export async function* readSessionLines(filePath: string): AsyncGenerator { +export type SessionLine = string | Buffer + +type ReadSessionLinesOptions = { + largeLineAsBuffer?: boolean + largeLineThresholdBytes?: number + startByteOffset?: number + byteOffsetTracker?: { lastCompleteLineOffset: number } +} + +export function readSessionLines( + filePath: string, + shouldSkipHead?: (head: string) => boolean, +): AsyncGenerator +export function readSessionLines( + filePath: string, + shouldSkipHead?: (head: string) => boolean, + options?: ReadSessionLinesOptions & { largeLineAsBuffer: true }, +): AsyncGenerator +export async function* readSessionLines( + filePath: string, + shouldSkipHead?: (head: string) => boolean, + options: ReadSessionLinesOptions = {}, +): AsyncGenerator { let size: number try { size = (await stat(filePath)).size @@ -78,15 +97,116 @@ export async function* readSessionLines(filePath: string): AsyncGenerator MAX_SESSION_FILE_BYTES) { - warn(`skipped oversize file ${filePath} (${size} bytes > cap ${MAX_SESSION_FILE_BYTES})`) + if (size > MAX_STREAM_SESSION_FILE_BYTES) { + warn( + `skipped oversize file ${filePath} (${size} bytes > stream cap ${MAX_STREAM_SESSION_FILE_BYTES})`, + ) return } - const stream = createReadStream(filePath, { encoding: 'utf-8' }) - const rl = createInterface({ input: stream, crlfDelay: Infinity }) + const stream = createReadStream( + filePath, + options.startByteOffset !== undefined ? { start: options.startByteOffset } : undefined, + ) + const SKIP_HEAD = 2048 + const largeLineThreshold = options.largeLineThresholdBytes ?? LARGE_STREAM_LINE_BYTES + const formatLine = (buf: Buffer, lineLen: number, head?: string): SessionLine => { + if (options.largeLineAsBuffer && lineLen > largeLineThreshold) return buf + return head !== undefined && lineLen <= SKIP_HEAD ? head : buf.toString('utf-8') + } + let parts: Buffer[] = [] + let len = 0 + let skipping = false + let headChecked = false + let chunkBase = options.startByteOffset ?? 0 + const tracker = options.byteOffsetTracker + try { - for await (const line of rl) yield line + for await (const raw of stream) { + const chunk = raw as Buffer + let pos = 0 + + while (pos < chunk.length) { + const nl = chunk.indexOf(0x0a, pos) + + if (skipping) { + if (nl === -1) { + pos = chunk.length + } else { + if (tracker) tracker.lastCompleteLineOffset = chunkBase + nl + 1 + skipping = false + pos = nl + 1 + } + continue + } + + if (nl !== -1) { + if (pos < nl) { + parts.push(chunk.subarray(pos, nl)) + len += nl - pos + } + pos = nl + 1 + if (tracker) tracker.lastCompleteLineOffset = chunkBase + pos + + if (len === 0) { + parts = [] + headChecked = false + continue + } + + const buf = parts.length === 1 ? parts[0]! : Buffer.concat(parts, len) + const lineLen = len + parts = [] + len = 0 + headChecked = false + + if (shouldSkipHead) { + const head = lineLen > SKIP_HEAD + ? buf.subarray(0, SKIP_HEAD).toString('utf-8') + : buf.toString('utf-8') + if (shouldSkipHead(head)) continue + yield formatLine(buf, lineLen, head) + } else { + yield formatLine(buf, lineLen) + } + } else { + const slice = chunk.subarray(pos) + parts.push(slice) + len += slice.length + pos = chunk.length + + // Mid-line skip: once we have enough bytes to check the head, + // enter scanning mode — just look for \n without accumulating. + if (shouldSkipHead && !headChecked && len >= SKIP_HEAD) { + headChecked = true + const headBuf = parts.length === 1 + ? parts[0]!.subarray(0, SKIP_HEAD) + : Buffer.concat(parts, len).subarray(0, SKIP_HEAD) + if (shouldSkipHead(headBuf.toString('utf-8'))) { + skipping = true + parts = [] + len = 0 + } + } + } + } + chunkBase += chunk.length + } + + if (!skipping && len > 0) { + const buf = parts.length === 1 ? parts[0]! : Buffer.concat(parts, len) + const lineLen = len + if (shouldSkipHead) { + const head = lineLen > SKIP_HEAD + ? buf.subarray(0, SKIP_HEAD).toString('utf-8') + : buf.toString('utf-8') + if (!shouldSkipHead(head)) { + yield formatLine(buf, lineLen, head) + } + } else { + yield formatLine(buf, lineLen) + } + } } catch (err) { warn(`stream read failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`) } finally { diff --git a/src/ink-win.ts b/src/ink-win.ts new file mode 100644 index 0000000..5fd4bad --- /dev/null +++ b/src/ink-win.ts @@ -0,0 +1,14 @@ +const BSU = '\x1b[?2026h' +const ESU = '\x1b[?2026l' +let patched = false + +export function patchStdoutForWindows(): void { + if (process.platform !== 'win32' || patched) return + patched = true + + const origWrite = process.stdout.write.bind(process.stdout) + process.stdout.write = function (chunk: unknown, ...args: unknown[]): boolean { + if (chunk === BSU || chunk === ESU) return true + return (origWrite as Function)(chunk, ...args) + } as typeof process.stdout.write +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..19e43e3 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,988 @@ +import { Command } from 'commander' +import { installMenubarApp } from './menubar-installer.js' +import { exportCsv, exportJson, type PeriodExport } from './export.js' +import { loadPricing, setModelAliases } from './models.js' +import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache } from './parser.js' +import { convertCost } from './currency.js' +import { renderStatusBar } from './format.js' +import { type PeriodData, type ProviderCost } from './menubar-json.js' +import { buildMenubarPayload } from './menubar-json.js' +import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString } from './daily-cache.js' +import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from './day-aggregator.js' +import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js' +import { aggregateModelEfficiency } from './model-efficiency.js' +import { renderDashboard } from './dashboard.js' +import { formatDateRangeLabel, parseDateRangeFlags, getDateRange, toPeriod, type Period } from './cli-date.js' +import { runOptimize, scanAndDetect } from './optimize.js' +import { renderCompare } from './compare.js' +import { getAllProviders } from './providers/index.js' +import { clearPlan, readConfig, readPlan, saveConfig, savePlan, getConfigFilePath, type PlanId } from './config.js' +import { clampResetDay, getPlanUsageOrNull, type PlanUsage } from './plan-usage.js' +import { getPresetPlan, isPlanId, isPlanProvider, planDisplayName } from './plans.js' +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) +const { version } = require('../package.json') +import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js' + +async function hydrateCache() { + try { + return await ensureCacheHydrated( + (range) => parseAllSessions(range, 'all'), + aggregateProjectsIntoDays, + ) + } catch { + return emptyCache() + } +} + +function collect(val: string, acc: string[]): string[] { + acc.push(val) + return acc +} + +function parseNumber(value: string): number { + return Number(value) +} + +function parseInteger(value: string): number { + return parseInt(value, 10) +} + +type JsonPlanSummary = { + id: PlanId + budget: number + spent: number + percentUsed: number + status: 'under' | 'near' | 'over' + projectedMonthEnd: number + daysUntilReset: number + periodStart: string + periodEnd: string +} + +function toJsonPlanSummary(planUsage: PlanUsage): JsonPlanSummary { + return { + id: planUsage.plan.id, + budget: convertCost(planUsage.budgetUsd), + spent: convertCost(planUsage.spentApiEquivalentUsd), + percentUsed: Math.round(planUsage.percentUsed * 10) / 10, + status: planUsage.status, + projectedMonthEnd: convertCost(planUsage.projectedMonthUsd), + daysUntilReset: planUsage.daysUntilReset, + periodStart: planUsage.periodStart.toISOString(), + periodEnd: planUsage.periodEnd.toISOString(), + } +} + +function assertFormat(value: string, allowed: readonly string[], command: string): void { + if (!allowed.includes(value)) { + process.stderr.write( + `codeburn ${command}: unknown format "${value}". Valid values: ${allowed.join(', ')}.\n` + ) + process.exit(1) + } +} + +async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise { + await loadPricing() + const { range, label } = getDateRange(period) + const projects = filterProjectsByName(await parseAllSessions(range, provider), project, exclude) + const report: ReturnType & { plan?: JsonPlanSummary } = buildJsonReport(projects, label, period) + const planUsage = await getPlanUsageOrNull() + if (planUsage) { + report.plan = toJsonPlanSummary(planUsage) + } + console.log(JSON.stringify(report, null, 2)) +} + +const program = new Command() + .name('codeburn') + .description('See where your AI coding tokens go - by task, tool, model, and project') + .version(version) + .option('--verbose', 'print warnings to stderr on read failures and skipped files') + .option('--timezone ', 'IANA timezone for date grouping (e.g. Asia/Tokyo, America/New_York)') + +program.hook('preAction', async (thisCommand) => { + const tz = thisCommand.opts<{ timezone?: string }>().timezone ?? process.env['CODEBURN_TZ'] + if (tz) { + try { + Intl.DateTimeFormat(undefined, { timeZone: tz }) + } catch { + console.error(`\n Invalid timezone: "${tz}". Use an IANA timezone like "America/New_York" or "Asia/Tokyo".\n`) + process.exit(1) + } + process.env.TZ = tz + } + const config = await readConfig() + setModelAliases(config.modelAliases ?? {}) + if (thisCommand.opts<{ verbose?: boolean }>().verbose) { + process.env['CODEBURN_VERBOSE'] = '1' + } + await loadCurrency() +}) + +function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: string) { + const sessions = projects.flatMap(p => p.sessions) + const { code } = getCurrency() + + const totalCostUSD = projects.reduce((s, p) => s + p.totalCostUSD, 0) + const totalCalls = projects.reduce((s, p) => s + p.totalApiCalls, 0) + const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0) + const totalInput = sessions.reduce((s, sess) => s + sess.totalInputTokens, 0) + const totalOutput = sessions.reduce((s, sess) => s + sess.totalOutputTokens, 0) + const totalCacheRead = sessions.reduce((s, sess) => s + sess.totalCacheReadTokens, 0) + const totalCacheWrite = sessions.reduce((s, sess) => s + sess.totalCacheWriteTokens, 0) + // Match src/menubar-json.ts:cacheHitPercent: reads over reads+fresh-input. cache_write + // counts tokens being stored, not served, so it doesn't belong in the denominator. + const cacheHitDenom = totalInput + totalCacheRead + const cacheHitPercent = cacheHitDenom > 0 ? Math.round((totalCacheRead / cacheHitDenom) * 1000) / 10 : 0 + + // Per-day rollup. Mirrors parser.ts categoryBreakdown semantics so a + // consumer summing daily[].editTurns over a period gets the same total as + // sum(activities[].editTurns) for that period: every turn counts once for + // `turns`, edit turns count for `editTurns`, edit turns with zero retries + // count for `oneShotTurns`. Issue #279 — daily-resolution efficiency + // dashboards need this without re-deriving from activity-level rollups. + const dailyMap: Record = {} + for (const sess of sessions) { + for (const turn of sess.turns) { + // Prefer the user-message timestamp on the turn; fall back to the first + // assistant-call timestamp when the user line is missing (continuation + // sessions where the JSONL begins mid-conversation). Previously these + // turns dropped from daily but stayed in activities, breaking the + // sum(daily[].editTurns) === sum(activities[].editTurns) invariant. + const ts = turn.timestamp || turn.assistantCalls[0]?.timestamp + if (!ts) { continue } + const day = dateKey(ts) + if (!dailyMap[day]) { dailyMap[day] = { cost: 0, calls: 0, turns: 0, editTurns: 0, oneShotTurns: 0 } } + dailyMap[day].turns += 1 + if (turn.hasEdits) { + dailyMap[day].editTurns += 1 + if (turn.retries === 0) dailyMap[day].oneShotTurns += 1 + } + for (const call of turn.assistantCalls) { + dailyMap[day].cost += call.costUSD + dailyMap[day].calls += 1 + } + } + } + const daily = Object.entries(dailyMap).sort().map(([date, d]) => ({ + date, + cost: convertCost(d.cost), + calls: d.calls, + turns: d.turns, + editTurns: d.editTurns, + oneShotTurns: d.oneShotTurns, + // Pre-computed convenience for dashboards that don't want to do the math. + // null when there are no edit turns (the rate is undefined, not zero — + // a day where the user only had Q&A turns shouldn't read as 0% one-shot). + oneShotRate: d.editTurns > 0 + ? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10 + : null, + })) + + const projectList = projects.map(p => ({ + name: p.project, + path: p.projectPath, + cost: convertCost(p.totalCostUSD), + avgCostPerSession: p.sessions.length > 0 + ? convertCost(p.totalCostUSD / p.sessions.length) + : null, + calls: p.totalApiCalls, + sessions: p.sessions.length, + })) + + const modelMap: Record = {} + const modelEfficiency = aggregateModelEfficiency(projects) + for (const sess of sessions) { + for (const [model, d] of Object.entries(sess.modelBreakdown)) { + if (!modelMap[model]) { modelMap[model] = { calls: 0, cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } } + modelMap[model].calls += d.calls + modelMap[model].cost += d.costUSD + modelMap[model].inputTokens += d.tokens.inputTokens + modelMap[model].outputTokens += d.tokens.outputTokens + modelMap[model].cacheReadTokens += d.tokens.cacheReadInputTokens + modelMap[model].cacheWriteTokens += d.tokens.cacheCreationInputTokens + } + } + const models = Object.entries(modelMap) + .sort(([, a], [, b]) => b.cost - a.cost) + .map(([name, { cost, ...rest }]) => { + const efficiency = modelEfficiency.get(name) + return { + name, + ...rest, + cost: convertCost(cost), + editTurns: efficiency?.editTurns ?? 0, + oneShotTurns: efficiency?.oneShotTurns ?? 0, + oneShotRate: efficiency?.oneShotRate ?? null, + retriesPerEdit: efficiency?.retriesPerEdit ?? null, + costPerEdit: efficiency?.costPerEditUSD !== null && efficiency?.costPerEditUSD !== undefined + ? convertCost(efficiency.costPerEditUSD) + : null, + } + }) + + const catMap: Record = {} + for (const sess of sessions) { + for (const [cat, d] of Object.entries(sess.categoryBreakdown)) { + if (!catMap[cat]) { catMap[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 } } + catMap[cat].turns += d.turns + catMap[cat].cost += d.costUSD + catMap[cat].editTurns += d.editTurns + catMap[cat].oneShotTurns += d.oneShotTurns + } + } + const activities = Object.entries(catMap) + .sort(([, a], [, b]) => b.cost - a.cost) + .map(([cat, d]) => ({ + category: CATEGORY_LABELS[cat as TaskCategory] ?? cat, + cost: convertCost(d.cost), + turns: d.turns, + editTurns: d.editTurns, + oneShotTurns: d.oneShotTurns, + oneShotRate: d.editTurns > 0 ? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10 : null, + })) + + const toolMap: Record = {} + const mcpMap: Record = {} + const bashMap: Record = {} + for (const sess of sessions) { + for (const [tool, d] of Object.entries(sess.toolBreakdown)) { + toolMap[tool] = (toolMap[tool] ?? 0) + d.calls + } + for (const [server, d] of Object.entries(sess.mcpBreakdown)) { + mcpMap[server] = (mcpMap[server] ?? 0) + d.calls + } + for (const [cmd, d] of Object.entries(sess.bashBreakdown)) { + bashMap[cmd] = (bashMap[cmd] ?? 0) + d.calls + } + } + + const sortedMap = (m: Record) => + Object.entries(m).sort(([, a], [, b]) => b - a).map(([name, calls]) => ({ name, calls })) + + const topSessions = projects + .flatMap(p => p.sessions.map(s => ({ project: p.project, sessionId: s.sessionId, date: s.firstTimestamp ? dateKey(s.firstTimestamp) : null, cost: convertCost(s.totalCostUSD), calls: s.apiCalls }))) + .sort((a, b) => b.cost - a.cost) + .slice(0, 5) + + return { + generated: new Date().toISOString(), + currency: code, + period, + periodKey, + overview: { + cost: convertCost(totalCostUSD), + calls: totalCalls, + sessions: totalSessions, + cacheHitPercent, + tokens: { + input: totalInput, + output: totalOutput, + cacheRead: totalCacheRead, + cacheWrite: totalCacheWrite, + }, + }, + daily, + projects: projectList, + models, + activities, + tools: sortedMap(toolMap), + mcpServers: sortedMap(mcpMap), + shellCommands: sortedMap(bashMap), + topSessions, + } +} + +program + .command('report', { isDefault: true }) + .description('Interactive usage dashboard') + .option('-p, --period ', 'Starting period: today, week, 30days, month, all', 'week') + .option('--from ', 'Start date (YYYY-MM-DD). Overrides --period when set') + .option('--to ', 'End date (YYYY-MM-DD). Overrides --period when set') + .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') + .option('--format ', 'Output format: tui, json', 'tui') + .option('--project ', 'Show only projects matching name (repeatable)', collect, []) + .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) + .option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30) + .action(async (opts) => { + assertFormat(opts.format, ['tui', 'json'], 'report') + let customRange: DateRange | null = null + try { + customRange = parseDateRangeFlags(opts.from, opts.to) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.error(`\n Error: ${message}\n`) + process.exit(1) + } + + const period = toPeriod(opts.period) + if (opts.format === 'json') { + await loadPricing() + if (customRange) { + const label = formatDateRangeLabel(opts.from, opts.to) + const projects = filterProjectsByName( + await parseAllSessions(customRange, opts.provider), + opts.project, + opts.exclude, + ) + console.log(JSON.stringify(buildJsonReport(projects, label, 'custom'), null, 2)) + } else { + await runJsonReport(period, opts.provider, opts.project, opts.exclude) + } + return + } + const customRangeLabel = customRange ? formatDateRangeLabel(opts.from, opts.to) : undefined + await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange, customRangeLabel) + }) + +function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData { + const sessions = projects.flatMap(p => p.sessions) + const catTotals: Record = {} + const modelTotals: Record = {} + let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0 + + for (const sess of sessions) { + inputTokens += sess.totalInputTokens + outputTokens += sess.totalOutputTokens + cacheReadTokens += sess.totalCacheReadTokens + cacheWriteTokens += sess.totalCacheWriteTokens + for (const [cat, d] of Object.entries(sess.categoryBreakdown)) { + if (!catTotals[cat]) catTotals[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 } + catTotals[cat].turns += d.turns + catTotals[cat].cost += d.costUSD + catTotals[cat].editTurns += d.editTurns + catTotals[cat].oneShotTurns += d.oneShotTurns + } + for (const [model, d] of Object.entries(sess.modelBreakdown)) { + if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0 } + modelTotals[model].calls += d.calls + modelTotals[model].cost += d.costUSD + } + } + + return { + label, + cost: projects.reduce((s, p) => s + p.totalCostUSD, 0), + calls: projects.reduce((s, p) => s + p.totalApiCalls, 0), + sessions: projects.reduce((s, p) => s + p.sessions.length, 0), + inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, + categories: Object.entries(catTotals) + .sort(([, a], [, b]) => b.cost - a.cost) + .map(([cat, d]) => ({ name: CATEGORY_LABELS[cat as TaskCategory] ?? cat, ...d })), + models: Object.entries(modelTotals) + .sort(([, a], [, b]) => b.cost - a.cost) + .map(([name, d]) => ({ name, ...d })), + } +} + +program + .command('status') + .description('Compact status output (today + month)') + .option('--format ', 'Output format: terminal, menubar-json, json', 'terminal') + .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') + .option('--project ', 'Show only projects matching name (repeatable)', collect, []) + .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) + .option('--period ', 'Primary period for menubar-json: today, week, 30days, month, all', 'today') + .option('--no-optimize', 'Skip optimize findings (menubar-json only, faster)') + .action(async (opts) => { + assertFormat(opts.format, ['terminal', 'menubar-json', 'json'], 'status') + await loadPricing() + const pf = opts.provider + const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude) + if (opts.format === 'menubar-json') { + const periodInfo = getDateRange(opts.period) + const now = new Date() + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const todayRange: DateRange = { start: todayStart, end: now } + const todayStr = toDateString(todayStart) + const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)) + const rangeStartStr = toDateString(periodInfo.range.start) + const rangeEndStr = toDateString(periodInfo.range.end) + const isAllProviders = pf === 'all' + + const cache = await hydrateCache() + let todayAllProjects: ProjectSummary[] | null = null + let todayAllDays: ReturnType | null = null + + const getTodayAllProjects = async (): Promise => { + if (!todayAllProjects) { + todayAllProjects = fp(await parseAllSessions(todayRange, 'all')) + } + return todayAllProjects + } + + const getTodayAllDays = async (): Promise> => { + if (!todayAllDays) { + todayAllDays = aggregateProjectsIntoDays(await getTodayAllProjects()) + } + return todayAllDays + } + + // CURRENT PERIOD DATA + // - .all provider: assemble from cache + today (fast) + // - specific provider: parse the period range with provider filter (correct, but slower) + let currentData: PeriodData + let scanProjects: ProjectSummary[] + let scanRange: DateRange + + if (isAllProviders) { + // Parse today's all-provider sessions once; historical data comes from cache to avoid + // double-counting. Reusing the same parsed object is important for the menubar path: + // large active sessions can OOM if this command retains multiple near-identical scans. + const todayProjects = await getTodayAllProjects() + const todayDays = await getTodayAllDays() + const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr) + const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr) + const allDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date)) + currentData = buildPeriodDataFromDays(allDays, periodInfo.label) + scanProjects = todayProjects + scanRange = periodInfo.range + } else { + const projects = fp(await parseAllSessions(periodInfo.range, pf)) + currentData = buildPeriodData(periodInfo.label, projects) + scanProjects = projects + scanRange = periodInfo.range + } + + // PROVIDERS + // For .all: enumerate every provider with cost across the period (from cache) + installed-but-zero. + // For specific: just this single provider with its scoped cost. + const allProviders = await getAllProviders() + const displayNameByName = new Map(allProviders.map(p => [p.name, p.displayName])) + const providers: ProviderCost[] = [] + if (isAllProviders) { + const allDaysForProviders = [ + ...getDaysInRange(cache, rangeStartStr, yesterdayStr), + ...(await getTodayAllDays()).filter(d => d.date === todayStr), + ] + const providerTotals: Record = {} + for (const d of allDaysForProviders) { + for (const [name, p] of Object.entries(d.providers)) { + providerTotals[name] = (providerTotals[name] ?? 0) + p.cost + } + } + for (const [name, cost] of Object.entries(providerTotals)) { + providers.push({ name: displayNameByName.get(name) ?? name, cost }) + } + for (const p of allProviders) { + if (providers.some(pc => pc.name === p.displayName)) continue + const sources = await p.discoverSessions() + if (sources.length > 0) providers.push({ name: p.displayName, cost: 0 }) + } + } else { + const display = displayNameByName.get(pf) ?? pf + providers.push({ name: display, cost: currentData.cost }) + } + + // DAILY HISTORY (last 365 days) + // Cache stores per-provider cost+calls per day in DailyEntry.providers, so we can derive + // a provider-filtered history without re-parsing. Tokens aren't broken down per provider + // in the cache, so the filtered view shows zero tokens (heatmap/trend still works on cost). + const historyStartStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS)) + const allCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr) + const fullHistory = [...allCacheDays, ...(await getTodayAllDays()).filter(d => d.date === todayStr)] + const dailyHistory = fullHistory.map(d => { + if (isAllProviders) { + const topModels = Object.entries(d.models) + .filter(([name]) => name !== '') + .sort(([, a], [, b]) => b.cost - a.cost) + .slice(0, 5) + .map(([name, m]) => ({ + name, + cost: m.cost, + calls: m.calls, + inputTokens: m.inputTokens, + outputTokens: m.outputTokens, + })) + return { + date: d.date, + cost: d.cost, + calls: d.calls, + inputTokens: d.inputTokens, + outputTokens: d.outputTokens, + cacheReadTokens: d.cacheReadTokens, + cacheWriteTokens: d.cacheWriteTokens, + topModels, + } + } + const prov = d.providers[pf] ?? { calls: 0, cost: 0 } + return { + date: d.date, + cost: prov.cost, + calls: prov.calls, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + topModels: [], + } + }) + + const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange) + console.log(JSON.stringify(buildMenubarPayload(currentData, providers, optimize, dailyHistory))) + return + } + + if (opts.format === 'json') { + const todayProjects = fp(await parseAllSessions(getDateRange('today').range, pf)) + const todayData = buildPeriodData('today', todayProjects) + clearSessionCache() + const monthProjects = fp(await parseAllSessions(getDateRange('month').range, pf)) + const monthData = buildPeriodData('month', monthProjects) + clearSessionCache() + const { code, rate } = getCurrency() + const payload: { + currency: string + today: { cost: number; calls: number } + month: { cost: number; calls: number } + plan?: JsonPlanSummary + } = { + currency: code, + today: { cost: Math.round(todayData.cost * rate * 100) / 100, calls: todayData.calls }, + month: { cost: Math.round(monthData.cost * rate * 100) / 100, calls: monthData.calls }, + } + const planUsage = await getPlanUsageOrNull() + if (planUsage) { + payload.plan = toJsonPlanSummary(planUsage) + } + console.log(JSON.stringify(payload)) + return + } + + const monthProjects2 = fp(await parseAllSessions(getDateRange('month').range, pf)) + clearSessionCache() + console.log(renderStatusBar(monthProjects2)) + }) + +program + .command('today') + .description('Today\'s usage dashboard') + .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') + .option('--format ', 'Output format: tui, json', 'tui') + .option('--project ', 'Show only projects matching name (repeatable)', collect, []) + .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) + .option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30) + .action(async (opts) => { + assertFormat(opts.format, ['tui', 'json'], 'today') + if (opts.format === 'json') { + await runJsonReport('today', opts.provider, opts.project, opts.exclude) + return + } + await renderDashboard('today', opts.provider, opts.refresh, opts.project, opts.exclude) + }) + +program + .command('month') + .description('This month\'s usage dashboard') + .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') + .option('--format ', 'Output format: tui, json', 'tui') + .option('--project ', 'Show only projects matching name (repeatable)', collect, []) + .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) + .option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30) + .action(async (opts) => { + assertFormat(opts.format, ['tui', 'json'], 'month') + if (opts.format === 'json') { + await runJsonReport('month', opts.provider, opts.project, opts.exclude) + return + } + await renderDashboard('month', opts.provider, opts.refresh, opts.project, opts.exclude) + }) + +program + .command('export') + .description('Export usage data to CSV or JSON') + .option('-f, --format ', 'Export format: csv, json', 'csv') + .option('-o, --output ', 'Output file path') + .option('--from ', 'Start date (YYYY-MM-DD). Exports a single custom period when set') + .option('--to ', 'End date (YYYY-MM-DD). Exports a single custom period when set') + .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') + .option('--project ', 'Show only projects matching name (repeatable)', collect, []) + .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) + .action(async (opts) => { + assertFormat(opts.format, ['csv', 'json'], 'export') + await loadPricing() + const pf = opts.provider + const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude) + let customRange: DateRange | null = null + try { + customRange = parseDateRangeFlags(opts.from, opts.to) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.error(`\n Error: ${message}\n`) + process.exit(1) + } + + let periods: PeriodExport[] + if (customRange) { + periods = [{ label: formatDateRangeLabel(opts.from, opts.to), projects: fp(await parseAllSessions(customRange, pf)) }] + clearSessionCache() + } else { + const thirtyDayProjects = fp(await parseAllSessions(getDateRange('30days').range, pf)) + clearSessionCache() + periods = [ + { label: 'Today', projects: filterProjectsByDateRange(thirtyDayProjects, getDateRange('today').range) }, + { label: '7 Days', projects: filterProjectsByDateRange(thirtyDayProjects, getDateRange('week').range) }, + { label: '30 Days', projects: thirtyDayProjects }, + ] + } + + if (periods.every(p => p.projects.length === 0)) { + console.log('\n No usage data found.\n') + return + } + + const defaultName = `codeburn-${toDateString(new Date())}` + const outputPath = opts.output ?? `${defaultName}.${opts.format}` + + let savedPath: string + try { + if (opts.format === 'json') { + savedPath = await exportJson(periods, outputPath) + } else { + savedPath = await exportCsv(periods, outputPath) + } + } catch (err) { + // Protection guards in export.ts (symlink refusal, non-codeburn folder refusal, etc.) + // throw with a user-readable message. Print just the message, not the stack, so the CLI + // doesn't spray its internals at the user. + const message = err instanceof Error ? err.message : String(err) + console.error(`\n Export failed: ${message}\n`) + process.exit(1) + } + + const exportedLabel = customRange ? formatDateRangeLabel(opts.from, opts.to) : 'Today + 7 Days + 30 Days' + console.log(`\n Exported (${exportedLabel}) to: ${savedPath}\n`) + }) + +program + .command('menubar') + .description('Install and launch the macOS menubar app (one command, no clone)') + .option('--force', 'Reinstall even if an older copy is already in ~/Applications') + .action(async (opts: { force?: boolean }) => { + try { + const result = await installMenubarApp({ force: opts.force }) + console.log(`\n Ready. ${result.installedPath}\n`) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.error(`\n Menubar install failed: ${message}\n`) + process.exit(1) + } + }) + +program + .command('currency [code]') + .description('Set display currency (e.g. codeburn currency GBP)') + .option('--symbol ', 'Override the currency symbol') + .option('--reset', 'Reset to USD (removes currency config)') + .action(async (code?: string, opts?: { symbol?: string; reset?: boolean }) => { + if (opts?.reset) { + const config = await readConfig() + delete config.currency + await saveConfig(config) + console.log('\n Currency reset to USD.\n') + return + } + + if (!code) { + const { code: activeCode, rate, symbol } = getCurrency() + if (activeCode === 'USD' && rate === 1) { + console.log('\n Currency: USD (default)') + console.log(` Config: ${getConfigFilePath()}\n`) + } else { + console.log(`\n Currency: ${activeCode}`) + console.log(` Symbol: ${symbol}`) + console.log(` Rate: 1 USD = ${rate} ${activeCode}`) + console.log(` Config: ${getConfigFilePath()}\n`) + } + return + } + + const upperCode = code.toUpperCase() + if (!isValidCurrencyCode(upperCode)) { + console.error(`\n "${code}" is not a valid ISO 4217 currency code.\n`) + process.exitCode = 1 + return + } + + const config = await readConfig() + config.currency = { + code: upperCode, + ...(opts?.symbol ? { symbol: opts.symbol } : {}), + } + await saveConfig(config) + + await loadCurrency() + const { rate, symbol } = getCurrency() + + console.log(`\n Currency set to ${upperCode}.`) + console.log(` Symbol: ${symbol}`) + console.log(` Rate: 1 USD = ${rate} ${upperCode}`) + console.log(` Config saved to ${getConfigFilePath()}\n`) + }) + +program + .command('model-alias [from] [to]') + .description('Map a provider model name to a canonical one for pricing (e.g. codeburn model-alias my-model claude-opus-4-6)') + .option('--remove ', 'Remove an alias') + .option('--list', 'List configured aliases') + .action(async (from?: string, to?: string, opts?: { remove?: string; list?: boolean }) => { + const config = await readConfig() + const aliases = config.modelAliases ?? {} + + if (opts?.list || (!from && !opts?.remove)) { + const entries = Object.entries(aliases) + if (entries.length === 0) { + console.log('\n No model aliases configured.') + console.log(` Config: ${getConfigFilePath()}\n`) + } else { + console.log('\n Model aliases:') + for (const [src, dst] of entries) { + console.log(` ${src} -> ${dst}`) + } + console.log(` Config: ${getConfigFilePath()}\n`) + } + return + } + + if (opts?.remove) { + if (!(opts.remove in aliases)) { + console.error(`\n Alias not found: ${opts.remove}\n`) + process.exitCode = 1 + return + } + delete aliases[opts.remove] + config.modelAliases = Object.keys(aliases).length > 0 ? aliases : undefined + await saveConfig(config) + console.log(`\n Removed alias: ${opts.remove}\n`) + return + } + + if (!from || !to) { + console.error('\n Usage: codeburn model-alias \n') + process.exitCode = 1 + return + } + + aliases[from] = to + config.modelAliases = aliases + await saveConfig(config) + console.log(`\n Alias saved: ${from} -> ${to}`) + console.log(` Config: ${getConfigFilePath()}\n`) + }) + +program + .command('plan [action] [id]') + .description('Show or configure a subscription plan for overage tracking') + .option('--format ', 'Output format: text or json', 'text') + .option('--monthly-usd ', 'Monthly plan price in USD (for custom)', parseNumber) + .option('--provider ', 'Provider scope: all, claude, codex, cursor', 'all') + .option('--reset-day ', 'Day of month plan resets (1-28)', parseInteger, 1) + .action(async (action?: string, id?: string, opts?: { format?: string; monthlyUsd?: number; provider?: string; resetDay?: number }) => { + assertFormat(opts?.format ?? 'text', ['text', 'json'], 'plan') + const mode = action ?? 'show' + + if (mode === 'show') { + const plan = await readPlan() + const displayPlan = !plan || plan.id === 'none' + ? { id: 'none', monthlyUsd: 0, provider: 'all', resetDay: 1, setAt: null } + : { + id: plan.id, + monthlyUsd: plan.monthlyUsd, + provider: plan.provider, + resetDay: clampResetDay(plan.resetDay), + setAt: plan.setAt, + } + if (opts?.format === 'json') { + console.log(JSON.stringify(displayPlan)) + return + } + if (!plan || plan.id === 'none') { + console.log('\n Plan: none') + console.log(' API-pricing view is active.') + console.log(` Config: ${getConfigFilePath()}\n`) + return + } + console.log(`\n Plan: ${planDisplayName(plan.id)} (${plan.id})`) + console.log(` Budget: $${plan.monthlyUsd}/month`) + console.log(` Provider: ${plan.provider}`) + console.log(` Reset day: ${clampResetDay(plan.resetDay)}`) + console.log(` Set at: ${plan.setAt}`) + console.log(` Config: ${getConfigFilePath()}\n`) + return + } + + if (mode === 'reset') { + await clearPlan() + console.log('\n Plan reset. API-pricing view is active.\n') + return + } + + if (mode !== 'set') { + console.error('\n Usage: codeburn plan [set | reset]\n') + process.exitCode = 1 + return + } + + if (!id || !isPlanId(id)) { + console.error(`\n Plan id must be one of: claude-pro, claude-max, cursor-pro, custom, none; got "${id ?? ''}".\n`) + process.exitCode = 1 + return + } + + const resetDay = opts?.resetDay ?? 1 + if (!Number.isInteger(resetDay) || resetDay < 1 || resetDay > 28) { + console.error(`\n --reset-day must be an integer from 1 to 28; got ${resetDay}.\n`) + process.exitCode = 1 + return + } + + if (id === 'none') { + await clearPlan() + console.log('\n Plan reset. API-pricing view is active.\n') + return + } + + if (id === 'custom') { + if (opts?.monthlyUsd === undefined) { + console.error('\n Custom plans require --monthly-usd .\n') + process.exitCode = 1 + return + } + const monthlyUsd = opts.monthlyUsd + if (!Number.isFinite(monthlyUsd) || monthlyUsd <= 0) { + console.error(`\n --monthly-usd must be a positive number; got ${opts.monthlyUsd}.\n`) + process.exitCode = 1 + return + } + const provider = opts?.provider ?? 'all' + if (!isPlanProvider(provider)) { + console.error(`\n --provider must be one of: all, claude, codex, cursor; got "${provider}".\n`) + process.exitCode = 1 + return + } + await savePlan({ + id: 'custom', + monthlyUsd, + provider, + resetDay, + setAt: new Date().toISOString(), + }) + console.log(`\n Plan set to custom ($${monthlyUsd}/month, ${provider}, reset day ${resetDay}).`) + console.log(` Config saved to ${getConfigFilePath()}\n`) + return + } + + const preset = getPresetPlan(id) + if (!preset) { + console.error(`\n Unknown preset "${id}".\n`) + process.exitCode = 1 + return + } + + await savePlan({ + ...preset, + resetDay, + setAt: new Date().toISOString(), + }) + console.log(`\n Plan set to ${planDisplayName(preset.id)} ($${preset.monthlyUsd}/month).`) + console.log(` Provider: ${preset.provider}`) + console.log(` Reset day: ${resetDay}`) + console.log(` Config saved to ${getConfigFilePath()}\n`) + }) + +program + .command('optimize') + .description('Find token waste and get exact fixes') + .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', '30days') + .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') + .action(async (opts) => { + await loadPricing() + const { range, label } = getDateRange(opts.period) + const projects = await parseAllSessions(range, opts.provider) + await runOptimize(projects, label, range) + }) + +program + .command('compare') + .description('Compare two AI models side-by-side') + .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', 'all') + .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') + .action(async (opts) => { + await loadPricing() + const { range } = getDateRange(opts.period) + await renderCompare(range, opts.provider) + }) + +program + .command('models') + .description('Per-model token + cost table, optionally exploded by task type') + .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', '30days') + .option('--from ', 'Custom range start (YYYY-MM-DD)') + .option('--to ', 'Custom range end (YYYY-MM-DD)') + .option('--provider ', 'Filter by provider (e.g. claude, codex, cursor)', 'all') + .option('--task ', 'Filter to one task type (e.g. feature, debugging, refactoring)') + .option('--by-task', 'One row per (provider, model, task) instead of one row per (provider, model)') + .option('--top ', 'Show only the top N rows', (v: string) => parseInt(v, 10)) + .option('--min-cost ', 'Hide rows below this cost threshold', (v: string) => parseFloat(v)) + .option('--no-totals', 'Suppress the footer totals row') + .option('--format ', 'Output format: table, markdown, json, csv', 'table') + .action(async (opts) => { + const { aggregateModels, renderTable, renderMarkdown, renderJson, renderCsv } = await import('./models-report.js') + await loadPricing() + + let range + if (opts.from || opts.to) { + const customRange = parseDateRangeFlags(opts.from, opts.to) + if (!customRange) { + process.stderr.write('codeburn: --from and --to must be valid YYYY-MM-DD dates\n') + process.exit(1) + } + range = customRange + } else { + range = getDateRange(opts.period).range + } + + const projects = await parseAllSessions(range, opts.provider) + const rows = await aggregateModels(projects, { + byTask: !!opts.byTask, + taskFilter: opts.task, + topN: typeof opts.top === 'number' && Number.isFinite(opts.top) ? opts.top : undefined, + minCost: typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) ? opts.minCost : 0.01, + }) + + const fmt = (opts.format ?? 'table').toLowerCase() + if (rows.length === 0 && (fmt === 'table' || fmt === 'markdown')) { + process.stdout.write('No model usage found for the selected period.\n') + return + } + if (fmt === 'json') { + process.stdout.write(renderJson(rows) + '\n') + } else if (fmt === 'csv') { + process.stdout.write(renderCsv(rows, { byTask: !!opts.byTask }) + '\n') + } else if (fmt === 'markdown' || fmt === 'md') { + process.stdout.write(renderMarkdown(rows, { byTask: !!opts.byTask, showTotals: opts.totals !== false }) + '\n') + } else if (fmt === 'table') { + process.stdout.write(renderTable(rows, { byTask: !!opts.byTask, showTotals: opts.totals !== false }) + '\n') + } else { + process.stderr.write(`codeburn: unknown --format "${opts.format}". Choose table, markdown, json, or csv.\n`) + process.exit(1) + } + }) + +program + .command('yield') + .description('Track which AI spend shipped to main vs reverted/abandoned (experimental)') + .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', 'week') + .action(async (opts) => { + const { computeYield, formatYieldSummary } = await import('./yield.js') + await loadPricing() + const { range, label } = getDateRange(opts.period) + console.log(`\n Analyzing yield for ${label}...\n`) + const summary = await computeYield(range, process.cwd()) + console.log(formatYieldSummary(summary)) + }) + +program.parse() diff --git a/src/menubar-installer.ts b/src/menubar-installer.ts index 3557141..915aefa 100644 --- a/src/menubar-installer.ts +++ b/src/menubar-installer.ts @@ -1,24 +1,56 @@ import { spawn } from 'node:child_process' +import { createHash } from 'node:crypto' import { createWriteStream } from 'node:fs' -import { mkdir, mkdtemp, rename, rm, stat } from 'node:fs/promises' +import { chmod, mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' import { homedir, platform, tmpdir } from 'node:os' import { join } from 'node:path' import { pipeline } from 'node:stream/promises' import { Readable } from 'node:stream' -/// Public GitHub repo that hosts signed macOS release builds. `/releases/latest` returns the -/// newest tagged release; we filter its assets list for our zipped .app bundle. -const RELEASE_API = 'https://api.github.com/repos/getagentseal/codeburn/releases/latest' +/// Public GitHub repo that hosts macOS release builds. CLI and menubar releases share +/// the repository, so we scan recent releases and choose the newest `mac-v*` release +/// that actually contains the menubar zip. +const RELEASE_API = 'https://api.github.com/repos/getagentseal/codeburn/releases?per_page=20' const APP_BUNDLE_NAME = 'CodeBurnMenubar.app' -const ASSET_PATTERN = /^CodeBurnMenubar-.*\.zip$/ +const EXPECTED_BUNDLE_ID = 'org.agentseal.codeburn-menubar' +const VERSIONED_ASSET_PATTERN = /^CodeBurnMenubar-v.+\.zip$/ const APP_PROCESS_NAME = 'CodeBurnMenubar' const SUPPORTED_OS = 'darwin' const MIN_MACOS_MAJOR = 14 +const PERSISTED_CLI_PATH = join(homedir(), 'Library', 'Application Support', 'CodeBurn', 'codeburn-cli-path.v1') export type InstallResult = { installedPath: string; launched: boolean } -type ReleaseAsset = { name: string; browser_download_url: string } -type ReleaseResponse = { tag_name: string; assets: ReleaseAsset[] } +export type ReleaseAsset = { name: string; browser_download_url: string } +export type ReleaseResponse = { tag_name: string; assets: ReleaseAsset[] } +export type ResolvedAssets = { release: ReleaseResponse; zip: ReleaseAsset; checksum: ReleaseAsset } + +export function resolveMenubarReleaseAssets(release: ReleaseResponse): ResolvedAssets { + const zip = release.assets.find(a => VERSIONED_ASSET_PATTERN.test(a.name)) + if (!zip) { + throw new Error( + `No ${APP_BUNDLE_NAME} versioned zip found in release ${release.tag_name}. ` + + `Check https://github.com/getagentseal/codeburn/releases.` + ) + } + const checksum = release.assets.find(a => a.name === `${zip.name}.sha256`) + if (!checksum) { + throw new Error(`Missing checksum asset ${zip.name}.sha256 in release ${release.tag_name}.`) + } + return { release, zip, checksum } +} + +export function resolveLatestMenubarReleaseAssets(releases: ReleaseResponse[]): ResolvedAssets { + for (const release of releases) { + if (!release.tag_name.startsWith('mac-v')) continue + try { + return resolveMenubarReleaseAssets(release) + } catch { + continue + } + } + throw new Error('No mac-v* release with a CodeBurnMenubar-v*.zip and checksum was found.') +} function userApplicationsDir(): string { return join(homedir(), 'Applications') @@ -57,10 +89,9 @@ async function sysProductVersion(): Promise { }) } -async function fetchLatestReleaseAsset(): Promise { +async function fetchLatestReleaseAssets(): Promise { const response = await fetch(RELEASE_API, { headers: { - // Identify the installer so GitHub's abuse heuristics treat us as a known client. 'User-Agent': 'codeburn-menubar-installer', Accept: 'application/vnd.github+json', }, @@ -68,15 +99,30 @@ async function fetchLatestReleaseAsset(): Promise { if (!response.ok) { throw new Error(`GitHub release lookup failed: HTTP ${response.status}`) } - const body = await response.json() as ReleaseResponse - const asset = body.assets.find(a => ASSET_PATTERN.test(a.name)) - if (!asset) { + const body = await response.json() as ReleaseResponse[] + return resolveLatestMenubarReleaseAssets(body) +} + +async function verifyChecksum(archivePath: string, checksumUrl: string): Promise { + const response = await fetch(checksumUrl, { + headers: { 'User-Agent': 'codeburn-menubar-installer' }, + redirect: 'follow', + }) + if (!response.ok) { + throw new Error(`Checksum download failed: HTTP ${response.status}`) + } + const text = await response.text() + const expected = text.trim().split(/\s+/)[0]!.toLowerCase() + const fileBytes = await readFile(archivePath) + const actual = createHash('sha256').update(fileBytes).digest('hex') + if (actual !== expected) { throw new Error( - `No ${APP_BUNDLE_NAME} zip found in release ${body.tag_name}. ` + - `Check https://github.com/getagentseal/codeburn/releases.` + `Checksum mismatch for ${archivePath}.\n` + + ` Expected: ${expected}\n` + + ` Got: ${actual}\n` + + `The download may be corrupted or tampered with.` ) } - return asset } async function downloadToFile(url: string, destPath: string): Promise { @@ -103,6 +149,57 @@ async function runCommand(command: string, args: string[]): Promise { }) } +async function captureCommand(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }) + let out = '' + let err = '' + proc.stdout.on('data', (chunk: Buffer) => { out += chunk.toString() }) + proc.stderr.on('data', (chunk: Buffer) => { err += chunk.toString() }) + proc.on('error', reject) + proc.on('close', (code) => { + if (code === 0) resolve(out.trim()) + else reject(new Error(`${command} exited with status ${code}${err ? `: ${err.trim()}` : ''}`)) + }) + }) +} + +async function verifyBundleIdentity(appPath: string): Promise { + const bundleID = await captureCommand('/usr/libexec/PlistBuddy', [ + '-c', + 'Print :CFBundleIdentifier', + join(appPath, 'Contents', 'Info.plist'), + ]) + if (bundleID !== EXPECTED_BUNDLE_ID) { + throw new Error(`Unexpected menubar bundle id ${bundleID}; expected ${EXPECTED_BUNDLE_ID}.`) + } + await runCommand('/usr/bin/codesign', ['--verify', '--deep', '--strict', appPath]) +} + +async function resolvePersistentCodeburnPath(): Promise { + const path = await captureCommand('/usr/bin/env', [ + 'PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin', + 'which', + 'codeburn', + ]) + if (!path.startsWith('/')) { + throw new Error('Resolved codeburn path is not absolute.') + } + if (path.includes('/_npx/') || path.includes('/.npm/_npx/')) { + throw new Error( + 'The menubar app needs a persistent codeburn command. Install CodeBurn globally first: npm install -g codeburn' + ) + } + return path +} + +async function persistCodeburnPath(): Promise { + const cliPath = await resolvePersistentCodeburnPath() + await mkdir(join(homedir(), 'Library', 'Application Support', 'CodeBurn'), { recursive: true, mode: 0o700 }) + await writeFile(PERSISTED_CLI_PATH, `${cliPath}\n`, { mode: 0o600 }) + await chmod(PERSISTED_CLI_PATH, 0o600) +} + async function isAppRunning(): Promise { return new Promise((resolve) => { const proc = spawn('/usr/bin/pgrep', ['-f', APP_PROCESS_NAME]) @@ -117,10 +214,15 @@ async function killRunningApp(): Promise { proc.on('close', () => resolve()) proc.on('error', () => resolve()) }) + for (let i = 0; i < 10; i++) { + if (!(await isAppRunning())) return + await new Promise(r => setTimeout(r, 500)) + } } export async function installMenubarApp(options: { force?: boolean } = {}): Promise { await ensureSupportedPlatform() + await persistCodeburnPath() const appsDir = userApplicationsDir() const targetPath = join(appsDir, APP_BUNDLE_NAME) @@ -134,22 +236,28 @@ export async function installMenubarApp(options: { force?: boolean } = {}): Prom } console.log('Looking up the latest CodeBurn Menubar release...') - const asset = await fetchLatestReleaseAsset() + const { zip, checksum } = await fetchLatestReleaseAssets() const stagingDir = await mkdtemp(join(tmpdir(), 'codeburn-menubar-')) try { - const archivePath = join(stagingDir, asset.name) - console.log(`Downloading ${asset.name}...`) - await downloadToFile(asset.browser_download_url, archivePath) + const archivePath = join(stagingDir, zip.name) + console.log(`Downloading ${zip.name}...`) + await downloadToFile(zip.browser_download_url, archivePath) + + console.log('Verifying checksum...') + await verifyChecksum(archivePath, checksum.browser_download_url) console.log('Unpacking...') - await runCommand('/usr/bin/unzip', ['-q', archivePath, '-d', stagingDir]) + await runCommand('/usr/bin/ditto', ['-x', '-k', archivePath, stagingDir]) const unpackedApp = join(stagingDir, APP_BUNDLE_NAME) if (!(await exists(unpackedApp))) { throw new Error(`Archive did not contain ${APP_BUNDLE_NAME}.`) } + console.log('Verifying app bundle...') + await verifyBundleIdentity(unpackedApp) + // Clear Gatekeeper's quarantine xattr. Without this, the first launch shows the // "cannot verify developer" prompt even for a signed + notarized app when the bundle // was delivered via curl/fetch instead of the Mac App Store. diff --git a/src/model-efficiency.ts b/src/model-efficiency.ts new file mode 100644 index 0000000..dec5204 --- /dev/null +++ b/src/model-efficiency.ts @@ -0,0 +1,60 @@ +import { getShortModelName } from './models.js' +import type { ProjectSummary } from './types.js' + +export type ModelEfficiency = { + model: string + editTurns: number + oneShotTurns: number + retries: number + editCostUSD: number + oneShotRate: number | null + retriesPerEdit: number | null + costPerEditUSD: number | null +} + +type MutableModelEfficiency = Omit + +function rate(num: number, den: number): number | null { + if (den === 0) return null + return Math.round((num / den) * 1000) / 10 +} + +export function aggregateModelEfficiency(projects: ProjectSummary[]): Map { + const byModel = new Map() + + function ensure(model: string): MutableModelEfficiency { + let stats = byModel.get(model) + if (!stats) { + stats = { model, editTurns: 0, oneShotTurns: 0, retries: 0, editCostUSD: 0 } + byModel.set(model, stats) + } + return stats + } + + for (const project of projects) { + for (const session of project.sessions) { + for (const turn of session.turns) { + if (!turn.hasEdits || turn.assistantCalls.length === 0) continue + + const primaryCall = turn.assistantCalls.find(c => getShortModelName(c.model) !== '') + if (!primaryCall) continue + const primaryModel = getShortModelName(primaryCall.model) + + const stats = ensure(primaryModel) + stats.editTurns++ + if (turn.retries === 0) stats.oneShotTurns++ + stats.retries += turn.retries + stats.editCostUSD += turn.assistantCalls.reduce((sum, call) => { + return getShortModelName(call.model) === '' ? sum : sum + call.costUSD + }, 0) + } + } + } + + return new Map([...byModel.entries()].map(([model, stats]) => [model, { + ...stats, + oneShotRate: rate(stats.oneShotTurns, stats.editTurns), + retriesPerEdit: stats.editTurns > 0 ? Math.round((stats.retries / stats.editTurns) * 10) / 10 : null, + costPerEditUSD: stats.editTurns > 0 ? stats.editCostUSD / stats.editTurns : null, + }])) +} diff --git a/src/models-report.ts b/src/models-report.ts new file mode 100644 index 0000000..70e9170 --- /dev/null +++ b/src/models-report.ts @@ -0,0 +1,645 @@ +import chalk from 'chalk' +import stripAnsi from 'strip-ansi' + +import { formatCost, formatTokens } from './format.js' +import { getProvider } from './providers/index.js' +import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js' + +export type ModelReportRow = { + provider: string + providerDisplayName: string + model: string + modelDisplayName: string + category: TaskCategory | null + inputTokens: number + outputTokens: number + cacheWriteTokens: number + cacheReadTokens: number + totalTokens: number + costUSD: number + calls: number + topCategory?: TaskCategory + topCategoryCost?: number + topCategoryShare?: number +} + +export type AggregateOptions = { + byTask?: boolean + taskFilter?: TaskCategory + topN?: number + minCost?: number +} + +type Bucket = { + provider: string + model: string + category: TaskCategory | null + inputTokens: number + outputTokens: number + cacheWriteTokens: number + cacheReadTokens: number + costUSD: number + calls: number +} + +type ModelKey = string +type CategoryKey = TaskCategory + +function bucketKey(provider: string, model: string, category: TaskCategory | null): string { + return `${provider} ${model} ${category ?? ''}` +} + +/// Walks every parsed turn, attributes each assistant call to a +/// (provider, model, category) bucket, and returns rows keyed by either +/// (provider, model) when `byTask` is false or (provider, model, category) when true. +/// +/// Default view: rows sorted by cost descending. +/// byTask view: rows grouped by (provider, model) so the renderer can blank +/// repeated provider/model cells. Group order follows total cost across that +/// model; within each group, rows go by cost descending. +export async function aggregateModels(projects: ProjectSummary[], opts: AggregateOptions = {}): Promise { + const buckets = new Map() + const perModelCategoryCost = new Map>() + const perModelTotalCost = new Map() + + for (const project of projects) { + for (const session of project.sessions) { + for (const turn of session.turns) { + if (opts.taskFilter && turn.category !== opts.taskFilter) continue + for (const call of turn.assistantCalls) { + const provider = call.provider || 'unknown' + const model = call.model || 'unknown' + const category: TaskCategory | null = opts.byTask ? turn.category : null + const key = bucketKey(provider, model, category) + let bucket = buckets.get(key) + if (!bucket) { + bucket = { + provider, + model, + category, + inputTokens: 0, + outputTokens: 0, + cacheWriteTokens: 0, + cacheReadTokens: 0, + costUSD: 0, + calls: 0, + } + buckets.set(key, bucket) + } + bucket.inputTokens += call.usage.inputTokens + bucket.outputTokens += call.usage.outputTokens + call.usage.reasoningTokens + bucket.cacheWriteTokens += call.usage.cacheCreationInputTokens + bucket.cacheReadTokens += call.usage.cacheReadInputTokens + call.usage.cachedInputTokens + bucket.costUSD += call.costUSD + bucket.calls += 1 + + const modelKey = `${provider} ${model}` + let perCat = perModelCategoryCost.get(modelKey) + if (!perCat) { + perCat = new Map() + perModelCategoryCost.set(modelKey, perCat) + } + perCat.set(turn.category, (perCat.get(turn.category) ?? 0) + call.costUSD) + perModelTotalCost.set(modelKey, (perModelTotalCost.get(modelKey) ?? 0) + call.costUSD) + } + } + } + } + + const providerCache = new Map string }>() + async function resolveProvider(name: string) { + const cached = providerCache.get(name) + if (cached) return cached + const p = await getProvider(name) + const entry = { + displayName: p?.displayName ?? name, + formatModel: p ? (m: string) => p.modelDisplayName(m) : (m: string) => m, + } + providerCache.set(name, entry) + return entry + } + + const rows: ModelReportRow[] = [] + for (const bucket of buckets.values()) { + const meta = await resolveProvider(bucket.provider) + const total = bucket.inputTokens + bucket.outputTokens + bucket.cacheWriteTokens + bucket.cacheReadTokens + const row: ModelReportRow = { + provider: bucket.provider, + providerDisplayName: meta.displayName, + model: bucket.model, + modelDisplayName: meta.formatModel(bucket.model), + category: bucket.category, + inputTokens: bucket.inputTokens, + outputTokens: bucket.outputTokens, + cacheWriteTokens: bucket.cacheWriteTokens, + cacheReadTokens: bucket.cacheReadTokens, + totalTokens: total, + costUSD: bucket.costUSD, + calls: bucket.calls, + } + + if (!opts.byTask) { + const perCat = perModelCategoryCost.get(`${bucket.provider} ${bucket.model}`) + if (perCat && perCat.size > 0) { + let topCat: TaskCategory = 'general' + let topCost = -1 + let totalCost = 0 + for (const [cat, cost] of perCat.entries()) { + totalCost += cost + if (cost > topCost) { + topCost = cost + topCat = cat + } + } + row.topCategory = topCat + row.topCategoryCost = topCost + row.topCategoryShare = totalCost > 0 ? topCost / totalCost : 0 + } + } + + rows.push(row) + } + + if (opts.byTask) { + rows.sort((a, b) => { + const aTotal = perModelTotalCost.get(`${a.provider} ${a.model}`) ?? 0 + const bTotal = perModelTotalCost.get(`${b.provider} ${b.model}`) ?? 0 + if (aTotal !== bTotal) return bTotal - aTotal + if (a.provider !== b.provider) return a.provider.localeCompare(b.provider) + if (a.model !== b.model) return a.model.localeCompare(b.model) + return b.costUSD - a.costUSD + }) + } else { + rows.sort((a, b) => b.costUSD - a.costUSD) + } + + let filtered = rows + if (opts.minCost !== undefined) { + filtered = filtered.filter(r => r.costUSD >= opts.minCost!) + } + if (opts.topN !== undefined) { + filtered = filtered.slice(0, opts.topN) + } + return filtered +} + +function visibleLength(text: string): number { + return stripAnsi(text).length +} + +function pad(text: string, width: number, align: 'left' | 'right' = 'left'): string { + const visible = visibleLength(text) + if (visible >= width) return text + const filler = ' '.repeat(width - visible) + return align === 'left' ? text + filler : filler + text +} + +function categoryLabel(c: TaskCategory): string { + return CATEGORY_LABELS[c] ?? c +} + +/// Box-drawing preset matching tokscale's comfy-table layout. Pure Unicode; +/// every modern terminal handles these. JSON / CSV / Markdown formats already +/// cover the no-Unicode case for downstream tooling. +const BOX = { + topLeft: '┌', + topRight: '┐', + bottomLeft: '└', + bottomRight: '┘', + topT: '┬', + bottomT: '┴', + leftT: '├', + rightT: '┤', + cross: '┼', + horizontal: '─', + vertical: '│', +} + +type Column = { + header: string + align: 'left' | 'right' + width: number + /// Drop priority. 0 = always shown; higher numbers get dropped first when + /// the terminal is narrow. + priority: number + key: 'provider' | 'model' | 'task' | 'input' | 'output' | 'cacheWrite' | 'cacheRead' | 'total' | 'cost' +} + +type TableRenderOptions = { + byTask?: boolean + showTotals?: boolean + terminalWidth?: number + fullWidth?: boolean +} + +const DROP_COLUMN_GROUPS: Array> = [ + ['cacheWrite', 'cacheRead'], + ['input', 'output'], + ['task'], +] + +function defaultColumns(byTask: boolean): Column[] { + // Higher priority numbers drop FIRST when the terminal is narrow. + // Cache columns are the cheapest to lose, then input/output, then top-task. + // Provider/Model/Total/Cost stay regardless. + // Widths are MINIMUMS; sizeColumnsToContent() expands them to fit cell text. + return [ + { key: 'provider', header: 'Provider', align: 'left', width: 8, priority: 0 }, + { key: 'model', header: 'Model', align: 'left', width: 8, priority: 0 }, + { key: 'task', header: byTask ? 'Task' : 'Top Task', align: 'left', width: 8, priority: 1 }, + { key: 'input', header: 'Input', align: 'right', width: 6, priority: 2 }, + { key: 'output', header: 'Output', align: 'right', width: 6, priority: 2 }, + { key: 'cacheWrite', header: 'Cache Write', align: 'right', width: 11, priority: 3 }, + { key: 'cacheRead', header: 'Cache Read', align: 'right', width: 10, priority: 3 }, + { key: 'total', header: 'Total', align: 'right', width: 6, priority: 0 }, + { key: 'cost', header: 'Cost', align: 'right', width: 6, priority: 0 }, + ] +} + +/// Expands each column's width to fit the widest cell in that column, so a +/// short header (e.g. "Task") in a fixed 18-wide cell does not leave 14 chars +/// of trailing whitespace. Mirrors cli-table3 / comfy-table auto-sizing. +function sizeColumnsToContent(columns: Column[], rows: string[][]): Column[] { + return columns.map((col, i) => { + let maxLen = visibleLength(col.header) + for (const row of rows) { + const cell = row[i] ?? '' + const len = visibleLength(cell) + if (len > maxLen) maxLen = len + } + return { ...col, width: Math.max(col.width, maxLen) } + }) +} + +function frameWidth(columns: Column[]): number { + if (columns.length === 0) return 0 + // 1 (left border) + sum(col + 2 padding) + (N-1) inner separators + 1 (right border) + return 2 + columns.reduce((acc, c) => acc + c.width + 2, 0) + (columns.length - 1) +} + +function chooseColumns(byTask: boolean, available: number): Column[] { + const all = defaultColumns(byTask) + if (frameWidth(all) <= available) return all + + // Drop in this order so the table degrades sensibly. Cache columns drop as + // a pair (showing only one of cache write / cache read looks broken). + const kept = new Set(all) + for (const group of DROP_COLUMN_GROUPS) { + for (const key of group) { + const col = all.find(c => c.key === key) + if (col) kept.delete(col) + } + const remaining = all.filter(c => kept.has(c)) + if (frameWidth(remaining) <= available) return remaining + } + return all.filter(c => c.priority === 0) +} + +function expandedColumnWeight(col: Column): number { + switch (col.key) { + case 'task': + case 'model': + return 3 + case 'provider': + return 2 + default: + return 1 + } +} + +/// Expands a fitted table to the available terminal width. The extra cells are +/// spread across all visible columns, weighted toward text columns so grouped +/// model/task rows breathe on wide terminals without turning numeric columns +/// into huge empty gutters. +function expandColumnsToWidth(columns: Column[], targetWidth: number): Column[] { + let remaining = targetWidth - frameWidth(columns) + if (remaining <= 0 || columns.length === 0) return columns + + const expanded = columns.map(c => ({ ...c })) + const weights = expanded.map(expandedColumnWeight) + const totalWeight = weights.reduce((sum, w) => sum + w, 0) + + for (let i = 0; i < expanded.length; i++) { + const add = Math.floor((targetWidth - frameWidth(columns)) * (weights[i]! / totalWeight)) + if (add <= 0) continue + expanded[i]!.width += add + remaining -= add + } + + // Hand out rounding leftovers in the same preference order. + const preferred: Column['key'][] = ['task', 'model', 'provider', 'total', 'cost', 'input', 'output', 'cacheRead', 'cacheWrite'] + while (remaining > 0) { + let changed = false + for (const key of preferred) { + const col = expanded.find(c => c.key === key) + if (!col) continue + col.width += 1 + remaining -= 1 + changed = true + if (remaining === 0) break + } + if (!changed) break + } + + return expanded +} + +function renderRow(cells: string[], columns: Column[]): string { + const padded = cells.map((c, i) => pad(c, columns[i]!.width, columns[i]!.align)) + return BOX.vertical + ' ' + padded.join(' ' + BOX.vertical + ' ') + ' ' + BOX.vertical +} + +function renderBorder(columns: Column[], left: string, mid: string, right: string): string { + const segments = columns.map(c => BOX.horizontal.repeat(c.width + 2)) + return left + segments.join(mid) + right +} + +function defaultTerminalWidth(): number { + const cols = process.stdout.columns + if (typeof cols === 'number' && cols > 0) return cols + // Honor $COLUMNS when stdout is not a TTY (piped, tee'd, etc.); some + // shells set it even when isTTY is false. + const envCols = process.env['COLUMNS'] ? parseInt(process.env['COLUMNS'], 10) : NaN + if (Number.isFinite(envCols) && envCols > 0) return envCols + // Conservative fallback. 100 keeps the table readable on the most common + // terminal sizes (80, 100, 120) without trying to fit cache columns into + // a window that cannot hold them. + return 100 +} + +/// Renders a Unicode box-drawn table. Columns are auto-sized to their content +/// (with declared `width` as a minimum). When the terminal is narrow, drops +/// the lowest-priority columns (cache first, then input/output, then top-task) +/// so the table fits without wrapping. +export function renderTable( + rows: ModelReportRow[], + opts: TableRenderOptions = {}, +): string { + const byTask = opts.byTask ?? false + const showTotals = opts.showTotals ?? true + const available = opts.terminalWidth ?? defaultTerminalWidth() + const fullWidth = opts.fullWidth ?? true + + const valueOf = (row: ModelReportRow, key: Column['key'], isNewGroup: boolean): string => { + switch (key) { + case 'provider': return isNewGroup ? row.providerDisplayName : '' + case 'model': return isNewGroup ? row.modelDisplayName : '' + case 'task': + if (byTask) return row.category ? categoryLabel(row.category) : '' + return row.topCategory + ? `${categoryLabel(row.topCategory)} ${chalk.dim(`(${Math.round((row.topCategoryShare ?? 0) * 100)}%)`)}` + : chalk.dim('-') + case 'input': return formatTokens(row.inputTokens) + case 'output': return formatTokens(row.outputTokens) + case 'cacheWrite': return formatTokens(row.cacheWriteTokens) + case 'cacheRead': return formatTokens(row.cacheReadTokens) + case 'total': return formatTokens(row.totalTokens) + case 'cost': return formatCost(row.costUSD) + } + } + + // Build all cell content first so we can size columns to fit. + type RowCells = { kind: 'data' | 'totals'; cells: string[]; isNewGroup: boolean } + const rowEntries: RowCells[] = [] + let prevProviderModel = '' + for (const row of rows) { + const groupKey = `${row.provider} ${row.model}` + const isNewGroup = !byTask || groupKey !== prevProviderModel + prevProviderModel = groupKey + const allCells = defaultColumns(byTask).map(col => { + const raw = valueOf(row, col.key, isNewGroup) + if (col.key === 'provider' && raw) return chalk.dim(raw) + return raw + }) + rowEntries.push({ kind: 'data', cells: allCells, isNewGroup }) + } + + let totalsEntry: RowCells | null = null + if (showTotals && rows.length > 0) { + const totals = rows.reduce( + (acc, r) => { + acc.input += r.inputTokens + acc.output += r.outputTokens + acc.cacheWrite += r.cacheWriteTokens + acc.cacheRead += r.cacheReadTokens + acc.total += r.totalTokens + acc.cost += r.costUSD + return acc + }, + { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, total: 0, cost: 0 }, + ) + const cells = defaultColumns(byTask).map(col => { + switch (col.key) { + case 'provider': return '' + case 'model': return chalk.yellow.bold('Total') + case 'task': return '' + case 'input': return chalk.yellow(formatTokens(totals.input)) + case 'output': return chalk.yellow(formatTokens(totals.output)) + case 'cacheWrite': return chalk.yellow(formatTokens(totals.cacheWrite)) + case 'cacheRead': return chalk.yellow(formatTokens(totals.cacheRead)) + case 'total': return chalk.yellow.bold(formatTokens(totals.total)) + case 'cost': return chalk.yellow.bold(formatCost(totals.cost)) + } + }) + totalsEntry = { kind: 'totals', cells, isNewGroup: true } + } + + // Pick which columns to include based on terminal width, then size them. + // We index into `cells` by the column key to avoid object-identity pitfalls + // across defaultColumns() invocations. + const allKeys = defaultColumns(byTask).map(c => c.key) + const indexByKey = new Map(allKeys.map((k, i) => [k, i])) + const columns = chooseColumns(byTask, available) + const projectColumns = (cols: Column[], entry: RowCells) => + cols.map(c => entry.cells[indexByKey.get(c.key)!] ?? '') + const cellMatrix = [ + ...rowEntries.map(e => projectColumns(columns, e)), + ...(totalsEntry ? [projectColumns(columns, totalsEntry)] : []), + ] + const sized = sizeColumnsToContent(columns, cellMatrix) + + // If content sizing pushed the table back over budget, keep dropping the + // same low-value column groups until the rendered frame fits. + let final = sized + if (frameWidth(final) > available) { + let reduced = columns + for (const group of DROP_COLUMN_GROUPS) { + reduced = reduced.filter(c => !group.includes(c.key)) + const reducedMatrix = [ + ...rowEntries.map(e => projectColumns(reduced, e)), + ...(totalsEntry ? [projectColumns(reduced, totalsEntry)] : []), + ] + const candidate = sizeColumnsToContent(reduced, reducedMatrix) + final = candidate + if (frameWidth(candidate) <= available) break + } + } + + if (fullWidth && frameWidth(final) < available) { + final = expandColumnsToWidth(final, available) + } + + const lines: string[] = [] + lines.push(renderBorder(final, BOX.topLeft, BOX.topT, BOX.topRight)) + lines.push(renderRow(final.map(c => chalk.cyan.bold(c.header)), final)) + lines.push(renderBorder(final, BOX.leftT, BOX.cross, BOX.rightT)) + + let isFirstRow = true + for (const entry of rowEntries) { + if (byTask && entry.isNewGroup && !isFirstRow) { + lines.push(renderBorder(final, BOX.leftT, BOX.cross, BOX.rightT)) + } + isFirstRow = false + lines.push(renderRow(projectColumns(final, entry), final)) + } + + if (totalsEntry) { + lines.push(renderBorder(final, BOX.leftT, BOX.cross, BOX.rightT)) + lines.push(renderRow(projectColumns(final, totalsEntry), final)) + } + + lines.push(renderBorder(final, BOX.bottomLeft, BOX.bottomT, BOX.bottomRight)) + return lines.join('\n') +} + +export function renderJson(rows: ModelReportRow[]): string { + return JSON.stringify( + rows.map(r => ({ + provider: r.provider, + providerDisplayName: r.providerDisplayName, + model: r.model, + modelDisplayName: r.modelDisplayName, + category: r.category ?? r.topCategory ?? null, + topCategory: r.topCategory ?? null, + topCategoryShare: r.topCategoryShare ?? null, + inputTokens: r.inputTokens, + outputTokens: r.outputTokens, + cacheWriteTokens: r.cacheWriteTokens, + cacheReadTokens: r.cacheReadTokens, + totalTokens: r.totalTokens, + calls: r.calls, + costUSD: r.costUSD, + })), + null, + 2, + ) +} + +function csvEscape(value: string): string { + if (value.includes(',') || value.includes('"') || value.includes('\n')) { + return `"${value.replace(/"/g, '""')}"` + } + return value +} + +function mdEscape(value: string): string { + // Pipes break GitHub-flavored markdown tables; escape them. + return value.replace(/\|/g, '\\|') +} + +/// GitHub-flavored markdown table. Renders cleanly on GitHub, Notion, and most +/// chat platforms that understand markdown. Always shows provider/model on +/// every row (no blank-repeat trick) so the table remains useful when copied +/// into a context that loses whitespace alignment. +export function renderMarkdown(rows: ModelReportRow[], opts: { byTask?: boolean; showTotals?: boolean } = {}): string { + const byTask = opts.byTask ?? false + const showTotals = opts.showTotals ?? true + + const header = byTask + ? ['Provider', 'Model', 'Task', 'Input', 'Output', 'Cache Write', 'Cache Read', 'Total', 'Cost'] + : ['Provider', 'Model', 'Top Task', 'Input', 'Output', 'Cache Write', 'Cache Read', 'Total', 'Cost'] + const align = ['---', '---', '---', '---:', '---:', '---:', '---:', '---:', '---:'] + + const lines: string[] = [] + lines.push(`| ${header.join(' | ')} |`) + lines.push(`| ${align.join(' | ')} |`) + + for (const row of rows) { + const taskCell = byTask + ? row.category ? categoryLabel(row.category) : '' + : row.topCategory + ? `${categoryLabel(row.topCategory)} (${Math.round((row.topCategoryShare ?? 0) * 100)}%)` + : '-' + const cells = [ + mdEscape(row.providerDisplayName), + `\`${mdEscape(row.modelDisplayName)}\``, + taskCell, + formatTokens(row.inputTokens), + formatTokens(row.outputTokens), + formatTokens(row.cacheWriteTokens), + formatTokens(row.cacheReadTokens), + formatTokens(row.totalTokens), + formatCost(row.costUSD), + ] + lines.push(`| ${cells.join(' | ')} |`) + } + + if (showTotals && rows.length > 0) { + const totals = rows.reduce( + (acc, r) => { + acc.input += r.inputTokens + acc.output += r.outputTokens + acc.cacheWrite += r.cacheWriteTokens + acc.cacheRead += r.cacheReadTokens + acc.total += r.totalTokens + acc.cost += r.costUSD + return acc + }, + { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, total: 0, cost: 0 }, + ) + const totalCells = [ + '', + '**Total**', + '', + `**${formatTokens(totals.input)}**`, + `**${formatTokens(totals.output)}**`, + `**${formatTokens(totals.cacheWrite)}**`, + `**${formatTokens(totals.cacheRead)}**`, + `**${formatTokens(totals.total)}**`, + `**${formatCost(totals.cost)}**`, + ] + lines.push(`| ${totalCells.join(' | ')} |`) + } + + return lines.join('\n') +} + +export function renderCsv(rows: ModelReportRow[], opts: { byTask?: boolean } = {}): string { + const byTask = opts.byTask ?? false + // CSV intentionally repeats provider/model on every row so downstream + // consumers can sort/filter without first reconstructing the grouping. + const header = byTask + ? ['provider', 'model', 'task', 'input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_read_tokens', 'total_tokens', 'calls', 'cost_usd'] + : ['provider', 'model', 'top_task', 'top_task_share', 'input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_read_tokens', 'total_tokens', 'calls', 'cost_usd'] + const lines: string[] = [header.join(',')] + for (const r of rows) { + const cells = byTask + ? [ + csvEscape(r.providerDisplayName), + csvEscape(r.modelDisplayName), + r.category ? categoryLabel(r.category) : '', + String(r.inputTokens), + String(r.outputTokens), + String(r.cacheWriteTokens), + String(r.cacheReadTokens), + String(r.totalTokens), + String(r.calls), + r.costUSD.toFixed(6), + ] + : [ + csvEscape(r.providerDisplayName), + csvEscape(r.modelDisplayName), + r.topCategory ? categoryLabel(r.topCategory) : '', + r.topCategoryShare !== undefined ? r.topCategoryShare.toFixed(4) : '', + String(r.inputTokens), + String(r.outputTokens), + String(r.cacheWriteTokens), + String(r.cacheReadTokens), + String(r.totalTokens), + String(r.calls), + r.costUSD.toFixed(6), + ] + lines.push(cells.join(',')) + } + return lines.join('\n') +} diff --git a/src/models.ts b/src/models.ts index 12dff01..5d48822 100644 --- a/src/models.ts +++ b/src/models.ts @@ -25,6 +25,7 @@ type SnapshotEntry = [number, number, number | null, number | null] const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json' const CACHE_TTL_MS = 24 * 60 * 60 * 1000 const WEB_SEARCH_COST = 0.01 +const ONE_HOUR_CACHE_WRITE_MULTIPLIER_FROM_FIVE_MINUTE_RATE = 1.6 const FAST_MULTIPLIERS: Record = { 'claude-opus-4-7': 6, @@ -48,6 +49,14 @@ function loadSnapshot(): Map { } let pricingCache: Map = loadSnapshot() +let sortedPricingKeys: string[] | null = null + +function getSortedPricingKeys(): string[] { + if (sortedPricingKeys === null) { + sortedPricingKeys = Array.from(pricingCache.keys()).sort((a, b) => b.length - a.length) + } + return sortedPricingKeys +} function getCacheDir(): string { return join(homedir(), '.cache', 'codeburn') @@ -57,13 +66,29 @@ function getCachePath(): string { return join(getCacheDir(), 'litellm-pricing.json') } +/// Clamp a per-token rate to a sane non-negative value. Defense in depth +/// against a tampered LiteLLM JSON shipping a negative `input_cost_per_token`, +/// which would otherwise produce negative costs that subtract from totals. +/// We use Number.isFinite to also reject NaN/Infinity, and cap at $1/token +/// (well above the most expensive frontier model) so a stray decimal-place +/// shift in the upstream JSON can't wildly inflate spend numbers either. +function safePerTokenRate(n: number | undefined): number | null { + if (n === undefined || !Number.isFinite(n) || n < 0) return null + if (n > 1) return 1 + return n +} + function parseLiteLLMEntry(entry: LiteLLMEntry): ModelCosts | null { - if (entry.input_cost_per_token === undefined || entry.output_cost_per_token === undefined) return null + const inputCost = safePerTokenRate(entry.input_cost_per_token) + const outputCost = safePerTokenRate(entry.output_cost_per_token) + if (inputCost === null || outputCost === null) return null + const cacheWrite = safePerTokenRate(entry.cache_creation_input_token_cost) ?? inputCost * 1.25 + const cacheRead = safePerTokenRate(entry.cache_read_input_token_cost) ?? inputCost * 0.1 return { - inputCostPerToken: entry.input_cost_per_token, - outputCostPerToken: entry.output_cost_per_token, - cacheWriteCostPerToken: entry.cache_creation_input_token_cost ?? entry.input_cost_per_token * 1.25, - cacheReadCostPerToken: entry.cache_read_input_token_cost ?? entry.input_cost_per_token * 0.1, + inputCostPerToken: inputCost, + outputCostPerToken: outputCost, + cacheWriteCostPerToken: cacheWrite, + cacheReadCostPerToken: cacheRead, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: entry.provider_specific_entry?.fast ?? 1, } @@ -110,11 +135,13 @@ export async function loadPricing(): Promise { const cached = await loadCachedPricing() if (cached) { pricingCache = cached + sortedPricingKeys = null return } try { pricingCache = await fetchAndCachePricing() + sortedPricingKeys = null } catch { // snapshot already loaded at init; nothing more to do } @@ -130,24 +157,79 @@ const BUILTIN_ALIASES: Record = { 'anthropic--claude-4.5-opus': 'claude-opus-4-5', 'anthropic--claude-4.5-sonnet': 'claude-sonnet-4-5', 'anthropic--claude-4.5-haiku': 'claude-haiku-4-5', + 'claude-sonnet-4.6': 'claude-sonnet-4-6', + 'claude-sonnet-4.5': 'claude-sonnet-4-5', + 'claude-opus-4.7': 'claude-opus-4-7', + 'claude-opus-4.6': 'claude-opus-4-6', + 'claude-opus-4.5': 'claude-opus-4-5', 'cursor-auto': 'claude-sonnet-4-5', 'cursor-agent-auto': 'claude-sonnet-4-5', 'copilot-auto': 'claude-sonnet-4-5', 'copilot-openai-auto': 'gpt-5.3-codex', 'copilot-anthropic-auto': 'claude-sonnet-4-5', + 'ibm-bob-auto': 'claude-sonnet-4-5', 'kiro-auto': 'claude-sonnet-4-5', 'cline-auto': 'claude-sonnet-4-5', 'openclaw-auto': 'claude-sonnet-4-5', 'qwen-auto': 'claude-sonnet-4-5', - // Cursor emits dot-version tier-last names - 'claude-4.6-sonnet': 'claude-sonnet-4-6', - 'claude-4.5-sonnet-thinking': 'claude-sonnet-4-5', + 'kimi-auto': 'kimi-k2-thinking', + 'kimi-code': 'kimi-k2-thinking', + 'kimi-for-coding': 'kimi-k2-thinking', + // Cursor emits dot-version tier-last names plus tier/reasoning suffixes + // that LiteLLM does not index (`-high`, `-low`, `-medium`, `-thinking`, + // `-high-thinking`, `-fast-mode`). Missing aliases here surface as $0 in + // the dashboard for users on non-Auto models (issue #159). Sources: the + // display map at `src/providers/cursor.ts:modelDisplayNames`, Cursor's + // public model docs at https://cursor.com/docs/models, and forum bug + // reports that quote literal slugs (e.g. forum.cursor.com/t/154933). + 'claude-4-sonnet': 'claude-sonnet-4', + 'claude-4-sonnet-1m': 'claude-sonnet-4', 'claude-4-sonnet-thinking': 'claude-sonnet-4-5', - 'claude-4-opus': 'claude-opus-4-5', + 'claude-4.5-sonnet': 'claude-sonnet-4-5', + 'claude-4.5-sonnet-thinking': 'claude-sonnet-4-5', + 'claude-4.6-sonnet': 'claude-sonnet-4-6', + 'claude-4.6-sonnet-high': 'claude-sonnet-4-6', + 'claude-4.6-sonnet-low': 'claude-sonnet-4-6', + 'claude-4.6-sonnet-thinking': 'claude-sonnet-4-6', + 'claude-4.6-sonnet-high-thinking':'claude-sonnet-4-6', + 'claude-4-opus': 'claude-opus-4', + 'claude-4.5-opus': 'claude-opus-4-5', + 'claude-4.5-opus-high': 'claude-opus-4-5', + 'claude-4.5-opus-low': 'claude-opus-4-5', + 'claude-4.5-opus-medium': 'claude-opus-4-5', 'claude-4.5-opus-high-thinking': 'claude-opus-4-5', + 'claude-4.6-opus': 'claude-opus-4-6', + 'claude-4.6-opus-fast-mode': 'claude-opus-4-6', + 'claude-4.6-opus-high': 'claude-opus-4-6', + 'claude-4.6-opus-low': 'claude-opus-4-6', + 'claude-4.6-opus-medium': 'claude-opus-4-6', + 'claude-4.6-opus-high-thinking': 'claude-opus-4-6', + 'claude-4.7-opus': 'claude-opus-4-7', + // Dash form (NOT dot) seen in forum.cursor.com/t/158597. + 'claude-opus-4-7-thinking-high': 'claude-opus-4-7', + 'claude-4.5-haiku': 'claude-haiku-4-5', + 'claude-4.6-haiku': 'claude-haiku-4-5', + // Cursor's house models have no LiteLLM pricing entry. composer-1 is + // sonnet-4.5-class per Cursor docs; composer-2 is built on Sonnet 4.6 + // per cursor.com/blog/composer-2. + 'composer-1': 'claude-sonnet-4-5', + 'composer-1.5': 'claude-sonnet-4-5', + 'composer-2': 'claude-sonnet-4-6', + // Cursor's "fast" routing variant of GPT-5 is the same model behind a + // lower-latency endpoint; price as base GPT-5 until LiteLLM tracks it. + 'gpt-5-fast': 'gpt-5', 'gpt-4.1': 'gpt-4.1', 'gpt-5.2-low': 'gpt-5', 'gpt-5.1-codex-high': 'gpt-5.3-codex', + // Antigravity Gemini model IDs resolve to preview-priced entries. + 'gemini-3.1-pro': 'gemini-3.1-pro-preview', + 'gemini-3-flash': 'gemini-3-flash-preview', + 'gemini-3.1-pro-high': 'gemini-3.1-pro-preview', + 'gemini-3.1-pro-low': 'gemini-3.1-pro-preview', + 'gemini-3-flash-agent': 'gemini-3-flash-preview', + 'gemini-3-pro': 'gemini-3-pro-preview', + 'gemini-3.1-flash-image': 'gemini-3.1-flash-image-preview', + 'gemini-3.1-flash-lite': 'gemini-3.1-flash-lite-preview', } let userAliases: Record = {} @@ -178,13 +260,53 @@ export function getModelCosts(model: string): ModelCosts | null { const canonical = resolveAlias(getCanonicalName(model)) if (pricingCache.has(canonical)) return pricingCache.get(canonical)! - for (const [key, costs] of pricingCache) { - if (canonical.startsWith(key + '-') || canonical.startsWith(key)) return costs + // Iterate keys longest-first so a model id like `gpt-5-mini` matches the + // `gpt-5-mini` entry rather than collapsing to the shorter `gpt-5` entry + // due to dictionary insertion order. + for (const key of getSortedPricingKeys()) { + if (canonical.startsWith(key + '-') || canonical === key) { + return pricingCache.get(key)! + } } return null } +// Warn at most once per unknown model name per process. Without this, a model +// missing from the pricing snapshot would silently price at $0 for every +// session that used it, hiding real spend until the user noticed. +const warnedUnknownModels = new Set() + +/// Heuristic for "this looks like a local model that will never be in LiteLLM's +/// pricing JSON". We suppress the unknown-model warning for these because the +/// "update codeburn" advice can't help — local Ollama models, llama.cpp tags, +/// LM Studio loads, etc. are billed locally and don't have public pricing. +/// Users still get $0 in cost reports for them (correct — local inference is +/// effectively free); the warning was just noise. +function looksLikeLocalModel(name: string): boolean { + // Ollama and LM Studio tags include `:tag` (e.g. qwen3.6:35b-a3b-bf16). + if (name.includes(':') && !name.startsWith('http')) return true + // GGUF / quantized fingerprints commonly seen in local inference. + if (/[-_](q[2-8](_[a-z0-9]+)?|bf16|fp16|gguf|f16|f32)$/i.test(name)) return true + return false +} + +function shouldWarnAboutUnknownModel(name: string): boolean { + if (!name || name === '') return false + if (warnedUnknownModels.has(name)) return false + // Suppress for local/quantized models — the "update codeburn" hint is + // actively misleading there. Users who need cost visibility for local + // inference can still set an alias via `codeburn model-alias`. + if (looksLikeLocalModel(name)) return false + // The warning fired on every CLI invocation (including the default + // dashboard) which made first launches look broken — three "no pricing + // data" lines greet a user before the dashboard even draws. Now opt-in + // via --verbose. The unknown model still costs $0 in reports; users who + // suspect missing models run `codeburn --verbose` to see the list. + if (process.env['CODEBURN_VERBOSE'] !== '1') return false + return true +} + export function calculateCost( model: string, inputTokens: number, @@ -193,18 +315,43 @@ export function calculateCost( cacheReadTokens: number, webSearchRequests: number, speed: 'standard' | 'fast' = 'standard', + oneHourCacheCreationTokens = 0, ): number { const costs = getModelCosts(model) - if (!costs) return 0 + if (!costs) { + if (shouldWarnAboutUnknownModel(model)) { + warnedUnknownModels.add(model) + // Strip control characters and cap length: model names come from JSONL + // payloads written by external tools, so a hostile or corrupt file + // could embed terminal escape sequences here. + const safeName = model.replace(/[\x00-\x1F\x7F-\x9F]/g, '?').slice(0, 200) + const aliasHint = `Map it with: codeburn model-alias "${safeName}" ` + process.stderr.write( + `codeburn: no pricing data for model "${safeName}" — costs for this model will show $0. ` + + `${aliasHint}, or update with: npx codeburn@latest.\n` + ) + } + return 0 + } const multiplier = speed === 'fast' ? costs.fastMultiplier : 1 + // Clamp negative inputs to 0. A corrupt JSONL that emits a negative token + // count would otherwise produce a negative cost that silently subtracts + // from real spend in aggregate totals. NaN is also handled here; the + // arithmetic below short-circuits to 0 when any operand is non-finite. + const safe = (n: number) => (Number.isFinite(n) && n > 0 ? n : 0) + const safeOneHourCacheCreation = safe(oneHourCacheCreationTokens) + const safeCacheCreation = Math.max(safe(cacheCreationTokens), safeOneHourCacheCreation) + const safeFiveMinuteCacheCreation = Math.max(0, safeCacheCreation - safeOneHourCacheCreation) + return multiplier * ( - inputTokens * costs.inputCostPerToken + - outputTokens * costs.outputCostPerToken + - cacheCreationTokens * costs.cacheWriteCostPerToken + - cacheReadTokens * costs.cacheReadCostPerToken + - webSearchRequests * costs.webSearchCostPerRequest + safe(inputTokens) * costs.inputCostPerToken + + safe(outputTokens) * costs.outputCostPerToken + + safeFiveMinuteCacheCreation * costs.cacheWriteCostPerToken + + safeOneHourCacheCreation * costs.cacheWriteCostPerToken * ONE_HOUR_CACHE_WRITE_MULTIPLIER_FROM_FIVE_MINUTE_RATE + + safe(cacheReadTokens) * costs.cacheReadCostPerToken + + safe(webSearchRequests) * costs.webSearchCostPerRequest ) } @@ -214,61 +361,86 @@ const autoModelNames: Record = { 'copilot-auto': 'Copilot (auto)', 'copilot-openai-auto': 'Copilot (OpenAI)', 'copilot-anthropic-auto': 'Copilot (Anthropic)', + 'ibm-bob-auto': 'IBM Bob (auto)', 'kiro-auto': 'Kiro (auto)', 'cline-auto': 'Cline (auto)', 'openclaw-auto': 'OpenClaw (auto)', 'qwen-auto': 'Qwen (auto)', + 'kimi-auto': 'Kimi (auto)', } +const SHORT_NAMES: Record = { + 'claude-opus-4-7': 'Opus 4.7', + 'claude-opus-4-6': 'Opus 4.6', + 'claude-opus-4-5': 'Opus 4.5', + 'claude-opus-4-1': 'Opus 4.1', + 'claude-opus-4': 'Opus 4', + 'claude-sonnet-4-6': 'Sonnet 4.6', + 'claude-sonnet-4-5': 'Sonnet 4.5', + 'claude-sonnet-4': 'Sonnet 4', + 'claude-3-7-sonnet': 'Sonnet 3.7', + 'claude-3-5-sonnet': 'Sonnet 3.5', + 'claude-haiku-4-5': 'Haiku 4.5', + 'claude-3-5-haiku': 'Haiku 3.5', + 'gpt-4o-mini': 'GPT-4o Mini', + 'gpt-4o': 'GPT-4o', + 'gpt-4.1-nano': 'GPT-4.1 Nano', + 'gpt-4.1-mini': 'GPT-4.1 Mini', + 'gpt-4.1': 'GPT-4.1', + 'codex-auto-review': 'Codex Auto Review', + 'gpt-5.5-pro': 'GPT-5.5 Pro', + 'gpt-5.5': 'GPT-5.5', + 'gpt-5.4-pro': 'GPT-5.4 Pro', + 'gpt-5.4-nano': 'GPT-5.4 Nano', + 'gpt-5.4-mini': 'GPT-5.4 Mini', + 'gpt-5.4': 'GPT-5.4', + 'gpt-5.3-codex': 'GPT-5.3 Codex', + 'gpt-5.3': 'GPT-5.3', + 'gpt-5.2-pro': 'GPT-5.2 Pro', + 'gpt-5.2-low': 'GPT-5.2 Low', + 'gpt-5.2': 'GPT-5.2', + 'gpt-5.1-codex-mini': 'GPT-5.1 Codex Mini', + 'gpt-5.1-codex': 'GPT-5.1 Codex', + 'gpt-5.1': 'GPT-5.1', + 'gpt-5-pro': 'GPT-5 Pro', + 'gpt-5-nano': 'GPT-5 Nano', + 'gpt-5-mini': 'GPT-5 Mini', + 'gpt-5': 'GPT-5', + 'gemini-3.1-pro-preview': 'Gemini 3.1 Pro', + 'gemini-3-flash-preview': 'Gemini 3 Flash', + 'gemini-2.5-pro': 'Gemini 2.5 Pro', + 'gemini-2.5-flash': 'Gemini 2.5 Flash', + 'kimi-k2-thinking-turbo': 'Kimi K2 Thinking Turbo', + 'kimi-k2-thinking': 'Kimi K2 Thinking', + 'kimi-thinking-preview': 'Kimi Thinking', + 'kimi-k2.6': 'Kimi K2.6', + 'kimi-k2.5': 'Kimi K2.5', + 'kimi-k2p5': 'Kimi K2.5', + 'kimi-k2-instruct': 'Kimi K2 Instruct', + 'kimi-k2-0905': 'Kimi K2', + 'kimi-k2': 'Kimi K2', + 'kimi-latest': 'Kimi Latest', + 'moonshot-v1': 'Moonshot v1', + 'deepseek-coder-max': 'DeepSeek Coder Max', + 'deepseek-coder': 'DeepSeek Coder', + 'deepseek-r1': 'DeepSeek R1', + 'o4-mini': 'o4-mini', + 'o3': 'o3', + 'MiniMax-M2.7-highspeed': 'MiniMax M2.7 Highspeed', + 'MiniMax-M2.7': 'MiniMax M2.7', +} + +// Sorted longest-first so more-specific prefixes match before shorter ones. +// Without this, `gpt-5-mini` could resolve to "GPT-5" (the entry for `gpt-5`) +// if it happened to be iterated before `gpt-5-mini`, hiding a distinct model +// behind the wrong display name and pricing tier. +const SORTED_SHORT_NAMES: [string, string][] = Object.entries(SHORT_NAMES) + .sort((a, b) => b[0].length - a[0].length) + export function getShortModelName(model: string): string { if (autoModelNames[model]) return autoModelNames[model] const canonical = resolveAlias(getCanonicalName(model)) - const shortNames: Record = { - 'claude-opus-4-7': 'Opus 4.7', - 'claude-opus-4-6': 'Opus 4.6', - 'claude-opus-4-5': 'Opus 4.5', - 'claude-opus-4-1': 'Opus 4.1', - 'claude-opus-4': 'Opus 4', - 'claude-sonnet-4-6': 'Sonnet 4.6', - 'claude-sonnet-4-5': 'Sonnet 4.5', - 'claude-sonnet-4': 'Sonnet 4', - 'claude-3-7-sonnet': 'Sonnet 3.7', - 'claude-3-5-sonnet': 'Sonnet 3.5', - 'claude-haiku-4-5': 'Haiku 4.5', - 'claude-3-5-haiku': 'Haiku 3.5', - 'gpt-4o-mini': 'GPT-4o Mini', - 'gpt-4o': 'GPT-4o', - 'gpt-4.1-nano': 'GPT-4.1 Nano', - 'gpt-4.1-mini': 'GPT-4.1 Mini', - 'gpt-4.1': 'GPT-4.1', - 'codex-auto-review': 'Codex Auto Review', - 'gpt-5.5-pro': 'GPT-5.5 Pro', - 'gpt-5.5': 'GPT-5.5', - 'gpt-5.4-pro': 'GPT-5.4 Pro', - 'gpt-5.4-nano': 'GPT-5.4 Nano', - 'gpt-5.4-mini': 'GPT-5.4 Mini', - 'gpt-5.4': 'GPT-5.4', - 'gpt-5.3-codex': 'GPT-5.3 Codex', - 'gpt-5.2-pro': 'GPT-5.2 Pro', - 'gpt-5.2-low': 'GPT-5.2 Low', - 'gpt-5.2': 'GPT-5.2', - 'gpt-5.1-codex-mini': 'GPT-5.1 Codex Mini', - 'gpt-5.1-codex': 'GPT-5.1 Codex', - 'gpt-5.1': 'GPT-5.1', - 'gpt-5-pro': 'GPT-5 Pro', - 'gpt-5-nano': 'GPT-5 Nano', - 'gpt-5-mini': 'GPT-5 Mini', - 'gpt-5': 'GPT-5', - 'gemini-3.1-pro-preview': 'Gemini 3.1 Pro', - 'gemini-3-flash-preview': 'Gemini 3 Flash', - 'gemini-2.5-pro': 'Gemini 2.5 Pro', - 'gemini-2.5-flash': 'Gemini 2.5 Flash', - 'o4-mini': 'o4-mini', - 'o3': 'o3', - 'MiniMax-M2.7-highspeed': 'MiniMax M2.7 Highspeed', - 'MiniMax-M2.7': 'MiniMax M2.7', - } - for (const [key, name] of Object.entries(shortNames)) { + for (const [key, name] of SORTED_SHORT_NAMES) { if (canonical.startsWith(key)) return name } return canonical diff --git a/src/optimize.ts b/src/optimize.ts index 7077b29..c672bac 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -6,6 +6,7 @@ import { homedir } from 'os' import { readSessionLines, readSessionFileSync } from './fs-utils.js' import { discoverAllSessions } from './providers/index.js' +import { parseJsonlLine, shouldSkipLine } from './parser.js' import type { DateRange, ProjectSummary } from './types.js' import { formatCost } from './currency.js' import { formatTokens } from './format.js' @@ -53,6 +54,19 @@ const LOW_RATIO_MEDIUM_THRESHOLD = 3 const MIN_API_CALLS_FOR_CACHE = 10 const CACHE_EXCESS_HIGH_THRESHOLD = 15000 const UNUSED_MCP_HIGH_THRESHOLD = 3 +// MCP tool coverage detector thresholds. A server only earns a finding when +// every condition holds: the inventory is large enough to matter, real-world +// usage is poor, and we observed it in enough sessions to trust the signal. +const MCP_COVERAGE_MIN_TOOLS = 10 +const MCP_COVERAGE_MIN_SESSIONS = 2 +const MCP_COVERAGE_LOW_THRESHOLD = 0.20 +const MCP_COVERAGE_HIGH_IMPACT_TOKENS = 200_000 +// Anthropic prices cache writes at 125% of base input and cache reads at +// roughly 10% of base input. We use these to keep overhead estimates honest: +// most MCP schema bytes live in the cached prefix and only get charged at +// the discount rate after the first turn of a session. +const CACHE_WRITE_MULTIPLIER = 1.25 +const CACHE_READ_DISCOUNT = 0.10 const GHOST_AGENTS_HIGH_THRESHOLD = 5 const GHOST_AGENTS_MEDIUM_THRESHOLD = 2 const GHOST_SKILLS_HIGH_THRESHOLD = 10 @@ -61,6 +75,30 @@ const GHOST_COMMANDS_MEDIUM_THRESHOLD = 10 const MCP_NEW_CONFIG_GRACE_MS = 24 * 60 * 60 * 1000 const BASH_DEFAULT_LIMIT = 30000 const BASH_RECOMMENDED_LIMIT = 15000 +const MIN_SESSIONS_FOR_OUTLIER = 3 +const SESSION_OUTLIER_MULTIPLIER = 2 +const MIN_SESSION_OUTLIER_COST_USD = 1 +const SESSION_OUTLIER_PREVIEW = 5 +const CONTEXT_BLOAT_MIN_INPUT_TOKENS = 75_000 +const CONTEXT_BLOAT_MIN_RATIO = 25 +const CONTEXT_BLOAT_TARGET_RATIO = 15 +const CONTEXT_BLOAT_PREVIEW = 5 +const CONTEXT_BLOAT_LOW_INPUT_TOKENS = 200_000 +const CONTEXT_BLOAT_HIGH_INPUT_TOKENS = 500_000 +const CONTEXT_BLOAT_LOW_MAX_CANDIDATES = 2 +const CONTEXT_BLOAT_HIGH_MIN_CANDIDATES = 10 +const CONTEXT_BLOAT_GROWTH_RATIO = 2 +const CONTEXT_BLOAT_GROWTH_MAX_GAP_MS = 7 * 24 * 60 * 60 * 1000 +const CONTEXT_BLOAT_RATIO_DISPLAY_CAP = 1000 +const WORTH_IT_MIN_COST_USD = 2 +const WORTH_IT_NO_EDIT_MIN_COST_USD = 3 +const WORTH_IT_MIN_RETRIES = 3 +const WORTH_IT_RETRY_WITH_EDIT_MIN_RETRIES = 2 +const WORTH_IT_PREVIEW = 5 +const WORTH_IT_LOW_MAX_CANDIDATES = 2 +const WORTH_IT_LOW_MAX_TOTAL_COST_USD = 10 +const WORTH_IT_HIGH_MIN_CANDIDATES = 10 +const WORTH_IT_HIGH_TOTAL_COST_USD = 50 // ============================================================================ // Scoring constants @@ -74,9 +112,15 @@ const GRADE_A_MIN = 90 const GRADE_B_MIN = 75 const GRADE_C_MIN = 55 const GRADE_D_MIN = 30 -const URGENCY_IMPACT_WEIGHT = 0.7 -const URGENCY_TOKEN_WEIGHT = 0.3 -const URGENCY_TOKEN_NORMALIZE = 500_000 +// Rebalanced so a high-impact finding with zero observed tokens (e.g. +// detectGhostAgents firing on five files but tokensSaved=400) cannot +// outrank a medium-impact finding with many millions of tokens. +// Old: 0.7/0.3 → high+0 = 0.70, medium+1B = 0.65 (high+0 won). +// New: 0.5/0.5 → high+0 = 0.50, medium+1B = 0.75 (medium+1B wins). +// Token normalize lifted to 5M so the rank scales over a realistic range. +const URGENCY_IMPACT_WEIGHT = 0.5 +const URGENCY_TOKEN_WEIGHT = 0.5 +const URGENCY_TOKEN_NORMALIZE = 5_000_000 // ============================================================================ // File system constants @@ -98,6 +142,8 @@ const SHELL_PROFILES = ['.zshrc', '.bashrc', '.bash_profile', '.profile'] const TOP_ITEMS_PREVIEW = 3 const GHOST_NAMES_PREVIEW = 5 const GHOST_CLEANUP_COMMANDS_LIMIT = 10 +const OPTIMIZE_TEXT_CAP = 2000 +const OPTIMIZE_FIELD_CAP = 500 // ============================================================================ // Types @@ -106,8 +152,20 @@ const GHOST_CLEANUP_COMMANDS_LIMIT = 10 export type Impact = 'high' | 'medium' | 'low' export type HealthGrade = 'A' | 'B' | 'C' | 'D' | 'F' +/// Where a paste-style suggestion belongs. Without this, users couldn't tell +/// whether a prompt should go into CLAUDE.md (permanent rule), be pasted at +/// the start of a future session (one-time constraint), be asked of Claude +/// in the current chat (one-time prompt), or be added to a shell config file. +/// Issue #277 — users were dropping one-time session openers into CLAUDE.md +/// permanently because the destination wasn't clearly stated. +export type PasteDestination = + | 'claude-md' // permanent project rule, append to CLAUDE.md + | 'session-opener' // one-time paste at the start of a NEW session + | 'prompt' // one-time ask in the current Claude conversation + | 'shell-config' // append to ~/.zshrc / ~/.bashrc + export type WasteAction = - | { type: 'paste'; label: string; text: string } + | { type: 'paste'; label: string; text: string; destination?: PasteDestination } | { type: 'command'; label: string; text: string } | { type: 'file-content'; label: string; path: string; content: string } @@ -154,7 +212,33 @@ type ScanData = { // JSONL scanner // ============================================================================ -const FILE_READ_CONCURRENCY = 16 +function cappedString(value: unknown, cap = OPTIMIZE_FIELD_CAP): string | undefined { + return typeof value === 'string' ? value.slice(0, cap) : undefined +} + +function compactOptimizeInput(name: string, input: unknown): Record { + if (!input || typeof input !== 'object') return {} + const raw = input as Record + if (isReadTool(name)) { + const filePath = cappedString(raw['file_path'], OPTIMIZE_TEXT_CAP) + return filePath ? { file_path: filePath } : {} + } + if (name === 'Agent' || name === 'Task') { + const subagentType = cappedString(raw['subagent_type']) + return subagentType ? { subagent_type: subagentType } : {} + } + if (name === 'Skill') { + const skill = cappedString(raw['skill']) + const skillName = cappedString(raw['name']) + return { + ...(skill ? { skill } : {}), + ...(skillName ? { name: skillName } : {}), + } + } + return {} +} + +const FILE_READ_CONCURRENCY = 4 const RESULT_CACHE_TTL_MS = 60_000 const RECENT_WINDOW_HOURS = 48 const RECENT_WINDOW_MS = RECENT_WINDOW_HOURS * 60 * 60 * 1000 @@ -231,10 +315,19 @@ export async function scanJsonlFile( const sessionId = basename(filePath, '.jsonl') let lastVersion = '' - for await (const line of readSessionLines(filePath)) { - if (!line.trim()) continue - let entry: Record - try { entry = JSON.parse(line) } catch { continue } + const skipThreshold = dateRange + ? new Date(dateRange.start.getTime() - 86_400_000).toISOString() + : null + const skipFn = dateRange + ? (head: string) => shouldSkipLine(head, skipThreshold!) + : undefined + const lines = readSessionLines(filePath, skipFn, { largeLineAsBuffer: true }) + for await (const line of lines) { + if (typeof line === 'string' && !line.trim()) continue + if (Buffer.isBuffer(line) && line.length === 0) continue + const parsed = parseJsonlLine(line) + if (!parsed) continue + const entry = parsed as Record if (entry.version && typeof entry.version === 'string') lastVersion = entry.version @@ -249,11 +342,15 @@ export async function scanJsonlFile( const msg = entry.message as Record | undefined const msgContent = msg?.content if (typeof msgContent === 'string') { - userMessages.push(msgContent) + userMessages.push(msgContent.slice(0, OPTIMIZE_TEXT_CAP)) } else if (Array.isArray(msgContent)) { + let remaining = OPTIMIZE_TEXT_CAP for (const block of msgContent) { + if (remaining <= 0) break if (block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') { - userMessages.push(block.text) + const text = block.text.slice(0, remaining) + userMessages.push(text) + remaining -= text.length } } } @@ -275,9 +372,10 @@ export async function scanJsonlFile( for (const block of blocks) { if (block.type !== 'tool_use') continue + const name = typeof block.name === 'string' ? block.name : '' calls.push({ - name: block.name as string, - input: (block.input as Record) ?? {}, + name, + input: compactOptimizeInput(name, block.input), sessionId, project, recent, @@ -411,6 +509,7 @@ export function detectJunkReads(calls: ToolCall[], dateRange?: DateRange): Waste tokensSaved, fix: { type: 'paste', + destination: 'claude-md', label: 'Append to your project CLAUDE.md:', text: `Do not read or search files under these directories unless I explicitly ask: ${dirsToAvoid}.`, }, @@ -470,6 +569,7 @@ export function detectDuplicateReads(calls: ToolCall[], dateRange?: DateRange): tokensSaved, fix: { type: 'paste', + destination: 'prompt', label: 'Point Claude at exact locations in your prompt, for example:', text: 'In lines -, look at the function.', }, @@ -477,10 +577,329 @@ export function detectDuplicateReads(calls: ToolCall[], dateRange?: DateRange): } } +/** + * Per-server breakdown of MCP tool inventory vs invocations, computed from the + * `mcpInventory` field captured by the Claude parser. + * + * Each session that loaded a server contributes its observed tool list to + * the union for that server. Invocations come from the existing + * `mcpBreakdown` per-call counts plus the parser's `call.tools` stream. + */ +export type McpServerCoverage = { + server: string + toolsAvailable: number + toolsInvoked: number + unusedTools: string[] + invocations: number + loadedSessions: number + coverageRatio: number +} + +type McpSchemaCostEstimate = { + cacheWriteTokens: number + cacheReadTokens: number + effectiveInputTokens: number +} + +/** + * Aggregate MCP inventory and invocations across the projects in scope. + * + * Returns one entry per `mcp____*` namespace observed in any + * session's `mcpInventory`. Counts of invocations come from + * `session.mcpBreakdown` (per-server call totals already maintained by the + * parser). + */ +export function aggregateMcpCoverage(projects: ProjectSummary[]): McpServerCoverage[] { + type ServerAcc = { + inventory: Set + invokedTools: Set + invocations: number + loadedSessions: number + } + const servers = new Map() + + function getOrInit(server: string): ServerAcc { + let acc = servers.get(server) + if (!acc) { + acc = { inventory: new Set(), invokedTools: new Set(), invocations: 0, loadedSessions: 0 } + servers.set(server, acc) + } + return acc + } + + for (const project of projects) { + for (const session of project.sessions) { + // Only sessions with an observed inventory count toward `loadedSessions`. + // Pure invocation-only sessions (server seen via `call.mcpTools` or + // `session.mcpBreakdown` without any matching `deferred_tools_delta`) + // could otherwise satisfy the `MCP_COVERAGE_MIN_SESSIONS` threshold + // without giving us evidence that the schema was actually loaded. + const inventoriedServers = new Set() + const sessionInvoked = new Map>() + + // Inventory: union of tools observed available in this session. + for (const fqn of session.mcpInventory ?? []) { + const parts = fqn.split('__') + if (parts.length < 3 || parts[0] !== 'mcp') continue + const server = parts[1] + if (!server) continue + const tool = parts.slice(2).join('__') + if (!tool) continue + const acc = getOrInit(server) + acc.inventory.add(fqn) + inventoriedServers.add(server) + } + + // Invoked tools: walk turns to collect per-tool invocations. We can't + // get this from session.mcpBreakdown alone because that's keyed by + // server, not tool. + for (const turn of session.turns) { + for (const call of turn.assistantCalls) { + for (const fqn of call.mcpTools) { + const parts = fqn.split('__') + if (parts.length < 3 || parts[0] !== 'mcp') continue + const server = parts[1] + if (!server) continue + let invoked = sessionInvoked.get(server) + if (!invoked) { + invoked = new Set() + sessionInvoked.set(server, invoked) + } + invoked.add(fqn) + } + } + } + + // Invocation totals: trust mcpBreakdown which was already aggregated + // turn-by-turn, including any invocations the inventory pass missed. + for (const [server, data] of Object.entries(session.mcpBreakdown)) { + const acc = getOrInit(server) + acc.invocations += data.calls + } + + for (const [server, invoked] of sessionInvoked) { + const acc = getOrInit(server) + for (const fqn of invoked) acc.invokedTools.add(fqn) + } + + for (const server of inventoriedServers) { + getOrInit(server).loadedSessions += 1 + } + } + } + + const result: McpServerCoverage[] = [] + for (const [server, acc] of servers) { + if (acc.inventory.size === 0) continue + // Coverage is only meaningful against tools we actually observed in the + // inventory: invocations of tools never inventoried (older config, typo, + // etc.) would otherwise inflate the numerator and could even drive + // `unusedCount` negative. + const invokedInInventory = new Set() + for (const fqn of acc.invokedTools) { + if (acc.inventory.has(fqn)) invokedInInventory.add(fqn) + } + const unusedTools = Array.from(acc.inventory).filter(t => !invokedInInventory.has(t)).sort() + const toolsInvoked = acc.inventory.size - unusedTools.length + result.push({ + server, + toolsAvailable: acc.inventory.size, + toolsInvoked, + unusedTools, + invocations: acc.invocations, + loadedSessions: acc.loadedSessions, + coverageRatio: acc.inventory.size === 0 ? 0 : toolsInvoked / acc.inventory.size, + }) + } + result.sort((a, b) => b.toolsAvailable - a.toolsAvailable) + return result +} + +/** + * Cache-aware token cost estimate for the unused-tool overhead of one or + * more servers, summed across all sessions that loaded any of them. + * + * Returns three buckets: + * - `cacheWriteTokens`: schema bytes paid at full input price (each + * cache-creation event in a session that loaded one of the servers). + * - `cacheReadTokens`: schema bytes carried at the cache-read discount on + * subsequent turns (ongoing overhead). + * - `effectiveInputTokens`: equivalent fresh-input tokens, weighted by + * cache pricing. Used to estimate dollar cost downstream by multiplying + * by the project's input rate. + * + * We cap each call's contribution at the observed cache-creation / + * cache-read totals for that call: it is not meaningful to claim more MCP + * overhead than the call's own cache bucket could possibly contain. The + * cap is applied once across the combined unused-schema budget for all + * flagged servers, not per server, so two flagged servers cannot both + * independently claim the same call's cache bucket. + * + * Anthropic caches expire after roughly 5 minutes of inactivity, so a long + * session can rebuild the cache multiple times. Every call that reports + * `cacheCreationInputTokens > 0` is treated as another rebuild, not just + * the very first one. + * + * "Loaded" is defined exclusively by observed inventory: a session that + * invoked a server without ever emitting a `deferred_tools_delta` for it + * does not count, matching the invariant `aggregateMcpCoverage` uses for + * `loadedSessions`. + */ +export function estimateMcpSchemaCost( + unusedToolCount: number, + projects: ProjectSummary[], + server: string, +): McpSchemaCostEstimate +export function estimateMcpSchemaCost( + unusedToolCountsByServer: Record, + projects: ProjectSummary[], + servers: string[], +): McpSchemaCostEstimate +export function estimateMcpSchemaCost( + unusedToolCounts: Record | number, + projects: ProjectSummary[], + serverOrServers: string | string[], +): McpSchemaCostEstimate { + let servers: string[] + let counts: Record + if (typeof unusedToolCounts === 'number') { + if (typeof serverOrServers !== 'string') { + throw new TypeError('single-server MCP cost estimates require a string server name') + } + servers = [serverOrServers] + counts = { [serverOrServers]: unusedToolCounts } + } else { + if (!Array.isArray(serverOrServers)) { + throw new TypeError('multi-server MCP cost estimates require a string[] server list') + } + servers = serverOrServers + counts = unusedToolCounts + } + + const totalUnusedSchemaTokens = servers.reduce( + (s, srv) => s + (counts[srv] ?? 0) * TOKENS_PER_MCP_TOOL, + 0, + ) + if (totalUnusedSchemaTokens === 0) { + return { cacheWriteTokens: 0, cacheReadTokens: 0, effectiveInputTokens: 0 } + } + + const serverSet = new Set(servers) + let cacheWriteTokens = 0 + let cacheReadTokens = 0 + + for (const project of projects) { + for (const session of project.sessions) { + // A session counts only if its observed inventory included at least + // one of the flagged servers — same invariant `aggregateMcpCoverage` + // uses for `loadedSessions`. + let loaded = false + for (const fqn of session.mcpInventory ?? []) { + const seg = fqn.split('__')[1] + if (seg && serverSet.has(seg)) { loaded = true; break } + } + if (!loaded) continue + + for (const turn of session.turns) { + for (const call of turn.assistantCalls) { + // Both buckets can be non-zero on the same call (cache rebuild + // alongside a partial read), so account for them independently. + // The cap is applied to the combined unused-schema budget so + // multiple flagged servers cannot all claim the same call. + if (call.usage.cacheCreationInputTokens > 0) { + cacheWriteTokens += Math.min(totalUnusedSchemaTokens, call.usage.cacheCreationInputTokens) + } + if (call.usage.cacheReadInputTokens > 0) { + cacheReadTokens += Math.min(totalUnusedSchemaTokens, call.usage.cacheReadInputTokens) + } + } + } + } + } + + const effectiveInputTokens = cacheWriteTokens * CACHE_WRITE_MULTIPLIER + cacheReadTokens * CACHE_READ_DISCOUNT + return { cacheWriteTokens, cacheReadTokens, effectiveInputTokens } +} + +/** + * Find MCP servers whose tool inventory is largely unused. Replaces the + * older server-only `detectUnusedMcp` (which only flagged servers with + * literal zero invocations). + * + * A server is flagged when, taken together: + * - it exposed more than `MCP_COVERAGE_MIN_TOOLS` tools, + * - we saw it loaded in at least `MCP_COVERAGE_MIN_SESSIONS` sessions, + * - the coverage ratio is below `MCP_COVERAGE_LOW_THRESHOLD`. + * + * Token-savings estimates use the cache-aware accounting from + * `estimateMcpSchemaCost` so we don't mistake cached-prefix carry-over for + * fresh-input billing. + */ +export function detectMcpToolCoverage( + projects: ProjectSummary[], + coverage = aggregateMcpCoverage(projects), +): WasteFinding | null { + if (coverage.length === 0) return null + + const flagged = coverage.filter(c => + c.toolsAvailable > MCP_COVERAGE_MIN_TOOLS + && c.loadedSessions >= MCP_COVERAGE_MIN_SESSIONS + && c.coverageRatio < MCP_COVERAGE_LOW_THRESHOLD, + ) + if (flagged.length === 0) return null + + flagged.sort((a, b) => (b.toolsAvailable - b.toolsInvoked) - (a.toolsAvailable - a.toolsInvoked)) + + const lines: string[] = [] + const removeCommands: string[] = [] + const unusedCountsByServer: Record = {} + const flaggedServers: string[] = [] + + for (const c of flagged) { + unusedCountsByServer[c.server] = c.toolsAvailable - c.toolsInvoked + flaggedServers.push(c.server) + const pct = Math.round(c.coverageRatio * 100) + lines.push( + `${c.server}: ${c.toolsInvoked}/${c.toolsAvailable} tools used (${pct}% coverage) across ${c.loadedSessions} session${c.loadedSessions === 1 ? '' : 's'}`, + ) + removeCommands.push(`claude mcp remove '${c.server}'`) + } + + // Single combined cost pass: caps each call's contribution at the + // total unused-schema budget across all flagged servers, so two + // flagged servers cannot independently claim the same call's cache + // bucket and overstate `tokensSaved`. + const cost = estimateMcpSchemaCost(unusedCountsByServer, projects, flaggedServers) + const tokensSaved = Math.round(cost.effectiveInputTokens) + const impact: Impact = tokensSaved >= MCP_COVERAGE_HIGH_IMPACT_TOKENS + ? 'high' + : flagged.length >= UNUSED_MCP_HIGH_THRESHOLD + ? 'high' + : 'medium' + + return { + title: `${flagged.length} MCP server${flagged.length === 1 ? '' : 's'} with low tool coverage`, + explanation: + `Schema for unused tools is loaded into the system prompt every session and ` + + `carried in the cached prefix on every turn. ` + + `${lines.join('; ')}.`, + impact, + tokensSaved, + fix: { + type: 'command', + label: flagged.length === 1 + ? 'Remove the underused server, or trim its tools in your MCP config:' + : 'Remove underused servers, or trim their tools in your MCP config:', + text: removeCommands.join('\n'), + }, + } +} + export function detectUnusedMcp( calls: ToolCall[], projects: ProjectSummary[], projectCwds: Set, + mcpCoverage = aggregateMcpCoverage(projects), ): WasteFinding | null { const configured = loadMcpConfigs(projectCwds) if (configured.size === 0) return null @@ -497,10 +916,27 @@ export function detectUnusedMcp( } } + // Servers that the new coverage detector will flag fall under its + // jurisdiction (per-tool granularity, cache-aware costing) and we + // suppress them here to avoid double-flagging. Importantly, we suppress + // only the servers that actually clear the coverage detector's + // thresholds — a small, inventoried-but-uninvoked server that the + // coverage detector skips would otherwise become a blind spot. + const coverageReportedServers = new Set( + mcpCoverage + .filter(c => + c.toolsAvailable > MCP_COVERAGE_MIN_TOOLS + && c.loadedSessions >= MCP_COVERAGE_MIN_SESSIONS + && c.coverageRatio < MCP_COVERAGE_LOW_THRESHOLD, + ) + .map(c => c.server), + ) + const now = Date.now() const unused: string[] = [] for (const entry of configured.values()) { if (calledServers.has(entry.normalized)) continue + if (coverageReportedServers.has(entry.normalized)) continue if (entry.mtime > 0 && now - entry.mtime < MCP_NEW_CONFIG_GRACE_MS) continue unused.push(entry.original) } @@ -581,7 +1017,8 @@ export function detectBloatedClaudeMd(projectCwds: Set): WasteFinding | tokensSaved, fix: { type: 'paste', - label: 'Ask Claude to trim it:', + destination: 'prompt', + label: 'Ask Claude in the current session to trim it:', text: `Review CLAUDE.md and all @-imported files. Cut total expanded content to under ${CLAUDEMD_HEALTHY_LINES} lines. Remove anything Claude can figure out from the code itself. Keep only rules, gotchas, and non-obvious conventions.`, }, } @@ -628,6 +1065,7 @@ export function detectLowReadEditRatio(calls: ToolCall[]): WasteFinding | null { tokensSaved, fix: { type: 'paste', + destination: 'claude-md', label: 'Add to your CLAUDE.md:', text: 'Before editing any file, read it first. Before modifying a function, grep for all callers. Research before you edit.', }, @@ -698,7 +1136,8 @@ export function detectCacheBloat(apiCalls: ApiCallMeta[], projects: ProjectSumma tokensSaved, fix: { type: 'paste', - label: 'Check for recent Claude Code updates or heavy MCP/skill additions. As a workaround (not officially supported):', + destination: 'shell-config', + label: 'Check for recent Claude Code updates or heavy MCP/skill additions. As a workaround (not officially supported), add to ~/.zshrc or ~/.bashrc:', text: 'export ANTHROPIC_CUSTOM_HEADERS=\'User-Agent: claude-cli/2.1.98 (external, sdk-cli)\'', }, trend, @@ -847,12 +1286,388 @@ export function detectBashBloat(): WasteFinding | null { tokensSaved, fix: { type: 'paste', + destination: 'shell-config', label: 'Add to ~/.zshrc or ~/.bashrc:', text: `export BASH_MAX_OUTPUT_LENGTH=${BASH_RECOMMENDED_LIMIT}`, }, } } +function sessionTokenTotal(session: ProjectSummary['sessions'][number]): number { + return session.totalInputTokens + + session.totalOutputTokens + + session.totalCacheReadTokens + + session.totalCacheWriteTokens +} + +function sessionEffectiveContextTokens(session: ProjectSummary['sessions'][number]): number { + return session.totalInputTokens + + session.totalCacheReadTokens * CACHE_READ_DISCOUNT + + session.totalCacheWriteTokens * CACHE_WRITE_MULTIPLIER +} + +function formatContextRatio(ratio: number): string { + if (ratio >= CONTEXT_BLOAT_RATIO_DISPLAY_CAP) return `${CONTEXT_BLOAT_RATIO_DISPLAY_CAP}+` + return ratio.toFixed(1) +} + +// ============================================================================ +// Worth-it / low-worth-session detector helpers +// ============================================================================ + +// Use (\s|$|--) instead of \b after commit/push so `git commit-tree` and +// `git commit-graph` are not treated as deliveries. The `--` clause keeps +// `git commit --amend` matching as a real delivery command. +const DELIVERY_COMMAND_PATTERNS = [ + /(?:^|[;&|]\s*)git\s+(?:commit|push)(?=\s|$|--)(?![^;&|]*--dry-run)/, + /(?:^|[;&|]\s*)gh\s+pr\s+(?:create|merge)(?=\s|$|--)(?![^;&|]*--dry-run)/, +] + +function sessionDeliveryCommand(session: ProjectSummary['sessions'][number]): string | null { + const commands = Object.keys(session.bashBreakdown) + return commands.find(command => DELIVERY_COMMAND_PATTERNS.some(pattern => pattern.test(command))) ?? null +} + +function hasCategoryBreakdownData(session: ProjectSummary['sessions'][number]): boolean { + return Object.values(session.categoryBreakdown).some(category => + category.turns > 0 + || category.costUSD > 0 + || category.retries > 0 + || category.editTurns > 0 + || category.oneShotTurns > 0 + ) +} + +function sessionEditTurns(session: ProjectSummary['sessions'][number]): number { + if (hasCategoryBreakdownData(session)) { + return Object.values(session.categoryBreakdown).reduce((sum, c) => sum + c.editTurns, 0) + } + return session.turns.filter(turn => turn.hasEdits).length +} + +function sessionOneShotTurns(session: ProjectSummary['sessions'][number]): number { + if (hasCategoryBreakdownData(session)) { + return Object.values(session.categoryBreakdown).reduce((sum, c) => sum + c.oneShotTurns, 0) + } + return session.turns.filter(turn => turn.hasEdits && turn.retries === 0).length +} + +function sessionRetryCount(session: ProjectSummary['sessions'][number]): number { + if (hasCategoryBreakdownData(session)) { + return Object.values(session.categoryBreakdown).reduce((sum, c) => sum + c.retries, 0) + } + return session.turns.reduce((sum, turn) => sum + turn.retries, 0) +} + +function sessionTotalTurns(session: ProjectSummary['sessions'][number]): number { + if (hasCategoryBreakdownData(session)) { + return Object.values(session.categoryBreakdown).reduce((sum, c) => sum + c.turns, 0) + } + return session.turns.length +} + +// Token-savings estimate for a low-worth candidate. Two regimes: +// - No-edit sessions: full session tokens are at risk (the session produced +// no apparent output to weigh against the spend). +// - Sessions with edits but with retries / no one-shot: only the retry +// fraction is counted as recoverable. Edits may still have been useful; +// we credit the model with that and only flag the retry overhead. +// Ratio is bounded to [0, 1] so retry-heavy sessions with weird turn counts +// can't claim more than the full session token total. +function estimateLowWorthRecoverableTokens( + session: ProjectSummary['sessions'][number], + editTurns: number, + retries: number, +): number { + const tokens = sessionTokenTotal(session) + if (editTurns === 0) return tokens + const totalTurns = sessionTotalTurns(session) + if (totalTurns === 0) return 0 + const fraction = Math.min(1, Math.max(0, retries / totalTurns)) + return Math.round(tokens * fraction) +} + +export type LowWorthCandidate = { + project: string + sessionId: string + date: string + cost: number + tokens: number + reasons: string[] +} + +export function findLowWorthCandidates(projects: ProjectSummary[]): LowWorthCandidate[] { + const candidates: LowWorthCandidate[] = [] + + for (const project of projects) { + for (const session of project.sessions) { + if (session.totalCostUSD < WORTH_IT_MIN_COST_USD) continue + if (sessionDeliveryCommand(session)) continue + + const editTurns = sessionEditTurns(session) + const oneShotTurns = sessionOneShotTurns(session) + const retries = sessionRetryCount(session) + const reasons: string[] = [] + + if (editTurns === 0 && session.totalCostUSD >= WORTH_IT_NO_EDIT_MIN_COST_USD) { + reasons.push('no edit turns') + } + if (retries >= WORTH_IT_MIN_RETRIES) { + reasons.push(`${retries} retries`) + } + if ( + editTurns > 0 + && oneShotTurns === 0 + && retries >= WORTH_IT_RETRY_WITH_EDIT_MIN_RETRIES + ) { + reasons.push('no one-shot edit turns') + } + + if (reasons.length === 0) continue + + candidates.push({ + project: project.project, + sessionId: session.sessionId, + date: session.firstTimestamp.slice(0, 10), + cost: session.totalCostUSD, + tokens: estimateLowWorthRecoverableTokens(session, editTurns, retries), + reasons, + }) + } + } + + candidates.sort((a, b) => + b.cost - a.cost + || a.date.localeCompare(b.date) + || a.project.localeCompare(b.project) + || a.sessionId.localeCompare(b.sessionId) + ) + return candidates +} + +export function detectLowWorthSessions(projects: ProjectSummary[]): WasteFinding | null { + const candidates = findLowWorthCandidates(projects) + if (candidates.length === 0) return null + + const preview = candidates.slice(0, WORTH_IT_PREVIEW) + const list = preview + .map(s => `${s.project}/${s.sessionId} on ${s.date}: ${formatCost(s.cost)} (${s.reasons.join(', ')})`) + .join('; ') + const extra = candidates.length > preview.length ? `; +${candidates.length - preview.length} more` : '' + // Per-candidate `tokens` is already the recoverable estimate (full session + // for no-edit, retry-fraction for edit-with-retries). Sum across candidates. + const tokensSaved = Math.round(candidates.reduce((sum, s) => sum + s.tokens, 0)) + const totalCost = candidates.reduce((sum, s) => sum + s.cost, 0) + + // Three tiers consistent with detectContextBloat: high at >=10 candidates + // or >=$50 total spend at risk; low at <=2 candidates AND <$10 total; + // medium in between. + let impact: Impact + if (candidates.length >= WORTH_IT_HIGH_MIN_CANDIDATES || totalCost >= WORTH_IT_HIGH_TOTAL_COST_USD) { + impact = 'high' + } else if (candidates.length <= WORTH_IT_LOW_MAX_CANDIDATES && totalCost < WORTH_IT_LOW_MAX_TOTAL_COST_USD) { + impact = 'low' + } else { + impact = 'medium' + } + + return { + title: `${candidates.length} possibly low-worth expensive session${candidates.length === 1 ? '' : 's'}`, + explanation: `Sessions with meaningful spend but weak delivery signals: ${list}${extra}. This is a review candidate, not proof of waste: CodeBurn flags missing edit turns, repeated retries, and sessions without git delivery commands so you can decide whether the work was worth its cost before it becomes a habit.`, + impact, + tokensSaved, + fix: { + type: 'paste', + destination: 'session-opener', + label: 'Paste at the start of your NEXT expensive thread (one-time, do not add to CLAUDE.md):', + text: 'Before continuing, name the deliverable in one sentence (PR title, file changed, command output you expect). Stop and check with me if (a) you spend more than 10 minutes without an edit, or (b) the same approach fails twice. Do not retry past two attempts on any single fix.', + }, + } +} + +export type ContextBloatCandidate = { + project: string + sessionId: string + date: string + effectiveInputTokens: number + outputTokens: number + ratio: number + excessInputTokens: number + growthRatio: number | null +} + +export function findContextBloatCandidates(projects: ProjectSummary[]): ContextBloatCandidate[] { + const candidates: ContextBloatCandidate[] = [] + + for (const project of projects) { + const sessions = [...project.sessions].sort((a, b) => + new Date(a.firstTimestamp).getTime() - new Date(b.firstTimestamp).getTime() + ) + let previousInputTokens: number | null = null + let previousTimestampMs: number | null = null + + for (const session of sessions) { + const inputTokens = sessionEffectiveContextTokens(session) + const outputTokens = session.totalOutputTokens + const ratio = inputTokens / Math.max(outputTokens, 1) + const currentMs = new Date(session.firstTimestamp).getTime() + const gapMs = previousTimestampMs !== null ? currentMs - previousTimestampMs : null + // Suppress growth ratio when the previous session is too far back to be + // a meaningful baseline (e.g. a small test run weeks before a real + // working session would otherwise produce alarming "1000x" figures). + const growthRatio = previousInputTokens !== null + && previousInputTokens > 0 + && gapMs !== null + && gapMs <= CONTEXT_BLOAT_GROWTH_MAX_GAP_MS + ? inputTokens / previousInputTokens + : null + + // Anchor growth to the immediately previous project session, even if + // that session is below threshold and never becomes a finding. + previousInputTokens = inputTokens + previousTimestampMs = currentMs + + if (inputTokens < CONTEXT_BLOAT_MIN_INPUT_TOKENS) continue + if (ratio < CONTEXT_BLOAT_MIN_RATIO) continue + + candidates.push({ + project: project.project, + sessionId: session.sessionId, + date: session.firstTimestamp.slice(0, 10), + effectiveInputTokens: inputTokens, + outputTokens, + ratio, + excessInputTokens: Math.max(0, inputTokens - outputTokens * CONTEXT_BLOAT_TARGET_RATIO), + growthRatio, + }) + } + } + + candidates.sort((a, b) => + b.excessInputTokens - a.excessInputTokens + || a.date.localeCompare(b.date) + || a.project.localeCompare(b.project) + || a.sessionId.localeCompare(b.sessionId) + ) + return candidates +} + +export function detectContextBloat(projects: ProjectSummary[], excludedSessionIds?: ReadonlySet): WasteFinding | null { + const candidates = findContextBloatCandidates(projects) + .filter(c => !excludedSessionIds?.has(c.sessionId)) + if (candidates.length === 0) return null + + const preview = candidates.slice(0, CONTEXT_BLOAT_PREVIEW) + const list = preview + .map(c => { + const growth = c.growthRatio !== null && c.growthRatio >= CONTEXT_BLOAT_GROWTH_RATIO + ? `, ${c.growthRatio.toFixed(1)}x previous session input` + : '' + return `${c.project}/${c.sessionId} on ${c.date}: ${formatTokens(c.effectiveInputTokens)} effective input/cache vs ${formatTokens(c.outputTokens)} output (${formatContextRatio(c.ratio)}:1${growth})` + }) + .join('; ') + const extra = candidates.length > preview.length ? `; +${candidates.length - preview.length} more` : '' + // Savings estimate only counts context above a healthier 15:1 input-output ratio. + // Detection stays stricter at 25:1 so borderline sessions are not shown. + const tokensSaved = Math.round(candidates.reduce((sum, c) => sum + c.excessInputTokens, 0)) + const totalInputTokens = candidates.reduce((sum, c) => sum + c.effectiveInputTokens, 0) + + // Tier on candidate count first, total context size second. A single 600K + // session is "high"; 1-2 modest-sized sessions are "low"; everything in + // between is "medium". + let impact: Impact + if (candidates.length >= CONTEXT_BLOAT_HIGH_MIN_CANDIDATES || totalInputTokens >= CONTEXT_BLOAT_HIGH_INPUT_TOKENS) { + impact = 'high' + } else if (candidates.length <= CONTEXT_BLOAT_LOW_MAX_CANDIDATES && totalInputTokens < CONTEXT_BLOAT_LOW_INPUT_TOKENS) { + impact = 'low' + } else { + impact = 'medium' + } + + return { + title: `${candidates.length} context-heavy session${candidates.length === 1 ? '' : 's'}`, + explanation: `Effective input/cache tokens swamp output in these sessions: ${list}${extra}. This can come from stale context carryover, inherently context-heavy work, or abandoned runs that loaded too much context; starting fresh with only the current goal and relevant files can cut repeated prompt overhead.`, + impact, + tokensSaved, + fix: { + type: 'paste', + destination: 'session-opener', + label: 'Paste at the start of your NEXT expensive thread (one-time, do not add to CLAUDE.md):', + text: 'Start fresh before continuing. Use only the current goal, the relevant files, the failing command/output, and the constraints below. Restate the working context in under 10 bullets before editing.', + }, + } +} + +export function detectSessionOutliers(projects: ProjectSummary[], excludedSessionIds?: ReadonlySet): WasteFinding | null { + type Outlier = { + project: string + sessionId: string + date: string + cost: number + avgCost: number + ratio: number + tokenExcess: number + } + + const outliers: Outlier[] = [] + + for (const project of projects) { + const sessions = project.sessions.filter(s => s.totalCostUSD > 0) + if (sessions.length < MIN_SESSIONS_FOR_OUTLIER) continue + + const totalCost = sessions.reduce((sum, s) => sum + s.totalCostUSD, 0) + const totalTokens = sessions.reduce((sum, s) => sum + sessionTokenTotal(s), 0) + for (const session of sessions) { + const avgCost = (totalCost - session.totalCostUSD) / (sessions.length - 1) + const avgTokens = (totalTokens - sessionTokenTotal(session)) / (sessions.length - 1) + if (avgCost <= 0) continue + + const ratio = session.totalCostUSD / avgCost + if (ratio <= SESSION_OUTLIER_MULTIPLIER) continue + if (session.totalCostUSD < MIN_SESSION_OUTLIER_COST_USD) continue + // Avoid reporting the same session under both this finding and the + // context-bloat finding. Context-bloat takes priority because its + // suggested fix ("start fresh") is more concrete than the generic + // "tighter constraint" advice here. + if (excludedSessionIds?.has(session.sessionId)) continue + + outliers.push({ + project: project.project, + sessionId: session.sessionId, + date: session.firstTimestamp.slice(0, 10), + cost: session.totalCostUSD, + avgCost, + ratio, + tokenExcess: Math.max(0, sessionTokenTotal(session) - avgTokens), + }) + } + } + + if (outliers.length === 0) return null + + outliers.sort((a, b) => b.cost - a.cost) + const preview = outliers.slice(0, SESSION_OUTLIER_PREVIEW) + const list = preview + .map(o => `${o.project}/${o.sessionId} on ${o.date}: ${formatCost(o.cost)} (${o.ratio.toFixed(1)}x avg)`) + .join('; ') + const extra = outliers.length > preview.length ? `; +${outliers.length - preview.length} more` : '' + const tokensSaved = Math.round(outliers.reduce((sum, o) => sum + o.tokenExcess, 0)) + const totalExcessCost = outliers.reduce((sum, o) => sum + Math.max(0, o.cost - o.avgCost), 0) + + return { + title: `${outliers.length} high-cost session outlier${outliers.length === 1 ? '' : 's'}`, + explanation: `Sessions costing more than ${SESSION_OUTLIER_MULTIPLIER}x their peer-session average in the same project: ${list}${extra}. These usually come from broad prompts, runaway loops, or context-heavy work that should be split into smaller sessions.`, + impact: outliers.length >= 3 || totalExcessCost >= 10 ? 'high' : 'medium', + tokensSaved, + fix: { + type: 'paste', + destination: 'session-opener', + label: 'Paste at the start of your NEXT expensive thread (one-time, do not add to CLAUDE.md):', + text: 'Before making changes, summarize the smallest viable plan. Keep context narrow, avoid broad searches, and stop after the first working patch so I can review before continuing.', + }, + } +} + // ============================================================================ // Scoring // ============================================================================ @@ -965,14 +1780,29 @@ export async function scanAndDetect( const costRate = computeInputCostRate(projects) const { toolCalls, projectCwds, apiCalls, userMessages } = await scanSessions(dateRange) + const mcpCoverage = aggregateMcpCoverage(projects) const findings: WasteFinding[] = [] + // Priority order for the per-session findings: low-worth → context-bloat → + // outliers. Each later detector excludes sessions already named by an + // earlier one so a single session is not listed in three findings. + const lowWorthSessionIds = new Set(findLowWorthCandidates(projects).map(c => c.sessionId)) + const contextBloatVisibleIds = new Set( + findContextBloatCandidates(projects) + .filter(c => !lowWorthSessionIds.has(c.sessionId)) + .map(c => c.sessionId), + ) + const outlierExclusions = new Set([...lowWorthSessionIds, ...contextBloatVisibleIds]) const syncDetectors: Array<() => WasteFinding | null> = [ () => detectCacheBloat(apiCalls, projects, dateRange), () => detectLowReadEditRatio(toolCalls), () => detectJunkReads(toolCalls, dateRange), () => detectDuplicateReads(toolCalls, dateRange), - () => detectUnusedMcp(toolCalls, projects, projectCwds), + () => detectUnusedMcp(toolCalls, projects, projectCwds, mcpCoverage), + () => detectMcpToolCoverage(projects, mcpCoverage), + () => detectLowWorthSessions(projects), + () => detectContextBloat(projects, lowWorthSessionIds), + () => detectSessionOutliers(projects, outlierExclusions), () => detectBloatedClaudeMd(projectCwds), () => detectBashBloat(), ] @@ -1020,6 +1850,33 @@ function wrap(text: string, width: number, indent: string): string { return lines.join('\n') } +/// Section header for a finding's fix block, declaring its intended +/// destination. Issue #277: users were dropping one-time session openers +/// into CLAUDE.md as permanent rules because the prompts had no labeled +/// home in the output. +function renderActionHeader(action: WasteAction): string { + const headerWidth = PANEL_WIDTH - 4 + const fillTo = (label: string): string => { + const inner = ` ${label} ` + const trailing = Math.max(2, headerWidth - inner.length - 4) + return `--${inner}${SEP.repeat(trailing)}`.padEnd(headerWidth) + } + switch (action.type) { + case 'file-content': + return fillTo(`Suggested ${action.path} addition`) + case 'command': + return fillTo('Run this command') + case 'paste': + switch (action.destination) { + case 'claude-md': return fillTo('Suggested CLAUDE.md addition (permanent rule)') + case 'session-opener': return fillTo('One-time session opener (do NOT add to CLAUDE.md)') + case 'prompt': return fillTo('Ask Claude in the current session') + case 'shell-config': return fillTo('Add to your shell config') + default: return fillTo('Suggested action') + } + } +} + function renderFinding(n: number, f: WasteFinding, costRate: number): string[] { const lines: string[] = [] const costSaved = f.tokensSaved * costRate @@ -1041,16 +1898,19 @@ function renderFinding(n: number, f: WasteFinding, costRate: number): string[] { lines.push(chalk.hex(GOLD)(` Potential savings: ${savings}`)) lines.push('') + // Destination header — issue #277. Tells the user where each suggestion + // belongs (CLAUDE.md / session opener / current chat / shell config) so + // permanent rules and one-time prompts are no longer interchangeable in + // the output. const a = f.fix + lines.push(chalk.hex(ORANGE)(` ${renderActionHeader(a)}`)) + lines.push(chalk.hex(DIM)(` ${a.label}`)) if (a.type === 'file-content') { - lines.push(chalk.hex(DIM)(` ${a.label}`)) for (const line of a.content.split('\n')) lines.push(chalk.hex(CYAN)(` ${line}`)) } else if (a.type === 'command') { - lines.push(chalk.hex(DIM)(` ${a.label}`)) for (const line of a.text.split('\n')) lines.push(chalk.hex(CYAN)(` ${line}`)) } else { - lines.push(chalk.hex(DIM)(` ${a.label}`)) - lines.push(chalk.hex(CYAN)(` ${a.text}`)) + for (const line of a.text.split('\n')) lines.push(chalk.hex(CYAN)(` ${line}`)) } lines.push('') return lines diff --git a/src/parser.ts b/src/parser.ts index ab4eacd..51c70b0 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -3,6 +3,22 @@ import { basename, join } from 'path' import { readSessionLines } from './fs-utils.js' import { calculateCost, getShortModelName } from './models.js' import { discoverAllSessions, getProvider } from './providers/index.js' +import { flushCodexCache } from './codex-cache.js' +import { flushAntigravityCache } from './providers/antigravity.js' +import { isSqliteBusyError } from './sqlite.js' +import { + type CachedCall, + type CachedFile, + type CachedTurn, + type ProviderSection, + type SessionCache, + cleanupOrphanedTempFiles, + computeEnvFingerprint, + fingerprintFile, + loadCache, + reconcileFile, + saveCache, +} from './session-cache.js' import type { ParsedProviderCall } from './providers/types.js' import type { AssistantMessageContent, @@ -24,7 +40,23 @@ function unsanitizePath(dirName: string): string { return dirName.replace(/-/g, '/') } -function parseJsonlLine(line: string): JournalEntry | null { +function normalizeProjectPathKey(projectPath: string): string { + const normalized = projectPath.trim().replace(/\\/g, '/') + return (normalized.replace(/\/+$/, '') || normalized).toLowerCase() +} + +const LARGE_JSONL_LINE_BYTES = 32 * 1024 + +export function parseJsonlLine(line: string | Buffer): JournalEntry | null { + if (Buffer.isBuffer(line)) { + if (line.length > LARGE_JSONL_LINE_BYTES) return parseLargeJsonlBuffer(line) + try { + return JSON.parse(line.toString('utf-8')) as JournalEntry + } catch { + return null + } + } + if (line.length > LARGE_JSONL_LINE_BYTES) return parseLargeJsonlLine(line) try { return JSON.parse(line) as JournalEntry } catch { @@ -32,6 +64,829 @@ function parseJsonlLine(line: string): JournalEntry | null { } } +const RAW_HEAD_BYTES = 2048 + +type JsonValueBounds = { + start: number + end: number + kind: 'string' | 'object' | 'array' | 'scalar' +} + +function findJsonStringEnd(source: string, start: number, limit = source.length): number { + for (let i = start + 1; i < limit; i++) { + const ch = source.charCodeAt(i) + if (ch === 0x5c) { + i++ + continue + } + if (ch === 0x22) return i + } + return -1 +} + +function findJsonContainerEnd(source: string, start: number, open: number, close: number, limit = source.length): number { + let depth = 0 + let inString = false + for (let i = start; i < limit; i++) { + const ch = source.charCodeAt(i) + if (inString) { + if (ch === 0x5c) { + i++ + } else if (ch === 0x22) { + inString = false + } + continue + } + if (ch === 0x22) { + inString = true + } else if (ch === open) { + depth++ + } else if (ch === close) { + depth-- + if (depth === 0) return i + } + } + return -1 +} + +function findJsonValueBounds(source: string, start: number, limit = source.length): JsonValueBounds | null { + let i = start + while (i < limit && /\s/.test(source[i]!)) i++ + if (i >= limit) return null + const ch = source.charCodeAt(i) + if (ch === 0x22) { + const end = findJsonStringEnd(source, i, limit) + return end === -1 ? null : { start: i, end: end + 1, kind: 'string' } + } + if (ch === 0x7b) { + const end = findJsonContainerEnd(source, i, 0x7b, 0x7d, limit) + return end === -1 ? null : { start: i, end: end + 1, kind: 'object' } + } + if (ch === 0x5b) { + const end = findJsonContainerEnd(source, i, 0x5b, 0x5d, limit) + return end === -1 ? null : { start: i, end: end + 1, kind: 'array' } + } + let end = i + while (end < limit) { + const c = source.charCodeAt(end) + if (c === 0x2c || c === 0x7d || c === 0x5d || /\s/.test(source[end]!)) break + end++ + } + return { start: i, end, kind: 'scalar' } +} + +function findObjectFieldValue(source: string, objectStart: number, objectEnd: number, field: string): JsonValueBounds | null { + if (source.charCodeAt(objectStart) !== 0x7b) return null + let i = objectStart + 1 + while (i < objectEnd - 1) { + while (i < objectEnd && /\s/.test(source[i]!)) i++ + if (source.charCodeAt(i) === 0x2c) { + i++ + continue + } + if (source.charCodeAt(i) !== 0x22) { + i++ + continue + } + const keyEnd = findJsonStringEnd(source, i, objectEnd) + if (keyEnd === -1) return null + const key = source.slice(i + 1, keyEnd) + i = keyEnd + 1 + while (i < objectEnd && /\s/.test(source[i]!)) i++ + if (source.charCodeAt(i) !== 0x3a) continue + const value = findJsonValueBounds(source, i + 1, objectEnd) + if (!value) return null + if (key === field) return value + i = value.end + } + return null +} + +function readJsonString(source: string, bounds: JsonValueBounds | null, cap = Number.POSITIVE_INFINITY): string | undefined { + if (!bounds || bounds.kind !== 'string') return undefined + let out = '' + for (let i = bounds.start + 1; i < bounds.end - 1 && out.length < cap; i++) { + const ch = source[i]! + if (ch !== '\\') { + out += ch + continue + } + const next = source[++i] + if (!next) break + if (next === 'n') out += '\n' + else if (next === 'r') out += '\r' + else if (next === 't') out += '\t' + else if (next === 'b') out += '\b' + else if (next === 'f') out += '\f' + else if (next === 'u' && i + 4 < bounds.end) { + const hex = source.slice(i + 1, i + 5) + const code = Number.parseInt(hex, 16) + if (Number.isFinite(code)) out += String.fromCharCode(code) + i += 4 + } else { + out += next + } + } + return out +} + +function readJsonNumberField(source: string, objectBounds: JsonValueBounds | null, field: string): number | undefined { + if (!objectBounds || objectBounds.kind !== 'object') return undefined + const bounds = findObjectFieldValue(source, objectBounds.start, objectBounds.end, field) + if (!bounds) return undefined + const value = Number(source.slice(bounds.start, bounds.end)) + return Number.isFinite(value) ? value : undefined +} + +function parseLargeUsage(source: string, usageBounds: JsonValueBounds | null) { + const usage: AssistantMessageContent['usage'] = { + input_tokens: readJsonNumberField(source, usageBounds, 'input_tokens') ?? 0, + output_tokens: readJsonNumberField(source, usageBounds, 'output_tokens') ?? 0, + cache_creation_input_tokens: readJsonNumberField(source, usageBounds, 'cache_creation_input_tokens'), + cache_read_input_tokens: readJsonNumberField(source, usageBounds, 'cache_read_input_tokens'), + } + + if (usageBounds?.kind === 'object') { + const cacheCreation = findObjectFieldValue(source, usageBounds.start, usageBounds.end, 'cache_creation') + const ephemeral5m = readJsonNumberField(source, cacheCreation, 'ephemeral_5m_input_tokens') + const ephemeral1h = readJsonNumberField(source, cacheCreation, 'ephemeral_1h_input_tokens') + if (ephemeral5m !== undefined || ephemeral1h !== undefined) { + ;(usage as AssistantMessageContent['usage']).cache_creation = { + ...(ephemeral5m !== undefined ? { ephemeral_5m_input_tokens: ephemeral5m } : {}), + ...(ephemeral1h !== undefined ? { ephemeral_1h_input_tokens: ephemeral1h } : {}), + } + } + + const serverToolUse = findObjectFieldValue(source, usageBounds.start, usageBounds.end, 'server_tool_use') + const webSearch = readJsonNumberField(source, serverToolUse, 'web_search_requests') + const webFetch = readJsonNumberField(source, serverToolUse, 'web_fetch_requests') + if (webSearch !== undefined || webFetch !== undefined) { + ;(usage as AssistantMessageContent['usage']).server_tool_use = { + ...(webSearch !== undefined ? { web_search_requests: webSearch } : {}), + ...(webFetch !== undefined ? { web_fetch_requests: webFetch } : {}), + } + } + + const speed = readJsonString(source, findObjectFieldValue(source, usageBounds.start, usageBounds.end, 'speed')) + if (speed === 'standard' || speed === 'fast') usage.speed = speed + } + + return usage +} + +function extractLargeToolBlocks(source: string, contentBounds: JsonValueBounds | null): ToolUseBlock[] { + if (!contentBounds || contentBounds.kind !== 'array') return [] + const tools: ToolUseBlock[] = [] + let i = contentBounds.start + 1 + while (i < contentBounds.end - 1 && tools.length < MAX_TOOL_BLOCKS) { + while (i < contentBounds.end && /\s/.test(source[i]!)) i++ + if (source.charCodeAt(i) === 0x2c) { + i++ + continue + } + if (source.charCodeAt(i) !== 0x7b) { + i++ + continue + } + const objectEnd = findJsonContainerEnd(source, i, 0x7b, 0x7d, contentBounds.end) + if (objectEnd === -1) break + const objectBounds = { start: i, end: objectEnd + 1, kind: 'object' as const } + const blockType = readJsonString(source, findObjectFieldValue(source, objectBounds.start, objectBounds.end, 'type')) + if (blockType === 'tool_use') { + const name = readJsonString(source, findObjectFieldValue(source, objectBounds.start, objectBounds.end, 'name')) ?? '' + const id = readJsonString(source, findObjectFieldValue(source, objectBounds.start, objectBounds.end, 'id')) ?? '' + const inputBounds = findObjectFieldValue(source, objectBounds.start, objectBounds.end, 'input') + const input: Record = {} + if (inputBounds?.kind === 'object') { + if (name === 'Skill') { + const skill = readJsonString(source, findObjectFieldValue(source, inputBounds.start, inputBounds.end, 'skill'), 200) + const skillName = readJsonString(source, findObjectFieldValue(source, inputBounds.start, inputBounds.end, 'name'), 200) + if (skill !== undefined) input['skill'] = skill + if (skillName !== undefined) input['name'] = skillName + } else if (name === 'Read' || name === 'FileReadTool') { + const filePath = readJsonString(source, findObjectFieldValue(source, inputBounds.start, inputBounds.end, 'file_path'), BASH_COMMAND_CAP) + if (filePath !== undefined) input['file_path'] = filePath + } else if (name === 'Agent' || name === 'Task') { + const subagentType = readJsonString(source, findObjectFieldValue(source, inputBounds.start, inputBounds.end, 'subagent_type'), 200) + if (subagentType !== undefined) input['subagent_type'] = subagentType + } else if (BASH_TOOLS.has(name)) { + const command = readJsonString(source, findObjectFieldValue(source, inputBounds.start, inputBounds.end, 'command'), BASH_COMMAND_CAP) + if (command !== undefined) input['command'] = command + } + } + tools.push({ type: 'tool_use', id, name, input }) + } + i = objectEnd + 1 + } + return tools +} + +function extractLargeUserText(source: string, contentBounds: JsonValueBounds | null): string | undefined { + if (!contentBounds) return undefined + if (contentBounds.kind === 'string') return readJsonString(source, contentBounds, USER_TEXT_CAP) + if (contentBounds.kind !== 'array') return undefined + + let text = '' + let i = contentBounds.start + 1 + while (i < contentBounds.end - 1 && text.length < USER_TEXT_CAP) { + while (i < contentBounds.end && /\s/.test(source[i]!)) i++ + if (source.charCodeAt(i) === 0x2c) { + i++ + continue + } + if (source.charCodeAt(i) !== 0x7b) { + i++ + continue + } + const objectEnd = findJsonContainerEnd(source, i, 0x7b, 0x7d, contentBounds.end) + if (objectEnd === -1) break + const objectBounds = { start: i, end: objectEnd + 1, kind: 'object' as const } + const type = readJsonString(source, findObjectFieldValue(source, objectBounds.start, objectBounds.end, 'type')) + if (type === 'text' || type === 'input_text') { + const part = readJsonString( + source, + findObjectFieldValue(source, objectBounds.start, objectBounds.end, 'text'), + USER_TEXT_CAP - text.length, + ) + if (part) text += (text ? ' ' : '') + part + } + i = objectEnd + 1 + } + return text || undefined +} + +function extractLargeAddedNames(source: string, attachmentBounds: JsonValueBounds | null): string[] { + if (!attachmentBounds || attachmentBounds.kind !== 'object') return [] + const attachmentType = readJsonString(source, findObjectFieldValue(source, attachmentBounds.start, attachmentBounds.end, 'type')) + if (attachmentType !== 'deferred_tools_delta') return [] + const addedNames = findObjectFieldValue(source, attachmentBounds.start, attachmentBounds.end, 'addedNames') + if (!addedNames || addedNames.kind !== 'array') return [] + const names: string[] = [] + let i = addedNames.start + 1 + while (i < addedNames.end - 1 && names.length < MAX_ADDED_NAMES) { + while (i < addedNames.end && /\s/.test(source[i]!)) i++ + if (source.charCodeAt(i) === 0x2c) { + i++ + continue + } + if (source.charCodeAt(i) !== 0x22) { + i++ + continue + } + const end = findJsonStringEnd(source, i, addedNames.end) + if (end === -1) break + const name = readJsonString(source, { start: i, end: end + 1, kind: 'string' }, 500) + if (name) names.push(name) + i = end + 1 + } + return names +} + +function parseLargeJsonlLine(line: string): JournalEntry | null { + const rootEnd = findJsonContainerEnd(line, 0, 0x7b, 0x7d) + if (rootEnd === -1) return null + const rootStart = 0 + const rootLimit = rootEnd + 1 + const type = readJsonString(line, findObjectFieldValue(line, rootStart, rootLimit, 'type')) + if (!type) return null + + const entry: JournalEntry = { type } + const timestamp = readJsonString(line, findObjectFieldValue(line, rootStart, rootLimit, 'timestamp')) + const sessionId = readJsonString(line, findObjectFieldValue(line, rootStart, rootLimit, 'sessionId')) + const cwd = readJsonString(line, findObjectFieldValue(line, rootStart, rootLimit, 'cwd')) + if (timestamp !== undefined) entry.timestamp = timestamp + if (sessionId !== undefined) entry.sessionId = sessionId + if (cwd !== undefined) entry.cwd = cwd + const addedNames = extractLargeAddedNames(line, findObjectFieldValue(line, rootStart, rootLimit, 'attachment')) + if (addedNames.length > 0) { + ;(entry as Record)['attachment'] = { type: 'deferred_tools_delta', addedNames } + } + + if (type === 'user') { + const message = findObjectFieldValue(line, rootStart, rootLimit, 'message') + if (message?.kind === 'object') { + const content = findObjectFieldValue(line, message.start, message.end, 'content') + const text = extractLargeUserText(line, content) + if (text !== undefined) entry.message = { role: 'user', content: text } + } + return entry + } + + if (type !== 'assistant') return entry + const message = findObjectFieldValue(line, rootStart, rootLimit, 'message') + if (message?.kind !== 'object') return entry + const model = readJsonString(line, findObjectFieldValue(line, message.start, message.end, 'model')) + const usageBounds = findObjectFieldValue(line, message.start, message.end, 'usage') + if (!model || usageBounds?.kind !== 'object') return entry + const id = readJsonString(line, findObjectFieldValue(line, message.start, message.end, 'id')) + const contentBounds = findObjectFieldValue(line, message.start, message.end, 'content') + + entry.message = { + type: 'message', + role: 'assistant', + model, + ...(id !== undefined ? { id } : {}), + content: extractLargeToolBlocks(line, contentBounds), + usage: parseLargeUsage(line, usageBounds), + } + + return entry +} + +type BufferJsonValueBounds = { + start: number + end: number + kind: 'string' | 'object' | 'array' | 'scalar' +} + +function isJsonWhitespaceByte(ch: number | undefined): boolean { + return ch === 0x20 || ch === 0x0a || ch === 0x0d || ch === 0x09 +} + +function findJsonStringEndBuffer(source: Buffer, start: number, limit = source.length): number { + for (let i = start + 1; i < limit; i++) { + const ch = source[i] + if (ch === 0x5c) { + i++ + continue + } + if (ch === 0x22) return i + } + return -1 +} + +function findJsonContainerEndBuffer(source: Buffer, start: number, open: number, close: number, limit = source.length): number { + let depth = 0 + let inString = false + for (let i = start; i < limit; i++) { + const ch = source[i] + if (inString) { + if (ch === 0x5c) { + i++ + } else if (ch === 0x22) { + inString = false + } + continue + } + if (ch === 0x22) { + inString = true + } else if (ch === open) { + depth++ + } else if (ch === close) { + depth-- + if (depth === 0) return i + } + } + return -1 +} + +function findJsonValueBoundsBuffer(source: Buffer, start: number, limit = source.length): BufferJsonValueBounds | null { + let i = start + while (i < limit && isJsonWhitespaceByte(source[i])) i++ + if (i >= limit) return null + const ch = source[i] + if (ch === 0x22) { + const end = findJsonStringEndBuffer(source, i, limit) + return end === -1 ? null : { start: i, end: end + 1, kind: 'string' } + } + if (ch === 0x7b) { + const end = findJsonContainerEndBuffer(source, i, 0x7b, 0x7d, limit) + return end === -1 ? null : { start: i, end: end + 1, kind: 'object' } + } + if (ch === 0x5b) { + const end = findJsonContainerEndBuffer(source, i, 0x5b, 0x5d, limit) + return end === -1 ? null : { start: i, end: end + 1, kind: 'array' } + } + let end = i + while (end < limit) { + const c = source[end] + if (c === 0x2c || c === 0x7d || c === 0x5d || isJsonWhitespaceByte(c)) break + end++ + } + return { start: i, end, kind: 'scalar' } +} + +function bufferKeyEquals(source: Buffer, keyStart: number, keyEnd: number, field: string): boolean { + if (keyEnd - keyStart !== field.length) return false + return source.subarray(keyStart, keyEnd).equals(Buffer.from(field)) +} + +function findObjectFieldValueBuffer(source: Buffer, objectStart: number, objectEnd: number, field: string): BufferJsonValueBounds | null { + if (source[objectStart] !== 0x7b) return null + let i = objectStart + 1 + while (i < objectEnd - 1) { + while (i < objectEnd && isJsonWhitespaceByte(source[i])) i++ + if (source[i] === 0x2c) { + i++ + continue + } + if (source[i] !== 0x22) { + i++ + continue + } + const keyEnd = findJsonStringEndBuffer(source, i, objectEnd) + if (keyEnd === -1) return null + const keyStart = i + 1 + i = keyEnd + 1 + while (i < objectEnd && isJsonWhitespaceByte(source[i])) i++ + if (source[i] !== 0x3a) continue + const value = findJsonValueBoundsBuffer(source, i + 1, objectEnd) + if (!value) return null + if (bufferKeyEquals(source, keyStart, keyEnd, field)) return value + i = value.end + } + return null +} + +function appendBufferJsonSegment(source: Buffer, start: number, end: number, current: string, cap: number): string { + if (start >= end || current.length >= cap) return current + const remaining = cap - current.length + const cappedEnd = Number.isFinite(cap) ? Math.min(end, start + remaining * 4) : end + return current + source.subarray(start, cappedEnd).toString('utf-8').slice(0, remaining) +} + +function readJsonStringBuffer(source: Buffer, bounds: BufferJsonValueBounds | null, cap = Number.POSITIVE_INFINITY): string | undefined { + if (!bounds || bounds.kind !== 'string') return undefined + let out = '' + let segmentStart = bounds.start + 1 + for (let i = bounds.start + 1; i < bounds.end - 1 && out.length < cap; i++) { + const ch = source[i] + if (ch !== 0x5c) continue + + out = appendBufferJsonSegment(source, segmentStart, i, out, cap) + if (out.length >= cap) break + const next = source[++i] + if (next === undefined) break + if (next === 0x6e) out += '\n' + else if (next === 0x72) out += '\r' + else if (next === 0x74) out += '\t' + else if (next === 0x62) out += '\b' + else if (next === 0x66) out += '\f' + else if (next === 0x75 && i + 4 < bounds.end) { + const hex = source.subarray(i + 1, i + 5).toString('ascii') + const code = Number.parseInt(hex, 16) + if (Number.isFinite(code)) out += String.fromCharCode(code) + i += 4 + } else { + out += String.fromCharCode(next) + } + segmentStart = i + 1 + } + return appendBufferJsonSegment(source, segmentStart, bounds.end - 1, out, cap) +} + +function readJsonNumberFieldBuffer(source: Buffer, objectBounds: BufferJsonValueBounds | null, field: string): number | undefined { + if (!objectBounds || objectBounds.kind !== 'object') return undefined + const bounds = findObjectFieldValueBuffer(source, objectBounds.start, objectBounds.end, field) + if (!bounds) return undefined + const value = Number(source.subarray(bounds.start, bounds.end).toString('ascii')) + return Number.isFinite(value) ? value : undefined +} + +function parseLargeUsageBuffer(source: Buffer, usageBounds: BufferJsonValueBounds | null) { + const usage: AssistantMessageContent['usage'] = { + input_tokens: readJsonNumberFieldBuffer(source, usageBounds, 'input_tokens') ?? 0, + output_tokens: readJsonNumberFieldBuffer(source, usageBounds, 'output_tokens') ?? 0, + cache_creation_input_tokens: readJsonNumberFieldBuffer(source, usageBounds, 'cache_creation_input_tokens'), + cache_read_input_tokens: readJsonNumberFieldBuffer(source, usageBounds, 'cache_read_input_tokens'), + } + + if (usageBounds?.kind === 'object') { + const cacheCreation = findObjectFieldValueBuffer(source, usageBounds.start, usageBounds.end, 'cache_creation') + const ephemeral5m = readJsonNumberFieldBuffer(source, cacheCreation, 'ephemeral_5m_input_tokens') + const ephemeral1h = readJsonNumberFieldBuffer(source, cacheCreation, 'ephemeral_1h_input_tokens') + if (ephemeral5m !== undefined || ephemeral1h !== undefined) { + ;(usage as AssistantMessageContent['usage']).cache_creation = { + ...(ephemeral5m !== undefined ? { ephemeral_5m_input_tokens: ephemeral5m } : {}), + ...(ephemeral1h !== undefined ? { ephemeral_1h_input_tokens: ephemeral1h } : {}), + } + } + + const serverToolUse = findObjectFieldValueBuffer(source, usageBounds.start, usageBounds.end, 'server_tool_use') + const webSearch = readJsonNumberFieldBuffer(source, serverToolUse, 'web_search_requests') + const webFetch = readJsonNumberFieldBuffer(source, serverToolUse, 'web_fetch_requests') + if (webSearch !== undefined || webFetch !== undefined) { + ;(usage as AssistantMessageContent['usage']).server_tool_use = { + ...(webSearch !== undefined ? { web_search_requests: webSearch } : {}), + ...(webFetch !== undefined ? { web_fetch_requests: webFetch } : {}), + } + } + + const speed = readJsonStringBuffer(source, findObjectFieldValueBuffer(source, usageBounds.start, usageBounds.end, 'speed')) + if (speed === 'standard' || speed === 'fast') usage.speed = speed + } + + return usage +} + +function extractLargeToolBlocksBuffer(source: Buffer, contentBounds: BufferJsonValueBounds | null): ToolUseBlock[] { + if (!contentBounds || contentBounds.kind !== 'array') return [] + const tools: ToolUseBlock[] = [] + let i = contentBounds.start + 1 + while (i < contentBounds.end - 1 && tools.length < MAX_TOOL_BLOCKS) { + while (i < contentBounds.end && isJsonWhitespaceByte(source[i])) i++ + if (source[i] === 0x2c) { + i++ + continue + } + if (source[i] !== 0x7b) { + i++ + continue + } + const objectEnd = findJsonContainerEndBuffer(source, i, 0x7b, 0x7d, contentBounds.end) + if (objectEnd === -1) break + const objectBounds = { start: i, end: objectEnd + 1, kind: 'object' as const } + const blockType = readJsonStringBuffer(source, findObjectFieldValueBuffer(source, objectBounds.start, objectBounds.end, 'type')) + if (blockType === 'tool_use') { + const name = readJsonStringBuffer(source, findObjectFieldValueBuffer(source, objectBounds.start, objectBounds.end, 'name')) ?? '' + const id = readJsonStringBuffer(source, findObjectFieldValueBuffer(source, objectBounds.start, objectBounds.end, 'id')) ?? '' + const inputBounds = findObjectFieldValueBuffer(source, objectBounds.start, objectBounds.end, 'input') + const input: Record = {} + if (inputBounds?.kind === 'object') { + if (name === 'Skill') { + const skill = readJsonStringBuffer(source, findObjectFieldValueBuffer(source, inputBounds.start, inputBounds.end, 'skill'), 200) + const skillName = readJsonStringBuffer(source, findObjectFieldValueBuffer(source, inputBounds.start, inputBounds.end, 'name'), 200) + if (skill !== undefined) input['skill'] = skill + if (skillName !== undefined) input['name'] = skillName + } else if (name === 'Read' || name === 'FileReadTool') { + const filePath = readJsonStringBuffer(source, findObjectFieldValueBuffer(source, inputBounds.start, inputBounds.end, 'file_path'), BASH_COMMAND_CAP) + if (filePath !== undefined) input['file_path'] = filePath + } else if (name === 'Agent' || name === 'Task') { + const subagentType = readJsonStringBuffer(source, findObjectFieldValueBuffer(source, inputBounds.start, inputBounds.end, 'subagent_type'), 200) + if (subagentType !== undefined) input['subagent_type'] = subagentType + } else if (BASH_TOOLS.has(name)) { + const command = readJsonStringBuffer(source, findObjectFieldValueBuffer(source, inputBounds.start, inputBounds.end, 'command'), BASH_COMMAND_CAP) + if (command !== undefined) input['command'] = command + } + } + tools.push({ type: 'tool_use', id, name, input }) + } + i = objectEnd + 1 + } + return tools +} + +function extractLargeUserTextBuffer(source: Buffer, contentBounds: BufferJsonValueBounds | null): string | undefined { + if (!contentBounds) return undefined + if (contentBounds.kind === 'string') return readJsonStringBuffer(source, contentBounds, USER_TEXT_CAP) + if (contentBounds.kind !== 'array') return undefined + + let text = '' + let i = contentBounds.start + 1 + while (i < contentBounds.end - 1 && text.length < USER_TEXT_CAP) { + while (i < contentBounds.end && isJsonWhitespaceByte(source[i])) i++ + if (source[i] === 0x2c) { + i++ + continue + } + if (source[i] !== 0x7b) { + i++ + continue + } + const objectEnd = findJsonContainerEndBuffer(source, i, 0x7b, 0x7d, contentBounds.end) + if (objectEnd === -1) break + const objectBounds = { start: i, end: objectEnd + 1, kind: 'object' as const } + const type = readJsonStringBuffer(source, findObjectFieldValueBuffer(source, objectBounds.start, objectBounds.end, 'type')) + if (type === 'text' || type === 'input_text') { + const part = readJsonStringBuffer( + source, + findObjectFieldValueBuffer(source, objectBounds.start, objectBounds.end, 'text'), + USER_TEXT_CAP - text.length, + ) + if (part) text += (text ? ' ' : '') + part + } + i = objectEnd + 1 + } + return text || undefined +} + +function extractLargeAddedNamesBuffer(source: Buffer, attachmentBounds: BufferJsonValueBounds | null): string[] { + if (!attachmentBounds || attachmentBounds.kind !== 'object') return [] + const attachmentType = readJsonStringBuffer( + source, + findObjectFieldValueBuffer(source, attachmentBounds.start, attachmentBounds.end, 'type'), + ) + if (attachmentType !== 'deferred_tools_delta') return [] + const addedNames = findObjectFieldValueBuffer(source, attachmentBounds.start, attachmentBounds.end, 'addedNames') + if (!addedNames || addedNames.kind !== 'array') return [] + const names: string[] = [] + let i = addedNames.start + 1 + while (i < addedNames.end - 1 && names.length < MAX_ADDED_NAMES) { + while (i < addedNames.end && isJsonWhitespaceByte(source[i])) i++ + if (source[i] === 0x2c) { + i++ + continue + } + if (source[i] !== 0x22) { + i++ + continue + } + const end = findJsonStringEndBuffer(source, i, addedNames.end) + if (end === -1) break + const name = readJsonStringBuffer(source, { start: i, end: end + 1, kind: 'string' }, 500) + if (name) names.push(name) + i = end + 1 + } + return names +} + +function parseLargeJsonlBuffer(line: Buffer): JournalEntry | null { + let rootStart = 0 + while (rootStart < line.length && isJsonWhitespaceByte(line[rootStart])) rootStart++ + if (line[rootStart] !== 0x7b) return null + const rootEnd = findJsonContainerEndBuffer(line, rootStart, 0x7b, 0x7d) + if (rootEnd === -1) return null + const rootLimit = rootEnd + 1 + const type = readJsonStringBuffer(line, findObjectFieldValueBuffer(line, rootStart, rootLimit, 'type')) + if (!type) return null + + const entry: JournalEntry = { type } + const timestamp = readJsonStringBuffer(line, findObjectFieldValueBuffer(line, rootStart, rootLimit, 'timestamp')) + const sessionId = readJsonStringBuffer(line, findObjectFieldValueBuffer(line, rootStart, rootLimit, 'sessionId')) + const cwd = readJsonStringBuffer(line, findObjectFieldValueBuffer(line, rootStart, rootLimit, 'cwd')) + if (timestamp !== undefined) entry.timestamp = timestamp + if (sessionId !== undefined) entry.sessionId = sessionId + if (cwd !== undefined) entry.cwd = cwd + const addedNames = extractLargeAddedNamesBuffer(line, findObjectFieldValueBuffer(line, rootStart, rootLimit, 'attachment')) + if (addedNames.length > 0) { + ;(entry as Record)['attachment'] = { type: 'deferred_tools_delta', addedNames } + } + + if (type === 'user') { + const message = findObjectFieldValueBuffer(line, rootStart, rootLimit, 'message') + if (message?.kind === 'object') { + const content = findObjectFieldValueBuffer(line, message.start, message.end, 'content') + const text = extractLargeUserTextBuffer(line, content) + if (text !== undefined) entry.message = { role: 'user', content: text } + } + return entry + } + + if (type !== 'assistant') return entry + const message = findObjectFieldValueBuffer(line, rootStart, rootLimit, 'message') + if (message?.kind !== 'object') return entry + const model = readJsonStringBuffer(line, findObjectFieldValueBuffer(line, message.start, message.end, 'model')) + const usageBounds = findObjectFieldValueBuffer(line, message.start, message.end, 'usage') + if (!model || usageBounds?.kind !== 'object') return entry + const id = readJsonStringBuffer(line, findObjectFieldValueBuffer(line, message.start, message.end, 'id')) + const contentBounds = findObjectFieldValueBuffer(line, message.start, message.end, 'content') + + entry.message = { + type: 'message', + role: 'assistant', + model, + ...(id !== undefined ? { id } : {}), + content: extractLargeToolBlocksBuffer(line, contentBounds), + usage: parseLargeUsageBuffer(line, usageBounds), + } + + return entry +} + +function getTopLevelRawJsonStringField(head: string, field: string): string | null { + let i = 0 + while (i < head.length && /\s/.test(head[i]!)) i++ + if (head.charCodeAt(i) !== 0x7b) return null + i++ + while (i < head.length) { + while (i < head.length && /\s/.test(head[i]!)) i++ + if (head.charCodeAt(i) === 0x2c) { + i++ + continue + } + if (head.charCodeAt(i) === 0x7d) return null + if (head.charCodeAt(i) !== 0x22) return null + const keyEnd = findJsonStringEnd(head, i) + if (keyEnd === -1) return null + const key = head.slice(i + 1, keyEnd) + i = keyEnd + 1 + while (i < head.length && /\s/.test(head[i]!)) i++ + if (head.charCodeAt(i) !== 0x3a) return null + const value = findJsonValueBounds(head, i + 1) + if (!value) return null + if (key === field) return readJsonString(head, value) ?? null + i = value.end + } + return null +} + +export function shouldSkipLine(line: string, threshold: string): boolean { + const head = line.length > RAW_HEAD_BYTES ? line.slice(0, RAW_HEAD_BYTES) : line + const type = getTopLevelRawJsonStringField(head, 'type') + if (type !== 'user' && type !== 'assistant') return false + const ts = getTopLevelRawJsonStringField(head, 'timestamp') + if (!ts || ts.length < 10) return false + return ts < threshold +} + +const USER_TEXT_CAP = 2000 +const BASH_COMMAND_CAP = 2000 +const MAX_TOOL_BLOCKS = 500 +const MAX_ADDED_NAMES = 1000 + +export function compactEntry(raw: JournalEntry): JournalEntry { + const entry: JournalEntry = { type: raw.type } + + if (raw.timestamp !== undefined) entry.timestamp = raw.timestamp + if (raw.sessionId !== undefined) entry.sessionId = raw.sessionId + if (raw.cwd !== undefined) entry.cwd = raw.cwd + + const att = (raw as Record)['attachment'] + if (att && typeof att === 'object') { + const a = att as Record + if (a['type'] === 'deferred_tools_delta' && Array.isArray(a['addedNames'])) { + const names: string[] = [] + for (let i = 0; i < Math.min(a['addedNames'].length, MAX_ADDED_NAMES); i++) { + const n = a['addedNames'][i] + if (typeof n === 'string') names.push(n) + } + ;(entry as Record)['attachment'] = { type: 'deferred_tools_delta', addedNames: names } + } + } + + if (!raw.message) return entry + + if (raw.message.role === 'user') { + const content = raw.message.content + if (typeof content === 'string') { + entry.message = { role: 'user', content: content.slice(0, USER_TEXT_CAP) } + } else if (Array.isArray(content)) { + let remaining = USER_TEXT_CAP + const blocks: { type: 'text'; text: string }[] = [] + for (const b of content) { + if (remaining <= 0) break + if (!b || typeof b !== 'object' || b.type !== 'text') continue + const text = (b as { text?: unknown }).text + if (typeof text !== 'string') continue + const sliced = text.slice(0, remaining) + blocks.push({ type: 'text', text: sliced }) + remaining -= sliced.length + } + entry.message = { role: 'user', content: blocks } + } + return entry + } + + const msg = raw.message as AssistantMessageContent + if (!msg.usage || !msg.model) return entry + + const rawContent = msg.content + const contentArr = Array.isArray(rawContent) ? rawContent : [] + const toolBlocks = contentArr.filter((b): b is ToolUseBlock => b != null && typeof b === 'object' && b.type === 'tool_use') + const compactContent: ContentBlock[] = toolBlocks.slice(0, MAX_TOOL_BLOCKS).map(tb => { + let input: Record = {} + if (tb.name === 'Skill') { + const ri = (tb.input ?? {}) as Record + if (typeof ri['skill'] === 'string') input['skill'] = (ri['skill'] as string).slice(0, 200) + if (typeof ri['name'] === 'string') input['name'] = (ri['name'] as string).slice(0, 200) + } else if (tb.name === 'Read' || tb.name === 'FileReadTool') { + const ri = (tb.input ?? {}) as Record + if (typeof ri['file_path'] === 'string') input['file_path'] = (ri['file_path'] as string).slice(0, BASH_COMMAND_CAP) + } else if (tb.name === 'Agent' || tb.name === 'Task') { + const ri = (tb.input ?? {}) as Record + if (typeof ri['subagent_type'] === 'string') input['subagent_type'] = (ri['subagent_type'] as string).slice(0, 200) + } else if (BASH_TOOLS.has(tb.name)) { + const ri = (tb.input ?? {}) as Record + if (typeof ri['command'] === 'string') { + input['command'] = (ri['command'] as string).slice(0, BASH_COMMAND_CAP) + } + } + return { type: 'tool_use' as const, id: tb.id ?? '', name: tb.name, input } + }) + + const u = msg.usage + const compactUsage: AssistantMessageContent['usage'] = { + input_tokens: u.input_tokens, + output_tokens: u.output_tokens, + } + if (u.cache_creation_input_tokens) compactUsage.cache_creation_input_tokens = u.cache_creation_input_tokens + if (u.cache_creation) { + compactUsage.cache_creation = { + ...(u.cache_creation.ephemeral_5m_input_tokens ? { ephemeral_5m_input_tokens: u.cache_creation.ephemeral_5m_input_tokens } : {}), + ...(u.cache_creation.ephemeral_1h_input_tokens ? { ephemeral_1h_input_tokens: u.cache_creation.ephemeral_1h_input_tokens } : {}), + } + } + if (u.cache_read_input_tokens) compactUsage.cache_read_input_tokens = u.cache_read_input_tokens + if (u.server_tool_use) { + compactUsage.server_tool_use = { + ...(u.server_tool_use.web_search_requests ? { web_search_requests: u.server_tool_use.web_search_requests } : {}), + ...(u.server_tool_use.web_fetch_requests ? { web_fetch_requests: u.server_tool_use.web_fetch_requests } : {}), + } + } + if (u.speed) compactUsage.speed = u.speed + + entry.message = { + type: 'message', + role: 'assistant', + model: msg.model, + usage: compactUsage, + content: compactContent, + ...(msg.id ? { id: msg.id } : {}), + } + + return entry +} + function extractToolNames(content: ContentBlock[]): string[] { return content .filter((b): b is ToolUseBlock => b.type === 'tool_use') @@ -42,6 +897,17 @@ function extractMcpTools(tools: string[]): string[] { return tools.filter(t => t.startsWith('mcp__')) } +function extractSkillNames(content: ContentBlock[]): string[] { + return content + .filter((b): b is ToolUseBlock => b.type === 'tool_use' && b.name === 'Skill') + .map(b => { + const input = (b.input ?? {}) as Record + const raw = input['skill'] ?? input['name'] + return typeof raw === 'string' ? raw.trim() : '' + }) + .filter(name => name.length > 0) +} + function extractCoreTools(tools: string[]): string[] { return tools.filter(t => !t.startsWith('mcp__')) } @@ -74,16 +940,39 @@ function getMessageId(entry: JournalEntry): string | null { return msg?.id ?? null } +function positiveNumber(n: number | undefined): number { + return n !== undefined && Number.isFinite(n) && n > 0 ? n : 0 +} + +function extractClaudeCacheCreation(usage: AssistantMessageContent['usage']): { totalTokens: number; oneHourTokens: number } { + const legacyTotal = positiveNumber(usage.cache_creation_input_tokens) + const cacheCreation = usage.cache_creation + const fiveMinuteTokens = positiveNumber(cacheCreation?.ephemeral_5m_input_tokens) + const oneHourTokens = positiveNumber(cacheCreation?.ephemeral_1h_input_tokens) + const splitTotal = fiveMinuteTokens + oneHourTokens + + if (splitTotal === 0) return { totalTokens: legacyTotal, oneHourTokens: 0 } + + // Valid Claude usage reports the legacy total and split total as equal. + // Keep the larger value so malformed partial splits do not drop tokens. + const totalTokens = Math.max(legacyTotal, splitTotal) + return { + totalTokens, + oneHourTokens: Math.min(oneHourTokens, totalTokens), + } +} + function parseApiCall(entry: JournalEntry): ParsedApiCall | null { if (entry.type !== 'assistant') return null const msg = entry.message as AssistantMessageContent | undefined if (!msg?.usage || !msg?.model) return null const usage = msg.usage + const cacheCreation = extractClaudeCacheCreation(usage) const tokens: TokenUsage = { inputTokens: usage.input_tokens ?? 0, outputTokens: usage.output_tokens ?? 0, - cacheCreationInputTokens: usage.cache_creation_input_tokens ?? 0, + cacheCreationInputTokens: cacheCreation.totalTokens, cacheReadInputTokens: usage.cache_read_input_tokens ?? 0, cachedInputTokens: 0, reasoningTokens: 0, @@ -91,6 +980,7 @@ function parseApiCall(entry: JournalEntry): ParsedApiCall | null { } const tools = extractToolNames(msg.content ?? []) + const skills = extractSkillNames(msg.content ?? []) const costUSD = calculateCost( msg.model, tokens.inputTokens, @@ -99,6 +989,7 @@ function parseApiCall(entry: JournalEntry): ParsedApiCall | null { tokens.cacheReadInputTokens, tokens.webSearchRequests, usage.speed ?? 'standard', + cacheCreation.oneHourTokens, ) const bashCmds = extractBashCommandsFromContent(msg.content ?? []) @@ -110,15 +1001,41 @@ function parseApiCall(entry: JournalEntry): ParsedApiCall | null { costUSD, tools, mcpTools: extractMcpTools(tools), + skills, hasAgentSpawn: tools.includes('Agent'), hasPlanMode: tools.includes('EnterPlanMode'), speed: usage.speed ?? 'standard', timestamp: entry.timestamp ?? '', bashCommands: bashCmds, deduplicationKey: msg.id ?? `claude:${entry.timestamp}`, + cacheCreationOneHourTokens: cacheCreation.oneHourTokens || undefined, } } +function dedupeStreamingMessageIds(entries: JournalEntry[]): JournalEntry[] { + const firstIdxById = new Map() + const lastIdxById = new Map() + for (let i = 0; i < entries.length; i++) { + const id = getMessageId(entries[i]!) + if (!id) continue + if (!firstIdxById.has(id)) firstIdxById.set(id, i) + lastIdxById.set(id, i) + } + if (lastIdxById.size === 0) return entries + const result: JournalEntry[] = [] + for (let i = 0; i < entries.length; i++) { + const id = getMessageId(entries[i]!) + if (id && lastIdxById.get(id) !== i) continue + if (id && firstIdxById.get(id) !== i) { + const firstTs = entries[firstIdxById.get(id)!]!.timestamp + result.push({ ...entries[i]!, timestamp: firstTs ?? entries[i]!.timestamp }) + continue + } + result.push(entries[i]!) + } + return result +} + function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set): ParsedTurn[] { const turns: ParsedTurn[] = [] let currentUserMessage = '' @@ -164,16 +1081,70 @@ function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set): Parse return turns } +/** + * Extract MCP tool inventory observed across a session's JSONL entries. + * + * Claude Code emits `attachment.type === "deferred_tools_delta"` entries whose + * `addedNames` array lists every tool currently available at that turn (built-in + * tools plus all `mcp____` names exposed by configured MCP + * servers). Tool inventory can change mid-session if the user reloads MCP + * config, so we union every occurrence rather than trusting only the first. + * + * Built-in tools are filtered out: only `mcp__*` identifiers survive. + */ +// Fully-qualified MCP tool name shape: `mcp____`. Both server +// and tool segments must be non-empty. Names like `mcp__server` (no tool +// segment) or `mcp__server__` (trailing empty tool) would silently pollute +// the inventory and break downstream `split('__')` consumers, so they're +// rejected here. +function isMcpToolName(name: string): boolean { + if (!name.startsWith('mcp__')) return false + const rest = name.slice(5) // strip `mcp__` + const sep = rest.indexOf('__') + if (sep <= 0) return false // missing or empty server + if (sep >= rest.length - 2) return false // missing or empty tool + return true +} + +export function extractMcpInventory(entries: JournalEntry[]): string[] { + const inventory = new Set() + for (const entry of entries) { + const att = entry['attachment'] + if (!att || typeof att !== 'object') continue + const a = att as { type?: unknown; addedNames?: unknown } + if (a.type !== 'deferred_tools_delta') continue + if (!Array.isArray(a.addedNames)) continue + for (const name of a.addedNames) { + if (typeof name !== 'string') continue + if (!isMcpToolName(name)) continue + inventory.add(name) + } + } + if (inventory.size === 0) return [] + return Array.from(inventory).sort() +} + +function extractCanonicalCwd(entries: JournalEntry[]): string | undefined { + for (const entry of entries) { + if (typeof entry.cwd !== 'string') continue + const cwd = entry.cwd.trim() + if (cwd) return cwd + } + return undefined +} + function buildSessionSummary( sessionId: string, project: string, turns: ClassifiedTurn[], + mcpInventory?: string[], ): SessionSummary { const modelBreakdown: SessionSummary['modelBreakdown'] = Object.create(null) const toolBreakdown: SessionSummary['toolBreakdown'] = Object.create(null) const mcpBreakdown: SessionSummary['mcpBreakdown'] = Object.create(null) const bashBreakdown: SessionSummary['bashBreakdown'] = Object.create(null) const categoryBreakdown: SessionSummary['categoryBreakdown'] = Object.create(null) + const skillBreakdown: SessionSummary['skillBreakdown'] = Object.create(null) let totalCost = 0 let totalInput = 0 @@ -198,6 +1169,19 @@ function buildSessionSummary( if (turn.retries === 0) categoryBreakdown[turn.category].oneShotTurns++ } + if (turn.subCategory) { + const skillKey = turn.subCategory + if (!skillBreakdown[skillKey]) { + skillBreakdown[skillKey] = { turns: 0, costUSD: 0, editTurns: 0, oneShotTurns: 0 } + } + skillBreakdown[skillKey].turns++ + skillBreakdown[skillKey].costUSD += turnCost + if (turn.hasEdits) { + skillBreakdown[skillKey].editTurns++ + if (turn.retries === 0) skillBreakdown[skillKey].oneShotTurns++ + } + } + for (const call of turn.assistantCalls) { totalCost += call.costUSD totalInput += call.usage.inputTokens @@ -257,6 +1241,8 @@ function buildSessionSummary( mcpBreakdown, bashBreakdown, categoryBreakdown, + skillBreakdown, + ...(mcpInventory && mcpInventory.length > 0 ? { mcpInventory } : {}), } } @@ -265,7 +1251,7 @@ async function parseSessionFile( project: string, seenMsgIds: Set, dateRange?: DateRange, -): Promise { +): Promise<{ session: SessionSummary; canonicalCwd?: string } | null> { // Skip files whose mtime is older than the range start. A session file // can only contain entries up to its last-modified time; if that predates // the requested range, nothing in this file can match. @@ -278,10 +1264,21 @@ async function parseSessionFile( const entries: JournalEntry[] = [] let hasLines = false - for await (const line of readSessionLines(filePath)) { + // When a dateRange is given, skip user/assistant lines whose timestamp + // is older than range.start - 24h without calling JSON.parse. Huge lines + // that cannot be skipped are yielded as Buffers and compact-parsed without + // converting the whole line into a V8 string. + const earlySkipThreshold = dateRange + ? new Date(dateRange.start.getTime() - 86_400_000).toISOString() + : null + const skipFn = earlySkipThreshold + ? (head: string) => shouldSkipLine(head, earlySkipThreshold) + : undefined + + for await (const line of readSessionLines(filePath, skipFn, { largeLineAsBuffer: true })) { hasLines = true const entry = parseJsonlLine(line) - if (entry) entries.push(entry) + if (entry) entries.push(compactEntry(entry)) } if (!hasLines) return null @@ -289,7 +1286,8 @@ async function parseSessionFile( if (entries.length === 0) return null const sessionId = basename(filePath, '.jsonl') - let turns = groupIntoTurns(entries, seenMsgIds) + const dedupedEntries = dedupeStreamingMessageIds(entries) + let turns = groupIntoTurns(dedupedEntries, seenMsgIds) if (dateRange) { // Bucket a turn by the timestamp of its first assistant call (when the cost was // actually incurred). Filtering entries directly produced orphan assistant calls @@ -307,7 +1305,18 @@ async function parseSessionFile( } const classified = turns.map(classifyTurn) - return buildSessionSummary(sessionId, project, classified) + // Inventory is extracted from the full entry stream, not just the + // turns we kept after date filtering: tool availability is set up + // once at the start of a session (with possible mid-session reloads), + // and we want to reflect what was loaded even if the user only ran + // turns inside a narrow date window. + const mcpInventory = extractMcpInventory(entries) + const canonicalCwd = extractCanonicalCwd(entries) + + return { + session: buildSessionSummary(sessionId, project, classified, mcpInventory), + ...(canonicalCwd ? { canonicalCwd } : {}), + } } async function collectJsonlFiles(dirPath: string): Promise { @@ -326,27 +1335,135 @@ async function collectJsonlFiles(dirPath: string): Promise { return jsonlFiles } -async function scanProjectDirs(dirs: Array<{ path: string; name: string }>, seenMsgIds: Set, dateRange?: DateRange): Promise { - const projectMap = new Map() +async function scanProjectDirs( + dirs: Array<{ path: string; name: string }>, + seenMsgIds: Set, + diskCache: SessionCache, + dateRange?: DateRange, +): Promise { + const section = getOrCreateProviderSection(diskCache, 'claude') + const allDiscoveredFiles = new Set() + + type FileInfo = { dirName: string; fp: NonNullable>> } + const unchangedFiles: Array<{ filePath: string; dirName: string; cached: CachedFile }> = [] + const changedFiles: Array<{ filePath: string; info: FileInfo }> = [] for (const { path: dirPath, name: dirName } of dirs) { const jsonlFiles = await collectJsonlFiles(dirPath) - for (const filePath of jsonlFiles) { - const session = await parseSessionFile(filePath, dirName, seenMsgIds, dateRange) - if (session && session.apiCalls > 0) { - const existing = projectMap.get(dirName) ?? [] - existing.push(session) - projectMap.set(dirName, existing) + allDiscoveredFiles.add(filePath) + const fp = await fingerprintFile(filePath) + if (!fp) continue + + const action = reconcileFile(fp, section.files[filePath]) + if (action.action === 'unchanged') { + unchangedFiles.push({ filePath, dirName, cached: section.files[filePath]! }) + } else { + changedFiles.push({ filePath, info: { dirName, fp } }) } } } + // Pre-seed dedup set from cached (unchanged) files + for (const { cached } of unchangedFiles) { + for (const turn of cached.turns) { + for (const call of turn.calls) { + seenMsgIds.add(call.deduplicationKey) + } + } + } + + // Parse changed files, update cache + for (const { filePath, info } of changedFiles) { + // Clear stale entry before parse — if parse fails, file is excluded + delete section.files[filePath] + + const tracker = { lastCompleteLineOffset: 0 } + const entries = await parseClaudeEntries(filePath, tracker) + if (!entries) continue + + const turns = groupIntoTurns(dedupeStreamingMessageIds(entries), seenMsgIds) + section.files[filePath] = { + fingerprint: info.fp, + lastCompleteLineOffset: tracker.lastCompleteLineOffset, + canonicalCwd: extractCanonicalCwd(entries), + mcpInventory: extractMcpInventory(entries), + turns: turns.map(parsedTurnToCachedTurn), + } + } + + // Remove deleted files from cache + for (const cachedPath of Object.keys(section.files)) { + if (!allDiscoveredFiles.has(cachedPath)) { + delete section.files[cachedPath] + } + } + + // Query-time: derive ProjectSummary[] from all cached turns + const projectMap = new Map() + + const allFiles = [ + ...unchangedFiles.map(f => ({ filePath: f.filePath, dirName: f.dirName })), + ...changedFiles.map(f => ({ filePath: f.filePath, dirName: f.info.dirName })), + ] + + for (const { filePath, dirName } of allFiles) { + const cachedFile = section.files[filePath] + if (!cachedFile || cachedFile.turns.length === 0) continue + + let classifiedTurns = cachedFile.turns.map(cachedTurnToClassified) + + if (dateRange) { + classifiedTurns = classifiedTurns.filter(turn => { + if (turn.assistantCalls.length === 0) return false + const firstCallTs = turn.assistantCalls[0]!.timestamp + if (!firstCallTs) return false + const ts = new Date(firstCallTs) + return ts >= dateRange.start && ts <= dateRange.end + }) + } + + if (classifiedTurns.length === 0) continue + + const sessionId = basename(filePath, '.jsonl') + const projectPath = cachedFile.canonicalCwd ?? unsanitizePath(dirName) + const mcpInv = cachedFile.mcpInventory.length > 0 ? cachedFile.mcpInventory : undefined + const session = buildSessionSummary(sessionId, dirName, classifiedTurns, mcpInv) + + if (session.apiCalls > 0) { + const projectKey = cachedFile.canonicalCwd + ? normalizeProjectPathKey(cachedFile.canonicalCwd) + : `slug:${dirName}` + const existing = projectMap.get(projectKey) + if (existing) { + existing.sessions.push(session) + } else { + projectMap.set(projectKey, { project: dirName, projectPath, sessions: [session] }) + } + } + } + + // Fold slug-keyed entries into cwd-keyed entries + const cwdKeyByDirName = new Map() + for (const [key, entry] of projectMap) { + if (!key.startsWith('slug:') && !cwdKeyByDirName.has(entry.project)) { + cwdKeyByDirName.set(entry.project, key) + } + } + for (const [key, entry] of [...projectMap]) { + if (!key.startsWith('slug:')) continue + const cwdKey = cwdKeyByDirName.get(entry.project) + if (!cwdKey) continue + const target = projectMap.get(cwdKey)! + target.sessions.push(...entry.sessions) + projectMap.delete(key) + } + const projects: ProjectSummary[] = [] - for (const [dirName, sessions] of projectMap) { + for (const { project, projectPath, sessions } of projectMap.values()) { projects.push({ - project: dirName, - projectPath: unsanitizePath(dirName), + project, + projectPath, sessions, totalCostUSD: sessions.reduce((s, sess) => s + sess.totalCostUSD, 0), totalApiCalls: sessions.reduce((s, sess) => s + sess.apiCalls, 0), @@ -375,6 +1492,7 @@ function providerCallToTurn(call: ParsedProviderCall): ParsedTurn { costUSD: call.costUSD, tools, mcpTools: extractMcpTools(tools), + skills: [], hasAgentSpawn: tools.includes('Agent'), hasPlanMode: tools.includes('EnterPlanMode'), speed: call.speed, @@ -391,65 +1509,293 @@ function providerCallToTurn(call: ParsedProviderCall): ParsedTurn { } } +// ── Cache Conversion ─────────────────────────────────────────────────── + +function apiCallToCachedCall(call: ParsedApiCall): CachedCall { + return { + provider: call.provider, + model: call.model, + usage: { ...call.usage, cacheCreationOneHourTokens: call.cacheCreationOneHourTokens ?? 0 }, + speed: call.speed, + timestamp: call.timestamp, + tools: call.tools, + bashCommands: call.bashCommands, + skills: call.skills, + deduplicationKey: call.deduplicationKey, + } +} + +function parsedTurnToCachedTurn(turn: ParsedTurn): CachedTurn { + return { + timestamp: turn.timestamp, + sessionId: turn.sessionId, + userMessage: turn.userMessage.slice(0, 2000), + calls: turn.assistantCalls.map(apiCallToCachedCall), + } +} + +function providerCallToCachedTurn(call: ParsedProviderCall): CachedTurn { + return { + timestamp: call.timestamp, + sessionId: call.sessionId, + userMessage: call.userMessage.slice(0, 2000), + calls: [{ + provider: call.provider, + model: call.model, + usage: { + inputTokens: call.inputTokens, + outputTokens: call.outputTokens, + cacheCreationInputTokens: call.cacheCreationInputTokens, + cacheReadInputTokens: call.cacheReadInputTokens, + cachedInputTokens: call.cachedInputTokens, + reasoningTokens: call.reasoningTokens, + webSearchRequests: call.webSearchRequests, + cacheCreationOneHourTokens: 0, + }, + speed: call.speed, + timestamp: call.timestamp, + tools: call.tools, + bashCommands: call.bashCommands, + skills: [], + deduplicationKey: call.deduplicationKey, + project: call.project, + projectPath: call.projectPath, + }], + } +} + +function cachedCallToApiCall(call: CachedCall): ParsedApiCall { + const u = call.usage + const outputForCost = call.provider === 'claude' + ? u.outputTokens + : u.outputTokens + u.reasoningTokens + const costUSD = calculateCost( + call.model, u.inputTokens, outputForCost, + u.cacheCreationInputTokens, u.cacheReadInputTokens, + u.webSearchRequests, call.speed, u.cacheCreationOneHourTokens, + ) + return { + provider: call.provider, + model: call.model, + usage: { + inputTokens: u.inputTokens, + outputTokens: u.outputTokens, + cacheCreationInputTokens: u.cacheCreationInputTokens, + cacheReadInputTokens: u.cacheReadInputTokens, + cachedInputTokens: u.cachedInputTokens, + reasoningTokens: u.reasoningTokens, + webSearchRequests: u.webSearchRequests, + }, + costUSD, + tools: call.tools, + mcpTools: extractMcpTools(call.tools), + skills: call.skills, + hasAgentSpawn: call.tools.includes('Agent'), + hasPlanMode: call.tools.includes('EnterPlanMode'), + speed: call.speed, + timestamp: call.timestamp, + bashCommands: call.bashCommands, + deduplicationKey: call.deduplicationKey, + cacheCreationOneHourTokens: u.cacheCreationOneHourTokens || undefined, + } +} + +function cachedTurnToClassified(turn: CachedTurn): ClassifiedTurn { + const parsed: ParsedTurn = { + userMessage: turn.userMessage, + assistantCalls: turn.calls.map(cachedCallToApiCall), + timestamp: turn.timestamp, + sessionId: turn.sessionId, + } + return classifyTurn(parsed) +} + +// ── Cache-Aware Parsing Helpers ──────────────────────────────────────── + +async function parseClaudeEntries( + filePath: string, + tracker: { lastCompleteLineOffset: number }, +): Promise { + const entries: JournalEntry[] = [] + let hasLines = false + for await (const line of readSessionLines(filePath, undefined, { + largeLineAsBuffer: true, + byteOffsetTracker: tracker, + })) { + hasLines = true + const entry = parseJsonlLine(line) + if (entry) entries.push(compactEntry(entry)) + } + if (!hasLines || entries.length === 0) return null + return entries +} + +function getOrCreateProviderSection(cache: SessionCache, provider: string): ProviderSection { + const envFp = computeEnvFingerprint(provider) + const existing = cache.providers[provider] + if (existing && existing.envFingerprint === envFp) return existing + const section = { envFingerprint: envFp, files: {} } + cache.providers[provider] = section + return section +} + +const warnedProviderReadFailures = new Set() + +function warnProviderReadFailureOnce(providerName: string, err: unknown): void { + const key = `${providerName}:sqlite-busy` + if (warnedProviderReadFailures.has(key)) return + warnedProviderReadFailures.add(key) + if (isSqliteBusyError(err)) { + process.stderr.write( + `codeburn: skipped ${providerName} data because its SQLite database is temporarily locked; will retry on the next refresh.\n` + ) + } +} + async function parseProviderSources( providerName: string, sources: Array<{ path: string; project: string }>, seenKeys: Set, + diskCache: SessionCache, dateRange?: DateRange, ): Promise { const provider = await getProvider(providerName) if (!provider) return [] - const sessionMap = new Map() + const section = getOrCreateProviderSection(diskCache, providerName) + const allDiscoveredFiles = new Set() + + type SourceInfo = { source: { path: string; project: string }; fp: NonNullable>> } + const unchangedSources: Array<{ source: { path: string; project: string }; cached: CachedFile }> = [] + const changedSources: SourceInfo[] = [] for (const source of sources) { - if (dateRange) { - try { - const s = await stat(source.path) - if (s.mtimeMs < dateRange.start.getTime()) continue - } catch { /* fall through; treat unknown stat as "may contain data" */ } - } - const parser = provider.createSessionParser( - { path: source.path, project: source.project, provider: providerName }, - seenKeys, - ) + allDiscoveredFiles.add(source.path) + const fp = await fingerprintFile(source.path) + if (!fp) continue - for await (const call of parser.parse()) { + const action = reconcileFile(fp, section.files[source.path]) + if (action.action === 'unchanged') { + unchangedSources.push({ source, cached: section.files[source.path]! }) + } else { + changedSources.push({ source, fp }) + } + } + + // Parser dedup: cross-provider keys + cached file keys. + // Separate from seenKeys so parsing doesn't suppress query-time output. + const parserDedup = new Set(seenKeys) + for (const { cached } of unchangedSources) { + for (const turn of cached.turns) { + for (const call of turn.calls) { + parserDedup.add(call.deduplicationKey) + } + } + } + + // Parse changed files, update cache + let didParse = false + try { + for (const { source, fp } of changedSources) { if (dateRange) { - if (!call.timestamp) continue - const ts = new Date(call.timestamp) + if (fp.mtimeMs < dateRange.start.getTime()) continue + } + + // Clear stale entry before parse — if parse fails, file is excluded + delete section.files[source.path] + + const parser = provider.createSessionParser( + { path: source.path, project: source.project, provider: providerName }, + parserDedup, + ) + + try { + const turns: CachedTurn[] = [] + for await (const call of parser.parse()) { + turns.push(providerCallToCachedTurn(call)) + } + section.files[source.path] = { fingerprint: fp, mcpInventory: [], turns } + didParse = true + } catch (err) { + if (isSqliteBusyError(err)) { + warnProviderReadFailureOnce(providerName, err) + continue + } + throw err + } + } + } finally { + if (didParse && providerName === 'codex') await flushCodexCache() + if (didParse && providerName === 'antigravity') { + const liveIds = new Set(sources.map(s => basename(s.path, '.pb'))) + await flushAntigravityCache(liveIds) + } + } + + // Remove deleted files from cache + for (const cachedPath of Object.keys(section.files)) { + if (!allDiscoveredFiles.has(cachedPath)) { + delete section.files[cachedPath] + } + } + + // Query-time: derive SessionSummary from all cached turns. + // Uses seenKeys (shared across providers) for cross-provider dedup. + const sessionMap = new Map() + + for (const source of sources) { + const cachedFile = section.files[source.path] + if (!cachedFile) continue + + for (const turn of cachedFile.turns) { + const hasDup = turn.calls.some(c => seenKeys.has(c.deduplicationKey)) + if (hasDup) continue + + for (const c of turn.calls) seenKeys.add(c.deduplicationKey) + + if (dateRange) { + const callTs = turn.calls[0]?.timestamp + if (!callTs) continue + const ts = new Date(callTs) if (ts < dateRange.start || ts > dateRange.end) continue } - const turn = providerCallToTurn(call) - const classified = classifyTurn(turn) - const key = `${providerName}:${call.sessionId}:${source.project}` + const classified = cachedTurnToClassified(turn) + const project = turn.calls[0]?.project ?? source.project + const key = `${providerName}:${turn.sessionId}:${project}` const existing = sessionMap.get(key) if (existing) { existing.turns.push(classified) + if (!existing.projectPath && turn.calls[0]?.projectPath) { + existing.projectPath = turn.calls[0]!.projectPath + } } else { - sessionMap.set(key, { project: source.project, turns: [classified] }) + sessionMap.set(key, { project, projectPath: turn.calls[0]?.projectPath, turns: [classified] }) } } } - const projectMap = new Map() - for (const [key, { project, turns }] of sessionMap) { + const projectMap = new Map() + for (const [key, { project, projectPath, turns }] of sessionMap) { const sessionId = key.split(':')[1] ?? key const session = buildSessionSummary(sessionId, project, turns) if (session.apiCalls > 0) { - const existing = projectMap.get(project) ?? [] - existing.push(session) - projectMap.set(project, existing) + const existing = projectMap.get(project) + if (existing) { + existing.sessions.push(session) + if (!existing.projectPath && projectPath) existing.projectPath = projectPath + } else { + projectMap.set(project, { projectPath, sessions: [session] }) + } } } const projects: ProjectSummary[] = [] - for (const [dirName, sessions] of projectMap) { + for (const [dirName, { projectPath, sessions }] of projectMap) { projects.push({ project: dirName, - projectPath: unsanitizePath(dirName), + projectPath: projectPath ?? unsanitizePath(dirName), sessions, totalCostUSD: sessions.reduce((s, sess) => s + sess.totalCostUSD, 0), totalApiCalls: sessions.reduce((s, sess) => s + sess.apiCalls, 0), @@ -465,7 +1811,15 @@ const sessionCache = new Map() function cacheKey(dateRange?: DateRange, providerFilter?: string): string { const s = dateRange ? `${dateRange.start.getTime()}:${dateRange.end.getTime()}` : 'none' - return `${s}:${providerFilter ?? 'all'}` + // Include the Claude config-dir env so a config change in a long-lived + // process (menubar / GNOME extension / test workers) does not return + // stale data keyed under a previous configuration. + const claudeEnv = (process.env['CLAUDE_CONFIG_DIRS'] ?? '') + '|' + (process.env['CLAUDE_CONFIG_DIR'] ?? '') + return `${s}:${providerFilter ?? 'all'}:${claudeEnv}` +} + +export function clearSessionCache(): void { + sessionCache.clear() } function cachePut(key: string, data: ProjectSummary[]) { @@ -505,11 +1859,43 @@ export function filterProjectsByName( return result } +function turnIsInDateRange(turn: ClassifiedTurn, dateRange: DateRange): boolean { + if (turn.assistantCalls.length === 0) return false + const firstCallTs = turn.assistantCalls[0]!.timestamp + if (!firstCallTs) return false + const ts = new Date(firstCallTs) + return ts >= dateRange.start && ts <= dateRange.end +} + +export function filterProjectsByDateRange(projects: ProjectSummary[], dateRange: DateRange): ProjectSummary[] { + const filtered: ProjectSummary[] = [] + for (const project of projects) { + const sessions: SessionSummary[] = [] + for (const session of project.sessions) { + const turns = session.turns.filter(turn => turnIsInDateRange(turn, dateRange)) + if (turns.length === 0) continue + sessions.push(buildSessionSummary(session.sessionId, session.project, turns, session.mcpInventory)) + } + if (sessions.length === 0) continue + filtered.push({ + project: project.project, + projectPath: project.projectPath, + sessions, + totalCostUSD: sessions.reduce((s, sess) => s + sess.totalCostUSD, 0), + totalApiCalls: sessions.reduce((s, sess) => s + sess.apiCalls, 0), + }) + } + return filtered.sort((a, b) => b.totalCostUSD - a.totalCostUSD) +} + export async function parseAllSessions(dateRange?: DateRange, providerFilter?: string): Promise { const key = cacheKey(dateRange, providerFilter) const cached = sessionCache.get(key) if (cached && Date.now() - cached.ts < CACHE_TTL_MS) return cached.data + const diskCache = await loadCache() + await cleanupOrphanedTempFiles() + const seenMsgIds = new Set() const seenKeys = new Set() const allSources = await discoverAllSessions(providerFilter) @@ -518,7 +1904,7 @@ export async function parseAllSessions(dateRange?: DateRange, providerFilter?: s const nonClaudeSources = allSources.filter(s => s.provider !== 'claude') const claudeDirs = claudeSources.map(s => ({ path: s.path, name: s.project })) - const claudeProjects = await scanProjectDirs(claudeDirs, seenMsgIds, dateRange) + const claudeProjects = await scanProjectDirs(claudeDirs, seenMsgIds, diskCache, dateRange) const providerGroups = new Map>() for (const source of nonClaudeSources) { @@ -529,10 +1915,12 @@ export async function parseAllSessions(dateRange?: DateRange, providerFilter?: s const otherProjects: ProjectSummary[] = [] for (const [providerName, sources] of providerGroups) { - const projects = await parseProviderSources(providerName, sources, seenKeys, dateRange) + const projects = await parseProviderSources(providerName, sources, seenKeys, diskCache, dateRange) otherProjects.push(...projects) } + try { await saveCache(diskCache) } catch {} + const mergedMap = new Map() for (const p of [...claudeProjects, ...otherProjects]) { const existing = mergedMap.get(p.project) diff --git a/src/providers/antigravity.ts b/src/providers/antigravity.ts new file mode 100644 index 0000000..95f96c9 --- /dev/null +++ b/src/providers/antigravity.ts @@ -0,0 +1,465 @@ +import { readdir, readFile, mkdir, stat, open, rename, unlink } from 'fs/promises' +import { execFile } from 'child_process' +import { randomBytes } from 'crypto' +import { basename, join } from 'path' +import { homedir } from 'os' +import https from 'https' + +import { calculateCost } from '../models.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +const CONVERSATIONS_DIR = join(homedir(), '.gemini', 'antigravity', 'conversations') +const CACHE_VERSION = 2 + +const RPC_TIMEOUT_MS = 5000 +const MAX_RESPONSE_BYTES = 16 * 1024 * 1024 + +export type ServerInfo = { + port: number + csrfToken: string +} + +type ModelMap = Record + +type UsageEntry = { + model: string + inputTokens: string + outputTokens: string + thinkingOutputTokens?: string + responseOutputTokens?: string + apiProvider: string + responseId?: string +} + +export type GeneratorMetadata = { + stepIndices?: number[] + chatModel?: { + model: string + usage: UsageEntry + chatStartMetadata?: { + createdAt?: string + } + } +} + +type ModelMapResponse = { + models?: Record + response?: { + models?: Record + } +} + +type GeneratorMetadataResponse = { + generatorMetadata?: GeneratorMetadata[] + response?: { + generatorMetadata?: GeneratorMetadata[] + } +} + +type CachedCascade = { + mtimeMs: number + sizeBytes: number + calls: ParsedProviderCall[] +} + +type AntigravityCache = { + version: number + cascades: Record +} + +let cachedServer: ServerInfo | null | undefined +let cachedModelMap: ModelMap | undefined +let memCache: AntigravityCache | null = null +let cacheDirty = false +let httpsAgent: https.Agent | undefined + +const SERVER_PORT_FLAGS = ['https_server_port', 'extension_server_port'] +const CSRF_TOKEN_FLAGS = ['csrf_token', 'extension_server_csrf_token'] + +function getAgent(): https.Agent { + if (!httpsAgent) httpsAgent = new https.Agent({ rejectUnauthorized: false }) + return httpsAgent +} + +function getCacheDir(): string { + return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn') +} + +function getCachePath(): string { + return join(getCacheDir(), 'antigravity-results.json') +} + +function execFileText(command: string, args: string[], timeout = 3000): Promise { + return new Promise((resolve, reject) => { + execFile(command, args, { encoding: 'utf-8', timeout, maxBuffer: 1024 * 1024 }, (err, stdout) => { + if (err) reject(err) + else resolve(stdout) + }) + }) +} + +function getFlagValue(line: string, names: string[]): string | null { + for (const name of names) { + const match = line.match(new RegExp(`--${name}(?:=|\\s+)(?:"([^"]+)"|'([^']+)'|([^\\s]+))`, 'i')) + const value = match?.[1] ?? match?.[2] ?? match?.[3] + if (value && !value.startsWith('--')) return value + } + return null +} + +function isLikelyCsrfToken(value: string): boolean { + return value.length >= 16 && /^[A-Za-z0-9._~:/+=-]+$/.test(value) +} + +export function parseAntigravityServerInfoFromLine(line: string): ServerInfo | null { + const lower = line.toLowerCase() + if (!lower.includes('language_server') || !lower.includes('antigravity')) return null + + const rawPort = getFlagValue(line, SERVER_PORT_FLAGS) + const csrfToken = getFlagValue(line, CSRF_TOKEN_FLAGS) + if (!rawPort || !csrfToken) return null + if (!isLikelyCsrfToken(csrfToken)) return null + + const port = Number(rawPort) + if (!Number.isInteger(port) || port <= 0 || port > 65535) return null + + return { port, csrfToken } +} + +export function parseAntigravityServerInfo(lines: string[]): ServerInfo | null { + for (const line of lines) { + const server = parseAntigravityServerInfoFromLine(line) + if (server) return server + } + return null +} + +export function extractAntigravityModelMap(resp: unknown): ModelMap { + if (!resp || typeof resp !== 'object') return {} + const data = resp as ModelMapResponse + const models = data.response?.models ?? data.models + const map: ModelMap = {} + if (!models) return map + for (const [key, info] of Object.entries(models)) { + if (info && typeof info === 'object' && typeof info.model === 'string') { + map[info.model] = key + } + } + return map +} + +export function extractAntigravityGeneratorMetadata(resp: unknown): GeneratorMetadata[] { + if (!resp || typeof resp !== 'object') return [] + const data = resp as GeneratorMetadataResponse + const metadata = data.response?.generatorMetadata ?? data.generatorMetadata + return Array.isArray(metadata) ? metadata : [] +} + +async function loadCache(): Promise { + if (memCache) return memCache + try { + const raw = await readFile(getCachePath(), 'utf-8') + const cache = JSON.parse(raw) as AntigravityCache + if (cache.version === CACHE_VERSION && cache.cascades && typeof cache.cascades === 'object') { + memCache = cache + return cache + } + } catch { /* no cache or invalid */ } + memCache = { version: CACHE_VERSION, cascades: {} } + return memCache +} + +async function flushCache(liveCascadeIds?: Set): Promise { + if (!memCache) return + // If the caller supplied liveCascadeIds, we must run the eviction step + // even when no cascade was added or updated this run; otherwise deleted + // .pb files would persist in the cache forever once it stops getting + // dirty writes. Mark the cache dirty when an eviction happens so the + // file write below proceeds. + if (liveCascadeIds) { + for (const id of Object.keys(memCache.cascades)) { + if (!liveCascadeIds.has(id)) { + delete memCache.cascades[id] + cacheDirty = true + } + } + } + if (!cacheDirty) return + try { + + const dir = getCacheDir() + await mkdir(dir, { recursive: true }) + const finalPath = getCachePath() + const tempPath = `${finalPath}.${randomBytes(8).toString('hex')}.tmp` + const handle = await open(tempPath, 'w', 0o600) + try { + await handle.writeFile(JSON.stringify(memCache), { encoding: 'utf-8' }) + await handle.sync() + } finally { + await handle.close() + } + try { + await rename(tempPath, finalPath) + } catch { + try { await unlink(tempPath) } catch { /* cleanup */ } + } + cacheDirty = false + } catch { /* best-effort */ } +} + +async function readProcessCommandLines(): Promise { + if (process.platform === 'win32') { + const script = [ + "$ErrorActionPreference = 'SilentlyContinue'", + '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', + "Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -and $_.CommandLine -like '*language_server*' -and $_.CommandLine -like '*antigravity*' } | ForEach-Object { $_.CommandLine }", + ].join('; ') + const output = await execFileText('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], 5000) + return output.split(/\r?\n/) + } + + const output = await execFileText('ps', ['-ww', '-eo', 'args']) + return output.split('\n') +} + +async function detectServer(): Promise { + if (cachedServer !== undefined) return cachedServer + try { + cachedServer = parseAntigravityServerInfo(await readProcessCommandLines()) + return cachedServer + } catch { /* process discovery failed or timed out */ } + cachedServer = null + return null +} + +async function rpc(server: ServerInfo, method: string, body: Record = {}): Promise { + return new Promise((resolve, reject) => { + const data = JSON.stringify(body) + const req = https.request({ + hostname: '127.0.0.1', + port: server.port, + path: `/exa.language_server_pb.LanguageServerService/${method}`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Connect-Protocol-Version': '1', + 'X-Codeium-Csrf-Token': server.csrfToken, + 'Content-Length': Buffer.byteLength(data), + }, + agent: getAgent(), + timeout: RPC_TIMEOUT_MS, + }, (res) => { + const chunks: Buffer[] = [] + let totalBytes = 0 + res.on('data', (chunk: Buffer) => { + totalBytes += chunk.length + if (totalBytes > MAX_RESPONSE_BYTES) { + res.destroy() + reject(new Error(`RPC ${method}: response too large`)) + return + } + chunks.push(chunk) + }) + res.on('end', () => { + if (res.statusCode !== 200) { + reject(new Error(`RPC ${method}: HTTP ${res.statusCode}`)) + return + } + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8'))) + } catch { + reject(new Error(`RPC ${method}: invalid JSON`)) + } + }) + res.on('error', reject) + }) + req.on('error', reject) + req.on('timeout', () => { req.destroy(); reject(new Error(`RPC ${method}: timeout`)) }) + req.write(data) + req.end() + }) +} + +async function getModelMap(server: ServerInfo): Promise { + if (cachedModelMap) return cachedModelMap + try { + cachedModelMap = extractAntigravityModelMap(await rpc(server, 'GetAvailableModels')) + return cachedModelMap + } catch { /* best-effort */ } + cachedModelMap = {} + return cachedModelMap +} + +// Strip Antigravity-specific suffixes so the pricing DB can match +function normalizePricingModel(model: string): string { + return model.replace(/-(high|low|agent)$/, '') +} + +async function discoverSessions(): Promise { + const sources: SessionSource[] = [] + let files: string[] + try { + files = await readdir(CONVERSATIONS_DIR) + } catch { + return sources + } + + for (const file of files) { + if (!file.endsWith('.pb')) continue + sources.push({ + path: join(CONVERSATIONS_DIR, file), + project: 'antigravity', + provider: 'antigravity', + }) + } + return sources +} + +function createParser(source: SessionSource, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + const cascadeId = basename(source.path, '.pb') + const cache = await loadCache() + + const s = await stat(source.path).catch(() => null) + if (!s) return + + const cached = cache.cascades[cascadeId] + if (cached && cached.mtimeMs === s.mtimeMs && cached.sizeBytes === s.size) { + for (const call of cached.calls) { + if (seenKeys.has(call.deduplicationKey)) continue + seenKeys.add(call.deduplicationKey) + yield call + } + return + } + + const server = await detectServer() + if (!server) { + if (cached) { + for (const call of cached.calls) { + if (seenKeys.has(call.deduplicationKey)) continue + seenKeys.add(call.deduplicationKey) + yield call + } + } + return + } + + const modelMap = await getModelMap(server) + + let metadata: GeneratorMetadata[] + try { + metadata = extractAntigravityGeneratorMetadata( + await rpc(server, 'GetCascadeTrajectoryGeneratorMetadata', { cascadeId }), + ) + } catch { + if (cached) { + for (const call of cached.calls) { + if (seenKeys.has(call.deduplicationKey)) continue + seenKeys.add(call.deduplicationKey) + yield call + } + } + return + } + + const results: ParsedProviderCall[] = [] + + for (let i = 0; i < metadata.length; i++) { + const entry = metadata[i]! + const usage = entry.chatModel?.usage + if (!usage) continue + + const inputTokens = parseInt(usage.inputTokens ?? '0', 10) + const outputTokens = parseInt(usage.outputTokens ?? '0', 10) + const thinkingTokens = parseInt(usage.thinkingOutputTokens ?? '0', 10) + const responseTokens = parseInt(usage.responseOutputTokens ?? '0', 10) + + if (inputTokens === 0 && outputTokens === 0) continue + + const responseId = usage.responseId || String(i) + const dedupKey = `antigravity:${cascadeId}:${responseId}` + + const model = modelMap[usage.model] ?? usage.model + const pricingModel = normalizePricingModel(model) + const timestamp = entry.chatModel?.chatStartMetadata?.createdAt ?? '' + const costUSD = calculateCost(pricingModel, inputTokens, responseTokens + thinkingTokens, 0, 0, 0) + + results.push({ + provider: 'antigravity', + model, + inputTokens, + outputTokens: responseTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: thinkingTokens, + webSearchRequests: 0, + costUSD, + tools: [], + bashCommands: [], + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: '', + sessionId: cascadeId, + }) + } + + cache.cascades[cascadeId] = { + mtimeMs: s.mtimeMs, + sizeBytes: s.size, + calls: results, + } + cacheDirty = true + + for (const call of results) { + if (seenKeys.has(call.deduplicationKey)) continue + seenKeys.add(call.deduplicationKey) + yield call + } + }, + } +} + +const modelDisplayNames: Record = { + 'gemini-3-pro': 'Gemini 3 Pro', + 'gemini-3.1-pro-high': 'Gemini 3.1 Pro', + 'gemini-3.1-pro-low': 'Gemini 3.1 Pro (Low)', + 'gemini-3-flash': 'Gemini 3 Flash', + 'gemini-3-flash-agent': 'Gemini 3 Flash', + 'gemini-3.1-flash-image': 'Gemini 3.1 Flash', + 'gemini-3.1-flash-lite': 'Gemini 3.1 Flash Lite', + 'claude-opus-4-6-thinking': 'Opus 4.6', + 'claude-sonnet-4-6': 'Sonnet 4.6', +} + +export function createAntigravityProvider(): Provider { + return { + name: 'antigravity', + displayName: 'Antigravity', + + modelDisplayName(model: string): string { + return modelDisplayNames[model] ?? model + }, + + toolDisplayName(rawTool: string): string { + return rawTool + }, + + async discoverSessions(): Promise { + return discoverSessions() + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export async function flushAntigravityCache(liveCascadeIds?: Set): Promise { + await flushCache(liveCascadeIds) +} + +export const antigravity = createAntigravityProvider() diff --git a/src/providers/claude.ts b/src/providers/claude.ts index cb8caef..43c06a5 100644 --- a/src/providers/claude.ts +++ b/src/providers/claude.ts @@ -1,5 +1,5 @@ import { readdir, stat } from 'fs/promises' -import { basename, join } from 'path' +import { basename, delimiter as pathDelimiter, join, resolve } from 'path' import { homedir } from 'os' import type { Provider, SessionSource, SessionParser } from './types.js' @@ -19,12 +19,42 @@ const shortNames: Record = { 'claude-3-5-haiku': 'Haiku 3.5', } -function getClaudeDir(): string { - return process.env['CLAUDE_CONFIG_DIR'] || join(homedir(), '.claude') +function expandHome(p: string): string { + if (p === '~') return homedir() + if (p.startsWith('~/') || p.startsWith('~\\')) return join(homedir(), p.slice(2)) + return p } -function getProjectsDir(): string { - return join(getClaudeDir(), 'projects') +/// Returns every Claude config dir to scan, in priority order with duplicates +/// removed (resolved-path equality). Precedence: `CLAUDE_CONFIG_DIRS` (a +/// `path.delimiter`-separated list, ":" on POSIX, ";" on Windows), then +/// `CLAUDE_CONFIG_DIR` (single dir), then `~/.claude`. Sessions from every +/// returned dir are merged into one ProjectSummary per project name in +/// `src/parser.ts:scanProjectDirs`, so two dirs holding the same sanitized +/// project slug naturally aggregate (issue #208 option 1). +function getClaudeConfigDirs(): string[] { + const multi = process.env['CLAUDE_CONFIG_DIRS'] + if (multi !== undefined && multi !== '') { + const dirs = multi + .split(pathDelimiter) + .map(s => s.trim()) + .filter(s => s.length > 0) + .map(s => resolve(expandHome(s))) + if (dirs.length > 0) { + const seen = new Set() + const out: string[] = [] + for (const d of dirs) { + if (!seen.has(d)) { + seen.add(d) + out.push(d) + } + } + return out + } + } + const single = process.env['CLAUDE_CONFIG_DIR'] + if (single !== undefined && single !== '') return [resolve(expandHome(single))] + return [join(homedir(), '.claude')] } function getDesktopSessionsDir(): string { @@ -77,21 +107,57 @@ export const claude: Provider = { async discoverSessions(): Promise { const sources: SessionSource[] = [] + const seenProjectDirs = new Set() + const configDirs = getClaudeConfigDirs() + let anyDirReadable = false - const projectsDir = getProjectsDir() - try { - const entries = await readdir(projectsDir) + for (const claudeDir of configDirs) { + const projectsDir = join(claudeDir, 'projects') + let entries: string[] + try { + entries = await readdir(projectsDir) + anyDirReadable = true + } catch { + // Missing or unreadable dir is not fatal: a user can configure both + // a real and a stale path in CLAUDE_CONFIG_DIRS without breaking. + continue + } for (const dirName of entries) { const dirPath = join(projectsDir, dirName) + // Resolve before deduping so two CLAUDE_CONFIG_DIRS entries that + // reach the same projects/ directory (via symlinks or + // overlapping configs) emit only one SessionSource. + const resolved = resolve(dirPath) + if (seenProjectDirs.has(resolved)) continue const dirStat = await stat(dirPath).catch(() => null) - if (dirStat?.isDirectory()) { - sources.push({ path: dirPath, project: dirName, provider: 'claude' }) - } + if (!dirStat?.isDirectory()) continue + seenProjectDirs.add(resolved) + // `project: dirName` is identical across config dirs for the same + // sanitized slug, which is exactly what makes the parser merge + // their sessions into a single ProjectSummary. + sources.push({ path: dirPath, project: dirName, provider: 'claude' }) } - } catch {} + } + + // If the user explicitly set CLAUDE_CONFIG_DIRS and every entry was + // unreadable, emit a one-line stderr hint. Catches the most common + // misconfiguration: a Windows user typing `:` (POSIX delimiter) when + // the platform expects `;`, which produces a single bogus path that + // silently resolves to nothing on disk. + const explicitMulti = process.env['CLAUDE_CONFIG_DIRS'] + if (!anyDirReadable && explicitMulti !== undefined && explicitMulti !== '' && configDirs.length > 0) { + process.stderr.write( + `codeburn: CLAUDE_CONFIG_DIRS was set but no listed directory could be read. ` + + `Tried: ${configDirs.join(', ')}. ` + + `Use "${pathDelimiter}" as the separator on this platform.\n`, + ) + } const desktopDirs = await findDesktopProjectDirs(getDesktopSessionsDir()) for (const dirPath of desktopDirs) { + const resolved = resolve(dirPath) + if (seenProjectDirs.has(resolved)) continue + seenProjectDirs.add(resolved) sources.push({ path: dirPath, project: basename(dirPath), provider: 'claude' }) } diff --git a/src/providers/cline.ts b/src/providers/cline.ts new file mode 100644 index 0000000..7317706 --- /dev/null +++ b/src/providers/cline.ts @@ -0,0 +1,73 @@ +import { stat } from 'fs/promises' +import { homedir } from 'os' +import { basename, join } from 'path' + +import { discoverClineTasks, createClineParser, getVSCodeGlobalStoragePath } from './vscode-cline-parser.js' +import type { Provider, SessionSource, SessionParser } from './types.js' + +const EXTENSION_ID = 'saoudrizwan.claude-dev' + +export function getClineDataPath(): string { + return join(homedir(), '.cline', 'data') +} + +function normalizeOverrideDirs(overrideDirs?: string | string[]): string[] | undefined { + if (overrideDirs === undefined) return undefined + // Cline has two default roots, so tests and future callers can override one or both. + return Array.isArray(overrideDirs) ? overrideDirs : [overrideDirs] +} + +async function dedupeTaskSources(sources: SessionSource[]): Promise { + const candidates = await Promise.all(sources.map(async source => ({ + source, + mtimeMs: (await stat(join(source.path, 'ui_messages.json')).catch(() => null))?.mtimeMs ?? 0, + }))) + + const seenTaskIds = new Set() + const deduped: SessionSource[] = [] + + for (const { source } of candidates.sort((a, b) => b.mtimeMs - a.mtimeMs)) { + const taskId = basename(source.path) + if (seenTaskIds.has(taskId)) continue + seenTaskIds.add(taskId) + deduped.push(source) + } + + return deduped +} + +export function createClineProvider(overrideDirs?: string | string[]): Provider { + const configuredDirs = normalizeOverrideDirs(overrideDirs) + + return { + name: 'cline', + displayName: 'Cline', + + modelDisplayName(model: string): string { + return model + }, + + toolDisplayName(rawTool: string): string { + return rawTool + }, + + async discoverSessions(): Promise { + const baseDirs = configuredDirs ?? [ + getVSCodeGlobalStoragePath(EXTENSION_ID), + getClineDataPath(), + ] + + const sources = await Promise.all( + baseDirs.map(dir => discoverClineTasks(EXTENSION_ID, 'cline', 'Cline', dir)), + ) + + return dedupeTaskSources(sources.flat()) + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createClineParser(source, seenKeys, 'cline') + }, + } +} + +export const cline = createClineProvider() diff --git a/src/providers/codex.ts b/src/providers/codex.ts index 2eac408..ca20deb 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -1,13 +1,17 @@ import { readdir, stat } from 'fs/promises' +import { createReadStream } from 'fs' +import { createInterface } from 'readline' import { basename, join } from 'path' import { homedir } from 'os' -import { readSessionFile } from '../fs-utils.js' +import { readSessionLines } from '../fs-utils.js' import { calculateCost } from '../models.js' +import { readCachedCodexResults, writeCachedCodexResults, getCachedCodexProject, fingerprintFile } from '../codex-cache.js' import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' const modelDisplayNames: Record = { 'codex-auto-review': 'Codex Auto Review', + 'gpt-5.5': 'GPT-5.5', 'gpt-5.4-mini': 'GPT-5.4 Mini', 'gpt-5.4': 'GPT-5.4', 'gpt-5.3-codex': 'GPT-5.3 Codex', @@ -60,6 +64,10 @@ type CodexTokenUsage = { total_tokens?: number } +const CHARS_PER_TOKEN = 4 +const RAW_HEAD_BYTES = 64 * 1024 +const LARGE_TEXT_CAP = 2000 + function getCodexDir(override?: string): string { return override ?? process.env['CODEX_HOME'] ?? join(homedir(), '.codex') } @@ -68,13 +76,44 @@ function sanitizeProject(cwd: string): string { return cwd.replace(/^\//, '').replace(/\//g, '-') } +// Cap how many bytes we'll read while looking for the first newline. Real +// Codex session_meta lines are ~22-27 KB; this leaves plenty of headroom while +// keeping memory bounded if a corrupt file has no newline at all. +const FIRST_LINE_READ_CAP = 1024 * 1024 + async function readFirstLine(filePath: string): Promise { - const content = await readSessionFile(filePath) - if (content === null) return null - const line = content.split('\n')[0] - if (!line?.trim()) return null + // Codex CLI 0.128+ writes a session_meta line that can exceed 20 KB because + // it embeds the full base_instructions / system prompt. A fixed-size buffer + // would miss the trailing newline and reject the session as invalid. + // Stream the file via readline so we can read the first line up to + // FIRST_LINE_READ_CAP, which keeps memory bounded if the file has no newline. + const stream = createReadStream(filePath, { + encoding: 'utf-8', + start: 0, + end: FIRST_LINE_READ_CAP - 1, + }) + // Silence stream errors so a late read-ahead error after we've already + // returned the first line cannot escape as an unhandled 'error' event. + // readline's async iterator re-throws underlying stream errors (ENOENT, + // EACCES, etc.) on Node 16+, which the catch below handles for the cases + // that matter for validation. + stream.on('error', () => {}) + const rl = createInterface({ input: stream, crlfDelay: Infinity }) + let firstLine: string | undefined try { - return JSON.parse(line) as CodexEntry + for await (const line of rl) { + firstLine = line + break + } + } catch { + return null + } finally { + rl.close() + stream.destroy() + } + if (!firstLine || !firstLine.trim()) return null + try { + return JSON.parse(firstLine) as CodexEntry } catch { return null } @@ -89,6 +128,116 @@ async function isValidCodexSession(filePath: string): Promise<{ valid: boolean; return { valid, meta: valid ? entry : undefined } } +function getRawJsonStringField(head: string, field: string): string | undefined { + const re = new RegExp(`"${field}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`) + const match = re.exec(head) + if (!match) return undefined + try { + return JSON.parse(`"${match[1]}"`) as string + } catch { + return match[1] + } +} + +function payloadHead(head: string): string { + const idx = head.indexOf('"payload"') + return idx === -1 ? head : head.slice(idx) +} + +function countJsonStringBytes(source: Buffer, valueStart: number): number { + let count = 0 + for (let i = valueStart; i < source.length; i++) { + const ch = source[i] + if (ch === 0x5c) { + i++ + count++ + continue + } + if (ch === 0x22) return count + count++ + } + return count +} + +function extractFirstJsonText(source: Buffer, cap = LARGE_TEXT_CAP): string { + const key = Buffer.from('"text"') + const idx = source.indexOf(key) + if (idx === -1) return '' + const colon = source.indexOf(0x3a, idx + key.length) + if (colon === -1) return '' + const qStart = source.indexOf(0x22, colon + 1) + if (qStart === -1) return '' + const chunks: number[] = [] + for (let i = qStart + 1; i < source.length && chunks.length < cap; i++) { + const ch = source[i] + if (ch === 0x5c) { + const next = source[++i] + if (next === 0x6e) chunks.push(0x0a) + else if (next === 0x72) chunks.push(0x0d) + else if (next === 0x74) chunks.push(0x09) + else if (next !== undefined) chunks.push(next) + continue + } + if (ch === 0x22) break + chunks.push(ch) + } + return Buffer.from(chunks).toString('utf-8') +} + +function countFirstJsonText(source: Buffer): number { + const key = Buffer.from('"text"') + const idx = source.indexOf(key) + if (idx === -1) return 0 + const colon = source.indexOf(0x3a, idx + key.length) + if (colon === -1) return 0 + const qStart = source.indexOf(0x22, colon + 1) + if (qStart === -1) return 0 + return countJsonStringBytes(source, qStart + 1) +} + +function parseCodexLine(line: string | Buffer): CodexEntry | null { + if (typeof line === 'string') { + const trimmed = line.trim() + if (!trimmed) return null + try { + return JSON.parse(trimmed) as CodexEntry + } catch { + return null + } + } + + if (line.length === 0) return null + const head = line.subarray(0, RAW_HEAD_BYTES).toString('utf-8') + const type = getRawJsonStringField(head, 'type') + if (!type) return null + const pHead = payloadHead(head) + const payloadType = getRawJsonStringField(pHead, 'type') + const role = getRawJsonStringField(pHead, 'role') + + const entry: CodexEntry = { + type, + timestamp: getRawJsonStringField(head, 'timestamp'), + payload: { + type: payloadType, + role, + cwd: getRawJsonStringField(pHead, 'cwd'), + model_provider: getRawJsonStringField(pHead, 'model_provider'), + originator: getRawJsonStringField(pHead, 'originator'), + session_id: getRawJsonStringField(pHead, 'session_id'), + model: getRawJsonStringField(pHead, 'model'), + name: getRawJsonStringField(pHead, 'name'), + }, + } + + if (type === 'response_item' && payloadType === 'message' && role === 'user') { + entry.payload!.content = [{ type: 'input_text', text: extractFirstJsonText(line) }] + } else if (type === 'response_item' && payloadType === 'message' && role === 'assistant') { + entry.payload!.content = [{ type: 'output_text', text: 'x'.repeat(Math.min(countFirstJsonText(line), LARGE_TEXT_CAP)) }] + } + + return entry +} + async function discoverSessionsInDir(codexDir: string): Promise { const sessionsDir = join(codexDir, 'sessions') const sources: SessionSource[] = [] @@ -121,6 +270,12 @@ async function discoverSessionsInDir(codexDir: string): Promise const s = await stat(filePath).catch(() => null) if (!s?.isFile()) continue + const cachedProject = await getCachedCodexProject(filePath) + if (cachedProject) { + sources.push({ path: filePath, project: cachedProject, provider: 'codex' }) + continue + } + const { valid, meta } = await isValidCodexSession(filePath) if (!valid || !meta) continue @@ -145,26 +300,48 @@ function resolveModel(info: CodexEntry['payload'], sessionModel?: string): strin function createParser(source: SessionSource, seenKeys: Set): SessionParser { return { async *parse(): AsyncGenerator { - const content = await readSessionFile(source.path) - if (content === null) return - const lines = content.split('\n').filter(l => l.trim()) + const cached = await readCachedCodexResults(source.path) + if (cached) { + for (const call of cached) { + if (seenKeys.has(call.deduplicationKey)) continue + seenKeys.add(call.deduplicationKey) + yield call + } + return + } + + const fp = await fingerprintFile(source.path) + if (!fp) return + let sessionModel: string | undefined let sessionId = '' - let prevCumulativeTotal = 0 + // Null sentinel rather than `0` so the FIRST event is never confused + // with a duplicate. A session that only emits last_token_usage (no + // total_token_usage) reports cumulativeTotal=0 on every event; with a + // 0-initialized prev, the first event would have matched and been + // dropped. Once we've observed any event, we record its cumulative + // total and dedup on equality regardless of whether it is zero. + let prevCumulativeTotal: number | null = null let prevInput = 0 let prevCached = 0 let prevOutput = 0 let prevReasoning = 0 let pendingTools: string[] = [] let pendingUserMessage = '' + let pendingOutputChars = 0 + let estCounter = 0 + let sawAnyLine = false + const results: ParsedProviderCall[] = [] - for (const line of lines) { - let entry: CodexEntry - try { - entry = JSON.parse(line) as CodexEntry - } catch { - continue - } + // Stream the session file line by line. Heavy Codex sessions can exceed + // 250 MB on disk; reading the entire file into a string would either hit + // the readSessionFile cap or push V8 toward its 512 MB string limit + // after split('\n'). readSessionLines streams raw buffers and hands + // huge lines to the compact parser without full string conversion. + for await (const rawLine of readSessionLines(source.path, undefined, { largeLineAsBuffer: true })) { + sawAnyLine = true + const entry = parseCodexLine(rawLine) + if (!entry) continue if (entry.type === 'session_meta') { sessionId = entry.payload?.session_id ?? basename(source.path, '.jsonl') @@ -197,12 +374,65 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars continue } + if (entry.type === 'response_item' && entry.payload?.type === 'message' && entry.payload?.role === 'assistant') { + const texts = (entry.payload.content ?? []) + .filter(c => c.type === 'output_text' || c.type === 'text') + .map(c => c.text ?? '') + pendingOutputChars += texts.join('').length + continue + } + if (entry.type === 'event_msg' && entry.payload?.type === 'token_count') { const info = entry.payload.info - if (!info) continue + if (!info) { + if (pendingOutputChars === 0 && pendingUserMessage.length === 0) continue + const estInput = Math.ceil(pendingUserMessage.length / CHARS_PER_TOKEN) + const estOutput = Math.ceil(pendingOutputChars / CHARS_PER_TOKEN) + if (estInput === 0 && estOutput === 0) continue + + const model = sessionModel ?? 'gpt-5' + const timestamp = entry.timestamp ?? '' + const dedupKey = `codex:${sessionId}:${timestamp}:est${estCounter++}` + + if (seenKeys.has(dedupKey)) { pendingTools = []; pendingUserMessage = ''; pendingOutputChars = 0; continue } + seenKeys.add(dedupKey) + + const costUSD = calculateCost(model, estInput, estOutput, 0, 0, 0) + + results.push({ + provider: 'codex', + model, + inputTokens: estInput, + outputTokens: estOutput, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + costIsEstimated: true, + tools: pendingTools, + bashCommands: [], + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: pendingUserMessage, + sessionId, + }) + + pendingTools = [] + pendingUserMessage = '' + pendingOutputChars = 0 + continue + } const cumulativeTotal = info.total_token_usage?.total_tokens ?? 0 - if (cumulativeTotal > 0 && cumulativeTotal === prevCumulativeTotal) continue + // Dedup guard. Two consecutive events with cumulativeTotal=0 but + // non-empty last_token_usage would have been double-counted with + // the previous `> 0` clause. The null sentinel ensures the FIRST + // event always passes (so a session that never reports cumulative + // doesn't lose its opening turn). + if (prevCumulativeTotal !== null && cumulativeTotal === prevCumulativeTotal) continue prevCumulativeTotal = cumulativeTotal const last = info.last_token_usage @@ -225,14 +455,19 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars reasoningTokens = (total.reasoning_output_tokens ?? 0) - prevReasoning } - if (!last) { - const total = info.total_token_usage - if (total) { - prevInput = total.input_tokens ?? 0 - prevCached = total.cached_input_tokens ?? 0 - prevOutput = total.output_tokens ?? 0 - prevReasoning = total.reasoning_output_tokens ?? 0 - } + // Always advance the prev counters to track the cumulative state. + // Previously prev was only updated on the fallback branch, so a + // session with mixed last_token_usage / no-last events would + // compute the next fallback delta against a stale prev=0 baseline, + // double-counting the entire cumulative window. The prev value + // must mirror what cumulative reports regardless of whether this + // event used `last` or fell back to deltas. + const total = info.total_token_usage + if (total) { + prevInput = total.input_tokens ?? 0 + prevCached = total.cached_input_tokens ?? 0 + prevOutput = total.output_tokens ?? 0 + prevReasoning = total.reasoning_output_tokens ?? 0 } const totalTokens = inputTokens + cachedInputTokens + outputTokens + reasoningTokens @@ -244,7 +479,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars const model = resolveModel(entry.payload, sessionModel) const timestamp = entry.timestamp ?? '' - const dedupKey = `codex:${source.path}:${timestamp}:${cumulativeTotal}` + const dedupKey = `codex:${sessionId}:${timestamp}:${cumulativeTotal}` if (seenKeys.has(dedupKey)) continue seenKeys.add(dedupKey) @@ -258,7 +493,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars 0, ) - yield { + results.push({ provider: 'codex', model, inputTokens: uncachedInputTokens, @@ -276,12 +511,24 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars deduplicationKey: dedupKey, userMessage: pendingUserMessage, sessionId, - } + }) pendingTools = [] pendingUserMessage = '' + pendingOutputChars = 0 } } + + // If the stream yielded nothing the file was unreadable, oversized, or + // empty. Skip cache write so a transient failure can't pin an empty + // result set against a fingerprint that would otherwise be re-parsed. + if (!sawAnyLine) return + + await writeCachedCodexResults(source.path, source.project, results, fp) + + for (const call of results) { + yield call + } }, } } diff --git a/src/providers/copilot.ts b/src/providers/copilot.ts index 6cf486f..e7c35e3 100644 --- a/src/providers/copilot.ts +++ b/src/providers/copilot.ts @@ -66,10 +66,19 @@ type LegacyToolRequest = { type?: string } +// Per-event-type shapes. The previous union included a permissive catch-all +// branch (`{ type: string; data: Record }`); a literal type +// like `'user.message'` is assignable to `string`, so TS picked the catch-all +// over the specific branches when narrowing on `type`, which propagated +// `unknown`/`{}` into `event.data.content` etc. We now keep only the three +// shapes we actually read from. Unknown event types fall through the if/else +// chain without further narrowing — they are not in the union, but JSON.parse +// returns `any` so we re-type as LegacyCopilotEvent and let the runtime type +// guards (`event.type === 'X'`) ignore anything else. type LegacyCopilotEvent = - | { type: 'session.model_change'; timestamp?: string; data: { newModel: string } } - | { type: 'user.message'; timestamp?: string; data: { content: string; interactionId?: string } } - | { type: 'assistant.message'; timestamp?: string; data: { messageId: string; outputTokens: number; interactionId?: string; toolRequests?: LegacyToolRequest[] } } + | { type: 'session.model_change'; timestamp?: string; data: { newModel: string; model?: string } } + | { type: 'user.message'; timestamp?: string; data: { content: string; interactionId?: string; model?: string } } + | { type: 'assistant.message'; timestamp?: string; data: { messageId: string; outputTokens: number; interactionId?: string; toolRequests?: LegacyToolRequest[]; model?: string } } function parseLegacyEvents(content: string, sessionId: string, seenKeys: Set): ParsedProviderCall[] { const results: ParsedProviderCall[] = [] @@ -85,8 +94,14 @@ function parseLegacyEvents(content: string, sessionId: string, seenKeys: Set t.name ?? '') .filter(Boolean) @@ -158,6 +178,7 @@ const transcriptToolCallModelHints: Array<{ prefix: string; model: string }> = [ { prefix: 'toolu_bdrk_', model: COPILOT_ANTHROPIC_AUTO }, { prefix: 'toolu_vrtx_', model: COPILOT_ANTHROPIC_AUTO }, { prefix: 'tooluse_', model: COPILOT_ANTHROPIC_AUTO }, + { prefix: 'toolu_', model: COPILOT_ANTHROPIC_AUTO }, // OpenAI tool-call IDs. { prefix: 'call_', model: COPILOT_OPENAI_AUTO }, ] @@ -166,6 +187,12 @@ function inferModelFromToolCallIds(events: TranscriptEvent[]): string { const modelCounts = new Map() for (const e of events) { + // Some newer events (like tool.execution_complete) explicitly include the model ID. + const data = e.data as { model?: string } + if (typeof data.model === 'string' && data.model) { + modelCounts.set(data.model, (modelCounts.get(data.model) ?? 0) + 100) + } + if (e.type !== 'assistant.message') continue const msg = e as { data: { toolRequests?: TranscriptToolRequest[] } } for (const t of msg.data.toolRequests ?? []) { @@ -228,7 +255,10 @@ function parseTranscriptEvents(content: string, sessionId: string, seenKeys: Set const inputTokens = Math.ceil(pendingUserMessage.length / CHARS_PER_TOKEN) - const tools = (data.toolRequests ?? []) + // Same defensive guard as the modern event branch — corrupt legacy + // sessions have shipped toolRequests as non-array values. + const legacyToolRequests = Array.isArray(data.toolRequests) ? data.toolRequests : [] + const tools = legacyToolRequests .map(t => t.name ?? '') .filter(Boolean) .map(n => toolNameMap[n] ?? n) @@ -318,6 +348,7 @@ function getVSCodeWorkspaceStorageDirs(): string[] { return [ join(homedir(), '.config', 'Code', 'User', 'workspaceStorage'), join(homedir(), '.config', 'Code - Insiders', 'User', 'workspaceStorage'), + join(homedir(), '.vscode-server', 'data', 'User', 'workspaceStorage'), ] } diff --git a/src/providers/crush.ts b/src/providers/crush.ts new file mode 100644 index 0000000..5661d82 --- /dev/null +++ b/src/providers/crush.ts @@ -0,0 +1,258 @@ +import { readFile } from 'fs/promises' +import { join, resolve } from 'path' +import { homedir, platform } from 'os' + +import { calculateCost } from '../models.js' +import { isSqliteAvailable, getSqliteLoadError, openDatabase, type SqliteDatabase } from '../sqlite.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +/// Crush stores per-project SQLite databases discovered through a JSON registry. +/// We only read both. Schema source: charmbracelet/crush +/// internal/db/migrations/20250424200609_initial.sql, verified against v0.66.1. +/// The schema *comments* in that file claim millisecond timestamps, but every +/// INSERT/UPDATE in internal/db/sql/{sessions,messages}.sql uses +/// strftime('%s', 'now') which returns Unix seconds. We treat values as seconds. + +type ProjectEntry = { + path: string + data_dir: string +} + +type SessionRow = { + id: string + prompt_tokens: number | null + completion_tokens: number | null + cost: number | null + created_at: number | null + updated_at: number | null + message_count: number | null +} + +function getRegistryPath(): string { + const explicit = process.env['CRUSH_GLOBAL_DATA'] + if (explicit) return join(explicit, 'projects.json') + + if (platform() === 'win32') { + const localAppData = process.env['LOCALAPPDATA'] ?? join(homedir(), 'AppData', 'Local') + return join(localAppData, 'crush', 'projects.json') + } + + const xdg = process.env['XDG_DATA_HOME'] ?? join(homedir(), '.local', 'share') + return join(xdg, 'crush', 'projects.json') +} + +async function loadRegistry(path: string): Promise { + let raw: string + try { + raw = await readFile(path, 'utf-8') + } catch { + return [] + } + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return [] + } + // Crush writes projects.json as an object keyed by project id. Older builds + // (and tokscale's sample fixtures) emit an array. Accept both shapes. + let entries: unknown[] + if (Array.isArray(parsed)) { + entries = parsed + } else if (parsed && typeof parsed === 'object') { + entries = Object.values(parsed) + } else { + return [] + } + const out: ProjectEntry[] = [] + for (const e of entries) { + if (!e || typeof e !== 'object') continue + const obj = e as Record + if (typeof obj['path'] !== 'string' || typeof obj['data_dir'] !== 'string') continue + out.push({ path: obj['path'], data_dir: obj['data_dir'] }) + } + return out +} + +function resolveDbPath(entry: ProjectEntry): string { + // data_dir defaults to ".crush" relative to the project path. Absolute paths + // are honored if a user has overridden the layout. + return join(resolve(entry.path, entry.data_dir), 'crush.db') +} + +function sanitizeProject(path: string): string { + return path.replace(/^\//, '').replace(/\//g, '-') +} + +function validateSchema(db: SqliteDatabase): boolean { + try { + db.query<{ cnt: number }>('SELECT COUNT(*) as cnt FROM sessions LIMIT 1') + db.query<{ cnt: number }>('SELECT COUNT(*) as cnt FROM messages LIMIT 1') + return true + } catch { + return false + } +} + +function epochSecondsToIso(epochSeconds: number | null): string { + if (epochSeconds === null || !Number.isFinite(epochSeconds)) { + return new Date(0).toISOString() + } + return new Date(epochSeconds * 1000).toISOString() +} + +function dominantModel(db: SqliteDatabase, sessionId: string): string { + try { + const rows = db.query<{ model: string | null }>( + `SELECT model FROM messages + WHERE session_id = ? AND model IS NOT NULL AND model <> '' + GROUP BY model + ORDER BY COUNT(*) DESC + LIMIT 1`, + [sessionId], + ) + if (rows.length === 0) return 'unknown' + return rows[0]!.model ?? 'unknown' + } catch { + return 'unknown' + } +} + +function createParser(source: SessionSource, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + if (!isSqliteAvailable()) { + process.stderr.write(getSqliteLoadError() + '\n') + return + } + + // Source paths are encoded as `:`. Split from the + // right because dbPath may contain a colon on Windows (drive letter). + const segments = source.path.split(':') + const sessionId = segments[segments.length - 1]! + const dbPath = segments.slice(0, -1).join(':') + + let db: SqliteDatabase + try { + db = openDatabase(dbPath) + } catch (err) { + process.stderr.write( + `codeburn: cannot open Crush database: ${err instanceof Error ? err.message : err}\n`, + ) + return + } + + try { + if (!validateSchema(db)) return + + const rows = db.query( + `SELECT id, prompt_tokens, completion_tokens, cost, created_at, updated_at, message_count + FROM sessions + WHERE id = ? AND parent_session_id IS NULL`, + [sessionId], + ) + if (rows.length === 0) return + const session = rows[0]! + + const inputTokens = session.prompt_tokens ?? 0 + const outputTokens = session.completion_tokens ?? 0 + const cost = session.cost ?? 0 + if (inputTokens === 0 && outputTokens === 0 && cost === 0) return + + const dedupKey = `crush:${sessionId}` + if (seenKeys.has(dedupKey)) return + seenKeys.add(dedupKey) + + const model = dominantModel(db, sessionId) + // Crush already records cost in dollars; trust it. Fall back to + // pricing-table calculation only when the row is missing a cost. + const costUSD = cost > 0 + ? cost + : calculateCost(model, inputTokens, outputTokens, 0, 0, 0) + + yield { + provider: 'crush', + model, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + tools: [], + bashCommands: [], + timestamp: epochSecondsToIso(session.updated_at ?? session.created_at), + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: '', + sessionId, + } + } finally { + db.close() + } + }, + } +} + +async function discoverFromDb(dbPath: string, project: string): Promise { + let db: SqliteDatabase + try { + db = openDatabase(dbPath) + } catch { + return [] + } + try { + if (!validateSchema(db)) return [] + const rows = db.query<{ id: string }>( + `SELECT id FROM sessions + WHERE parent_session_id IS NULL + AND (cost > 0 OR prompt_tokens > 0 OR completion_tokens > 0) + ORDER BY created_at DESC`, + ) + return rows.map(row => ({ + path: `${dbPath}:${row.id}`, + project, + provider: 'crush', + })) + } catch { + return [] + } finally { + db.close() + } +} + +export function createCrushProvider(): Provider { + return { + name: 'crush', + displayName: 'Crush', + + modelDisplayName(model: string): string { + return model + }, + + toolDisplayName(rawTool: string): string { + return rawTool + }, + + async discoverSessions(): Promise { + if (!isSqliteAvailable()) return [] + const registry = await loadRegistry(getRegistryPath()) + const sources: SessionSource[] = [] + for (const entry of registry) { + const dbPath = resolveDbPath(entry) + const project = sanitizeProject(entry.path) + const found = await discoverFromDb(dbPath, project) + sources.push(...found) + } + return sources + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const crush = createCrushProvider() diff --git a/src/providers/cursor.ts b/src/providers/cursor.ts index e9619b8..ebd7f91 100644 --- a/src/providers/cursor.ts +++ b/src/providers/cursor.ts @@ -1,10 +1,10 @@ -import { existsSync } from 'fs' +import { existsSync, statSync, readdirSync, readFileSync } from 'fs' import { join } from 'path' import { homedir } from 'os' import { calculateCost } from '../models.js' import { readCachedResults, writeCachedResults } from '../cursor-cache.js' -import { isSqliteAvailable, getSqliteLoadError, openDatabase, type SqliteDatabase } from '../sqlite.js' +import { isSqliteAvailable, getSqliteLoadError, openDatabase, blobToText, type SqliteDatabase } from '../sqlite.js' import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' const CURSOR_COST_MODEL = 'claude-sonnet-4-5' @@ -27,21 +27,22 @@ const modelDisplayNames: Record = { } type BubbleRow = { + bubble_key: string input_tokens: number | null output_tokens: number | null model: string | null created_at: string | null conversation_id: string | null - user_text: string | null + user_text: Uint8Array | string | null text_length: number | null bubble_type: number | null - code_blocks: string | null + code_blocks: Uint8Array | string | null } type AgentKvRow = { key: string role: string | null - content: string | null + content: Uint8Array | string | null request_id: string | null content_length: number } @@ -69,6 +70,190 @@ function getCursorDbPath(): string { return join(homedir(), '.config', 'Cursor', 'User', 'globalStorage', 'state.vscdb') } +function getCursorWorkspaceStorageDir(globalDbPath: string): string { + // Sibling of globalStorage. Cursor lays out User/{globalStorage,workspaceStorage}/. + // We derive the workspaceStorage path from the global DB path so a test or + // override can supply both consistently from one root. + // globalDbPath = .../User/globalStorage/state.vscdb + // workspaceStorage = .../User/workspaceStorage + const userDir = join(globalDbPath, '..', '..') + return join(userDir, 'workspaceStorage') +} + +/// Per-conversation workspace lookup table. Cursor stores each chat as +/// `bubbleId::` rows in the GLOBAL state.vscdb but +/// does NOT carry a workspace path on the bubble itself. The mapping lives +/// in per-workspace dirs at `workspaceStorage//`: +/// - `workspace.json` carries the folder URI (`file:///Users/me/proj`) +/// - `state.vscdb`'s `ItemTable['composer.composerData']` lists every +/// composerId opened in that workspace +/// We walk every workspace dir, pull both, and build composerId -> folder. +type WorkspaceMapping = { + composerToWorkspace: Map // composerId -> folder URI + workspaceProjectName: Map // folder URI -> sanitized project name +} + +const ORPHAN_TAG = '__orphan__' +// Catch-all project label for composers that did not register against any +// workspace. When the user has no workspaces at all this is the only label +// shown, matching the pre-PR `cursor` project so legacy installs are not +// renamed by the breakdown change. +const ORPHAN_PROJECT = 'cursor' + +function sanitizeWorkspaceUri(uri: string): string { + // Mirrors Claude's slug convention so two providers reporting the same + // project path produce identical project keys for cross-provider rollup. + // file:///Users/me/myproject → -Users-me-myproject + // vscode-remote://wsl+Ubuntu/home/me/proj → -wsl-Ubuntu-home-me-proj + let path: string + if (uri.startsWith('file://')) { + path = uri.slice('file://'.length) + } else { + // Other URI schemes (vscode-remote://, ssh+remote://, etc.): swap "://" + // for a leading "/" so the slugifier produces a predictable shape. + path = uri.replace(/^[^:]+:\/\//, '/').replace(/\+/g, '-') + } + try { + path = decodeURIComponent(path) + } catch { + // Malformed percent encoding — keep as-is rather than throw. + } + return path.replace(/\/+/g, '-') +} + +let workspaceMapCache: WorkspaceMapping | null = null +let workspaceMapCacheRoot: string | null = null + +/// Visible for tests so a fixture can rebuild the map after writing fresh +/// workspace directories. +export function clearCursorWorkspaceMapCache(): void { + workspaceMapCache = null + workspaceMapCacheRoot = null +} + +function loadWorkspaceMap(workspaceStorageDir: string): WorkspaceMapping { + if (workspaceMapCache && workspaceMapCacheRoot === workspaceStorageDir) { + return workspaceMapCache + } + const result: WorkspaceMapping = { + composerToWorkspace: new Map(), + workspaceProjectName: new Map(), + } + + let entries: string[] + try { + entries = readdirSync(workspaceStorageDir) + } catch { + workspaceMapCache = result + workspaceMapCacheRoot = workspaceStorageDir + return result + } + + for (const hashDir of entries) { + const wsJsonPath = join(workspaceStorageDir, hashDir, 'workspace.json') + const wsDbPath = join(workspaceStorageDir, hashDir, 'state.vscdb') + + let wsJsonRaw: string + try { + wsJsonRaw = readFileSync(wsJsonPath, 'utf-8') + } catch { + continue + } + + let folder: string | undefined + try { + const parsed = JSON.parse(wsJsonRaw) as { folder?: string } + folder = parsed.folder + } catch { + continue + } + if (!folder) continue + if (!existsSync(wsDbPath)) continue + + let db: SqliteDatabase + try { + db = openDatabase(wsDbPath) + } catch { + continue + } + try { + const rows = db.query<{ value: string }>( + "SELECT value FROM ItemTable WHERE key='composer.composerData'", + ) + if (rows.length === 0) continue + let parsed: { allComposers?: Array<{ composerId?: string }> } + try { + parsed = JSON.parse(rows[0]!.value) + } catch { + continue + } + const project = sanitizeWorkspaceUri(folder) + let added = 0 + for (const c of parsed.allComposers ?? []) { + if (typeof c.composerId === 'string') { + result.composerToWorkspace.set(c.composerId, folder) + added += 1 + } + } + if (added > 0) { + result.workspaceProjectName.set(folder, project) + } + } catch { + // best-effort + } finally { + db.close() + } + } + + workspaceMapCache = result + workspaceMapCacheRoot = workspaceStorageDir + return result +} + +/// Pulls the composer id out of a `bubbleId::` key. +/// Returns null when the composer segment contains a CR/LF, which is the +/// signature Cursor uses for tool-call sub-composer rows in real data — +/// e.g. `bubbleId:task-call_xxxx\nfc_yyyy:` is one key with a +/// literal newline between the `task-call_` and `fc_` halves. Those rows +/// are not standalone composers and would otherwise inflate the orphan +/// project's session count. +function parseComposerIdFromKey(key: string | undefined): string | null { + if (!key) return null + const firstColon = key.indexOf(':') + if (firstColon < 0) return null + const secondColon = key.indexOf(':', firstColon + 1) + if (secondColon < 0) return null + const candidate = key.slice(firstColon + 1, secondColon) + if (!candidate) return null + // Reject any multi-line / control-char composer id. Real composer ids + // (UUIDs) and synthetic fixture ids are both single-line. + if (/[\r\n\x00]/.test(candidate)) return null + return candidate +} + +// Encodes the active workspace into source.path so the parser knows which +// composers to filter for. `#cursor-ws=` is a private separator: `state.vscdb` +// does not contain `#` (we construct the path ourselves), and the literal +// token only appears in source paths emitted from this provider, so there +// is no realistic collision. +const WORKSPACE_SEP = '#cursor-ws=' + +function encodeSourcePath(dbPath: string, workspaceTag: string): string { + return `${dbPath}${WORKSPACE_SEP}${workspaceTag}` +} + +function decodeSourcePath(sourcePath: string): { dbPath: string; workspaceTag: string } { + const idx = sourcePath.indexOf(WORKSPACE_SEP) + // Backwards-compat: a bare DB path with no workspace tag means "give me + // every call from this DB". Older cached SessionSource entries and any + // hand-constructed source from a test land here. + if (idx < 0) return { dbPath: sourcePath, workspaceTag: '__all__' } + return { + dbPath: sourcePath.slice(0, idx), + workspaceTag: sourcePath.slice(idx + WORKSPACE_SEP.length), + } +} + type CodeBlock = { languageId?: string } function extractLanguages(codeBlocksJson: string | null): string[] { @@ -100,15 +285,16 @@ function modelForDisplay(raw: string | null): string { const BUBBLE_QUERY_BASE = ` SELECT + key as bubble_key, json_extract(value, '$.tokenCount.inputTokens') as input_tokens, json_extract(value, '$.tokenCount.outputTokens') as output_tokens, json_extract(value, '$.modelInfo.modelName') as model, json_extract(value, '$.createdAt') as created_at, json_extract(value, '$.conversationId') as conversation_id, - substr(json_extract(value, '$.text'), 1, 500) as user_text, + CAST(substr(json_extract(value, '$.text'), 1, 500) AS BLOB) as user_text, length(json_extract(value, '$.text')) as text_length, json_extract(value, '$.type') as bubble_type, - json_extract(value, '$.codeBlocks') as code_blocks + CAST(json_extract(value, '$.codeBlocks') AS BLOB) as code_blocks FROM cursorDiskKV WHERE key LIKE 'bubbleId:%' ` @@ -117,7 +303,7 @@ const AGENTKV_QUERY = ` SELECT key, json_extract(value, '$.role') as role, - json_extract(value, '$.content') as content, + CAST(json_extract(value, '$.content') AS BLOB) as content, json_extract(value, '$.providerOptions.cursor.requestId') as request_id, length(value) as content_length FROM cursorDiskKV @@ -130,7 +316,7 @@ const USER_MESSAGES_QUERY = ` SELECT json_extract(value, '$.conversationId') as conversation_id, json_extract(value, '$.createdAt') as created_at, - substr(json_extract(value, '$.text'), 1, 500) as text + CAST(substr(json_extract(value, '$.text'), 1, 500) AS BLOB) as text FROM cursorDiskKV WHERE key LIKE 'bubbleId:%' AND json_extract(value, '$.type') = 1 @@ -138,10 +324,17 @@ const USER_MESSAGES_QUERY = ` ORDER BY ROWID ASC ` -const BUBBLE_QUERY_SINCE = BUBBLE_QUERY_BASE + ` - AND (json_extract(value, '$.createdAt') > ? OR json_extract(value, '$.createdAt') IS NULL) +// Split into HEAD (predicates we always emit) and TAIL (ORDER BY) so the +// caller can splice in an optional `ROWID >= ?` cutoff without rewriting +// the whole template. The original combined string is preserved as +// BUBBLE_QUERY_SINCE for any caller that doesn't want the cap. +const BUBBLE_QUERY_SINCE_HEAD = BUBBLE_QUERY_BASE + ` + AND json_extract(value, '$.createdAt') IS NOT NULL + AND json_extract(value, '$.createdAt') > ?` +const BUBBLE_QUERY_SINCE_TAIL = ` ORDER BY ROWID ASC ` +const BUBBLE_QUERY_SINCE = BUBBLE_QUERY_SINCE_HEAD + BUBBLE_QUERY_SINCE_TAIL function validateSchema(db: SqliteDatabase): boolean { try { @@ -154,22 +347,43 @@ function validateSchema(db: SqliteDatabase): boolean { } } -type UserMsgRow = { conversation_id: string; created_at: string; text: string } +type UserMsgRow = { conversation_id: string; created_at: string; text: Uint8Array | string } -function buildUserMessageMap(db: SqliteDatabase, timeFloor: string): Map { - const map = new Map() +/// Per-conversation user-message buffer. We pop messages in arrival order via +/// the `pos` cursor — a previous implementation called Array.shift() which is +/// O(n) per call on large conversations and pinned multi-GB Cursor DBs at +/// minutes-of-parse for power users. The cursor walk is O(1). +type UserMessageQueue = { + messages: string[] + pos: number +} + +function buildUserMessageMap(db: SqliteDatabase, timeFloor: string): Map { + const map = new Map() try { const rows = db.query(USER_MESSAGES_QUERY, [timeFloor]) for (const row of rows) { if (!row.conversation_id || !row.text) continue - const existing = map.get(row.conversation_id) ?? [] - existing.push(row.text) - map.set(row.conversation_id, existing) + const text = blobToText(row.text) + const existing = map.get(row.conversation_id) + if (existing) { + existing.messages.push(text) + } else { + map.set(row.conversation_id, { messages: [text], pos: 0 }) + } } } catch {} return map } +function takeUserMessage(queues: Map, conversationId: string): string { + const queue = queues.get(conversationId) + if (!queue || queue.pos >= queue.messages.length) return '' + const msg = queue.messages[queue.pos] + queue.pos += 1 + return msg +} + function parseBubbles(db: SqliteDatabase, seenKeys: Set): { calls: ParsedProviderCall[] } { const results: ParsedProviderCall[] = [] let skipped = 0 @@ -177,11 +391,53 @@ function parseBubbles(db: SqliteDatabase, seenKeys: Set): { calls: Parse const LOOKBACK_DAYS = 180 const timeFloor = new Date(Date.now() - LOOKBACK_DAYS * 24 * 60 * 60 * 1000).toISOString() + // Hard cap on rows to scan. The BUBBLE_QUERY_SINCE filter relies on + // json_extract over the value BLOB, which SQLite cannot serve from an + // index — every row is JSON-decoded. Multi-GB Cursor DBs (power users, + // years of usage) regularly exceed 500k bubble rows and were producing + // 30s+ parse stalls. Compute a ROWID cutoff that limits the scan to the + // MAX_BUBBLES most-recent bubbles when the user is over the cap, and + // warn so they know older sessions may be missing. + const MAX_BUBBLES = 250_000 + let rowIdCutoff = 0 + try { + const countRows = db.query<{ cnt: number }>( + "SELECT COUNT(*) as cnt FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'" + ) + const total = countRows[0]?.cnt ?? 0 + if (total > MAX_BUBBLES) { + // Find the ROWID of the (MAX_BUBBLES)th most-recent bubble. Anything + // below this rowid is older and gets skipped. Bubbles are written + // chronologically so ROWID order ≈ insertion order. + const cutoffRows = db.query<{ rid: number }>( + `SELECT MIN(rid) as rid FROM ( + SELECT ROWID as rid FROM cursorDiskKV + WHERE key LIKE 'bubbleId:%' + ORDER BY ROWID DESC + LIMIT ? + )`, + [MAX_BUBBLES] + ) + rowIdCutoff = cutoffRows[0]?.rid ?? 0 + process.stderr.write( + `codeburn: Cursor database has ${total.toLocaleString()} bubbles, ` + + `scanning the most recent ${MAX_BUBBLES.toLocaleString()}. ` + + `Older sessions may be missing from this report.\n` + ) + } + } catch { /* best-effort diagnostic */ } + const userMessages = buildUserMessageMap(db, timeFloor) + // Append the rowid cutoff when active. Empty string when not capped so the + // query string compares identically to the un-capped version on small DBs. + const rowIdFilter = rowIdCutoff > 0 ? ' AND ROWID >= ?' : '' + const params: unknown[] = rowIdCutoff > 0 ? [timeFloor, rowIdCutoff] : [timeFloor] + const cappedQuery = BUBBLE_QUERY_SINCE_HEAD + rowIdFilter + BUBBLE_QUERY_SINCE_TAIL + let rows: BubbleRow[] try { - rows = db.query(BUBBLE_QUERY_SINCE, [timeFloor]) + rows = db.query(cappedQuery, params) } catch { return { calls: results } } @@ -203,8 +459,27 @@ function parseBubbles(db: SqliteDatabase, seenKeys: Set): { calls: Parse } const createdAt = row.created_at ?? '' - const conversationId = row.conversation_id ?? 'unknown' - const dedupKey = `cursor:${conversationId}:${createdAt}:${inputTokens}:${outputTokens}` + if (!createdAt) continue + // The JSON `conversationId` field on bubbles is empty in current + // Cursor builds. The real composerId lives in the row key + // `bubbleId::`. Extract from the key so the + // workspace map join works. parseComposerIdFromKey returns null for + // non-UUID composer segments (Cursor stores tool-call output under + // `bubbleId:task-call_xxx\nfc_yyy:` and similar shapes — + // those bubbles are NOT standalone sessions; their tokens are + // already accounted for inside the parent composer's stream). + const parsedComposerId = parseComposerIdFromKey(row.bubble_key) + if (!parsedComposerId) { + skipped++ + continue + } + const conversationId = parsedComposerId + // Use the SQLite row key (bubbleId:) as the dedup key. + // Cursor mutates token counts on the row in place when streaming + // completes — including tokens in the dedup key (the previous + // implementation) caused the same bubble to be counted twice once + // its tokens stabilized. + const dedupKey = `cursor:bubble:${row.bubble_key}` if (seenKeys.has(dedupKey)) continue seenKeys.add(dedupKey) @@ -214,13 +489,12 @@ function parseBubbles(db: SqliteDatabase, seenKeys: Set): { calls: Parse const costUSD = calculateCost(pricingModel, inputTokens, outputTokens, 0, 0, 0) - const timestamp = createdAt || new Date().toISOString() - const convMessages = userMessages.get(conversationId) ?? [] - const userQuestion = convMessages.length > 0 ? convMessages.shift()! : '' - const assistantText = row.user_text ?? '' + const timestamp = createdAt + const userQuestion = takeUserMessage(userMessages, conversationId) + const assistantText = blobToText(row.user_text) const userText = (userQuestion + ' ' + assistantText).trim() - const languages = extractLanguages(row.code_blocks) + const languages = extractLanguages(blobToText(row.code_blocks)) const hasCode = languages.length > 0 const cursorTools: string[] = hasCode ? ['cursor:edit', ...languages.map(l => `lang:${l}`)] : [] @@ -273,9 +547,21 @@ function extractTextLength(content: AgentKvContent[]): number { return total } -function parseAgentKv(db: SqliteDatabase, seenKeys: Set): { calls: ParsedProviderCall[] } { +function parseAgentKv(db: SqliteDatabase, seenKeys: Set, dbPath: string): { calls: ParsedProviderCall[] } { const results: ParsedProviderCall[] = [] + // Cursor's agentKv schema does not record per-message timestamps. Use the + // SQLite file's mtime as a bounded "last write" timestamp for all calls; + // it's at least honest (no future time, no always-now). Users running + // codeburn against an idle Cursor install will see agentKv calls land at + // the actual last activity time rather than today's date. + let agentKvTimestamp: string + try { + agentKvTimestamp = new Date(statSync(dbPath).mtimeMs).toISOString() + } catch { + agentKvTimestamp = new Date().toISOString() + } + let rows: AgentKvRow[] try { rows = db.query(AGENTKV_QUERY) @@ -289,20 +575,21 @@ function parseAgentKv(db: SqliteDatabase, seenKeys: Set): { calls: Parse for (const row of rows) { if (!row.role || !row.content) continue + const contentText = blobToText(row.content) let content: AgentKvContent[] let plainTextLength = 0 try { - const parsed = JSON.parse(row.content) + const parsed = JSON.parse(contentText) if (Array.isArray(parsed)) { content = parsed } else { content = [] - plainTextLength = row.content.length + plainTextLength = contentText.length } } catch { content = [] - plainTextLength = row.content.length + plainTextLength = contentText.length } const requestId = row.request_id ?? currentRequestId @@ -318,7 +605,7 @@ function parseAgentKv(db: SqliteDatabase, seenKeys: Set): { calls: Parse const existing = sessions.get(requestId) ?? { inputChars: 0, outputChars: 0, model: null, userText: '' } existing.inputChars += textLength if (!existing.userText) { - const text = content[0]?.text ?? row.content + const text = content[0]?.text ?? contentText const queryMatch = text.match(/([\s\S]*?)<\/user_query>/) existing.userText = queryMatch ? queryMatch[1].trim().slice(0, 500) : text.slice(0, 500) } @@ -362,7 +649,7 @@ function parseAgentKv(db: SqliteDatabase, seenKeys: Set): { calls: Parse costUSD, tools: [], bashCommands: [], - timestamp: new Date().toISOString(), + timestamp: agentKvTimestamp, speed: 'standard', deduplicationKey: dedupKey, userMessage: session.userText, @@ -381,41 +668,75 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars return } - const cached = await readCachedResults(source.path) - if (cached) { - for (const call of cached) { - if (seenKeys.has(call.deduplicationKey)) continue - seenKeys.add(call.deduplicationKey) - yield call + const { dbPath, workspaceTag } = decodeSourcePath(source.path) + + // Decide which composers belong to this source. The workspace map is + // built once per process from `workspaceStorage/*` and reused across + // every workspace-scoped source, so we pay the directory walk cost + // only once per CLI run regardless of how many projects the user has. + // `composerFilter` holds the set of composers EITHER allowed (workspace + // source) or denied (orphan source); `filterMode` says which. + let composerFilter: Set | null = null + let filterMode: 'include' | 'exclude' = 'include' + if (workspaceTag !== '__all__') { + const wsMap = loadWorkspaceMap(getCursorWorkspaceStorageDir(dbPath)) + if (workspaceTag === ORPHAN_TAG) { + // Orphan source: every composer that is mapped to SOME workspace + // is excluded here, so unmapped composers (and any non-UUID + // sub-composer ids that slip through) land in this bucket. + composerFilter = new Set(wsMap.composerToWorkspace.keys()) + filterMode = 'exclude' + } else { + composerFilter = new Set() + for (const [composerId, folder] of wsMap.composerToWorkspace) { + if (folder === workspaceTag) composerFilter.add(composerId) + } + filterMode = 'include' } - return } - let db: SqliteDatabase - try { - db = openDatabase(source.path) - } catch (err) { - process.stderr.write(`codeburn: cannot open Cursor database: ${err instanceof Error ? err.message : err}\n`) - return - } - - try { - if (!validateSchema(db)) { - process.stderr.write('codeburn: Cursor storage format not recognized. You may need to update CodeBurn.\n') + // Cache is keyed on the bare DB path so multiple workspace-scoped + // sources reuse one parsed bubble set per CLI run. Filtering happens + // post-cache so each source emits only its own composers. + let allCalls: ParsedProviderCall[] | null = null + const cached = await readCachedResults(dbPath) + if (cached) { + allCalls = cached + } else { + let db: SqliteDatabase + try { + db = openDatabase(dbPath) + } catch (err) { + process.stderr.write(`codeburn: cannot open Cursor database: ${err instanceof Error ? err.message : err}\n`) return } - - const { calls: bubbleCalls } = parseBubbles(db, seenKeys) - const { calls: agentKvCalls } = parseAgentKv(db, seenKeys) - const calls = [...bubbleCalls, ...agentKvCalls] - - await writeCachedResults(source.path, calls) - - for (const call of calls) { - yield call + try { + if (!validateSchema(db)) { + process.stderr.write('codeburn: Cursor storage format not recognized. You may need to update CodeBurn.\n') + return + } + // Use a fresh local Set for intra-parse dedup so the global + // seenKeys is not mutated by calls that the workspace filter is + // about to drop. Cross-source dedup happens at yield time. + const localSeen = new Set() + const { calls: bubbleCalls } = parseBubbles(db, localSeen) + const { calls: agentKvCalls } = parseAgentKv(db, localSeen, dbPath) + allCalls = [...bubbleCalls, ...agentKvCalls] + await writeCachedResults(dbPath, allCalls) + } finally { + db.close() } - } finally { - db.close() + } + + for (const call of allCalls) { + if (composerFilter !== null) { + const inSet = composerFilter.has(call.sessionId) + if (filterMode === 'include' && !inSet) continue + if (filterMode === 'exclude' && inSet) continue + } + if (seenKeys.has(call.deduplicationKey)) continue + seenKeys.add(call.deduplicationKey) + yield call } }, } @@ -440,7 +761,27 @@ export function createCursorProvider(dbPathOverride?: string): Provider { const dbPath = dbPathOverride ?? getCursorDbPath() if (!existsSync(dbPath)) return [] - return [{ path: dbPath, project: 'cursor', provider: 'cursor' }] + const wsMap = loadWorkspaceMap(getCursorWorkspaceStorageDir(dbPath)) + const sources: SessionSource[] = [] + for (const [folder, project] of wsMap.workspaceProjectName) { + sources.push({ + path: encodeSourcePath(dbPath, folder), + project, + provider: 'cursor', + }) + } + // Always emit a catch-all source for composers with no workspace + // mapping. About a third of composers in real-world Cursor installs + // are unmapped (multi-root workspaces, "no folder open" sessions, + // deleted workspaces with surviving global rows). When the user has + // no workspaces at all this source captures everything and the + // dashboard looks identical to the pre-PR `cursor` project. + sources.push({ + path: encodeSourcePath(dbPath, ORPHAN_TAG), + project: ORPHAN_PROJECT, + provider: 'cursor', + }) + return sources }, createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { diff --git a/src/providers/droid.ts b/src/providers/droid.ts index d744040..2b351a5 100644 --- a/src/providers/droid.ts +++ b/src/providers/droid.ts @@ -206,7 +206,12 @@ function createParser( if (assistantCalls.length === 0) return - // Distribute session-level token usage across calls + // KNOWN LIMITATION: Droid records token usage only at session level + // (settings.tokenUsage), not per-message. We split evenly across the + // emitted assistant calls and price all of them at settings.model + // (the latest model the session used). For sessions where the user + // switched models mid-stream, costs are approximate — we have no + // ground-truth breakdown to attribute tokens per model. const totalTokens = settings.tokenUsage if (!totalTokens) return diff --git a/src/providers/gemini.ts b/src/providers/gemini.ts index 48b3107..87517d8 100644 --- a/src/providers/gemini.ts +++ b/src/providers/gemini.ts @@ -3,6 +3,7 @@ import { join } from 'path' import { homedir } from 'os' import { calculateCost } from '../models.js' +import { extractBashCommands } from '../bash-utils.js' import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' const toolNameMap: Record = { @@ -83,7 +84,7 @@ function parseSession(data: GeminiSession, seenKeys: Set): ParsedProvide for (const msg of geminiMessages) { const t = msg.tokens! totalInput += t.input ?? 0 - totalOutput += (t.output ?? 0) + (t.thoughts ?? 0) + totalOutput += t.output ?? 0 totalCached += t.cached ?? 0 totalThoughts += t.thoughts ?? 0 if (msg.model && !model) model = msg.model @@ -93,8 +94,7 @@ function parseSession(data: GeminiSession, seenKeys: Set): ParsedProvide const mapped = toolNameMap[tc.displayName ?? ''] ?? toolNameMap[tc.name] ?? tc.displayName ?? tc.name allTools.push(mapped) if (mapped === 'Bash' && tc.args && typeof tc.args.command === 'string') { - const cmd = tc.args.command.split(/\s+/)[0] ?? '' - if (cmd) bashCommands.push(cmd) + bashCommands.push(...extractBashCommands(tc.args.command)) } } } @@ -119,7 +119,10 @@ function parseSession(data: GeminiSession, seenKeys: Set): ParsedProvide const tsDate = new Date(data.startTime) if (isNaN(tsDate.getTime()) || tsDate.getTime() < 1_000_000_000_000) return results - const costUSD = calculateCost(model, freshInput, totalOutput, 0, totalCached, 0) + // Gemini bills thoughts at the output token rate; calculateCost does not + // accept a reasoning parameter, so fold thoughts into the output count for + // pricing while keeping outputTokens / reasoningTokens reported separately. + const costUSD = calculateCost(model, freshInput, totalOutput + totalThoughts, 0, totalCached, 0) results.push({ provider: 'gemini', diff --git a/src/providers/goose.ts b/src/providers/goose.ts new file mode 100644 index 0000000..9f4abe5 --- /dev/null +++ b/src/providers/goose.ts @@ -0,0 +1,276 @@ +import { join } from 'path' +import { homedir, platform } from 'os' + +import { calculateCost, getShortModelName } from '../models.js' +import { extractBashCommands } from '../bash-utils.js' +import { isSqliteAvailable, getSqliteLoadError, openDatabase, blobToText, type SqliteDatabase } from '../sqlite.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +type SessionRow = { + id: string + name: string + working_dir: string | null + created_at: string | null + updated_at: string | null + accumulated_input_tokens: number | null + accumulated_output_tokens: number | null + provider_name: string | null + model_config_json: Uint8Array | string | null +} + +type ModelConfig = { + model_name?: string + reasoning?: boolean +} + +type MessageRow = { + message_id: string + role: string + content_json: Uint8Array | string + created_timestamp: number +} + +type ContentItem = { + type: string + toolCall?: { value?: { name?: string; arguments?: Record } } +} + +const toolNameMap: Record = { + developer__shell: 'Bash', + developer__text_editor: 'Edit', + developer__read_file: 'Read', + developer__write_file: 'Write', + developer__list_directory: 'LS', + developer__search_files: 'Grep', + computercontroller__shell: 'Bash', +} + +function sanitize(dir: string): string { + return dir.replace(/^\//, '').replace(/\//g, '-') +} + +function getDbPath(): string { + const root = process.env['GOOSE_PATH_ROOT'] + if (root) return join(root, 'data', 'sessions', 'sessions.db') + + const p = platform() + if (p === 'darwin' || p === 'linux') { + const base = process.env['XDG_DATA_HOME'] ?? join(homedir(), '.local', 'share') + return join(base, 'goose', 'sessions', 'sessions.db') + } + return join(homedir(), 'AppData', 'Roaming', 'Block', 'goose', 'sessions', 'sessions.db') +} + +function validateSchema(db: SqliteDatabase): boolean { + try { + db.query<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions LIMIT 1") + db.query<{ cnt: number }>("SELECT COUNT(*) as cnt FROM messages LIMIT 1") + return true + } catch { + return false + } +} + +function parseModelConfig(raw: string | null): ModelConfig { + if (!raw) return {} + try { + return JSON.parse(raw) as ModelConfig + } catch { + return {} + } +} + +function extractToolsFromMessages(db: SqliteDatabase, sessionId: string): { tools: string[]; bashCommands: string[] } { + const tools: string[] = [] + const bashCommands: string[] = [] + const seen = new Set() + + try { + const rows = db.query<{ content_json: Uint8Array | string }>( + "SELECT CAST(content_json AS BLOB) AS content_json FROM messages WHERE session_id = ? AND role = 'assistant' AND content_json LIKE '%toolRequest%'", + [sessionId], + ) + + for (const row of rows) { + let items: ContentItem[] + try { + items = JSON.parse(blobToText(row.content_json)) as ContentItem[] + } catch { + continue + } + for (const item of items) { + if (item.type !== 'toolRequest') continue + const rawName = item.toolCall?.value?.name ?? '' + if (!rawName) continue + const mapped = toolNameMap[rawName] ?? rawName.split('__').pop() ?? rawName + if (!seen.has(mapped)) { + seen.add(mapped) + tools.push(mapped) + } + if (mapped === 'Bash') { + const cmd = item.toolCall?.value?.arguments?.command + if (typeof cmd === 'string') { + for (const c of extractBashCommands(cmd)) { + if (!bashCommands.includes(c)) bashCommands.push(c) + } + } + } + } + } + } catch { /* best-effort */ } + + return { tools, bashCommands } +} + +function getFirstUserMessage(db: SqliteDatabase, sessionId: string): string { + try { + const rows = db.query<{ content_json: Uint8Array | string }>( + "SELECT CAST(content_json AS BLOB) AS content_json FROM messages WHERE session_id = ? AND role = 'user' ORDER BY created_timestamp ASC LIMIT 1", + [sessionId], + ) + if (rows.length === 0) return '' + const items = JSON.parse(blobToText(rows[0]!.content_json)) as ContentItem[] + const text = items.find(i => i.type === 'text') as { text?: string } | undefined + return (text?.text ?? '').slice(0, 500) + } catch { + return '' + } +} + +function createParser(source: SessionSource, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + if (!isSqliteAvailable()) { + process.stderr.write(getSqliteLoadError() + '\n') + return + } + + const segments = source.path.split(':') + const sessionId = segments[segments.length - 1]! + const dbPath = segments.slice(0, -1).join(':') + + let db: SqliteDatabase + try { + db = openDatabase(dbPath) + } catch (err) { + process.stderr.write(`codeburn: cannot open Goose database: ${err instanceof Error ? err.message : err}\n`) + return + } + + try { + if (!validateSchema(db)) return + + const rows = db.query( + 'SELECT id, name, working_dir, created_at, updated_at, accumulated_input_tokens, accumulated_output_tokens, provider_name, CAST(model_config_json AS BLOB) AS model_config_json FROM sessions WHERE id = ?', + [sessionId], + ) + if (rows.length === 0) return + + const session = rows[0]! + const inputTokens = session.accumulated_input_tokens ?? 0 + const outputTokens = session.accumulated_output_tokens ?? 0 + if (inputTokens === 0 && outputTokens === 0) return + + const dedupKey = `goose:${sessionId}` + if (seenKeys.has(dedupKey)) return + seenKeys.add(dedupKey) + + const config = parseModelConfig(blobToText(session.model_config_json)) + const model = config.model_name ?? 'unknown' + const costUSD = calculateCost(model, inputTokens, outputTokens, 0, 0, 0) + + const { tools, bashCommands } = extractToolsFromMessages(db, sessionId) + const userMessage = getFirstUserMessage(db, sessionId) + + const raw = session.updated_at || session.created_at || '' + let ts = new Date(raw) + if (isNaN(ts.getTime())) ts = new Date(raw + 'Z') + if (isNaN(ts.getTime())) ts = new Date() + + yield { + provider: 'goose', + model, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + tools, + bashCommands, + timestamp: ts.toISOString(), + speed: 'standard', + deduplicationKey: dedupKey, + userMessage, + sessionId, + } + } finally { + db.close() + } + }, + } +} + +async function discoverFromDb(dbPath: string): Promise { + let db: SqliteDatabase + try { + db = openDatabase(dbPath) + } catch { + return [] + } + + try { + const rows = db.query( + 'SELECT id, name, working_dir, created_at, updated_at, accumulated_input_tokens, accumulated_output_tokens, provider_name, CAST(model_config_json AS BLOB) AS model_config_json FROM sessions ORDER BY updated_at DESC', + ) + + return rows + .filter(r => (r.accumulated_input_tokens ?? 0) > 0 || (r.accumulated_output_tokens ?? 0) > 0) + .map(row => ({ + path: `${dbPath}:${row.id}`, + project: row.working_dir ? sanitize(row.working_dir) : 'goose', + provider: 'goose', + })) + } catch { + return [] + } finally { + db.close() + } +} + +const modelDisplayNames: Record = { + 'gpt-5.5': 'GPT-5.5', + 'gpt-5.4': 'GPT-5.4', + 'gpt-5.4-mini': 'GPT-5.4 Mini', + 'gpt-4o': 'GPT-4o', + 'gpt-4o-mini': 'GPT-4o Mini', +} + +export function createGooseProvider(): Provider { + return { + name: 'goose', + displayName: 'Goose', + + modelDisplayName(model: string): string { + return modelDisplayNames[model] ?? getShortModelName(model) + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async discoverSessions(): Promise { + if (!isSqliteAvailable()) return [] + const dbPath = getDbPath() + return discoverFromDb(dbPath) + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const goose = createGooseProvider() diff --git a/src/providers/ibm-bob.ts b/src/providers/ibm-bob.ts new file mode 100644 index 0000000..5aec0f6 --- /dev/null +++ b/src/providers/ibm-bob.ts @@ -0,0 +1,59 @@ +import { join } from 'path' +import { homedir } from 'os' + +import { getShortModelName } from '../models.js' +import { discoverClineTasksInBaseDirs, createClineParser } from './vscode-cline-parser.js' +import type { Provider, SessionSource, SessionParser } from './types.js' + +const PROVIDER_NAME = 'ibm-bob' +const DISPLAY_NAME = 'IBM Bob' +const EXTENSION_ID = 'ibm.bob-code' +const FALLBACK_MODEL = 'ibm-bob-auto' + +export function getIBMBobGlobalStorageDirs(): string[] { + const home = homedir() + if (process.platform === 'darwin') { + return [ + join(home, 'Library', 'Application Support', 'IBM Bob', 'User', 'globalStorage', EXTENSION_ID), + join(home, 'Library', 'Application Support', 'Bob-IDE', 'User', 'globalStorage', EXTENSION_ID), + ] + } + if (process.platform === 'win32') { + const appData = process.env['APPDATA'] ?? join(home, 'AppData', 'Roaming') + return [ + join(appData, 'IBM Bob', 'User', 'globalStorage', EXTENSION_ID), + join(appData, 'Bob-IDE', 'User', 'globalStorage', EXTENSION_ID), + ] + } + const configHome = process.env['XDG_CONFIG_HOME'] ?? join(home, '.config') + return [ + join(configHome, 'IBM Bob', 'User', 'globalStorage', EXTENSION_ID), + join(configHome, 'Bob-IDE', 'User', 'globalStorage', EXTENSION_ID), + ] +} + +export function createIBMBobProvider(overrideDir?: string): Provider { + return { + name: PROVIDER_NAME, + displayName: DISPLAY_NAME, + + modelDisplayName(model: string): string { + return getShortModelName(model) + }, + + toolDisplayName(rawTool: string): string { + return rawTool + }, + + async discoverSessions(): Promise { + const dirs = overrideDir ? [overrideDir] : getIBMBobGlobalStorageDirs() + return discoverClineTasksInBaseDirs(dirs, PROVIDER_NAME, DISPLAY_NAME) + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createClineParser(source, seenKeys, PROVIDER_NAME, FALLBACK_MODEL) + }, + } +} + +export const ibmBob = createIBMBobProvider() diff --git a/src/providers/index.ts b/src/providers/index.ts index eeec08a..288bea8 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,17 +1,51 @@ import { claude } from './claude.js' +import { cline } from './cline.js' import { codebuff } from './codebuff.js' import { codex } from './codex.js' import { copilot } from './copilot.js' import { droid } from './droid.js' import { gemini } from './gemini.js' +import { ibmBob } from './ibm-bob.js' import { kiloCode } from './kilo-code.js' import { kiro } from './kiro.js' +import { kimi } from './kimi.js' +import { mistralVibe } from './mistral-vibe.js' import { openclaw } from './openclaw.js' import { pi, omp } from './pi.js' import { qwen } from './qwen.js' import { rooCode } from './roo-code.js' import type { Provider, SessionSource } from './types.js' +let antigravityProvider: Provider | null = null +let antigravityLoadAttempted = false + +async function loadAntigravity(): Promise { + if (antigravityLoadAttempted) return antigravityProvider + antigravityLoadAttempted = true + try { + const { antigravity } = await import('./antigravity.js') + antigravityProvider = antigravity + return antigravity + } catch { + return null + } +} + +let gooseProvider: Provider | null = null +let gooseLoadAttempted = false + +async function loadGoose(): Promise { + if (gooseLoadAttempted) return gooseProvider + gooseLoadAttempted = true + try { + const { goose } = await import('./goose.js') + gooseProvider = goose + return goose + } catch { + return null + } +} + let cursorProvider: Provider | null = null let cursorLoadAttempted = false @@ -33,6 +67,9 @@ let opencodeLoadAttempted = false let cursorAgentProvider: Provider | null = null let cursorAgentLoadAttempted = false +let crushProvider: Provider | null = null +let crushLoadAttempted = false + async function loadOpenCode(): Promise { if (opencodeLoadAttempted) return opencodeProvider opencodeLoadAttempted = true @@ -57,14 +94,29 @@ async function loadCursorAgent(): Promise { } } -const coreProviders: Provider[] = [claude, codex, codebuff, copilot, droid, gemini, kiloCode, kiro, openclaw, pi, omp, qwen, rooCode] +async function loadCrush(): Promise { + if (crushLoadAttempted) return crushProvider + crushLoadAttempted = true + try { + const { crush } = await import('./crush.js') + crushProvider = crush + return crush + } catch { + return null + } +} + +const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, droid, gemini, ibmBob, kiloCode, kiro, kimi, mistralVibe, openclaw, pi, omp, qwen, rooCode] export async function getAllProviders(): Promise { - const [cursor, opencode, cursorAgent] = await Promise.all([loadCursor(), loadOpenCode(), loadCursorAgent()]) + const [ag, gs, cursor, opencode, cursorAgent, crush] = await Promise.all([loadAntigravity(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush()]) const all = [...coreProviders] + if (ag) all.push(ag) + if (gs) all.push(gs) if (cursor) all.push(cursor) if (opencode) all.push(opencode) if (cursorAgent) all.push(cursorAgent) + if (crush) all.push(crush) return all } @@ -84,6 +136,14 @@ export async function discoverAllSessions(providerFilter?: string): Promise { + if (name === 'antigravity') { + const ag = await loadAntigravity() + return ag ?? undefined + } + if (name === 'goose') { + const gs = await loadGoose() + return gs ?? undefined + } if (name === 'cursor') { const cursor = await loadCursor() return cursor ?? undefined @@ -96,5 +156,9 @@ export async function getProvider(name: string): Promise { const ca = await loadCursorAgent() return ca ?? undefined } + if (name === 'crush') { + const c = await loadCrush() + return c ?? undefined + } return coreProviders.find(p => p.name === name) } diff --git a/src/providers/kimi.ts b/src/providers/kimi.ts new file mode 100644 index 0000000..75242cc --- /dev/null +++ b/src/providers/kimi.ts @@ -0,0 +1,394 @@ +import { createHash } from 'crypto' +import { readdir, readFile, stat } from 'fs/promises' +import { basename, dirname, join } from 'path' +import { homedir } from 'os' + +import { extractBashCommands } from '../bash-utils.js' +import { readSessionLines } from '../fs-utils.js' +import { calculateCost, getShortModelName } from '../models.js' +import type { ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js' + +type JsonObject = Record + +const toolNameMap: Record = { + Shell: 'Bash', + Bash: 'Bash', + bash: 'Bash', + ReadFile: 'Read', + ReadMediaFile: 'Read', + WriteFile: 'Write', + StrReplaceFile: 'Edit', + Grep: 'Grep', + Glob: 'Glob', + SearchWeb: 'WebSearch', + FetchURL: 'WebFetch', + Agent: 'Agent', + AgentTool: 'Agent', + TaskList: 'Agent', + TaskOutput: 'Agent', + TaskStop: 'Agent', + AskUserQuestion: 'AskUser', + SetTodoList: 'TodoWrite', + Think: 'Think', + EnterPlanMode: 'EnterPlanMode', + ExitPlanMode: 'ExitPlanMode', + SendDMail: 'DMail', +} + +function asObject(value: unknown): JsonObject | null { + return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonObject : null +} + +function stringField(obj: JsonObject | null, key: string): string | undefined { + const value = obj?.[key] + return typeof value === 'string' ? value : undefined +} + +function numericField(obj: JsonObject, ...keys: string[]): number { + for (const key of keys) { + const raw = obj[key] + const n = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN + if (Number.isFinite(n) && n > 0) return Math.trunc(n) + } + return 0 +} + +function getShareDir(overrideDir?: string): string { + return overrideDir ?? process.env['KIMI_SHARE_DIR'] ?? join(homedir(), '.kimi') +} + +function md5(text: string): string { + return createHash('md5').update(text, 'utf-8').digest('hex') +} + +function projectNameFromPath(pathValue: string): string { + const cleaned = pathValue.replace(/\/+$/, '') + return basename(cleaned) || cleaned || 'kimi' +} + +async function loadProjectNames(shareDir: string): Promise> { + const projects = new Map() + const raw = await readFile(join(shareDir, 'kimi.json'), 'utf-8').catch(() => null) + if (!raw) return projects + + let data: unknown + try { + data = JSON.parse(raw) + } catch { + return projects + } + + const workDirs = asObject(data)?.['work_dirs'] + if (!Array.isArray(workDirs)) return projects + + for (const entry of workDirs) { + const obj = asObject(entry) + const pathValue = stringField(obj, 'path') + if (!pathValue) continue + const hash = md5(pathValue) + const project = projectNameFromPath(pathValue) + projects.set(hash, project) + + const kaos = stringField(obj, 'kaos') + if (kaos && kaos !== 'local') projects.set(`${kaos}_${hash}`, project) + } + + return projects +} + +function parseTomlString(raw: string): string | null { + const value = raw.trim() + if (!value) return null + if (value.startsWith('"')) { + const match = value.match(/^"((?:[^"\\]|\\.)*)"/) + if (!match) return null + try { + return JSON.parse(`"${match[1]}"`) as string + } catch { + return match[1] ?? null + } + } + if (value.startsWith("'")) { + const match = value.match(/^'([^']*)'/) + return match?.[1] ?? null + } + const match = value.match(/^([^#\s]+)/) + return match?.[1] ?? null +} + +function parseDefaultModelKey(configToml: string): string | null { + for (const line of configToml.split('\n')) { + const match = line.match(/^\s*default_model\s*=\s*(.+)$/) + if (!match) continue + return parseTomlString(match[1]!) + } + return null +} + +function parseModelSectionName(line: string): string | null { + const match = line.trim().match(/^\[models\.(?:"([^"]+)"|'([^']+)'|([^\]]+))\]$/) + if (!match) return null + return (match[1] ?? match[2] ?? match[3] ?? '').trim() || null +} + +function parseModelIdForKey(configToml: string, modelKey: string): string | null { + let inSection = false + for (const line of configToml.split('\n')) { + const section = parseModelSectionName(line) + if (section !== null) { + inSection = section === modelKey + continue + } + if (!inSection) continue + if (/^\s*\[/.test(line)) { + inSection = false + continue + } + const match = line.match(/^\s*model\s*=\s*(.+)$/) + if (!match) continue + return parseTomlString(match[1]!) + } + return null +} + +async function getConfiguredModel(shareDir: string): Promise { + const envModel = process.env['KIMI_MODEL_NAME']?.trim() + if (envModel) return envModel + + const raw = await readFile(join(shareDir, 'config.toml'), 'utf-8').catch(() => null) + if (!raw) return 'kimi-auto' + + const defaultModel = parseDefaultModelKey(raw) + if (!defaultModel) return 'kimi-auto' + + return parseModelIdForKey(raw, defaultModel) ?? defaultModel +} + +function parseJsonObject(text: string | undefined): JsonObject | null { + if (!text) return null + try { + return asObject(JSON.parse(text)) + } catch { + return null + } +} + +function extractUserText(value: unknown): string { + if (typeof value === 'string') return value.slice(0, 500) + if (!Array.isArray(value)) return '' + + return value + .map(part => stringField(asObject(part), 'text') ?? '') + .filter(Boolean) + .join(' ') + .slice(0, 500) +} + +function timestampToIso(value: unknown): string { + if (typeof value === 'string') return value + if (typeof value !== 'number' || !Number.isFinite(value)) return '' + + const millis = value > 1_000_000_000_000 ? value : value * 1000 + const date = new Date(millis) + return Number.isFinite(date.getTime()) ? date.toISOString() : '' +} + +function extractEnvelope(record: JsonObject): { type: string; payload: JsonObject; timestamp: string } | null { + const message = asObject(record['message']) + const envelope = message ?? record + const type = stringField(envelope, 'type') + const payload = asObject(envelope['payload']) + if (!type || !payload) return null + return { type, payload, timestamp: timestampToIso(record['timestamp']) } +} + +function extractUsage(payload: JsonObject): { + inputTokens: number + outputTokens: number + cacheReadInputTokens: number + cacheCreationInputTokens: number +} | null { + const usage = asObject(payload['token_usage']) ?? asObject(payload['usage']) + if (!usage) return null + + const cacheReadInputTokens = numericField(usage, 'input_cache_read', 'cache_read_input_tokens', 'cached_input_tokens') + const cacheCreationInputTokens = numericField(usage, 'input_cache_creation', 'cache_creation_input_tokens') + let inputTokens = numericField(usage, 'input_other', 'input_tokens') + if (inputTokens === 0) { + const totalInput = numericField(usage, 'input') + inputTokens = Math.max(0, totalInput - cacheReadInputTokens - cacheCreationInputTokens) + } + const outputTokens = numericField(usage, 'output', 'output_tokens') + + if (inputTokens === 0 && outputTokens === 0 && cacheReadInputTokens === 0 && cacheCreationInputTokens === 0) { + return null + } + + return { inputTokens, outputTokens, cacheReadInputTokens, cacheCreationInputTokens } +} + +function extractTool(payload: JsonObject): { tool: string; bashCommands: string[] } | null { + const fn = asObject(payload['function']) + const rawName = stringField(fn, 'name') ?? stringField(payload, 'name') + if (!rawName) return null + + const tool = toolNameMap[rawName] ?? rawName + const argsText = stringField(fn, 'arguments') ?? stringField(payload, 'arguments') + const args = parseJsonObject(argsText) + const command = stringField(args, 'command') + const bashCommands = tool === 'Bash' && command ? extractBashCommands(command) : [] + + return { tool, bashCommands } +} + +function createParser(source: SessionSource, shareDir: string, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + const configuredModel = await getConfiguredModel(shareDir) + const tools = new Set() + const bashCommands = new Set() + let currentUserMessage = '' + const sessionId = basename(dirname(source.path)) + let index = 0 + + for await (const line of readSessionLines(source.path)) { + if (!line.trim()) continue + + let record: JsonObject | null = null + try { + record = asObject(JSON.parse(line)) + } catch { + continue + } + if (!record) continue + + const envelope = extractEnvelope(record) + if (!envelope || envelope.type === 'metadata') continue + + if (envelope.type === 'TurnBegin' || envelope.type === 'SteerInput') { + currentUserMessage = extractUserText(envelope.payload['user_input']) + continue + } + + if (envelope.type === 'TurnEnd') { + currentUserMessage = '' + tools.clear() + bashCommands.clear() + continue + } + + if (envelope.type === 'ToolCall' || envelope.type === 'ToolCallRequest') { + const extracted = extractTool(envelope.payload) + if (!extracted) continue + tools.add(extracted.tool) + for (const command of extracted.bashCommands) bashCommands.add(command) + continue + } + + if (envelope.type !== 'StatusUpdate') continue + + const usage = extractUsage(envelope.payload) + if (!usage) continue + + const rawMessageId = stringField(envelope.payload, 'message_id') + const dedupKey = `kimi:${sessionId}:${rawMessageId ?? index}` + index++ + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + const model = stringField(envelope.payload, 'model') ?? stringField(envelope.payload, 'model_name') ?? configuredModel + const costUSD = calculateCost( + model, + usage.inputTokens, + usage.outputTokens, + usage.cacheCreationInputTokens, + usage.cacheReadInputTokens, + 0, + ) + + yield { + provider: 'kimi', + model, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cacheCreationInputTokens: usage.cacheCreationInputTokens, + cacheReadInputTokens: usage.cacheReadInputTokens, + cachedInputTokens: usage.cacheReadInputTokens, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + tools: [...tools], + bashCommands: [...bashCommands], + timestamp: envelope.timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: currentUserMessage, + sessionId, + } + + tools.clear() + bashCommands.clear() + } + }, + } +} + +async function addWireSource(sources: SessionSource[], filePath: string, project: string): Promise { + const s = await stat(filePath).catch(() => null) + if (!s?.isFile()) return + sources.push({ path: filePath, project, provider: 'kimi' }) +} + +export function createKimiProvider(overrideDir?: string): Provider { + const shareDir = getShareDir(overrideDir) + + return { + name: 'kimi', + displayName: 'Kimi', + + modelDisplayName(model: string): string { + return getShortModelName(model) + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async discoverSessions(): Promise { + const sources: SessionSource[] = [] + const sessionsRoot = join(shareDir, 'sessions') + const projectNames = await loadProjectNames(shareDir) + const workDirs = await readdir(sessionsRoot, { withFileTypes: true }).catch(() => []) + + for (const workDir of workDirs) { + if (!workDir.isDirectory()) continue + + const project = projectNames.get(workDir.name) ?? workDir.name + const workDirPath = join(sessionsRoot, workDir.name) + const sessionDirs = await readdir(workDirPath, { withFileTypes: true }).catch(() => []) + + for (const sessionDir of sessionDirs) { + if (!sessionDir.isDirectory()) continue + + const sessionPath = join(workDirPath, sessionDir.name) + await addWireSource(sources, join(sessionPath, 'wire.jsonl'), project) + + const subagentsPath = join(sessionPath, 'subagents') + const subagents = await readdir(subagentsPath, { withFileTypes: true }).catch(() => []) + for (const subagent of subagents) { + if (!subagent.isDirectory()) continue + await addWireSource(sources, join(subagentsPath, subagent.name, 'wire.jsonl'), project) + } + } + } + + return sources + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, shareDir, seenKeys) + }, + } +} + +export const kimi = createKimiProvider() diff --git a/src/providers/mistral-vibe.ts b/src/providers/mistral-vibe.ts new file mode 100644 index 0000000..7feb988 --- /dev/null +++ b/src/providers/mistral-vibe.ts @@ -0,0 +1,355 @@ +import { readdir, stat } from 'fs/promises' +import { basename, join } from 'path' +import { homedir } from 'os' + +import { readSessionFile, readSessionLines } from '../fs-utils.js' +import { calculateCost } from '../models.js' +import { extractBashCommands } from '../bash-utils.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +const METADATA_FILENAME = 'meta.json' +const MESSAGES_FILENAME = 'messages.jsonl' +const DEFAULT_MODEL = 'mistral-medium-3.5' + +const modelDisplayNames: Record = { + 'mistral-medium-3.5': 'Mistral Medium 3.5', + 'mistral-vibe-cli-latest': 'Mistral Vibe CLI', + 'devstral-small': 'Devstral Small', + 'devstral-small-latest': 'Devstral Small', + devstral: 'Devstral', + local: 'Local', +} + +const toolNameMap: Record = { + bash: 'Bash', + read_file: 'Read', + write_file: 'Write', + search_replace: 'Edit', + grep: 'Grep', + task: 'Agent', + todo: 'TodoWrite', + skill: 'Skill', + web_fetch: 'WebFetch', + web_search: 'WebSearch', + ask_user_question: 'AskUser', + exit_plan_mode: 'ExitPlanMode', +} + +type VibeStats = { + session_prompt_tokens?: number + session_completion_tokens?: number + input_price_per_million?: number + output_price_per_million?: number + tokens_per_second?: number +} + +type VibeModelConfig = { + name?: string + alias?: string + input_price?: number + output_price?: number +} + +type VibeMetadata = { + session_id?: string + start_time?: string + end_time?: string | null + environment?: { + working_directory?: string | null + } + stats?: VibeStats + config?: { + active_model?: string + models?: VibeModelConfig[] + } + title?: string | null +} + +type VibeToolCall = { + function?: { + name?: string + arguments?: string | Record | null + } +} + +type VibeMessage = { + role?: string + content?: unknown + tool_calls?: VibeToolCall[] | null +} + +function getMistralVibeSessionsDir(override?: string): string { + if (override) return override + const configuredHome = process.env['VIBE_HOME'] + const vibeHome = configuredHome ? expandHome(configuredHome) : join(homedir(), '.vibe') + return join(vibeHome, 'logs', 'session') +} + +function expandHome(path: string): string { + if (path === '~') return homedir() + if (path.startsWith('~/')) return join(homedir(), path.slice(2)) + return path +} + +async function isFile(path: string): Promise { + const s = await stat(path).catch(() => null) + return Boolean(s?.isFile()) +} + +async function isDirectory(path: string): Promise { + const s = await stat(path).catch(() => null) + return Boolean(s?.isDirectory()) +} + +async function hasSessionFiles(dir: string): Promise { + const [hasMetadata, hasMessages] = await Promise.all([ + isFile(join(dir, METADATA_FILENAME)), + isFile(join(dir, MESSAGES_FILENAME)), + ]) + return hasMetadata && hasMessages +} + +async function readJsonFile(path: string): Promise { + const raw = await readSessionFile(path) + if (raw === null) return null + try { + const parsed = JSON.parse(raw) as unknown + return typeof parsed === 'object' && parsed !== null ? parsed as T : null + } catch { + return null + } +} + +async function discoverSessionDirs(root: string): Promise { + const sessionDirs: string[] = [] + + let entries: string[] + try { + entries = (await readdir(root)).sort() + } catch { + return sessionDirs + } + + for (const entry of entries) { + const dir = join(root, entry) + if (!await isDirectory(dir)) continue + + if (await hasSessionFiles(dir)) { + sessionDirs.push(dir) + } + + const agentsDir = join(dir, 'agents') + if (!await isDirectory(agentsDir)) continue + + let agentEntries: string[] + try { + agentEntries = (await readdir(agentsDir)).sort() + } catch { + continue + } + + for (const agentEntry of agentEntries) { + const agentDir = join(agentsDir, agentEntry) + if (await isDirectory(agentDir) && await hasSessionFiles(agentDir)) { + sessionDirs.push(agentDir) + } + } + } + + return sessionDirs +} + +function activeModelConfig(metadata: VibeMetadata): VibeModelConfig | null { + const activeModel = metadata.config?.active_model + const models = metadata.config?.models + if (!activeModel || !Array.isArray(models)) return null + return models.find(m => m.alias === activeModel || m.name === activeModel) ?? null +} + +function resolveModel(metadata: VibeMetadata): string { + const activeModel = metadata.config?.active_model + if (activeModel) return activeModel + const configured = activeModelConfig(metadata) + return configured?.alias ?? configured?.name ?? DEFAULT_MODEL +} + +function safeNumber(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0 +} + +function calculateSessionCost(metadata: VibeMetadata, model: string, inputTokens: number, outputTokens: number): number { + const stats = metadata.stats ?? {} + const configured = activeModelConfig(metadata) + const inputPrice = safeNumber(stats.input_price_per_million) || safeNumber(configured?.input_price) + const outputPrice = safeNumber(stats.output_price_per_million) || safeNumber(configured?.output_price) + + if (inputPrice > 0 || outputPrice > 0) { + return (inputTokens / 1_000_000) * inputPrice + (outputTokens / 1_000_000) * outputPrice + } + + return calculateCost(model, inputTokens, outputTokens, 0, 0, 0) +} + +function normalizeContent(content: unknown): string { + if (typeof content === 'string') return content + if (Array.isArray(content)) { + return content + .map(part => { + if (typeof part === 'string') return part + if (part && typeof part === 'object' && 'text' in part && typeof part.text === 'string') return part.text + return '' + }) + .filter(Boolean) + .join(' ') + } + return '' +} + +function parseToolArguments(raw: string | Record | null | undefined): Record { + if (!raw) return {} + if (typeof raw === 'object') return raw + try { + const parsed = JSON.parse(raw) as unknown + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {} + } catch { + return {} + } +} + +function extractTools(messages: VibeMessage[]): { tools: string[]; bashCommands: string[] } { + const tools: string[] = [] + const bashCommands: string[] = [] + + for (const message of messages) { + if (message.role !== 'assistant') continue + for (const toolCall of message.tool_calls ?? []) { + const rawName = toolCall.function?.name + if (!rawName) continue + + const mappedName = toolNameMap[rawName] ?? rawName + tools.push(mappedName) + + if (mappedName !== 'Bash') continue + const args = parseToolArguments(toolCall.function?.arguments) + const command = args['command'] + if (typeof command === 'string') { + bashCommands.push(...extractBashCommands(command)) + } + } + } + + return { + tools: [...new Set(tools)], + bashCommands: [...new Set(bashCommands)], + } +} + +async function readMessages(path: string): Promise { + const messages: VibeMessage[] = [] + for await (const line of readSessionLines(path)) { + if (!line.trim()) continue + try { + const parsed = JSON.parse(line) as unknown + if (parsed && typeof parsed === 'object') messages.push(parsed as VibeMessage) + } catch { + continue + } + } + return messages +} + +function firstUserMessage(messages: VibeMessage[], fallback?: string | null): string { + for (const message of messages) { + if (message.role !== 'user') continue + const text = normalizeContent(message.content).trim() + if (text) return text.slice(0, 500) + } + return (fallback ?? '').slice(0, 500) +} + +function createParser(source: SessionSource, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + const metadataPath = join(source.path, METADATA_FILENAME) + const messagesPath = join(source.path, MESSAGES_FILENAME) + const metadata = await readJsonFile(metadataPath) + if (!metadata) return + + const stats = metadata.stats ?? {} + const inputTokens = safeNumber(stats.session_prompt_tokens) + const outputTokens = safeNumber(stats.session_completion_tokens) + if (inputTokens === 0 && outputTokens === 0) return + + const sessionId = metadata.session_id || basename(source.path) + const deduplicationKey = `mistral-vibe:${sessionId}` + if (seenKeys.has(deduplicationKey)) return + seenKeys.add(deduplicationKey) + + const messages = await readMessages(messagesPath) + const model = resolveModel(metadata) + const { tools, bashCommands } = extractTools(messages) + const costUSD = calculateSessionCost(metadata, model, inputTokens, outputTokens) + + yield { + provider: 'mistral-vibe', + model, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + tools, + bashCommands, + timestamp: metadata.end_time ?? metadata.start_time ?? '', + speed: 'standard', + deduplicationKey, + userMessage: firstUserMessage(messages, metadata.title), + sessionId, + } + }, + } +} + +export function createMistralVibeProvider(sessionsDir?: string): Provider { + const dir = getMistralVibeSessionsDir(sessionsDir) + + return { + name: 'mistral-vibe', + displayName: 'Mistral Vibe', + + modelDisplayName(model: string): string { + return modelDisplayNames[model] ?? model + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async discoverSessions(): Promise { + const dirs = await discoverSessionDirs(dir) + const sources: SessionSource[] = [] + + for (const sessionDir of dirs) { + const metadata = await readJsonFile(join(sessionDir, METADATA_FILENAME)) + if (!metadata) continue + const cwd = metadata.environment?.working_directory + sources.push({ + path: sessionDir, + project: cwd ? basename(cwd) : basename(sessionDir), + provider: 'mistral-vibe', + }) + } + + return sources + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const mistralVibe = createMistralVibeProvider() diff --git a/src/providers/openclaw.ts b/src/providers/openclaw.ts index 14575df..bc6da53 100644 --- a/src/providers/openclaw.ts +++ b/src/providers/openclaw.ts @@ -4,6 +4,7 @@ import { homedir } from 'os' import { readSessionFile } from '../fs-utils.js' import { calculateCost } from '../models.js' +import { extractBashCommands } from '../bash-utils.js' import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' const toolNameMap: Record = { @@ -78,8 +79,7 @@ function extractTools(content: Array<{ type?: string; name?: string; arguments?: const mapped = toolNameMap[block.name] ?? block.name tools.push(mapped) if (mapped === 'Bash' && block.arguments && typeof block.arguments.command === 'string') { - const cmd = block.arguments.command.split(/\s+/)[0] ?? '' - if (cmd) bashCommands.push(cmd) + bashCommands.push(...extractBashCommands(block.arguments.command)) } } } diff --git a/src/providers/opencode.ts b/src/providers/opencode.ts index 9dd32ff..6b0f8ed 100644 --- a/src/providers/opencode.ts +++ b/src/providers/opencode.ts @@ -4,7 +4,7 @@ import { homedir } from 'os' import { calculateCost, getShortModelName } from '../models.js' import { extractBashCommands } from '../bash-utils.js' -import { isSqliteAvailable, getSqliteLoadError, openDatabase, type SqliteDatabase } from '../sqlite.js' +import { isSqliteAvailable, getSqliteLoadError, openDatabase, blobToText, isSqliteBusyError, type SqliteDatabase } from '../sqlite.js' import type { Provider, SessionSource, @@ -15,18 +15,18 @@ import type { type MessageRow = { id: string time_created: number - data: string + data: Uint8Array | string } type PartRow = { message_id: string - data: string + data: Uint8Array | string } type SessionRow = { id: string - directory: string - title: string + directory: Uint8Array | string + title: Uint8Array | string time_created: number } @@ -64,6 +64,25 @@ const toolNameMap: Record = { patch: 'Patch', } +function normalizeToolName(rawTool?: string): string { + if (!rawTool) return '' + if (rawTool.startsWith('mcp__')) return rawTool + + const builtIn = toolNameMap[rawTool] + if (builtIn) return builtIn + + // OpenCode stores MCP calls as `_` with no separate server field. + // Built-ins are handled above, and server ids are assumed not to contain `_`. + const serverSeparator = rawTool.indexOf('_') + if (serverSeparator > 0 && serverSeparator < rawTool.length - 1) { + const server = rawTool.slice(0, serverSeparator) + const tool = rawTool.slice(serverSeparator + 1) + return `mcp__${server}__${tool}` + } + + return rawTool +} + function sanitize(dir: string): string { return dir.replace(/^\//, '').replace(/\//g, '-') } @@ -92,18 +111,43 @@ function parseTimestamp(raw: number): string { return new Date(ms).toISOString() } -function validateSchema(db: SqliteDatabase): boolean { - try { - db.query<{ cnt: number }>( - "SELECT COUNT(*) as cnt FROM session LIMIT 1" - ) - db.query<{ cnt: number }>( - "SELECT COUNT(*) as cnt FROM message LIMIT 1" - ) - return true - } catch { - return false +type SchemaCheckResult = + | { ok: true } + | { ok: false; missing: string[] } + +/// Inspects OpenCode's SQLite schema. Returns the list of expected tables that +/// are missing rather than just a boolean so the caller can produce an actionable +/// warning ("missing 'part' table") instead of a generic "format not recognized". +/// Only emits the warning when meaningful tables are absent — a brand-new +/// OpenCode install with an empty DB but valid schema does NOT trigger it. +function validateSchemaDetailed(db: SqliteDatabase): SchemaCheckResult { + const required = ['session', 'message', 'part'] + const missing: string[] = [] + for (const table of required) { + try { + db.query<{ cnt: number }>(`SELECT COUNT(*) as cnt FROM ${table} LIMIT 1`) + } catch (err) { + if (isSqliteBusyError(err)) throw err + missing.push(table) + } } + return missing.length === 0 ? { ok: true } : { ok: false, missing } +} + +function validateSchema(db: SqliteDatabase): boolean { + return validateSchemaDetailed(db).ok +} + +const warnedOpenCodeSchemas = new Set() + +function warnUnrecognizedOpenCodeSchemaOnce(missing: string[]): void { + const key = missing.slice().sort().join(',') + if (warnedOpenCodeSchemas.has(key)) return + warnedOpenCodeSchemas.add(key) + process.stderr.write( + `codeburn: OpenCode database is missing expected tables (${missing.join(', ')}). ` + + `Run OpenCode once to apply migrations, or report at https://github.com/getagentseal/codeburn/issues if this persists on a current OpenCode install.\n` + ) } function createParser( @@ -133,25 +177,31 @@ function createParser( } try { - if (!validateSchema(db)) { - process.stderr.write('codeburn: OpenCode storage format not recognized. You may need to update CodeBurn.\n') + const schema = validateSchemaDetailed(db) + if (!schema.ok) { + // Warn at most once per process per missing-table set so a directory + // with a half-migrated OpenCode DB doesn't spam stderr on every + // session iteration. Show which tables we couldn't find so the + // user (or a triage agent) knows whether to re-run OpenCode's + // migration or report a CodeBurn schema gap. + warnUnrecognizedOpenCodeSchemaOnce(schema.missing) return } const messages = db.query( - 'SELECT id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created ASC', + 'SELECT id, time_created, CAST(data AS BLOB) AS data FROM message WHERE session_id = ? ORDER BY time_created ASC', [sessionId], ) const parts = db.query( - 'SELECT message_id, data FROM part WHERE session_id = ? ORDER BY message_id, id', + 'SELECT message_id, CAST(data AS BLOB) AS data FROM part WHERE session_id = ? ORDER BY message_id, id', [sessionId], ) const partsByMsg = new Map() for (const part of parts) { try { - const parsed = JSON.parse(part.data) as PartData + const parsed = JSON.parse(blobToText(part.data)) as PartData const list = partsByMsg.get(part.message_id) ?? [] list.push(parsed) partsByMsg.set(part.message_id, list) @@ -165,7 +215,7 @@ function createParser( for (const msg of messages) { let data: MessageData try { - data = JSON.parse(msg.data) as MessageData + data = JSON.parse(blobToText(msg.data)) as MessageData } catch { continue } @@ -202,7 +252,7 @@ function createParser( const msgParts = partsByMsg.get(msg.id) ?? [] const toolParts = msgParts.filter((p) => p.type === 'tool') const tools = toolParts - .map((p) => toolNameMap[p.tool ?? ''] ?? p.tool ?? '') + .map((p) => normalizeToolName(p.tool)) .filter(Boolean) const bashCommands = toolParts @@ -264,14 +314,18 @@ async function discoverFromDb(dbPath: string): Promise { try { const rows = db.query( - 'SELECT id, directory, title, time_created FROM session WHERE time_archived IS NULL AND parent_id IS NULL ORDER BY time_created DESC', + 'SELECT id, CAST(directory AS BLOB) AS directory, CAST(title AS BLOB) AS title, time_created FROM session WHERE time_archived IS NULL AND parent_id IS NULL ORDER BY time_created DESC', ) - return rows.map((row) => ({ - path: `${dbPath}:${row.id}`, - project: row.directory ? sanitize(row.directory) : sanitize(row.title), - provider: 'opencode', - })) + return rows.map((row) => { + const dir = blobToText(row.directory) + const title = blobToText(row.title) + return { + path: `${dbPath}:${row.id}`, + project: dir ? sanitize(dir) : sanitize(title), + provider: 'opencode', + } + }) } catch { return [] } finally { diff --git a/src/providers/pi.ts b/src/providers/pi.ts index 8b75a31..7b4a94b 100644 --- a/src/providers/pi.ts +++ b/src/providers/pi.ts @@ -149,7 +149,14 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars if (msg.role !== 'assistant' || !msg.usage) continue - const { input, output, cacheRead, cacheWrite } = msg.usage + // Coerce undefined/null token fields to 0. Pi/OMP session files + // sometimes omit individual usage fields; the destructure used to + // pass undefined into calculateCost which then returned NaN, and + // that NaN propagated into every aggregate cost total. + const input = msg.usage.input ?? 0 + const output = msg.usage.output ?? 0 + const cacheRead = msg.usage.cacheRead ?? 0 + const cacheWrite = msg.usage.cacheWrite ?? 0 if (input === 0 && output === 0) continue const model = msg.model ?? 'gpt-5' diff --git a/src/providers/qwen.ts b/src/providers/qwen.ts index 3b61ce4..427b5fd 100644 --- a/src/providers/qwen.ts +++ b/src/providers/qwen.ts @@ -4,6 +4,7 @@ import { homedir } from 'os' import { readSessionFile } from '../fs-utils.js' import { calculateCost } from '../models.js' +import { extractBashCommands } from '../bash-utils.js' import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' const toolNameMap: Record = { @@ -66,8 +67,7 @@ function extractTools(parts: QwenPart[]): { tools: string[]; bashCommands: strin const mapped = toolNameMap[part.functionCall.name] ?? part.functionCall.name tools.push(mapped) if (mapped === 'Bash' && part.functionCall.args && typeof part.functionCall.args['command'] === 'string') { - const cmd = (part.functionCall.args['command'] as string).split(/\s+/)[0] ?? '' - if (cmd) bashCommands.push(cmd) + bashCommands.push(...extractBashCommands(part.functionCall.args['command'] as string)) } } } diff --git a/src/providers/types.ts b/src/providers/types.ts index 3ab967a..90d5e1c 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -19,6 +19,7 @@ export type ParsedProviderCall = { reasoningTokens: number webSearchRequests: number costUSD: number + costIsEstimated?: boolean tools: string[] bashCommands: string[] timestamp: string @@ -26,6 +27,8 @@ export type ParsedProviderCall = { deduplicationKey: string userMessage: string sessionId: string + project?: string + projectPath?: string } export type Provider = { diff --git a/src/providers/vscode-cline-parser.ts b/src/providers/vscode-cline-parser.ts index d1d26c0..ffad939 100644 --- a/src/providers/vscode-cline-parser.ts +++ b/src/providers/vscode-cline-parser.ts @@ -24,6 +24,23 @@ export function getVSCodeGlobalStoragePath(extensionId: string): string { export async function discoverClineTasks(extensionId: string, providerName: string, displayName: string, overrideDir?: string): Promise { const baseDir = overrideDir ?? getVSCodeGlobalStoragePath(extensionId) + return discoverClineTasksInBaseDirs([baseDir], providerName, displayName) +} + +export async function discoverClineTasksInBaseDirs(baseDirs: string[], providerName: string, displayName: string): Promise { + const sources: SessionSource[] = [] + const seen = new Set() + for (const baseDir of baseDirs) { + for (const source of await discoverClineTasksInBaseDir(baseDir, providerName, displayName)) { + if (seen.has(source.path)) continue + seen.add(source.path) + sources.push(source) + } + } + return sources +} + +async function discoverClineTasksInBaseDir(baseDir: string, providerName: string, displayName: string): Promise { const tasksDir = join(baseDir, 'tasks') const sources: SessionSource[] = [] @@ -50,28 +67,43 @@ export async function discoverClineTasks(extensionId: string, providerName: stri } const MODEL_TAG_RE = /([^<]+)<\/model>/ +const WORKSPACE_DIR_RE = /Current Workspace Directory \(([^)]+)\)/ -function extractModelFromHistory(taskDir: string): Promise { +type HistoryMeta = { model: string; workspace: string | null } + +function extractHistoryMeta(taskDir: string, fallbackModel: string): Promise { return readFile(join(taskDir, 'api_conversation_history.json'), 'utf-8') .then(raw => { const msgs = JSON.parse(raw) as Array<{ role?: string; content?: Array<{ text?: string }> }> - if (!Array.isArray(msgs)) return 'cline-auto' + if (!Array.isArray(msgs)) return { model: fallbackModel, workspace: null } + let model: string | null = null + let workspace: string | null = null for (const msg of msgs) { if (msg.role !== 'user' || !Array.isArray(msg.content)) continue for (const block of msg.content) { - const match = typeof block.text === 'string' && MODEL_TAG_RE.exec(block.text) - if (match) { - const raw = match[1] - return raw.includes('/') ? raw.split('/').pop()! : raw + if (typeof block.text !== 'string') continue + if (!model) { + const mm = MODEL_TAG_RE.exec(block.text) + if (mm) model = mm[1].includes('/') ? mm[1].split('/').pop()! : mm[1] } + if (!workspace) { + const wm = WORKSPACE_DIR_RE.exec(block.text) + if (wm) workspace = wm[1] + } + if (model && workspace) break } + if (model && workspace) break } - return 'cline-auto' + return { model: model ?? fallbackModel, workspace } }) - .catch(() => 'cline-auto') + .catch(() => ({ model: fallbackModel, workspace: null })) } -export function createClineParser(source: SessionSource, seenKeys: Set, providerName: string): SessionParser { +function workspaceToProject(workspace: string): string { + return basename(workspace) || workspace +} + +export function createClineParser(source: SessionSource, seenKeys: Set, providerName: string, fallbackModel = 'cline-auto'): SessionParser { return { async *parse(): AsyncGenerator { const taskDir = source.path @@ -93,7 +125,10 @@ export function createClineParser(source: SessionSource, seenKeys: Set, if (!Array.isArray(uiMessages)) return - const model = await extractModelFromHistory(taskDir) + const meta = await extractHistoryMeta(taskDir, fallbackModel) + const model = meta.model + const project = meta.workspace ? workspaceToProject(meta.workspace) : undefined + const projectPath = meta.workspace ?? undefined let userMessage = '' for (const msg of uiMessages) { @@ -156,6 +191,8 @@ export function createClineParser(source: SessionSource, seenKeys: Set, deduplicationKey: dedupKey, userMessage: index === 0 ? userMessage : '', sessionId: taskId, + project, + projectPath, } } }, diff --git a/src/session-cache.ts b/src/session-cache.ts new file mode 100644 index 0000000..41c91b2 --- /dev/null +++ b/src/session-cache.ts @@ -0,0 +1,319 @@ +import { readFile, stat, open, rename, unlink, readdir, mkdir } from 'fs/promises' +import { existsSync } from 'fs' +import { createHash, randomBytes } from 'crypto' +import { join } from 'path' +import { homedir } from 'os' + +// ── Types ────────────────────────────────────────────────────────────── + +export type CachedUsage = { + inputTokens: number + outputTokens: number + cacheCreationInputTokens: number + cacheReadInputTokens: number + cachedInputTokens: number + reasoningTokens: number + webSearchRequests: number + cacheCreationOneHourTokens: number +} + +export type CachedCall = { + provider: string + model: string + usage: CachedUsage + speed: 'standard' | 'fast' + timestamp: string + tools: string[] + bashCommands: string[] + skills: string[] + deduplicationKey: string + project?: string + projectPath?: string +} + +export type CachedTurn = { + timestamp: string + sessionId: string + userMessage: string + calls: CachedCall[] +} + +export type FileFingerprint = { + dev: number + ino: number + mtimeMs: number + sizeBytes: number +} + +export type CachedFile = { + fingerprint: FileFingerprint + lastCompleteLineOffset?: number + canonicalCwd?: string + mcpInventory: string[] + turns: CachedTurn[] +} + +export type ProviderSection = { + envFingerprint: string + files: Record +} + +export type SessionCache = { + version: number + providers: Record +} + +// ── Constants ────────────────────────────────────────────────────────── + +export const CACHE_VERSION = 1 + +const CACHE_FILE = 'session-cache.json' +const TEMP_FILE_MAX_AGE_MS = 5 * 60 * 1000 + +const PROVIDER_ENV_VARS: Record = { + claude: ['CLAUDE_CONFIG_DIRS', 'CLAUDE_CONFIG_DIR'], + codex: ['CODEX_HOME'], + droid: ['FACTORY_DIR'], + cursor: ['XDG_DATA_HOME'], + 'cursor-agent': ['XDG_DATA_HOME'], + opencode: ['XDG_DATA_HOME'], + goose: ['XDG_DATA_HOME'], + crush: ['XDG_DATA_HOME'], + antigravity: ['CODEBURN_CACHE_DIR'], + qwen: ['QWEN_DATA_DIR'], + 'ibm-bob': ['XDG_CONFIG_HOME'], +} + +// ── Cache Dir ────────────────────────────────────────────────────────── + +function getCacheDir(): string { + return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn') +} + +function getCachePath(): string { + return join(getCacheDir(), CACHE_FILE) +} + +// ── Env Fingerprint ──────────────────────────────────────────────────── + +export function computeEnvFingerprint(provider: string): string { + const vars = PROVIDER_ENV_VARS[provider] ?? [] + const parts = vars.map(v => `${v}=${process.env[v] ?? ''}`) + return createHash('sha256').update(parts.join('\0')).digest('hex').slice(0, 16) +} + +// ── Load / Save ──────────────────────────────────────────────────────── + +export function emptyCache(): SessionCache { + return { version: CACHE_VERSION, providers: {} } +} + +function isNum(v: unknown): v is number { + return typeof v === 'number' && Number.isFinite(v) +} + +function isStringArray(v: unknown): v is string[] { + return Array.isArray(v) && v.every(e => typeof e === 'string') +} + +function isOptionalString(v: unknown): boolean { + return v === undefined || typeof v === 'string' +} + +function isOptionalNum(v: unknown): boolean { + return v === undefined || isNum(v) +} + +function validateFingerprint(fp: unknown): fp is FileFingerprint { + if (!fp || typeof fp !== 'object') return false + const f = fp as Record + return isNum(f['dev']) && isNum(f['ino']) && isNum(f['mtimeMs']) && isNum(f['sizeBytes']) +} + +function validateUsage(u: unknown): u is CachedUsage { + if (!u || typeof u !== 'object') return false + const o = u as Record + return isNum(o['inputTokens']) && isNum(o['outputTokens']) + && isNum(o['cacheCreationInputTokens']) && isNum(o['cacheReadInputTokens']) + && isNum(o['cachedInputTokens']) && isNum(o['reasoningTokens']) + && isNum(o['webSearchRequests']) && isNum(o['cacheCreationOneHourTokens']) +} + +function validateCall(c: unknown): c is CachedCall { + if (!c || typeof c !== 'object') return false + const o = c as Record + return typeof o['provider'] === 'string' + && typeof o['model'] === 'string' + && typeof o['deduplicationKey'] === 'string' + && typeof o['timestamp'] === 'string' + && (o['speed'] === 'standard' || o['speed'] === 'fast') + && isStringArray(o['tools']) + && isStringArray(o['bashCommands']) + && isStringArray(o['skills']) + && isOptionalString(o['project']) + && isOptionalString(o['projectPath']) + && validateUsage(o['usage']) +} + +function validateTurn(t: unknown): t is CachedTurn { + if (!t || typeof t !== 'object') return false + const o = t as Record + return typeof o['timestamp'] === 'string' + && typeof o['sessionId'] === 'string' + && typeof o['userMessage'] === 'string' + && Array.isArray(o['calls']) + && (o['calls'] as unknown[]).every(validateCall) +} + +function validateCachedFile(f: unknown): f is CachedFile { + if (!f || typeof f !== 'object') return false + const o = f as Record + return validateFingerprint(o['fingerprint']) + && isOptionalNum(o['lastCompleteLineOffset']) + && isOptionalString(o['canonicalCwd']) + && isStringArray(o['mcpInventory']) + && Array.isArray(o['turns']) + && (o['turns'] as unknown[]).every(validateTurn) +} + +function validateProviderSection(s: unknown): s is ProviderSection { + if (!s || typeof s !== 'object') return false + const o = s as Record + if (typeof o['envFingerprint'] !== 'string') return false + if (!o['files'] || typeof o['files'] !== 'object' || Array.isArray(o['files'])) return false + return Object.values(o['files'] as Record).every(validateCachedFile) +} + +function validateCache(raw: unknown): raw is SessionCache { + if (!raw || typeof raw !== 'object') return false + const o = raw as Record + if (o['version'] !== CACHE_VERSION) return false + if (!o['providers'] || typeof o['providers'] !== 'object' || Array.isArray(o['providers'])) return false + return Object.values(o['providers'] as Record).every(validateProviderSection) +} + +export async function loadCache(): Promise { + try { + const raw = await readFile(getCachePath(), 'utf-8') + const parsed = JSON.parse(raw) + if (!validateCache(parsed)) return emptyCache() + return parsed + } catch { + return emptyCache() + } +} + +export async function saveCache(cache: SessionCache): Promise { + const dir = getCacheDir() + if (!existsSync(dir)) await mkdir(dir, { recursive: true }) + + const finalPath = getCachePath() + const tempPath = `${finalPath}.${randomBytes(8).toString('hex')}.tmp` + const payload = JSON.stringify(cache) + + const handle = await open(tempPath, 'w', 0o600) + try { + await handle.writeFile(payload, { encoding: 'utf-8' }) + await handle.sync() + } finally { + await handle.close() + } + + try { + await rename(tempPath, finalPath) + } catch (err) { + try { await unlink(tempPath) } catch {} + throw err + } +} + +// ── File Fingerprinting ──────────────────────────────────────────────── + +export async function fingerprintFile(filePath: string): Promise { + try { + const s = await stat(filePath) + return { dev: s.dev, ino: s.ino, mtimeMs: s.mtimeMs, sizeBytes: s.size } + } catch { + return null + } +} + +// ── Reconciliation ───────────────────────────────────────────────────── + +export type ReconcileAction = + | { action: 'unchanged' } + | { action: 'appended'; readFromOffset: number } + | { action: 'modified' } + | { action: 'new' } + +export function reconcileFile( + current: FileFingerprint, + cached: CachedFile | undefined, +): ReconcileAction { + if (!cached) return { action: 'new' } + + const fp = cached.fingerprint + + if ( + fp.dev === current.dev && + fp.ino === current.ino && + fp.mtimeMs === current.mtimeMs && + fp.sizeBytes === current.sizeBytes + ) { + return { action: 'unchanged' } + } + + if ( + cached.lastCompleteLineOffset !== undefined && + fp.dev === current.dev && + fp.ino === current.ino && + current.sizeBytes > fp.sizeBytes + ) { + return { action: 'appended', readFromOffset: cached.lastCompleteLineOffset } + } + + return { action: 'modified' } +} + +// ── Dedup Merge ──────────────────────────────────────────────────────── +// When appending incremental data, streaming Claude messages can re-emit +// the same dedup key with updated usage. Merge by key: keep the earliest +// timestamp, take incoming usage/tools/bashCommands/skills (latest wins). + +export function mergeCallByDedupKey( + existing: CachedCall, + incoming: CachedCall, +): CachedCall { + return { + ...incoming, + timestamp: existing.timestamp < incoming.timestamp + ? existing.timestamp + : incoming.timestamp, + } +} + +// ── Temp Cleanup ─────────────────────────────────────────────────────── + +export async function cleanupOrphanedTempFiles(): Promise { + const dir = getCacheDir() + if (!existsSync(dir)) return + + try { + const entries = await readdir(dir) + const now = Date.now() + + const prefix = 'session-cache.json.' + for (const entry of entries) { + if (!entry.startsWith(prefix) || !entry.endsWith('.tmp')) continue + try { + const fullPath = join(dir, entry) + const s = await stat(fullPath) + if (now - s.mtimeMs > TEMP_FILE_MAX_AGE_MS) { + await unlink(fullPath) + } + } catch {} + } + } catch {} +} + + diff --git a/src/sqlite.ts b/src/sqlite.ts index a1ca812..3fb3c6a 100644 --- a/src/sqlite.ts +++ b/src/sqlite.ts @@ -16,6 +16,7 @@ export type SqliteDatabase = { type DatabaseSyncCtor = new (path: string, options?: { readOnly?: boolean }) => { prepare(sql: string): { all(...params: unknown[]): Row[] } + exec?(sql: string): void close(): void } @@ -23,6 +24,20 @@ let DatabaseSync: DatabaseSyncCtor | null = null let loadAttempted = false let loadError: string | null = null +const textDecoder = new TextDecoder('utf-8', { fatal: false }) + +/// Safely decode a BLOB column (Uint8Array) to a UTF-8 string. Node's +/// node:sqlite crashes with a V8 CHECK abort when a TEXT column contains +/// invalid UTF-8 (common in Cursor chat blobs with truncated multi-byte +/// chars). By selecting those columns as `CAST(... AS BLOB)` in SQL, we +/// get a Uint8Array here and decode it in JS where bad bytes become the +/// U+FFFD replacement character instead of aborting the process. +export function blobToText(value: Uint8Array | string | null | undefined): string { + if (value == null) return '' + if (typeof value === 'string') return value + return textDecoder.decode(value) +} + /// Lazily imports `node:sqlite`. On Node 22/23 it emits an ExperimentalWarning the first /// time the module is loaded; we silence that specific warning once so dashboards aren't /// preceded by a scary stderr line every run. Any other warnings (including future @@ -71,7 +86,7 @@ function loadDriver(): boolean { `(underlying error: ${message})` return false } finally { - restore() + process.nextTick(restore) } } @@ -83,12 +98,35 @@ export function getSqliteLoadError(): string { return loadError ?? 'SQLite driver not available' } +export function isSqliteBusyError(err: unknown): boolean { + const e = err as { code?: unknown; errcode?: unknown; errstr?: unknown; message?: unknown } | null + const code = typeof e?.code === 'string' ? e.code : '' + const errcode = typeof e?.errcode === 'number' ? e.errcode : null + const message = [ + typeof e?.message === 'string' ? e.message : '', + typeof e?.errstr === 'string' ? e.errstr : '', + ].join(' ') + + return ( + errcode === 5 || + errcode === 6 || + code === 'SQLITE_BUSY' || + code === 'SQLITE_LOCKED' || + /\bSQLITE_(BUSY|LOCKED)\b|database (?:is |table is )?locked/i.test(message) + ) +} + export function openDatabase(path: string): SqliteDatabase { if (!loadDriver() || DatabaseSync === null) { throw new Error(getSqliteLoadError()) } const db = new DatabaseSync(path, { readOnly: true }) + try { + db.exec?.('PRAGMA busy_timeout = 1000') + } catch { + // Best effort. Some Node sqlite builds may not expose exec on DatabaseSync. + } return { query(sql: string, params: unknown[] = []): T[] { diff --git a/src/types.ts b/src/types.ts index 208eba5..312906d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,6 +25,10 @@ export type ApiUsage = { input_tokens: number output_tokens: number cache_creation_input_tokens?: number + cache_creation?: { + ephemeral_5m_input_tokens?: number + ephemeral_1h_input_tokens?: number + } cache_read_input_tokens?: number server_tool_use?: { web_search_requests?: number @@ -72,12 +76,14 @@ export type ParsedApiCall = { costUSD: number tools: string[] mcpTools: string[] + skills: string[] hasAgentSpawn: boolean hasPlanMode: boolean speed: 'standard' | 'fast' timestamp: string bashCommands: string[] deduplicationKey: string + cacheCreationOneHourTokens?: number } export type TaskCategory = @@ -97,6 +103,7 @@ export type TaskCategory = export type ClassifiedTurn = ParsedTurn & { category: TaskCategory + subCategory?: string retries: number hasEdits: boolean } @@ -118,6 +125,13 @@ export type SessionSummary = { mcpBreakdown: Record bashBreakdown: Record categoryBreakdown: Record + skillBreakdown: Record + // Observed MCP tools available in this session, captured from + // `attachment.deferred_tools_delta.addedNames` entries. Union across all + // turns. Each name is a fully-qualified `mcp____` identifier. + // Built-in tools (Bash, Edit, etc.) are filtered out. Provider-agnostic field; + // currently populated only by the Claude parser. + mcpInventory?: string[] } export type ProjectSummary = { diff --git a/src/yield.ts b/src/yield.ts index eba1900..c26a18f 100644 --- a/src/yield.ts +++ b/src/yield.ts @@ -1,4 +1,4 @@ -import { execSync } from 'child_process' +import { execFileSync } from 'child_process' import { parseAllSessions } from './parser.js' import type { DateRange, SessionSummary } from './types.js' @@ -20,27 +20,28 @@ export type YieldSummary = { details: SessionYield[] } -function runGit(cmd: string, cwd: string): string | null { +const SAFE_REF_PATTERN = /^[A-Za-z0-9._/\-]+$/ + +function runGit(args: string[], cwd: string): string | null { try { - return execSync(cmd, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim() + return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim() } catch { return null } } function isGitRepo(dir: string): boolean { - return runGit('git rev-parse --is-inside-work-tree', dir) === 'true' + return runGit(['rev-parse', '--is-inside-work-tree'], dir) === 'true' } function getMainBranch(cwd: string): string { - // Try to get default branch from remote - const result = runGit('git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null', cwd) + const result = runGit(['symbolic-ref', 'refs/remotes/origin/HEAD'], cwd) if (result) { - return result.replace('refs/remotes/origin/', '') + const branch = result.replace('refs/remotes/origin/', '') + if (SAFE_REF_PATTERN.test(branch)) return branch } - // Fallback: check common names - const branches = runGit('git branch -a', cwd) ?? '' + const branches = runGit(['branch', '-a'], cwd) ?? '' if (branches.includes('main')) return 'main' if (branches.includes('master')) return 'master' return 'main' @@ -49,8 +50,35 @@ function getMainBranch(cwd: string): string { type CommitInfo = { sha: string timestamp: Date - isRevert: boolean inMain: boolean + /** Set when a LATER commit's body says "This reverts commit " — i.e. the work in this commit was reverted out of main. */ + wasReverted: boolean +} + +/** + * Find SHAs that were the target of a `git revert` ANYWHERE in the repo's + * history (not just the time window). The standard `git revert` body + * format is "This reverts commit ." which we grep out. + * + * The previous implementation flagged a commit as `isRevert` based on the + * substring "revert" appearing in its OWN subject. Two bugs there: + * 1. Subjects like "Add revert button" matched. + * 2. The session that PERFORMED the revert was tagged "reverted", not the + * session whose work was being reverted — so the original session always + * looked productive even after its work was thrown away. + */ +function getRevertedShas(cwd: string): Set { + const bodies = runGit( + ['log', '--all', '--grep=^This reverts commit', '--format=%B%x1e'], + cwd, + ) ?? '' + const set = new Set() + const re = /This reverts commit ([0-9a-f]{7,40})/g + let m: RegExpExecArray | null + while ((m = re.exec(bodies)) !== null) { + set.add(m[1].toLowerCase()) + } + return set } function getCommitsInRange(cwd: string, since: Date, until: Date, mainBranch: string): CommitInfo[] { @@ -58,23 +86,30 @@ function getCommitsInRange(cwd: string, since: Date, until: Date, mainBranch: st const untilStr = until.toISOString() const log = runGit( - `git log --all --since="${sinceStr}" --until="${untilStr}" --format="%H|%aI|%s"`, + ['log', '--all', `--since=${sinceStr}`, `--until=${untilStr}`, '--format=%H|%aI|%s'], cwd ) if (!log) return [] const mainCommits = new Set( - (runGit(`git log ${mainBranch} --format="%H"`, cwd) ?? '').split('\n').filter(Boolean) + (runGit(['log', mainBranch, '--format=%H'], cwd) ?? '').split('\n').filter(Boolean) ) + const revertedShas = getRevertedShas(cwd) return log.split('\n').filter(Boolean).map(line => { - const [sha, timestamp, subject] = line.split('|') + const [sha] = line.split('|') + const timestamp = line.split('|')[1] ?? '' return { sha, timestamp: new Date(timestamp), - isRevert: subject.toLowerCase().includes('revert'), inMain: mainCommits.has(sha), + // wasReverted: matches when ANY later commit's body says + // "This reverts commit ". Compare against the full SHA AND its + // 7-char short prefix to be safe; git revert sometimes records the + // short form. + wasReverted: revertedShas.has(sha.toLowerCase()) || + revertedShas.has(sha.toLowerCase().slice(0, 7)), } }) } @@ -100,7 +135,10 @@ function categorizeSession( } const inMainCount = relevantCommits.filter(c => c.inMain).length - const revertedCount = relevantCommits.filter(c => c.isRevert && c.inMain).length + // A session is "reverted" when at least half of its in-main commits were + // later reverted out (revert detected via "This reverts commit " + // anywhere later in history, not in the same time window). + const revertedCount = relevantCommits.filter(c => c.inMain && c.wasReverted).length if (revertedCount > 0 && revertedCount >= inMainCount / 2) { return { category: 'reverted', commitCount: relevantCommits.length } diff --git a/tests/blob-to-text.test.ts b/tests/blob-to-text.test.ts new file mode 100644 index 0000000..aeb7ce3 --- /dev/null +++ b/tests/blob-to-text.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest' +import { blobToText, isSqliteBusyError } from '../src/sqlite.js' + +describe('blobToText', () => { + it('returns empty string for null', () => { + expect(blobToText(null)).toBe('') + }) + + it('returns empty string for undefined', () => { + expect(blobToText(undefined)).toBe('') + }) + + it('passes through strings unchanged', () => { + expect(blobToText('hello world')).toBe('hello world') + }) + + it('decodes valid UTF-8 Uint8Array', () => { + const buf = new TextEncoder().encode('café ☕') + expect(blobToText(buf)).toBe('café ☕') + }) + + it('replaces invalid UTF-8 bytes with U+FFFD instead of crashing', () => { + const buf = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x80, 0xfe]) + const result = blobToText(buf) + expect(result).toContain('Hello') + expect(result).toContain('�') + }) + + it('handles truncated multi-byte sequence', () => { + // é in UTF-8 is [0xc3, 0xa9]. Truncate to just [0xc3]. + const buf = new Uint8Array([0x63, 0x61, 0x66, 0xc3]) + const result = blobToText(buf) + expect(result).toBe('caf�') + }) + + it('handles empty Uint8Array', () => { + expect(blobToText(new Uint8Array(0))).toBe('') + }) +}) + +describe('isSqliteBusyError', () => { + it('detects node:sqlite busy errors by errcode', () => { + expect(isSqliteBusyError({ code: 'ERR_SQLITE_ERROR', errcode: 5, errstr: 'database is locked' })).toBe(true) + }) + + it('detects sqlite locked messages', () => { + expect(isSqliteBusyError(new Error('SQLITE_LOCKED: database table is locked'))).toBe(true) + }) + + it('ignores unrelated sqlite errors', () => { + expect(isSqliteBusyError(new Error('no such table: session'))).toBe(false) + }) +}) diff --git a/tests/classifier.test.ts b/tests/classifier.test.ts new file mode 100644 index 0000000..6a02d64 --- /dev/null +++ b/tests/classifier.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect } from 'vitest' + +import { classifyTurn } from '../src/classifier.js' +import type { ParsedApiCall, ParsedTurn } from '../src/types.js' + +function makeCall(opts: Partial & { tools?: string[]; skills?: string[] }): ParsedApiCall { + const tools = opts.tools ?? [] + return { + provider: 'claude', + model: 'Opus 4.7', + usage: { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + }, + costUSD: 0, + tools, + mcpTools: tools.filter(t => t.startsWith('mcp__')), + skills: opts.skills ?? [], + hasAgentSpawn: tools.includes('Agent'), + hasPlanMode: tools.includes('EnterPlanMode'), + speed: 'standard', + timestamp: '2026-05-04T00:00:00Z', + bashCommands: [], + deduplicationKey: 'k', + ...opts, + } +} + +function makeTurn(calls: ParsedApiCall[], userMessage = ''): ParsedTurn { + return { + userMessage, + assistantCalls: calls, + timestamp: '2026-05-04T00:00:00Z', + sessionId: 's1', + } +} + +describe('classifyTurn — Skill subCategory', () => { + it('attaches subCategory when a Skill tool fires alone (input.skill)', () => { + const turn = makeTurn([makeCall({ tools: ['Skill'], skills: ['init'] })]) + const c = classifyTurn(turn) + expect(c.category).toBe('general') + expect(c.subCategory).toBe('init') + }) + + it('attaches subCategory when skill identifier comes via input.name (extracted upstream)', () => { + const turn = makeTurn([makeCall({ tools: ['Skill'], skills: ['atelier'] })]) + const c = classifyTurn(turn) + expect(c.category).toBe('general') + expect(c.subCategory).toBe('atelier') + }) + + it('uses the first skill identifier when a single turn invokes multiple skills', () => { + const turn = makeTurn([makeCall({ tools: ['Skill', 'Skill'], skills: ['review', 'security-review'] })]) + const c = classifyTurn(turn) + expect(c.category).toBe('general') + expect(c.subCategory).toBe('review') + }) + + it('aggregates skills across multiple assistant calls in the same turn', () => { + const turn = makeTurn([ + makeCall({ tools: ['Skill'], skills: ['claude-api'] }), + makeCall({ tools: ['Skill'], skills: ['init'] }), + ]) + const c = classifyTurn(turn) + expect(c.category).toBe('general') + expect(c.subCategory).toBe('claude-api') + }) + + it('does not attach subCategory when the Skill tool fires but no skill name was extracted', () => { + const turn = makeTurn([makeCall({ tools: ['Skill'], skills: [] })]) + const c = classifyTurn(turn) + expect(c.category).toBe('general') + expect(c.subCategory).toBeUndefined() + }) + + it('does not attach subCategory when category is not general (e.g. Skill alongside Edit promotes to coding)', () => { + const turn = makeTurn([makeCall({ tools: ['Skill', 'Edit'], skills: ['init'] })]) + const c = classifyTurn(turn) + expect(c.category).toBe('coding') + expect(c.subCategory).toBeUndefined() + }) + + it('does not attach subCategory for non-Skill general turns', () => { + const turn = makeTurn([makeCall({ tools: [] })], 'just chatting') + const c = classifyTurn(turn) + expect(c.subCategory).toBeUndefined() + }) + + it('tolerates missing skills field on legacy ParsedApiCall shape', () => { + const baseCall = makeCall({ tools: ['Skill'], skills: ['init'] }) + const legacyCall = { ...baseCall } as unknown as ParsedApiCall & { skills?: string[] } + delete (legacyCall as { skills?: string[] }).skills + const c = classifyTurn(makeTurn([legacyCall])) + expect(c.category).toBe('general') + expect(c.subCategory).toBeUndefined() + }) +}) + +// Regression coverage for issue #196: feature verbs that lead a message +// were previously hijacked into 'debugging' just because the message contained +// an incidental "error" / "fix" / "issue" word later in the same sentence. +// Now whichever keyword pattern matches earliest wins. +describe('classifyTurn — feature vs debugging precedence (#196)', () => { + function codingTurn(userMessage: string): ParsedTurn { + return makeTurn([makeCall({ tools: ['Edit'] })], userMessage) + } + + it('classifies "add error handling" as feature, not debugging', () => { + const c = classifyTurn(codingTurn('add error handling to the auth module')) + expect(c.category).toBe('feature') + }) + + it('classifies "create an issue tracker" as feature, not debugging', () => { + const c = classifyTurn(codingTurn('create an issue tracker page in the dashboard')) + expect(c.category).toBe('feature') + }) + + it('classifies "implement the 404 page" as feature, not debugging', () => { + const c = classifyTurn(codingTurn('implement the 404 page with a friendly redirect')) + expect(c.category).toBe('feature') + }) + + it('still classifies "fix the layout for the new feature" as debugging', () => { + const c = classifyTurn(codingTurn('fix the layout for the new feature')) + expect(c.category).toBe('debugging') + }) + + it('still classifies a plain bug report as debugging', () => { + const c = classifyTurn(codingTurn('login is broken, traceback below')) + expect(c.category).toBe('debugging') + }) + + it('classifies "refactor the error handling" as refactoring', () => { + const c = classifyTurn(codingTurn('refactor the error handling so it is cleaner')) + expect(c.category).toBe('refactoring') + }) + + it('chat-only message starting with "add" stays feature even with "fix" later', () => { + const c = classifyTurn(makeTurn([], 'add a setting page; we will fix the styles after')) + expect(c.category).toBe('feature') + }) + + it('chat-only message starting with "fix" stays debugging even with "add" later', () => { + const c = classifyTurn(makeTurn([], 'fix the bug introduced when we added the new flag')) + expect(c.category).toBe('debugging') + }) +}) diff --git a/tests/cli-date.test.ts b/tests/cli-date.test.ts new file mode 100644 index 0000000..296d292 --- /dev/null +++ b/tests/cli-date.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, it, expect, vi } from 'vitest' +import { + getDateRange, + PERIODS, + PERIOD_LABELS, + toPeriod, + type Period, +} from '../src/cli-date.js' + +afterEach(() => { + vi.useRealTimers() +}) + +describe('getDateRange', () => { + it('"all" is bounded to the last 6 months, not epoch', () => { + const { range, label } = getDateRange('all') + const now = new Date() + + expect(label).toBe('Last 6 months') + + // Regression guard: must never silently fall back to epoch (the old + // dashboard bug) or any pre-2000 date. + expect(range.start.getFullYear()).toBeGreaterThan(2000) + + const monthsDiff = + (now.getFullYear() - range.start.getFullYear()) * 12 + + (now.getMonth() - range.start.getMonth()) + expect(monthsDiff).toBe(6) + expect(range.start.getDate()).toBe(1) + + // End is today, end of day. + expect(range.end.getHours()).toBe(23) + expect(range.end.getMinutes()).toBe(59) + }) + + it('"all" does not overflow past the target month at end-of-month', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 7, 31, 12, 0, 0)) + + const { range } = getDateRange('all') + + expect(range.start.getFullYear()).toBe(2026) + expect(range.start.getMonth()).toBe(1) + expect(range.start.getDate()).toBe(1) + }) + + it('"week" returns the last 7 days', () => { + const { range, label } = getDateRange('week') + expect(label).toBe('Last 7 Days') + // start = midnight 7 days ago, end = today 23:59:59.999 -> ~8 days span. + const diffDays = (range.end.getTime() - range.start.getTime()) / (1000 * 60 * 60 * 24) + expect(diffDays).toBeGreaterThanOrEqual(7) + expect(diffDays).toBeLessThanOrEqual(8) + }) + + it('"month" starts on day 1 of the current month', () => { + const { range } = getDateRange('month') + expect(range.start.getDate()).toBe(1) + expect(range.start.getHours()).toBe(0) + }) + + it('"30days" returns 30 days back', () => { + const { range, label } = getDateRange('30days') + expect(label).toBe('Last 30 Days') + const diffDays = (range.end.getTime() - range.start.getTime()) / (1000 * 60 * 60 * 24) + expect(diffDays).toBeGreaterThanOrEqual(30) + expect(diffDays).toBeLessThanOrEqual(31) + }) + + it('"today" starts at local midnight', () => { + const { range } = getDateRange('today') + expect(range.start.getHours()).toBe(0) + expect(range.start.getMinutes()).toBe(0) + expect(range.end.getHours()).toBe(23) + }) + + it('"yesterday" is supported (CLI-only convenience)', () => { + const { range, label } = getDateRange('yesterday') + expect(label).toMatch(/^Yesterday/) + expect(range.start.getHours()).toBe(0) + expect(range.end.getHours()).toBe(23) + }) + + it('unknown period exits with an error instead of silently falling back', () => { + expect(() => getDateRange('not-a-period')).toThrow() + }) +}) + +describe('PERIODS / PERIOD_LABELS', () => { + it('exposes the expected period set', () => { + expect(PERIODS).toEqual(['today', 'week', '30days', 'month', 'all']) + }) + + it('has a label for every period', () => { + for (const p of PERIODS) { + expect(PERIOD_LABELS[p]).toBeTruthy() + } + }) + + it('"all" tab label reflects the 6-month bound', () => { + // Short label used in the dashboard tab strip. The long-form label + // ("Last 6 months") comes from getDateRange().label. + expect(PERIOD_LABELS.all).toBe('6 Months') + }) +}) + +describe('toPeriod', () => { + it('round-trips known periods', () => { + const known: Period[] = ['today', 'week', '30days', 'month', 'all'] + for (const p of known) { + expect(toPeriod(p)).toBe(p) + } + }) + + it('exits with an error on unknown input instead of silently falling back', () => { + // Previously toPeriod silently fell back to 'week' for any unrecognized + // value, which let typos like `-p mounth` produce a quiet 7-day report + // while the user thought they were viewing the month. The new behavior + // is to fail loudly via process.exit(1) after writing to stderr. + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') }) as unknown as ReturnType + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + try { + expect(() => toPeriod('garbage')).toThrow('exit') + expect(exitSpy).toHaveBeenCalledWith(1) + expect(stderrSpy).toHaveBeenCalled() + } finally { + exitSpy.mockRestore() + stderrSpy.mockRestore() + } + }) +}) diff --git a/tests/cli-export-date-range.test.ts b/tests/cli-export-date-range.test.ts new file mode 100644 index 0000000..73d9e4b --- /dev/null +++ b/tests/cli-export-date-range.test.ts @@ -0,0 +1,96 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' + +import { describe, expect, it } from 'vitest' + +function runCli(args: string[], home: string) { + return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { + cwd: process.cwd(), + env: { + ...process.env, + CLAUDE_CONFIG_DIR: join(home, '.claude'), + HOME: home, + TZ: 'UTC', + }, + encoding: 'utf-8', + }) +} + +function userLine(sessionId: string, timestamp: string): string { + return JSON.stringify({ + type: 'user', + sessionId, + timestamp, + message: { role: 'user', content: 'add feature' }, + }) +} + +function assistantLine(sessionId: string, timestamp: string, messageId: string): string { + return JSON.stringify({ + type: 'assistant', + sessionId, + timestamp, + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [{ type: 'text', text: 'done' }], + usage: { + input_tokens: 1000, + output_tokens: 100, + }, + }, + }) +} + +describe('codeburn export custom date range', () => { + it('exports a single custom period filtered by --from/--to', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-export-')) + + try { + const projectDir = join(home, '.claude', 'projects', 'app') + await mkdir(projectDir, { recursive: true }) + await writeFile( + join(projectDir, 'in-range.jsonl'), + [ + userLine('in-range', '2026-04-10T09:00:00Z'), + assistantLine('in-range', '2026-04-10T09:01:00Z', 'msg-in-range'), + ].join('\n'), + ) + await writeFile( + join(projectDir, 'out-of-range.jsonl'), + [ + userLine('out-of-range', '2026-04-11T09:00:00Z'), + assistantLine('out-of-range', '2026-04-11T09:01:00Z', 'msg-out-of-range'), + ].join('\n'), + ) + + const outputPath = join(home, 'custom-export.json') + const result = runCli([ + 'export', + '--format', 'json', + '--from', '2026-04-10', + '--to', '2026-04-10', + '--provider', 'claude', + '--output', outputPath, + ], home) + + expect(result.status).toBe(0) + expect(result.stdout).toContain('Exported (2026-04-10 to 2026-04-10)') + + const exported = JSON.parse(await readFile(outputPath, 'utf-8')) as { + summary: Array<{ Period: string; Sessions: number }> + sessions: Array<{ 'Session ID': string }> + } + expect(exported.summary).toHaveLength(1) + expect(exported.summary[0]?.Period).toBe('2026-04-10 to 2026-04-10') + expect(exported.summary[0]?.Sessions).toBe(1) + expect(exported.sessions.map(s => s['Session ID'])).toEqual(['in-range']) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/cli-json-daily.test.ts b/tests/cli-json-daily.test.ts new file mode 100644 index 0000000..173878f --- /dev/null +++ b/tests/cli-json-daily.test.ts @@ -0,0 +1,172 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' + +import { describe, expect, it } from 'vitest' + +function runCli(args: string[], home: string) { + return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { + cwd: process.cwd(), + env: { + ...process.env, + CLAUDE_CONFIG_DIR: join(home, '.claude'), + HOME: home, + TZ: 'UTC', + }, + encoding: 'utf-8', + }) +} + +function userLine(sessionId: string, timestamp: string): string { + return JSON.stringify({ + type: 'user', + sessionId, + timestamp, + message: { role: 'user', content: 'do the thing' }, + }) +} + +function assistantEditLine(sessionId: string, timestamp: string, messageId: string): string { + // Includes a tool_use of `Edit` so the parser flags this turn as hasEdits=true. + // Single edit-turn with no retry (one assistant message in the turn) → counts + // as one oneShotTurn. + return JSON.stringify({ + type: 'assistant', + sessionId, + timestamp, + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [ + { type: 'text', text: 'editing' }, + { type: 'tool_use', id: 'tu-1', name: 'Edit', input: { file_path: '/tmp/x', old_string: 'a', new_string: 'b' } }, + ], + usage: { input_tokens: 1000, output_tokens: 100 }, + }, + }) +} + +function assistantNoEditLine(sessionId: string, timestamp: string, messageId: string): string { + // No edit tool — this turn does not count toward editTurns/oneShotTurns, + // but does count toward `turns` and `calls`. + return JSON.stringify({ + type: 'assistant', + sessionId, + timestamp, + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [{ type: 'text', text: 'just chatting' }], + usage: { input_tokens: 200, output_tokens: 30 }, + }, + }) +} + +describe('codeburn report --format json daily[] one-shot fields (issue #279)', () => { + it('exposes per-day turns / editTurns / oneShotTurns / oneShotRate', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-json-daily-')) + + try { + const projectDir = join(home, '.claude', 'projects', 'app') + await mkdir(projectDir, { recursive: true }) + + // Day 1 (2026-04-10): one edit-turn (one-shot), one chat-turn + // Day 2 (2026-04-11): one edit-turn (one-shot) + await writeFile( + join(projectDir, 'session.jsonl'), + [ + userLine('s1', '2026-04-10T09:00:00Z'), + assistantEditLine('s1', '2026-04-10T09:01:00Z', 'm-d1-edit'), + userLine('s1', '2026-04-10T10:00:00Z'), + assistantNoEditLine('s1', '2026-04-10T10:01:00Z', 'm-d1-chat'), + userLine('s1', '2026-04-11T09:00:00Z'), + assistantEditLine('s1', '2026-04-11T09:01:00Z', 'm-d2-edit'), + ].join('\n'), + ) + + const result = runCli([ + '--format', 'json', + '--from', '2026-04-10', + '--to', '2026-04-11', + '--provider', 'claude', + ], home) + + expect(result.status).toBe(0) + + const report = JSON.parse(result.stdout) as { + daily: Array<{ + date: string + cost: number + calls: number + turns: number + editTurns: number + oneShotTurns: number + oneShotRate: number | null + }> + } + + expect(report.daily).toHaveLength(2) + + const day1 = report.daily.find(d => d.date === '2026-04-10') + expect(day1).toBeDefined() + expect(day1!.turns).toBe(2) + expect(day1!.editTurns).toBe(1) + expect(day1!.oneShotTurns).toBe(1) + expect(day1!.oneShotRate).toBe(100) + + const day2 = report.daily.find(d => d.date === '2026-04-11') + expect(day2).toBeDefined() + expect(day2!.turns).toBe(1) + expect(day2!.editTurns).toBe(1) + expect(day2!.oneShotTurns).toBe(1) + expect(day2!.oneShotRate).toBe(100) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + + it('reports null oneShotRate when the day has no edit turns', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-json-daily-')) + + try { + const projectDir = join(home, '.claude', 'projects', 'app') + await mkdir(projectDir, { recursive: true }) + + await writeFile( + join(projectDir, 'chat-only.jsonl'), + [ + userLine('s2', '2026-04-10T09:00:00Z'), + assistantNoEditLine('s2', '2026-04-10T09:01:00Z', 'm-chat-1'), + userLine('s2', '2026-04-10T09:30:00Z'), + assistantNoEditLine('s2', '2026-04-10T09:31:00Z', 'm-chat-2'), + ].join('\n'), + ) + + const result = runCli([ + '--format', 'json', + '--from', '2026-04-10', + '--to', '2026-04-10', + '--provider', 'claude', + ], home) + + expect(result.status).toBe(0) + const report = JSON.parse(result.stdout) as { + daily: Array<{ date: string; turns: number; editTurns: number; oneShotTurns: number; oneShotRate: number | null }> + } + const day = report.daily.find(d => d.date === '2026-04-10')! + expect(day.turns).toBe(2) + expect(day.editTurns).toBe(0) + expect(day.oneShotTurns).toBe(0) + // null, not 0 — the rate is undefined when no edits happened, and a + // chat-only day would otherwise read as 0% one-shot which is misleading. + expect(day.oneShotRate).toBeNull() + } finally { + await rm(home, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/cli-plan.test.ts b/tests/cli-plan.test.ts index b146f2a..1430eb7 100644 --- a/tests/cli-plan.test.ts +++ b/tests/cli-plan.test.ts @@ -11,6 +11,9 @@ function runCli(args: string[], home: string) { env: { ...process.env, HOME: home, + USERPROFILE: home, // os.homedir() uses USERPROFILE on Windows + HOMEPATH: home, + HOMEDRIVE: '', }, encoding: 'utf-8', }) diff --git a/tests/cli-status-menubar.test.ts b/tests/cli-status-menubar.test.ts new file mode 100644 index 0000000..1513b5c --- /dev/null +++ b/tests/cli-status-menubar.test.ts @@ -0,0 +1,108 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' + +import { describe, expect, it } from 'vitest' + +function runCli(args: string[], home: string) { + return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { + cwd: process.cwd(), + env: { + ...process.env, + CLAUDE_CONFIG_DIR: join(home, '.claude'), + HOME: home, + TZ: 'UTC', + }, + encoding: 'utf-8', + timeout: 30_000, + }) +} + +function userLine(sessionId: string, timestamp: string): string { + return JSON.stringify({ + type: 'user', + sessionId, + timestamp, + message: { role: 'user', content: 'do the thing' }, + }) +} + +function assistantLine(sessionId: string, timestamp: string, messageId: string): string { + return JSON.stringify({ + type: 'assistant', + sessionId, + timestamp, + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [ + { type: 'text', text: 'done' }, + { type: 'tool_use', id: 'tu-1', name: 'Edit', input: { file_path: '/tmp/x', old_string: 'a', new_string: 'b' } }, + ], + usage: { input_tokens: 500, output_tokens: 50 }, + }, + }) +} + +describe('codeburn status --format menubar-json', () => { + it('returns valid MenubarPayload with expected top-level fields', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-')) + + try { + const projectDir = join(home, '.claude', 'projects', 'myapp') + await mkdir(projectDir, { recursive: true }) + + const now = new Date() + const h = now.getUTCHours() + const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 60_000) + const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z') + const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z') + const ts3 = new Date(base.getTime() + 120_000).toISOString().replace(/\.\d+Z$/, 'Z') + const ts4 = new Date(base.getTime() + 180_000).toISOString().replace(/\.\d+Z$/, 'Z') + + await writeFile( + join(projectDir, 'session.jsonl'), + [ + userLine('s1', ts1), + assistantLine('s1', ts2, 'msg-1'), + userLine('s1', ts3), + assistantLine('s1', ts4, 'msg-2'), + ].join('\n'), + ) + + const result = runCli([ + 'status', + '--format', 'menubar-json', + '--period', 'today', + '--provider', 'all', + '--no-optimize', + ], home) + + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + + const payload = JSON.parse(result.stdout) as Record + + expect(payload).toHaveProperty('generated') + expect(payload).toHaveProperty('current') + expect(payload).toHaveProperty('optimize') + expect(payload).toHaveProperty('history') + + const current = payload['current'] as Record + expect(current['cost']).toBeGreaterThan(0) + expect(current['calls']).toBe(2) + expect(current['sessions']).toBe(1) + expect(current).toHaveProperty('oneShotRate') + expect(current).toHaveProperty('topActivities') + expect(current).toHaveProperty('topModels') + expect(current).toHaveProperty('providers') + + const history = payload['history'] as { daily: unknown[] } + expect(Array.isArray(history.daily)).toBe(true) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/compare-stats.test.ts b/tests/compare-stats.test.ts index a7ecb85..63d3534 100644 --- a/tests/compare-stats.test.ts +++ b/tests/compare-stats.test.ts @@ -28,6 +28,7 @@ function makeTurn(model: string, cost: number, opts: { hasEdits?: boolean; retri costUSD: cost, tools: defaultTools, mcpTools: [], + skills: [], hasAgentSpawn: opts.hasAgentSpawn ?? false, hasPlanMode: opts.hasPlanMode ?? false, speed: opts.speed ?? 'standard' as const, @@ -56,6 +57,7 @@ function makeProject(turns: ClassifiedTurn[]): ProjectSummary { mcpBreakdown: {}, bashBreakdown: {}, categoryBreakdown: {} as SessionSummary['categoryBreakdown'], + skillBreakdown: {} as SessionSummary['skillBreakdown'], } return { project: 'test-project', diff --git a/tests/currency-rounding.test.ts b/tests/currency-rounding.test.ts new file mode 100644 index 0000000..8ad79c8 --- /dev/null +++ b/tests/currency-rounding.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { convertCost, roundForActiveCurrency, getFractionDigits } from '../src/currency.js' +import { CurrencyState } from '../src/currency.js' +import * as currencyMod from '../src/currency.js' + +// We poke the module-level state directly via switchCurrency for these tests. +// Each test restores USD afterwards so it doesn't bleed. +async function setActive(code: string, rate: number): Promise { + // switchCurrency does network + persistence; for unit tests we set the + // active state directly via the module's internal state. Since the module + // doesn't expose a setter, we go through getCurrency()'s state and patch. + // Instead use the public switchCurrency only when offline: nope, just + // exploit the fact that the module exports `getCurrency` which returns a + // ref. We can't easily mock fetch. So we test only convertCost (which uses + // active.rate) and rounding helpers — both pure functions of the state. + const state = currencyMod.getCurrency() + // @ts-expect-error — directly mutating for test + state.code = code + // @ts-expect-error + state.rate = rate + // @ts-expect-error + state.symbol = code +} + +beforeEach(async () => { + await setActive('USD', 1) +}) + +afterEach(async () => { + await setActive('USD', 1) +}) + +describe('convertCost — no rounding contract', () => { + it('returns unrounded float for USD (rate=1)', () => { + expect(convertCost(1.234567)).toBe(1.234567) + expect(convertCost(0.001)).toBe(0.001) + }) + + it('returns unrounded float for non-USD currencies', async () => { + await setActive('JPY', 150) + // 1 USD * 150 = 150, but a fractional input must NOT be rounded by convertCost. + expect(convertCost(0.123456)).toBeCloseTo(18.5184, 4) + expect(convertCost(1.5)).toBe(225) + }) + + it('rounding is the caller\'s responsibility (display vs export)', async () => { + // Regression guard: previously convertCost did its own rounding which + // produced ¥412.37 in CSV exports while the dashboard rendered ¥412. + // Confirm we now return the raw value and the caller decides. + await setActive('JPY', 150) + const raw = convertCost(2.7491) + expect(raw).toBe(412.365) // unrounded + expect(roundForActiveCurrency(raw)).toBe(412) // currency-aware rounding for export + }) +}) + +describe('roundForActiveCurrency', () => { + it('USD rounds to 2 decimals', async () => { + await setActive('USD', 1) + expect(roundForActiveCurrency(1.2345)).toBe(1.23) + expect(roundForActiveCurrency(1.235)).toBeCloseTo(1.24, 2) + expect(roundForActiveCurrency(0.005)).toBe(0.01) + }) + + it('JPY rounds to whole numbers', async () => { + await setActive('JPY', 150) + expect(roundForActiveCurrency(412.37)).toBe(412) + expect(roundForActiveCurrency(412.5)).toBe(413) + expect(roundForActiveCurrency(0.4)).toBe(0) + }) + + it('KRW rounds to whole numbers', async () => { + await setActive('KRW', 1300) + expect(roundForActiveCurrency(15999.7)).toBe(16000) + }) + + it('EUR rounds to 2 decimals like USD', async () => { + await setActive('EUR', 0.92) + expect(roundForActiveCurrency(1.2345)).toBe(1.23) + }) + + it('matches the display contract: roundForActiveCurrency(convertCost(x)) is what users see', async () => { + await setActive('JPY', 150) + // Dashboard displays via formatCost which uses getFractionDigits=0 for JPY. + // CSV exports must produce the same integer value, not a 2-decimal float. + expect(roundForActiveCurrency(convertCost(2.75))).toBe(413) + expect(roundForActiveCurrency(convertCost(2.745))).toBe(412) + }) +}) + +describe('getFractionDigits', () => { + it('returns 0 for zero-fraction currencies', () => { + expect(getFractionDigits('JPY')).toBe(0) + expect(getFractionDigits('KRW')).toBe(0) + expect(getFractionDigits('CLP')).toBe(0) + }) + + it('returns 2 for typical currencies', () => { + expect(getFractionDigits('USD')).toBe(2) + expect(getFractionDigits('EUR')).toBe(2) + expect(getFractionDigits('GBP')).toBe(2) + expect(getFractionDigits('INR')).toBe(2) + }) +}) diff --git a/tests/daily-cache.test.ts b/tests/daily-cache.test.ts index 3582e8a..2f384cc 100644 --- a/tests/daily-cache.test.ts +++ b/tests/daily-cache.test.ts @@ -77,7 +77,13 @@ describe('loadDailyCache', () => { expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v1.bak'))).toBe(true) }) - it('migrates an older supported version by filling missing fields', async () => { + it('discards a v2 cache and starts fresh (provider rollups would be stale)', async () => { + // MIN_SUPPORTED_VERSION was raised to DAILY_CACHE_VERSION because the + // migration path cannot recompute the providers / categories / models + // rollups from session data (the cache does not retain raw sessions), + // so a migrated old cache would carry forward stale provider totals + // for the full retention window. Older caches now get discarded and + // recomputed from scratch on next run. const saved = { version: 2, lastComputedDate: '2026-04-10', @@ -92,14 +98,40 @@ describe('loadDailyCache', () => { await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), JSON.stringify(saved), 'utf-8') const cache = await loadDailyCache() expect(cache.version).toBe(DAILY_CACHE_VERSION) - expect(cache.days).toHaveLength(1) - expect(cache.days[0].date).toBe('2026-04-10') - expect(cache.days[0].cost).toBe(10) - expect(cache.days[0].editTurns).toBe(0) - expect(cache.days[0].oneShotTurns).toBe(0) - expect(cache.days[0].categories).toEqual({}) - expect(cache.days[0].providers).toEqual({}) - expect(cache.days[0].models['claude-opus-4-6'].calls).toBe(5) + expect(cache.days).toEqual([]) + expect(cache.lastComputedDate).toBeNull() + // Old cache is renamed to .v2.bak rather than deleted. + expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v2.bak'))).toBe(true) + }) + + it('discards a v5 cache because cached Claude costs predate 1-hour cache pricing', async () => { + const saved = { + version: 5, + lastComputedDate: '2026-05-01', + days: [{ + date: '2026-05-01', + cost: 0.37575, + calls: 1, + sessions: 1, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 60_120, + editTurns: 0, + oneShotTurns: 0, + models: { 'Opus 4.7': { calls: 1, cost: 0.37575, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 60_120 } }, + categories: {}, + providers: { claude: { calls: 1, cost: 0.37575 } }, + }], + } + const { writeFile, mkdir } = await import('fs/promises') + await mkdir(TMP_CACHE_ROOT, { recursive: true }) + await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), JSON.stringify(saved), 'utf-8') + const cache = await loadDailyCache() + expect(cache.version).toBe(DAILY_CACHE_VERSION) + expect(cache.days).toEqual([]) + expect(cache.lastComputedDate).toBeNull() + expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v5.bak'))).toBe(true) }) it('round-trips a valid cache through save and load', async () => { @@ -163,6 +195,34 @@ describe('addNewDays', () => { const updated = addNewDays(base, [emptyDay('2026-04-05', 3)], '2026-04-05') expect(updated.lastComputedDate).toBe('2026-04-10') }) + + it('skips prune when newestDate is malformed (does not silently drop all days)', () => { + // Regression guard: a corrupt newestDate string used to produce a NaN + // cutoff, which made `d.date >= "Invalid Date"` always false and + // wiped every cached day on the next merge. The guard now leaves + // the entries untouched so the next valid run can prune normally. + const base: DailyCache = { + version: DAILY_CACHE_VERSION, + lastComputedDate: '2026-04-10', + days: [emptyDay('2026-04-08', 1), emptyDay('2026-04-09', 2), emptyDay('2026-04-10', 3)], + } + const updated = addNewDays(base, [], 'not-a-date') + expect(updated.days.map(d => d.date)).toEqual(['2026-04-08', '2026-04-09', '2026-04-10']) + }) + + it('still prunes when newestDate is valid', () => { + const old = '2020-01-01' + const recent = '2026-04-10' + const base: DailyCache = { + version: DAILY_CACHE_VERSION, + lastComputedDate: recent, + days: [emptyDay(old, 1), emptyDay(recent, 2)], + } + const updated = addNewDays(base, [], recent) + // 730-day retention from 2026-04-10 → cutoff ~2024-04-11; 2020-01-01 must be gone. + expect(updated.days.find(d => d.date === old)).toBeUndefined() + expect(updated.days.find(d => d.date === recent)).toBeDefined() + }) }) describe('getDaysInRange', () => { diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index a29ae38..da802f1 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -1,5 +1,8 @@ +import { homedir } from 'os' + import { describe, it, expect } from 'vitest' +import { shortProject } from '../src/dashboard.js' import { formatCost } from '../src/format.js' import type { ProjectSummary, SessionSummary } from '../src/types.js' @@ -37,6 +40,7 @@ function makeSession(id: string, cost: number, timestamp = '2026-04-14T10:00:00Z mcpBreakdown: {}, bashBreakdown: {}, categoryBreakdown: { ...EMPTY_CATEGORY_BREAKDOWN }, + skillBreakdown: {}, } } @@ -52,7 +56,7 @@ function makeProject(name: string, sessions: SessionSummary[]): ProjectSummary { // Logic replicated from TopSessions component function getTopSessions(projects: ProjectSummary[], n = 5) { - const all = projects.flatMap(p => p.sessions.map(s => ({ ...s, projectName: p.project }))) + const all = projects.flatMap(p => p.sessions.map(s => ({ ...s, projectPath: p.projectPath }))) return [...all].sort((a, b) => b.totalCostUSD - a.totalCostUSD).slice(0, n) } @@ -98,6 +102,36 @@ describe('TopSessions - top-5 selection', () => { }) }) +describe('shortProject - path shortening', () => { + const home = homedir() + + it('preserves directory names containing dashes', () => { + expect(shortProject(`${home}/work/my-project`)).toBe('work/my-project') + }) + + it('preserves directory names containing dots', () => { + expect(shortProject(`${home}/work/my.app.io`)).toBe('work/my.app.io') + }) + + it('returns "home" for the home dir itself', () => { + expect(shortProject(home)).toBe('home') + }) + + it('does not strip a sibling whose name shares the home prefix', () => { + const sibling = `${home}-backup/proj` + expect(shortProject(sibling).endsWith('proj')).toBe(true) + expect(shortProject(sibling)).not.toMatch(/^-/) + }) + + it('keeps only the last 3 segments for deeply nested paths', () => { + expect(shortProject(`${home}/a/b/c/d/e/f`)).toBe('d/e/f') + }) + + it('handles paths outside the home dir', () => { + expect(shortProject('/opt/myproject')).toBe('opt/myproject') + }) +}) + describe('avg/s in ProjectBreakdown', () => { it('returns dash for a project with no sessions', () => { const project = makeProject('proj', []) diff --git a/tests/date-range-filter.test.ts b/tests/date-range-filter.test.ts index c4f9408..5c6f106 100644 --- a/tests/date-range-filter.test.ts +++ b/tests/date-range-filter.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { parseDateRangeFlags } from '../src/cli-date.js' +import { formatDateRangeLabel, parseDateRangeFlags } from '../src/cli-date.js' describe('parseDateRangeFlags', () => { it('returns null when neither flag is provided', () => { @@ -26,10 +26,18 @@ describe('parseDateRangeFlags', () => { expect(range!.end.getHours()).toBe(23) }) - it('accepts --to alone (start = epoch)', () => { + it('accepts --to alone with a 6-month default start', () => { + // Previously the missing --from defaulted to epoch (1970), opening a + // 55-year scan window that was almost never what the user meant. The + // default is now 6 months back from now, matching the dashboard's + // "6 Months" period boundary. const range = parseDateRangeFlags(undefined, '2026-04-10') expect(range).not.toBeNull() - expect(range!.start.getTime()).toBe(new Date(0).getTime()) + expect(range!.start.getTime()).toBeGreaterThan(new Date(0).getTime()) + const sixMonthsMs = 6 * 31 * 24 * 60 * 60 * 1000 + const ageMs = Date.now() - range!.start.getTime() + expect(ageMs).toBeLessThanOrEqual(sixMonthsMs + 1000) + expect(ageMs).toBeGreaterThanOrEqual(sixMonthsMs - 1000) expect(range!.end.getDate()).toBe(10) }) @@ -48,10 +56,34 @@ describe('parseDateRangeFlags', () => { .toThrow('Invalid date format') }) + it('rejects month/day overflow instead of silently rolling forward', () => { + // Without overflow validation, JS Date silently turns Feb 31 into Mar 3 + // and 13/32 into 02/01 of the following year. That made `--from + // 2026-02-31 --to 2026-03-15` quietly drop sessions on Feb 28 - Mar 2. + expect(() => parseDateRangeFlags('2026-02-31', '2026-03-15')) + .toThrow('Invalid date "2026-02-31"') + expect(() => parseDateRangeFlags('2026-13-01', undefined)) + .toThrow('Invalid date "2026-13-01"') + expect(() => parseDateRangeFlags('2026-04-31', undefined)) + .toThrow('Invalid date "2026-04-31"') + expect(() => parseDateRangeFlags(undefined, '2026-02-30')) + .toThrow('Invalid date "2026-02-30"') + // Leap-day check: 2024 is a leap year, 2025 is not. + expect(parseDateRangeFlags('2024-02-29', '2024-03-01')).not.toBeNull() + expect(() => parseDateRangeFlags('2025-02-29', undefined)) + .toThrow('Invalid date "2025-02-29"') + }) + it('same day is valid (start midnight, end 23:59:59)', () => { const range = parseDateRangeFlags('2026-04-10', '2026-04-10') expect(range).not.toBeNull() expect(range!.start.getDate()).toBe(10) expect(range!.end.getDate()).toBe(10) }) + + it('formats custom range labels consistently', () => { + expect(formatDateRangeLabel('2026-04-07', '2026-04-10')).toBe('2026-04-07 to 2026-04-10') + expect(formatDateRangeLabel(undefined, '2026-04-10')).toBe('all to 2026-04-10') + expect(formatDateRangeLabel('2026-04-07', undefined)).toBe('2026-04-07 to today') + }) }) diff --git a/tests/day-aggregator.test.ts b/tests/day-aggregator.test.ts index 1c3baed..c58937b 100644 --- a/tests/day-aggregator.test.ts +++ b/tests/day-aggregator.test.ts @@ -29,6 +29,7 @@ function makeCall(timestamp: string, costUSD: number, model = 'Opus 4.7', provid costUSD, tools: [], mcpTools: [], + skills: [], hasAgentSpawn: false, hasPlanMode: false, speed: 'standard' as const, @@ -45,8 +46,8 @@ describe('aggregateProjectsIntoDays', () => { sessions: [{ sessionId: 's1', project: 'p', - firstTimestamp: '2026-04-09T10:00:00Z', - lastTimestamp: '2026-04-10T08:00:00Z', + firstTimestamp: '2026-04-09T10:00:00', + lastTimestamp: '2026-04-10T08:00:00', totalCostUSD: 10, totalInputTokens: 0, totalOutputTokens: 0, @@ -56,14 +57,14 @@ describe('aggregateProjectsIntoDays', () => { turns: [ { userMessage: 'hi', - timestamp: '2026-04-09T10:00:00Z', + timestamp: '2026-04-09T10:00:00', sessionId: 's1', category: 'coding', retries: 0, hasEdits: true, assistantCalls: [ - makeCall('2026-04-09T10:00:00Z', 4), - makeCall('2026-04-10T08:00:00Z', 6), + makeCall('2026-04-09T10:00:00', 4), + makeCall('2026-04-10T08:00:00', 6), ], }, ], @@ -72,6 +73,7 @@ describe('aggregateProjectsIntoDays', () => { mcpBreakdown: {}, bashBreakdown: {}, categoryBreakdown: {} as never, + skillBreakdown: {} as never, }], }), ] @@ -90,8 +92,8 @@ describe('aggregateProjectsIntoDays', () => { sessions: [{ sessionId: 's1', project: 'p', - firstTimestamp: '2026-04-09T10:00:00Z', - lastTimestamp: '2026-04-09T10:05:00Z', + firstTimestamp: '2026-04-09T10:00:00', + lastTimestamp: '2026-04-09T10:05:00', totalCostUSD: 3, totalInputTokens: 0, totalOutputTokens: 0, @@ -101,12 +103,12 @@ describe('aggregateProjectsIntoDays', () => { turns: [ { userMessage: 'hi', - timestamp: '2026-04-09T10:00:00Z', + timestamp: '2026-04-09T10:00:00', sessionId: 's1', category: 'coding', retries: 0, hasEdits: true, - assistantCalls: [makeCall('2026-04-09T10:00:00Z', 3)], + assistantCalls: [makeCall('2026-04-09T10:00:00', 3)], }, ], modelBreakdown: {}, @@ -114,6 +116,7 @@ describe('aggregateProjectsIntoDays', () => { mcpBreakdown: {}, bashBreakdown: {}, categoryBreakdown: {} as never, + skillBreakdown: {} as never, }], }), ] @@ -135,19 +138,20 @@ describe('aggregateProjectsIntoDays', () => { sessions: [{ sessionId: 's1', project: 'p', - firstTimestamp: '2026-04-09T23:59:00Z', - lastTimestamp: '2026-04-10T00:10:00Z', + firstTimestamp: '2026-04-09T23:59:00', + lastTimestamp: '2026-04-10T00:10:00', totalCostUSD: 1, totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0, apiCalls: 0, turns: [], modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {}, categoryBreakdown: {} as never, + skillBreakdown: {} as never, }], }), ] const days = aggregateProjectsIntoDays(projects) - const expectedDate = dateKey('2026-04-09T23:59:00Z') + const expectedDate = dateKey('2026-04-09T23:59:00') expect(days[0]!.date).toBe(expectedDate) expect(days[0]!.sessions).toBe(1) }) @@ -158,23 +162,24 @@ describe('aggregateProjectsIntoDays', () => { sessions: [{ sessionId: 's1', project: 'p', - firstTimestamp: '2026-04-10T10:00:00Z', - lastTimestamp: '2026-04-10T10:00:00Z', + firstTimestamp: '2026-04-10T10:00:00', + lastTimestamp: '2026-04-10T10:00:00', totalCostUSD: 10, totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0, apiCalls: 2, turns: [ { - userMessage: 'x', timestamp: '2026-04-10T10:00:00Z', sessionId: 's1', + userMessage: 'x', timestamp: '2026-04-10T10:00:00', sessionId: 's1', category: 'coding', retries: 0, hasEdits: false, assistantCalls: [ - makeCall('2026-04-10T10:00:00Z', 7, 'Opus 4.7', 'claude'), - makeCall('2026-04-10T10:00:00Z', 3, 'gpt-5', 'codex'), + makeCall('2026-04-10T10:00:00', 7, 'Opus 4.7', 'claude'), + makeCall('2026-04-10T10:00:00', 3, 'gpt-5', 'codex'), ], }, ], modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {}, categoryBreakdown: {} as never, + skillBreakdown: {} as never, }], }), ] @@ -290,6 +295,7 @@ describe('buildPeriodDataFromDays', () => { }], modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {}, categoryBreakdown: {} as never, + skillBreakdown: {} as never, }], }), ] diff --git a/tests/export.test.ts b/tests/export.test.ts index 83fdf5a..83b8b15 100644 --- a/tests/export.test.ts +++ b/tests/export.test.ts @@ -56,6 +56,7 @@ function makeProject(projectPath: string): ProjectSummary { costUSD: 1.23, tools: ['Read'], mcpTools: [], + skills: [], hasAgentSpawn: false, hasPlanMode: false, speed: 'standard', @@ -103,6 +104,7 @@ function makeProject(projectPath: string): ProjectSummary { brainstorming: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, general: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, }, + skillBreakdown: {}, }, ], totalCostUSD: 1.23, @@ -150,10 +152,45 @@ describe('exportCsv', () => { expect(projects).toContain("'\rcmd") }) + it('includes per-model efficiency metrics', async () => { + const periods: PeriodExport[] = [ + { + label: '30 Days', + projects: [makeProject('app')], + }, + ] + + const outputPath = join(tmpDir, 'models.csv') + const folder = await exportCsv(periods, outputPath) + const models = await readFile(join(folder, 'models.csv'), 'utf-8') + + expect(models).toContain('Edit Turns') + expect(models).toContain('One-shot Rate (%)') + expect(models).toContain('Retries/Edit') + expect(models).toContain('Cost/Edit') + expect(models).toContain(',1,100,0,') + }) + it('does not crash when periods array is empty', async () => { const outputPath = join(tmpDir, 'empty.csv') const folder = await exportCsv([], outputPath) const entries = await readdir(folder) expect(entries.length).toBeGreaterThanOrEqual(0) }) + + it('describes detail files without hardcoding a 30-day window', async () => { + const periods: PeriodExport[] = [ + { + label: '2026-04-07 to 2026-04-10', + projects: [makeProject('app')], + }, + ] + + const outputPath = join(tmpDir, 'custom.csv') + const folder = await exportCsv(periods, outputPath) + const readme = await readFile(join(folder, 'README.txt'), 'utf-8') + + expect(readme).toContain('selected detail period') + expect(readme).not.toContain('30-day window') + }) }) diff --git a/tests/fs-utils.test.ts b/tests/fs-utils.test.ts index 6510900..2264022 100644 --- a/tests/fs-utils.test.ts +++ b/tests/fs-utils.test.ts @@ -5,7 +5,6 @@ import { join } from 'path' import { MAX_SESSION_FILE_BYTES, - STREAM_THRESHOLD_BYTES, readSessionFile, readSessionLines, } from '../src/fs-utils.js' @@ -34,11 +33,12 @@ describe('readSessionFile', () => { expect(await readSessionFile(p)).toBe('hello\nworld\n') }) - it('returns content for files at the stream threshold via stream path', async () => { - const p = await tmpPath(Buffer.alloc(STREAM_THRESHOLD_BYTES, 'a')) + it('returns content for large files under the full-file cap', async () => { + const size = 8 * 1024 * 1024 + const p = await tmpPath(Buffer.alloc(size, 'a')) const got = await readSessionFile(p) expect(got).not.toBeNull() - expect(got!.length).toBe(STREAM_THRESHOLD_BYTES) + expect(got!.length).toBe(size) }) it('returns null and skips files over the cap', async () => { @@ -88,6 +88,28 @@ describe('readSessionLines', () => { expect(lines).toEqual(['line1', 'line2', 'line3']) }) + it('skips old large lines before materializing the full line', async () => { + const oldLine = `{"type":"assistant","timestamp":"2026-01-01T00:00:00Z","payload":"${'x'.repeat(100_000)}"}` + const newLine = '{"type":"assistant","timestamp":"2026-05-01T00:00:00Z"}' + const p = await tmpPath(`${oldLine}\n${newLine}\n`) + const lines: string[] = [] + for await (const line of readSessionLines(p, head => head.includes('2026-01-01'))) { + lines.push(line) + } + expect(lines).toEqual([newLine]) + }) + + it('yields large lines as Buffers when requested', async () => { + const largeLine = `{"type":"assistant","timestamp":"2026-05-01T00:00:00Z","payload":"${'x'.repeat(100_000)}"}` + const p = await tmpPath(`${largeLine}\nsmall\n`) + const lines: Array = [] + for await (const line of readSessionLines(p, undefined, { largeLineAsBuffer: true })) { + lines.push(line) + } + expect(Buffer.isBuffer(lines[0])).toBe(true) + expect(lines[1]).toBe('small') + }) + it('does not leak file descriptors when generator is abandoned early', async () => { const content = Array.from({ length: 1000 }, (_, i) => `line-${i}`).join('\n') const p = await tmpPath(content) @@ -95,4 +117,56 @@ describe('readSessionLines', () => { await gen.next() await gen.return(undefined) }) + + it('reads from startByteOffset, yielding only lines after the offset', async () => { + const content = 'line1\nline2\nline3\n' + const p = await tmpPath(content) + const offset = Buffer.byteLength('line1\n') + const lines: string[] = [] + for await (const line of readSessionLines(p, undefined, { startByteOffset: offset })) { + lines.push(line) + } + expect(lines).toEqual(['line2', 'line3']) + }) + + it('byteOffsetTracker tracks position after last complete newline', async () => { + const content = 'aaa\nbbb\nccc\n' + const p = await tmpPath(content) + const tracker = { lastCompleteLineOffset: 0 } + const lines: string[] = [] + for await (const line of readSessionLines(p, undefined, { byteOffsetTracker: tracker })) { + lines.push(line) + } + expect(lines).toEqual(['aaa', 'bbb', 'ccc']) + expect(tracker.lastCompleteLineOffset).toBe(Buffer.byteLength(content)) + }) + + it('byteOffsetTracker accounts for startByteOffset', async () => { + const content = 'line1\nline2\nline3\n' + const p = await tmpPath(content) + const offset = Buffer.byteLength('line1\n') + const tracker = { lastCompleteLineOffset: 0 } + for await (const _line of readSessionLines(p, undefined, { startByteOffset: offset, byteOffsetTracker: tracker })) {} + expect(tracker.lastCompleteLineOffset).toBe(Buffer.byteLength(content)) + }) + + it('byteOffsetTracker excludes trailing partial line (no final newline)', async () => { + const content = 'line1\nline2\npartial' + const p = await tmpPath(content) + const tracker = { lastCompleteLineOffset: 0 } + for await (const _line of readSessionLines(p, undefined, { byteOffsetTracker: tracker })) {} + expect(tracker.lastCompleteLineOffset).toBe(Buffer.byteLength('line1\nline2\n')) + }) + + it('byteOffsetTracker updates for skipped lines too', async () => { + const content = 'skip-me\nkeep-me\n' + const p = await tmpPath(content) + const tracker = { lastCompleteLineOffset: 0 } + const lines: string[] = [] + for await (const line of readSessionLines(p, head => head.includes('skip-me'), { byteOffsetTracker: tracker })) { + lines.push(line) + } + expect(lines).toEqual(['keep-me']) + expect(tracker.lastCompleteLineOffset).toBe(Buffer.byteLength(content)) + }) }) diff --git a/tests/mcp-coverage.test.ts b/tests/mcp-coverage.test.ts new file mode 100644 index 0000000..c2a4595 --- /dev/null +++ b/tests/mcp-coverage.test.ts @@ -0,0 +1,450 @@ +import { describe, it, expect } from 'vitest' + +import { + aggregateMcpCoverage, + detectMcpToolCoverage, + estimateMcpSchemaCost, +} from '../src/optimize.js' +import type { + ClassifiedTurn, + ParsedApiCall, + ProjectSummary, + SessionSummary, + TaskCategory, + TokenUsage, +} from '../src/types.js' + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +const ZERO_USAGE: TokenUsage = { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, +} + +function makeCall(opts: { + tools?: string[] + cacheCreation?: number + cacheRead?: number + cost?: number +} = {}): ParsedApiCall { + const tools = opts.tools ?? [] + return { + provider: 'claude', + model: 'Opus 4.7', + usage: { + ...ZERO_USAGE, + cacheCreationInputTokens: opts.cacheCreation ?? 0, + cacheReadInputTokens: opts.cacheRead ?? 0, + }, + costUSD: opts.cost ?? 0, + tools, + mcpTools: tools.filter(t => t.startsWith('mcp__')), + skills: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp: '2026-05-04T00:00:00Z', + bashCommands: [], + deduplicationKey: 'k', + } +} + +function makeTurn(calls: ParsedApiCall[]): ClassifiedTurn { + return { + userMessage: '', + assistantCalls: calls, + timestamp: '2026-05-04T00:00:00Z', + sessionId: 's1', + category: 'coding', + retries: 0, + hasEdits: false, + } +} + +function makeSession(opts: { + sessionId?: string + inventory?: string[] + turns?: ClassifiedTurn[] + mcpBreakdown?: Record +}): SessionSummary { + const turns = opts.turns ?? [] + const apiCalls = turns.reduce((s, t) => s + t.assistantCalls.length, 0) + const emptyCategoryBreakdown = {} as Record + return { + sessionId: opts.sessionId ?? 's1', + project: 'p', + firstTimestamp: '2026-05-04T00:00:00Z', + lastTimestamp: '2026-05-04T00:00:00Z', + totalCostUSD: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls, + turns, + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: opts.mcpBreakdown ?? {}, + bashBreakdown: {}, + categoryBreakdown: emptyCategoryBreakdown, + skillBreakdown: {}, + ...(opts.inventory ? { mcpInventory: opts.inventory } : {}), + } +} + +function project(sessions: SessionSummary[]): ProjectSummary { + return { + project: 'p', + projectPath: '/tmp/p', + sessions, + totalCostUSD: 0, + totalApiCalls: sessions.reduce((s, ses) => s + ses.apiCalls, 0), + } +} + +// --------------------------------------------------------------------------- +// aggregateMcpCoverage +// --------------------------------------------------------------------------- + +describe('aggregateMcpCoverage', () => { + it('returns empty list when no session has MCP inventory', () => { + const projects = [project([makeSession({})])] + expect(aggregateMcpCoverage(projects)).toEqual([]) + }) + + it('reports per-server tools available, invoked, and unused', () => { + const inventory = [ + 'mcp__hf__hub_repo_search', + 'mcp__hf__paper_search', + 'mcp__hf__hf_doc_search', + ] + const turns = [ + makeTurn([makeCall({ tools: ['mcp__hf__hub_repo_search'] })]), + ] + const sessions = [ + makeSession({ inventory, turns, mcpBreakdown: { hf: { calls: 1 } } }), + ] + const result = aggregateMcpCoverage([project(sessions)]) + + expect(result).toHaveLength(1) + expect(result[0]!.server).toBe('hf') + expect(result[0]!.toolsAvailable).toBe(3) + expect(result[0]!.toolsInvoked).toBe(1) + expect(result[0]!.unusedTools).toEqual([ + 'mcp__hf__hf_doc_search', + 'mcp__hf__paper_search', + ]) + expect(result[0]!.coverageRatio).toBeCloseTo(1 / 3, 5) + expect(result[0]!.invocations).toBe(1) + expect(result[0]!.loadedSessions).toBe(1) + }) + + it('unions inventory across multiple sessions for the same server', () => { + const sessions = [ + makeSession({ sessionId: 'a', inventory: ['mcp__x__a', 'mcp__x__b'] }), + makeSession({ sessionId: 'b', inventory: ['mcp__x__b', 'mcp__x__c'] }), + ] + const result = aggregateMcpCoverage([project(sessions)]) + expect(result[0]!.toolsAvailable).toBe(3) + expect(result[0]!.loadedSessions).toBe(2) + }) + + it('separates servers with similar names', () => { + const sessions = [ + makeSession({ inventory: ['mcp__hf__a', 'mcp__hugface__a'] }), + ] + const result = aggregateMcpCoverage([project(sessions)]) + expect(result.map(r => r.server).sort()).toEqual(['hf', 'hugface']) + }) + + it('skips invocations without inventory (foreign server, no inventory observed)', () => { + // A server can show up only via a call. We still report it so the + // operator knows it was invoked, but coverage is 0/0 and it is not a + // candidate for the unused-coverage finding. + const turns = [makeTurn([makeCall({ tools: ['mcp__ghost__t1'] })])] + const sessions = [ + makeSession({ turns, mcpBreakdown: { ghost: { calls: 1 } } }), + ] + const result = aggregateMcpCoverage([project(sessions)]) + // No inventory entry -> aggregator drops the server from the report + // because we cannot reason about coverage without an inventory baseline. + expect(result).toEqual([]) + }) +}) + +// --------------------------------------------------------------------------- +// estimateMcpSchemaCost — cache-aware accounting +// --------------------------------------------------------------------------- + +describe('estimateMcpSchemaCost', () => { + it('charges first cacheCreation turn at full price, subsequent turns at cache-read', () => { + const turns = [ + makeTurn([makeCall({ cacheCreation: 50_000 })]), // first turn: write + makeTurn([makeCall({ cacheRead: 60_000 })]), // ongoing: read + makeTurn([makeCall({ cacheRead: 60_000 })]), + ] + const sessions = [makeSession({ + inventory: Array.from({ length: 30 }, (_, i) => `mcp__svc__t${i}`), + turns, + mcpBreakdown: { svc: { calls: 0 } }, + })] + // 30 unused tools * 400 token estimate = 12_000 schema tokens + // cap by call cache buckets so we never overclaim + const cost = estimateMcpSchemaCost(30, [project(sessions)], 'svc') + expect(cost.cacheWriteTokens).toBe(12_000) // capped by 50k creation, 12k schema fits + expect(cost.cacheReadTokens).toBe(24_000) // 12k + 12k across two ongoing turns + // effective = write * 1.25 + read * 0.10 (cache pricing) + expect(cost.effectiveInputTokens).toBeCloseTo(12_000 * 1.25 + 24_000 * 0.10, 5) + }) + + it('caps by available cache bucket so we never overclaim', () => { + const turns = [makeTurn([makeCall({ cacheCreation: 1_000 })])] + const sessions = [makeSession({ + inventory: Array.from({ length: 30 }, (_, i) => `mcp__svc__t${i}`), + turns, + mcpBreakdown: { svc: { calls: 0 } }, + })] + // 30*400 = 12k schema tokens, but the call only had 1k cache-creation, + // so we should not claim more than 1k of overhead for that turn. + const cost = estimateMcpSchemaCost(30, [project(sessions)], 'svc') + expect(cost.cacheWriteTokens).toBe(1_000) + }) + + it('returns zero when no unused tools', () => { + const sessions = [makeSession({ + inventory: ['mcp__svc__t1'], + turns: [makeTurn([makeCall({ cacheCreation: 5000 })])], + })] + const cost = estimateMcpSchemaCost(0, [project(sessions)], 'svc') + expect(cost).toEqual({ cacheWriteTokens: 0, cacheReadTokens: 0, effectiveInputTokens: 0 }) + }) + + it('counts cache write AND cache read on the same call', () => { + // A long session can have a cache rebuild mid-stream where one call + // reports both buckets. The estimator must charge both, not skip the + // read because of the write. + const turns = [makeTurn([ + makeCall({ cacheCreation: 50_000, cacheRead: 30_000 }), + ])] + const sessions = [makeSession({ + inventory: Array.from({ length: 30 }, (_, i) => `mcp__svc__t${i}`), + turns, + mcpBreakdown: { svc: { calls: 0 } }, + })] + const cost = estimateMcpSchemaCost(30, [project(sessions)], 'svc') + expect(cost.cacheWriteTokens).toBe(12_000) // capped at 50k creation + expect(cost.cacheReadTokens).toBe(12_000) // capped at 30k read + }) + + it('counts every cache rebuild, not just the first one', () => { + // Sessions that span more than 5 minutes can rebuild the cache + // multiple times. The estimator should treat every cacheCreation + // bucket as another write. + const turns = [makeTurn([ + makeCall({ cacheCreation: 50_000 }), + makeCall({ cacheCreation: 50_000 }), // rebuild after cache TTL + makeCall({ cacheRead: 60_000 }), + ])] + const sessions = [makeSession({ + inventory: Array.from({ length: 30 }, (_, i) => `mcp__svc__t${i}`), + turns, + mcpBreakdown: { svc: { calls: 0 } }, + })] + const cost = estimateMcpSchemaCost(30, [project(sessions)], 'svc') + expect(cost.cacheWriteTokens).toBe(24_000) // both rebuilds counted + expect(cost.cacheReadTokens).toBe(12_000) + }) + + it('skips sessions where the server was never loaded', () => { + const turns = [makeTurn([makeCall({ cacheCreation: 100_000 })])] + const sessions = [makeSession({ + inventory: ['mcp__other__t1'], + turns, + })] + const cost = estimateMcpSchemaCost(10, [project(sessions)], 'svc') + expect(cost.cacheWriteTokens).toBe(0) + }) + + it('requires observed inventory for the server, not just invocations', () => { + // Session invoked the server (mcpBreakdown set, mcpTools called) but + // never reported a deferred_tools_delta for it. Cost should be 0 to + // stay consistent with aggregateMcpCoverage's loadedSessions rule. + const turns = [makeTurn([ + makeCall({ tools: ['mcp__svc__t1'], cacheCreation: 100_000 }), + ])] + const sessions = [makeSession({ + // No inventory at all + turns, + mcpBreakdown: { svc: { calls: 1 } }, + })] + const cost = estimateMcpSchemaCost(10, [project(sessions)], 'svc') + expect(cost.cacheWriteTokens).toBe(0) + expect(cost.cacheReadTokens).toBe(0) + }) + + it('caps combined unused-schema budget across multiple flagged servers', () => { + // Two flagged servers, each with 30 unused tools (12k schema each = + // 24k combined). One call has a 50k cache-creation bucket. The + // combined cap means total write tokens reported is min(24k, 50k) = + // 24k, not 24k + 24k = 48k. + const inventory = [ + ...Array.from({ length: 30 }, (_, i) => `mcp__a__t${i}`), + ...Array.from({ length: 30 }, (_, i) => `mcp__b__t${i}`), + ] + const turns = [makeTurn([makeCall({ cacheCreation: 50_000 })])] + const sessions = [makeSession({ inventory, turns })] + const cost = estimateMcpSchemaCost( + { a: 30, b: 30 }, + [project(sessions)], + ['a', 'b'], + ) + expect(cost.cacheWriteTokens).toBe(24_000) + }) + + it('still works with the single-server signature (backward compat)', () => { + const turns = [makeTurn([makeCall({ cacheCreation: 50_000 })])] + const sessions = [makeSession({ + inventory: Array.from({ length: 30 }, (_, i) => `mcp__svc__t${i}`), + turns, + })] + const cost = estimateMcpSchemaCost(30, [project(sessions)], 'svc') + expect(cost.cacheWriteTokens).toBe(12_000) + }) +}) + +// --------------------------------------------------------------------------- +// detectMcpToolCoverage — finding emission with thresholds +// --------------------------------------------------------------------------- + +describe('detectMcpToolCoverage', () => { + it('returns null when no inventory exists at all', () => { + expect(detectMcpToolCoverage([project([makeSession({})])])).toBeNull() + }) + + it('does not flag a server with healthy coverage', () => { + const inventory = Array.from({ length: 20 }, (_, i) => `mcp__svc__t${i}`) + const turns = [makeTurn( + Array.from({ length: 8 }, (_, i) => makeCall({ tools: [`mcp__svc__t${i}`] })), + )] + const sessions = [ + makeSession({ sessionId: 'a', inventory, turns }), + makeSession({ sessionId: 'b', inventory, turns }), + ] + // 8/20 = 40% coverage, above the 20% threshold -> no finding + expect(detectMcpToolCoverage([project(sessions)])).toBeNull() + }) + + it('does not flag a server with too few tools (signal too noisy)', () => { + // Below MCP_COVERAGE_MIN_TOOLS=10 + const inventory = ['mcp__svc__a', 'mcp__svc__b'] + const sessions = [ + makeSession({ sessionId: 'a', inventory }), + makeSession({ sessionId: 'b', inventory }), + ] + expect(detectMcpToolCoverage([project(sessions)])).toBeNull() + }) + + it('does not flag if seen in only one session (insufficient evidence)', () => { + const inventory = Array.from({ length: 20 }, (_, i) => `mcp__svc__t${i}`) + const sessions = [makeSession({ inventory })] + expect(detectMcpToolCoverage([project(sessions)])).toBeNull() + }) + + it('flags a large server with low coverage across multiple sessions', () => { + const inventory = Array.from({ length: 30 }, (_, i) => `mcp__hf__t${i}`) + const turns = [makeTurn([ + makeCall({ tools: ['mcp__hf__t0'], cacheCreation: 100_000 }), + ])] + const sessions = [ + makeSession({ sessionId: 'a', inventory, turns, mcpBreakdown: { hf: { calls: 1 } } }), + makeSession({ sessionId: 'b', inventory, turns, mcpBreakdown: { hf: { calls: 1 } } }), + ] + const finding = detectMcpToolCoverage([project(sessions)]) + expect(finding).not.toBeNull() + expect(finding!.title).toContain('1 MCP server') + expect(finding!.title).toContain('low tool coverage') + expect(finding!.explanation).toContain('hf') + expect(finding!.explanation).toContain('1/30') + expect(finding!.fix.type).toBe('command') + expect((finding!.fix as { text: string }).text).toContain("claude mcp remove 'hf'") + expect(finding!.tokensSaved).toBeGreaterThan(0) + }) + + it('escalates impact to high when token waste crosses the threshold', () => { + const inventory = Array.from({ length: 60 }, (_, i) => `mcp__big__t${i}`) + // 60 tools * 400 tokens = 24k schema. With many sessions and large + // cache-creation buckets, total effective tokens easily clear 200k. + const turns = [makeTurn([ + makeCall({ tools: ['mcp__big__t0'], cacheCreation: 50_000 }), + makeCall({ cacheRead: 60_000 }), + makeCall({ cacheRead: 60_000 }), + ])] + // Need enough sessions so the per-session ~28.8k effective tokens + // (24k write + 48k read × 0.10) sum past the 200k high-impact threshold. + const sessions = Array.from({ length: 8 }, (_, i) => + makeSession({ sessionId: `s${i}`, inventory, turns, mcpBreakdown: { big: { calls: 1 } } }), + ) + const finding = detectMcpToolCoverage([project(sessions)]) + expect(finding).not.toBeNull() + expect(finding!.impact).toBe('high') + }) + + it('does not count invocation-only sessions toward loadedSessions', () => { + // Server `svc` has inventory in only one session, but is invoked in + // a second session that never observed the schema. Pre-fix this + // would have satisfied the >=2 session threshold; it must not now. + const inventory = Array.from({ length: 20 }, (_, i) => `mcp__svc__t${i}`) + const turns = [makeTurn([ + makeCall({ tools: ['mcp__svc__t0'], cacheCreation: 50_000 }), + ])] + const sessions = [ + makeSession({ sessionId: 'a', inventory, turns, mcpBreakdown: { svc: { calls: 1 } } }), + // No inventory — this shouldn't be considered a "loaded" session. + makeSession({ sessionId: 'b', turns, mcpBreakdown: { svc: { calls: 1 } } }), + ] + expect(detectMcpToolCoverage([project(sessions)])).toBeNull() + }) + + it('does not let invocations of un-inventoried tools inflate coverage', () => { + // Inventory has 20 tools, none invoked. Calls hit a 21st tool that + // never appeared in any deferred_tools_delta (could be a renamed/ + // removed tool from an older session config). Coverage must stay 0% + // and unusedCount must not go negative. + const inventory = Array.from({ length: 20 }, (_, i) => `mcp__svc__t${i}`) + const turns = [makeTurn([makeCall({ tools: ['mcp__svc__ghost'] })])] + const sessions = [ + makeSession({ sessionId: 'a', inventory, turns, mcpBreakdown: { svc: { calls: 1 } } }), + makeSession({ sessionId: 'b', inventory, turns, mcpBreakdown: { svc: { calls: 1 } } }), + ] + const result = aggregateMcpCoverage([project(sessions)]) + expect(result[0]!.toolsAvailable).toBe(20) + expect(result[0]!.toolsInvoked).toBe(0) + expect(result[0]!.coverageRatio).toBe(0) + expect(result[0]!.unusedTools).toHaveLength(20) + }) + + it('handles multiple flagged servers and pluralises the title', () => { + const sessions: SessionSummary[] = [] + for (const server of ['svc1', 'svc2']) { + const inventory = Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`) + const turns = [makeTurn([ + makeCall({ tools: [`mcp__${server}__t0`], cacheCreation: 50_000 }), + ])] + sessions.push( + makeSession({ sessionId: `${server}-a`, inventory, turns, mcpBreakdown: { [server]: { calls: 1 } } }), + makeSession({ sessionId: `${server}-b`, inventory, turns, mcpBreakdown: { [server]: { calls: 1 } } }), + ) + } + const finding = detectMcpToolCoverage([project(sessions)]) + expect(finding).not.toBeNull() + expect(finding!.title).toContain('2 MCP servers') + expect((finding!.fix as { text: string }).text.split('\n')).toHaveLength(2) + }) +}) diff --git a/tests/menubar-installer.test.ts b/tests/menubar-installer.test.ts new file mode 100644 index 0000000..a37cdab --- /dev/null +++ b/tests/menubar-installer.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { + resolveLatestMenubarReleaseAssets, + resolveMenubarReleaseAssets, + type ReleaseResponse, +} from '../src/menubar-installer.js' + +function asset(name: string) { + return { name, browser_download_url: `https://example.test/${name}` } +} + +describe('resolveMenubarReleaseAssets', () => { + it('ignores dev zips and pairs the checksum with the versioned zip', () => { + const release: ReleaseResponse = { + tag_name: 'mac-v0.9.8', + assets: [ + asset('CodeBurnMenubar-dev.zip'), + asset('CodeBurnMenubar-dev.zip.sha256'), + asset('CodeBurnMenubar-v0.9.8.zip'), + asset('CodeBurnMenubar-v0.9.8.zip.sha256'), + ], + } + + const resolved = resolveMenubarReleaseAssets(release) + + expect(resolved.zip.name).toBe('CodeBurnMenubar-v0.9.8.zip') + expect(resolved.checksum?.name).toBe('CodeBurnMenubar-v0.9.8.zip.sha256') + }) + + it('fails when a release only contains dev assets', () => { + const release: ReleaseResponse = { + tag_name: 'mac-v0.9.8', + assets: [ + asset('CodeBurnMenubar-dev.zip'), + asset('CodeBurnMenubar-dev.zip.sha256'), + ], + } + + expect(() => resolveMenubarReleaseAssets(release)).toThrow(/versioned zip/) + }) + + it('fails when the versioned checksum is missing', () => { + const release: ReleaseResponse = { + tag_name: 'mac-v0.9.8', + assets: [ + asset('CodeBurnMenubar-v0.9.8.zip'), + ], + } + + expect(() => resolveMenubarReleaseAssets(release)).toThrow(/Missing checksum/) + }) + + it('selects the newest mac release instead of the newest repo release', () => { + const releases: ReleaseResponse[] = [ + { + tag_name: 'v0.9.9', + assets: [ + asset('codeburn-0.9.9.tgz'), + ], + }, + { + tag_name: 'mac-v0.9.8', + assets: [ + asset('CodeBurnMenubar-v0.9.8.zip'), + asset('CodeBurnMenubar-v0.9.8.zip.sha256'), + ], + }, + ] + + const resolved = resolveLatestMenubarReleaseAssets(releases) + + expect(resolved.release.tag_name).toBe('mac-v0.9.8') + expect(resolved.zip.name).toBe('CodeBurnMenubar-v0.9.8.zip') + }) +}) diff --git a/tests/model-efficiency.test.ts b/tests/model-efficiency.test.ts new file mode 100644 index 0000000..cd7ae89 --- /dev/null +++ b/tests/model-efficiency.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' + +import { aggregateModelEfficiency } from '../src/model-efficiency.js' +import type { ClassifiedTurn, ParsedApiCall, ProjectSummary, SessionSummary } from '../src/types.js' + +function call(model: string, costUSD = 1): ParsedApiCall { + return { + provider: 'claude', + model, + usage: { + inputTokens: 100, + outputTokens: 50, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + }, + costUSD, + tools: ['Edit'], + mcpTools: [], + skills: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp: '2026-05-05T00:00:00Z', + bashCommands: [], + deduplicationKey: `${model}-${costUSD}`, + } +} + +function turn(model: string, opts: { hasEdits?: boolean; retries?: number; costUSD?: number } = {}): ClassifiedTurn { + return { + userMessage: '', + assistantCalls: [call(model, opts.costUSD ?? 1)], + timestamp: '2026-05-05T00:00:00Z', + sessionId: 's1', + category: 'coding', + retries: opts.retries ?? 0, + hasEdits: opts.hasEdits ?? true, + } +} + +function multiModelTurn(calls: ParsedApiCall[], opts: { retries?: number; hasEdits?: boolean } = {}): ClassifiedTurn { + return { + userMessage: '', + assistantCalls: calls, + timestamp: '2026-05-05T00:00:00Z', + sessionId: 's1', + category: 'coding', + retries: opts.retries ?? 0, + hasEdits: opts.hasEdits ?? true, + } +} + +function project(turns: ClassifiedTurn[]): ProjectSummary { + const session: SessionSummary = { + sessionId: 's1', + project: 'app', + firstTimestamp: '2026-05-05T00:00:00Z', + lastTimestamp: '2026-05-05T00:00:00Z', + totalCostUSD: turns.reduce((sum, t) => sum + t.assistantCalls.reduce((s, c) => s + c.costUSD, 0), 0), + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: turns.reduce((sum, t) => sum + t.assistantCalls.length, 0), + turns, + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: {} as SessionSummary['categoryBreakdown'], + skillBreakdown: {}, + } + return { + project: 'app', + projectPath: '/app', + sessions: [session], + totalCostUSD: session.totalCostUSD, + totalApiCalls: session.apiCalls, + } +} + +describe('aggregateModelEfficiency', () => { + it('computes one-shot, retry, and cost-per-edit metrics by display model', () => { + const stats = aggregateModelEfficiency([project([ + turn('claude-sonnet-4-5', { hasEdits: true, retries: 0, costUSD: 2 }), + turn('claude-sonnet-4-5', { hasEdits: true, retries: 2, costUSD: 4 }), + turn('claude-opus-4-6', { hasEdits: true, retries: 0, costUSD: 10 }), + turn('claude-sonnet-4-5', { hasEdits: false, retries: 0, costUSD: 3 }), + ])]) + + const sonnet = stats.get('Sonnet 4.5') + expect(sonnet?.editTurns).toBe(2) + expect(sonnet?.oneShotTurns).toBe(1) + expect(sonnet?.oneShotRate).toBe(50) + expect(sonnet?.retriesPerEdit).toBe(1) + expect(sonnet?.costPerEditUSD).toBe(3) + + const opus = stats.get('Opus 4.6') + expect(opus?.oneShotRate).toBe(100) + }) + + it('returns no stats for non-edit turns', () => { + const stats = aggregateModelEfficiency([project([ + turn('claude-sonnet-4-5', { hasEdits: false }), + ])]) + + expect(stats.size).toBe(0) + }) + + it('attributes a multi-model turn to the first non-synthetic model', () => { + const stats = aggregateModelEfficiency([project([ + multiModelTurn([ + call('', 0), + call('claude-opus-4-6', 2), + call('claude-sonnet-4-5', 1), + ], { retries: 0, hasEdits: true }), + ])]) + + expect(stats.has('Opus 4.6')).toBe(true) + expect(stats.has('Sonnet 4.5')).toBe(false) + expect(stats.has('')).toBe(false) + const opus = stats.get('Opus 4.6')! + expect(opus.editTurns).toBe(1) + expect(opus.oneShotTurns).toBe(1) + expect(opus.costPerEditUSD).toBe(3) + }) + + it('skips a turn whose calls are all synthetic', () => { + const stats = aggregateModelEfficiency([project([ + multiModelTurn([ + call('', 0), + call('', 0), + ], { retries: 0, hasEdits: true }), + ])]) + + expect(stats.size).toBe(0) + }) +}) diff --git a/tests/models-hoist.test.ts b/tests/models-hoist.test.ts new file mode 100644 index 0000000..324e6ff --- /dev/null +++ b/tests/models-hoist.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from 'vitest' +import { calculateCost, getModelCosts, getShortModelName } from '../src/models.js' + +// Lock down the post-hoist refactor: every model name a real user has +// emitted in the last year should resolve to the same display name and +// the same costs as before. If this list grows or shrinks, the refactor +// is fine — it's the per-name resolution that must stay stable. +const KNOWN_NAMES = [ + 'claude-opus-4-7', + 'claude-opus-4-6', + 'claude-opus-4-5', + 'claude-sonnet-4-6', + 'claude-sonnet-4-5', + 'claude-haiku-4-5', + 'claude-3-5-sonnet', + 'claude-3-5-haiku', + 'claude-opus-4-7-20250101', + 'claude-sonnet-4-6-20250929', + 'anthropic/claude-opus-4-7', + 'anthropic--claude-4.6-opus', + 'anthropic--claude-4.6-sonnet', + 'claude-4.6-sonnet', + 'gpt-5', + 'gpt-5-mini', + 'gpt-5-nano', + 'gpt-5-pro', + 'gpt-5.1', + 'gpt-5.1-codex', + 'gpt-5.1-codex-mini', + 'gpt-5.2', + 'gpt-5.2-low', + 'gpt-5.3-codex', + 'gpt-5.4', + 'gpt-5.4-mini', + 'gpt-4o', + 'gpt-4o-mini', + 'gpt-4.1', + 'gpt-4.1-mini', + 'gpt-4.1-nano', + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-3.1-pro-preview', + 'gemini-3-flash-preview', + 'gemini-3.1-pro', + 'gemini-3-flash', + 'cursor-auto', + 'cursor-agent-auto', + 'copilot-auto', + 'copilot-openai-auto', + 'kiro-auto', + 'cline-auto', + 'qwen-auto', + 'kimi-auto', + 'kimi-for-coding', + 'kimi-k2-thinking-turbo', + 'kimi-k2.6', + 'o3', + 'o4-mini', + 'deepseek-coder', + 'deepseek-coder-max', + 'deepseek-r1', + 'MiniMax-M2.7', + 'MiniMax-M2.7-highspeed', +] + +describe('post-hoist resolution stability', () => { + it('every known model resolves to a non-empty short name', () => { + for (const name of KNOWN_NAMES) { + const short = getShortModelName(name) + expect(short, `short name for ${name}`).toBeTruthy() + expect(typeof short, `short name for ${name}`).toBe('string') + } + }) + + it('gpt-5-mini does NOT collide with gpt-5 (longest-prefix wins)', () => { + expect(getShortModelName('gpt-5-mini')).toBe('GPT-5 Mini') + expect(getShortModelName('gpt-5')).toBe('GPT-5') + expect(getShortModelName('gpt-5-nano')).toBe('GPT-5 Nano') + expect(getShortModelName('gpt-5-pro')).toBe('GPT-5 Pro') + }) + + it('gpt-5.1-codex-mini does NOT collapse to gpt-5.1-codex or gpt-5', () => { + expect(getShortModelName('gpt-5.1-codex-mini')).toBe('GPT-5.1 Codex Mini') + expect(getShortModelName('gpt-5.1-codex')).toBe('GPT-5.1 Codex') + expect(getShortModelName('gpt-5.1')).toBe('GPT-5.1') + }) + + it('claude-haiku-4-5 does NOT collapse to claude-haiku-4 or claude-3-5-haiku', () => { + expect(getShortModelName('claude-haiku-4-5')).toBe('Haiku 4.5') + expect(getShortModelName('claude-3-5-haiku')).toBe('Haiku 3.5') + }) + + it('kimi managed aliases resolve to priced Kimi models', () => { + expect(getShortModelName('kimi-auto')).toBe('Kimi (auto)') + expect(getShortModelName('kimi-for-coding')).toBe('Kimi K2 Thinking') + expect(getShortModelName('kimi-k2-thinking-turbo')).toBe('Kimi K2 Thinking Turbo') + expect(getShortModelName('kimi-k2.6')).toBe('Kimi K2.6') + expect(getModelCosts('kimi-auto')?.inputCostPerToken).toBeGreaterThan(0) + }) + + it('getModelCosts returns positive token costs for every known name', () => { + for (const name of KNOWN_NAMES) { + const c = getModelCosts(name) + expect(c, `costs for ${name}`).not.toBeNull() + expect(c!.inputCostPerToken).toBeGreaterThan(0) + expect(c!.outputCostPerToken).toBeGreaterThan(0) + } + }) + + it('calculateCost is stable for a typical Sonnet 4.6 turn', () => { + // 1k input, 2k output, 50k cache read — common Claude Code shape. + const cost = calculateCost('claude-sonnet-4-6', 1000, 2000, 0, 50_000, 0) + expect(cost).toBeGreaterThan(0) + expect(Number.isFinite(cost)).toBe(true) + }) + + it('calculateCost clamps NaN/negative inputs to 0', () => { + const c1 = calculateCost('claude-sonnet-4-6', NaN, 1000, 0, 0, 0) + const c2 = calculateCost('claude-sonnet-4-6', 0, 1000, 0, 0, 0) + expect(c1).toBe(c2) + const c3 = calculateCost('claude-sonnet-4-6', -1000, 1000, 0, 0, 0) + expect(c3).toBe(c2) + }) + + it('repeated calls return the same cost (memoized sort cache is consistent)', () => { + const a = getModelCosts('gpt-5-mini') + const b = getModelCosts('gpt-5-mini') + const c = getModelCosts('gpt-5-mini') + expect(a).toEqual(b) + expect(b).toEqual(c) + }) +}) diff --git a/tests/models-report.test.ts b/tests/models-report.test.ts new file mode 100644 index 0000000..552673a --- /dev/null +++ b/tests/models-report.test.ts @@ -0,0 +1,466 @@ +import { describe, it, expect } from 'vitest' +import chalk from 'chalk' +import stripAnsi from 'strip-ansi' + +import { aggregateModels, renderTable, renderMarkdown, renderJson, renderCsv, type ModelReportRow } from '../src/models-report.js' +import type { + ProjectSummary, + SessionSummary, + ClassifiedTurn, + ParsedApiCall, + TokenUsage, + TaskCategory, +} from '../src/types.js' + +function emptyTokens(): TokenUsage { + return { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + } +} + +function makeCall(opts: { + provider: string + model: string + costUSD: number + input?: number + output?: number + cacheWrite?: number + cacheRead?: number +}): ParsedApiCall { + return { + provider: opts.provider, + model: opts.model, + usage: { + ...emptyTokens(), + inputTokens: opts.input ?? 0, + outputTokens: opts.output ?? 0, + cacheCreationInputTokens: opts.cacheWrite ?? 0, + cacheReadInputTokens: opts.cacheRead ?? 0, + }, + costUSD: opts.costUSD, + tools: [], + mcpTools: [], + skills: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp: '2026-05-09T00:00:00.000Z', + bashCommands: [], + deduplicationKey: `${opts.provider}-${opts.model}-${opts.costUSD}`, + } +} + +function makeTurn(category: TaskCategory, calls: ParsedApiCall[]): ClassifiedTurn { + return { + userMessage: 'test', + assistantCalls: calls, + timestamp: '2026-05-09T00:00:00.000Z', + sessionId: 's1', + category, + retries: 0, + hasEdits: false, + } +} + +function makeSession(turns: ClassifiedTurn[]): SessionSummary { + return { + sessionId: 's1', + project: 'p', + firstTimestamp: '2026-05-09T00:00:00.000Z', + lastTimestamp: '2026-05-09T00:00:00.000Z', + totalCostUSD: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: 0, + turns, + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: {} as SessionSummary['categoryBreakdown'], + skillBreakdown: {}, + } +} + +function makeProject(turns: ClassifiedTurn[]): ProjectSummary { + return { + project: 'p', + projectPath: '/tmp/p', + sessions: [makeSession(turns)], + totalCostUSD: 0, + totalApiCalls: 0, + } +} + +describe('aggregateModels', () => { + it('groups by (provider, model) and sorts by cost descending in default mode', async () => { + const project = makeProject([ + makeTurn('feature', [ + makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', input: 1000, output: 200, cacheWrite: 500, cacheRead: 8000, costUSD: 5.0 }), + ]), + makeTurn('debugging', [ + makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', input: 800, output: 100, cacheWrite: 300, cacheRead: 5000, costUSD: 3.5 }), + ]), + makeTurn('feature', [ + makeCall({ provider: 'codex', model: 'gpt-5', input: 600, output: 80, costUSD: 1.2 }), + ]), + ]) + const rows = await aggregateModels([project]) + expect(rows.map(r => `${r.provider}:${r.model}`)).toEqual(['claude:claude-sonnet-4-6', 'codex:gpt-5']) + const claudeRow = rows[0]! + expect(claudeRow.inputTokens).toBe(1800) + expect(claudeRow.outputTokens).toBe(300) + expect(claudeRow.cacheWriteTokens).toBe(800) + expect(claudeRow.cacheReadTokens).toBe(13000) + expect(claudeRow.costUSD).toBeCloseTo(8.5, 6) + expect(claudeRow.calls).toBe(2) + expect(claudeRow.totalTokens).toBe(1800 + 300 + 800 + 13000) + }) + + it('reports the dominant task type with its cost share in default mode', async () => { + const project = makeProject([ + makeTurn('feature', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 6.0, input: 100, output: 20 })]), + makeTurn('debugging', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 2.0, input: 50, output: 10 })]), + makeTurn('refactoring', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 2.0, input: 50, output: 10 })]), + ]) + const rows = await aggregateModels([project]) + expect(rows[0]!.topCategory).toBe('feature') + expect(rows[0]!.topCategoryShare).toBeCloseTo(0.6, 3) + }) + + it('explodes rows by task in byTask mode and groups them so renderer can blank repeats', async () => { + const project = makeProject([ + makeTurn('feature', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 6.0, input: 100, output: 20 })]), + makeTurn('debugging', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 2.0, input: 50, output: 10 })]), + makeTurn('feature', [makeCall({ provider: 'codex', model: 'gpt-5', costUSD: 1.0, input: 60, output: 10 })]), + ]) + const rows = await aggregateModels([project], { byTask: true }) + expect(rows).toHaveLength(3) + // Group order: claude (8.0) before codex (1.0); within claude, feature (6.0) before debugging (2.0). + expect(rows.map(r => `${r.provider}:${r.model}:${r.category}`)).toEqual([ + 'claude:claude-sonnet-4-6:feature', + 'claude:claude-sonnet-4-6:debugging', + 'codex:gpt-5:feature', + ]) + }) + + it('respects taskFilter by excluding non-matching turns from every bucket', async () => { + const project = makeProject([ + makeTurn('feature', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 5.0, input: 100, output: 20 })]), + makeTurn('debugging', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 2.0, input: 50, output: 10 })]), + ]) + const rows = await aggregateModels([project], { taskFilter: 'feature' }) + expect(rows).toHaveLength(1) + expect(rows[0]!.costUSD).toBeCloseTo(5.0, 6) + }) + + it('applies topN and minCost filters', async () => { + const project = makeProject([ + makeTurn('feature', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 5.0, input: 100, output: 20 })]), + makeTurn('feature', [makeCall({ provider: 'codex', model: 'gpt-5', costUSD: 0.5, input: 50, output: 10 })]), + makeTurn('feature', [makeCall({ provider: 'cursor', model: 'auto', costUSD: 0.001, input: 10, output: 1 })]), + ]) + const top = await aggregateModels([project], { topN: 1 }) + expect(top).toHaveLength(1) + const above = await aggregateModels([project], { minCost: 0.01 }) + expect(above.find(r => r.provider === 'cursor')).toBeUndefined() + }) + + it('counts reasoning tokens as output tokens', async () => { + const project = makeProject([ + makeTurn('feature', [ + { + provider: 'codex', + model: 'gpt-5', + usage: { ...emptyTokens(), inputTokens: 100, outputTokens: 50, reasoningTokens: 200 }, + costUSD: 1.0, + tools: [], + mcpTools: [], + skills: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp: '2026-05-09T00:00:00.000Z', + bashCommands: [], + deduplicationKey: 'k', + }, + ]), + ]) + const rows = await aggregateModels([project]) + expect(rows[0]!.outputTokens).toBe(250) + }) +}) + +describe('renderTable', () => { + function visibleWidth(line: string): number { + return stripAnsi(line).length + } + + function row(partial: Partial): ModelReportRow { + return { + provider: 'claude', + providerDisplayName: 'Claude', + model: 'claude-sonnet-4-6', + modelDisplayName: 'Sonnet 4.6', + category: null, + inputTokens: 0, + outputTokens: 0, + cacheWriteTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + costUSD: 0, + calls: 0, + ...partial, + } + } + + it('blanks repeated provider/model cells in byTask mode but keeps them in default mode', () => { + const rows: ModelReportRow[] = [ + row({ category: 'feature', costUSD: 7.78, inputTokens: 512_000, outputTokens: 98_000, cacheWriteTokens: 1_400_000, cacheReadTokens: 6_200_000, totalTokens: 8_210_000 }), + row({ category: 'debugging', costUSD: 5.31, inputTokens: 380_000, outputTokens: 71_000, cacheWriteTokens: 920_000, cacheReadTokens: 4_100_000, totalTokens: 5_471_000 }), + ] + const out = renderTable(rows, { byTask: true, showTotals: false, terminalWidth: 200 }) + const lines = out.split('\n') + // Layout: top border, header, header-separator, data..., bottom border. + const dataLines = lines.slice(3, -1) + expect(dataLines[0]).toContain('Sonnet 4.6') + expect(dataLines[0]).toContain('Feature Dev') + expect(dataLines[1]).not.toContain('Sonnet 4.6') + expect(dataLines[1]).not.toContain('Claude') + expect(dataLines[1]).toContain('Debugging') + }) + + it('keeps provider/model cells on every row in default mode', () => { + const rows: ModelReportRow[] = [ + row({ topCategory: 'feature', topCategoryShare: 0.6, costUSD: 5.0 }), + row({ provider: 'codex', providerDisplayName: 'Codex', model: 'gpt-5', modelDisplayName: 'GPT-5', topCategory: 'debugging', topCategoryShare: 0.4, costUSD: 1.2 }), + ] + const out = renderTable(rows, { byTask: false, showTotals: false, terminalWidth: 200 }) + const dataLines = out.split('\n').slice(3, -1) + expect(dataLines[0]).toContain('Sonnet 4.6') + expect(dataLines[1]).toContain('GPT-5') + }) + + it('drops cache columns when terminal is narrow', () => { + const rows: ModelReportRow[] = [row({ topCategory: 'feature', topCategoryShare: 1, costUSD: 1 })] + const wide = renderTable(rows, { showTotals: false, terminalWidth: 200 }) + const narrow = renderTable(rows, { showTotals: false, terminalWidth: 80 }) + expect(wide).toContain('Cache Write') + expect(narrow).not.toContain('Cache Write') + expect(narrow).not.toContain('Cache Read') + }) + + it('expands table borders to the available terminal width by default', () => { + const rows: ModelReportRow[] = [ + row({ category: 'coding', costUSD: 1.0, inputTokens: 46_300, outputTokens: 3_700_000, cacheWriteTokens: 16_300_000, cacheReadTokens: 1_569_800_000, totalTokens: 1_589_800_000 }), + row({ category: 'delegation', costUSD: 0.5, inputTokens: 44_200, outputTokens: 1_900_000, cacheWriteTokens: 9_400_000, cacheReadTokens: 499_600_000, totalTokens: 511_000_000 }), + ] + const out = renderTable(rows, { byTask: true, showTotals: false, terminalWidth: 132 }) + const lines = out.split('\n') + expect(visibleWidth(lines[0]!)).toBe(132) + expect(visibleWidth(lines[1]!)).toBe(132) + expect(visibleWidth(lines.at(-1)!)).toBe(132) + }) + + it('keeps every colored table row aligned to the same visible width', () => { + const originalLevel = chalk.level + chalk.level = 1 + try { + const rows: ModelReportRow[] = [ + row({ category: 'coding', costUSD: 978.89, inputTokens: 46_300, outputTokens: 3_700_000, cacheWriteTokens: 16_300_000, cacheReadTokens: 1_569_800_000, totalTokens: 1_589_800_000 }), + row({ category: 'delegation', costUSD: 357.0, inputTokens: 44_200, outputTokens: 1_900_000, cacheWriteTokens: 9_400_000, cacheReadTokens: 499_600_000, totalTokens: 511_000_000 }), + row({ category: 'exploration', costUSD: 324.86, inputTokens: 96_800, outputTokens: 1_600_000, cacheWriteTokens: 16_600_000, cacheReadTokens: 359_400_000, totalTokens: 377_800_000 }), + ] + const out = renderTable(rows, { byTask: true, terminalWidth: 160 }) + const widths = out.split('\n').map(visibleWidth) + expect(new Set(widths)).toEqual(new Set([160])) + } finally { + chalk.level = originalLevel + } + }) + + it('can render compact tables when fullWidth is disabled', () => { + const rows: ModelReportRow[] = [ + row({ category: 'coding', costUSD: 1.0, inputTokens: 46_300, outputTokens: 3_700_000, totalTokens: 1_589_800_000 }), + ] + const out = renderTable(rows, { byTask: true, showTotals: false, terminalWidth: 160, fullWidth: false }) + expect(visibleWidth(out.split('\n')[0]!)).toBeLessThan(160) + }) + + it('emits a footer totals row by default and suppresses it under showTotals=false', () => { + const rows: ModelReportRow[] = [row({ costUSD: 1.0, inputTokens: 100, totalTokens: 100 })] + expect(renderTable(rows, { showTotals: true })).toContain('Total') + expect(renderTable(rows, { showTotals: false })).not.toMatch(/^\s*Total/m) + }) +}) + +describe('renderMarkdown', () => { + it('produces a GitHub-flavored markdown table with right-aligned numeric columns', () => { + const rows: ModelReportRow[] = [ + { + provider: 'claude', + providerDisplayName: 'Claude', + model: 'claude-sonnet-4-6', + modelDisplayName: 'Sonnet 4.6', + category: null, + topCategory: 'feature', + topCategoryShare: 0.6, + inputTokens: 100, + outputTokens: 50, + cacheWriteTokens: 0, + cacheReadTokens: 0, + totalTokens: 150, + costUSD: 1.5, + calls: 1, + }, + ] + const md = renderMarkdown(rows, { showTotals: false }) + const lines = md.split('\n') + expect(lines[0]).toBe('| Provider | Model | Top Task | Input | Output | Cache Write | Cache Read | Total | Cost |') + expect(lines[1]).toBe('| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |') + expect(lines[2]).toContain('| Claude |') + expect(lines[2]).toContain('`Sonnet 4.6`') + expect(lines[2]).toContain('Feature Dev (60%)') + }) + + it('escapes pipe characters in provider/model names', () => { + const rows: ModelReportRow[] = [ + { + provider: 'odd', + providerDisplayName: 'A|B', + model: 'm|n', + modelDisplayName: 'M|N', + category: null, + topCategory: 'feature', + topCategoryShare: 1, + inputTokens: 0, + outputTokens: 0, + cacheWriteTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + costUSD: 0, + calls: 0, + }, + ] + const md = renderMarkdown(rows, { showTotals: false }) + expect(md).toContain('A\\|B') + expect(md).toContain('M\\|N') + }) + + it('emits a bold totals row when showTotals is true', () => { + const rows: ModelReportRow[] = [ + { + provider: 'p', + providerDisplayName: 'P', + model: 'm', + modelDisplayName: 'M', + category: null, + topCategory: 'feature', + topCategoryShare: 1, + inputTokens: 100, + outputTokens: 50, + cacheWriteTokens: 0, + cacheReadTokens: 0, + totalTokens: 150, + costUSD: 1.5, + calls: 1, + }, + ] + const md = renderMarkdown(rows) + expect(md).toContain('**Total**') + }) +}) + +describe('renderJson', () => { + it('emits a JSON array with the documented field shape', () => { + const rows: ModelReportRow[] = [ + { + provider: 'claude', + providerDisplayName: 'Claude', + model: 'claude-sonnet-4-6', + modelDisplayName: 'Sonnet 4.6', + category: null, + topCategory: 'feature', + topCategoryCost: 6.0, + topCategoryShare: 0.6, + inputTokens: 100, + outputTokens: 50, + cacheWriteTokens: 0, + cacheReadTokens: 0, + totalTokens: 150, + costUSD: 1.5, + calls: 1, + }, + ] + const parsed = JSON.parse(renderJson(rows)) as Array> + expect(parsed).toHaveLength(1) + expect(parsed[0]).toMatchObject({ + provider: 'claude', + model: 'claude-sonnet-4-6', + modelDisplayName: 'Sonnet 4.6', + topCategory: 'feature', + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + calls: 1, + }) + }) +}) + +describe('renderCsv', () => { + it('produces a header row followed by one row per ModelReportRow', () => { + const rows: ModelReportRow[] = [ + { + provider: 'claude', + providerDisplayName: 'Claude', + model: 'claude-sonnet-4-6', + modelDisplayName: 'Sonnet 4.6', + category: null, + topCategory: 'feature', + topCategoryShare: 0.6, + inputTokens: 100, + outputTokens: 50, + cacheWriteTokens: 0, + cacheReadTokens: 0, + totalTokens: 150, + costUSD: 1.5, + calls: 1, + }, + ] + const csv = renderCsv(rows) + const lines = csv.split('\n') + expect(lines[0]).toBe('provider,model,top_task,top_task_share,input_tokens,output_tokens,cache_write_tokens,cache_read_tokens,total_tokens,calls,cost_usd') + expect(lines[1]).toBe('Claude,Sonnet 4.6,Feature Dev,0.6000,100,50,0,0,150,1,1.500000') + }) + + it('escapes commas in provider/model cells', () => { + const rows: ModelReportRow[] = [ + { + provider: 'weird', + providerDisplayName: 'Weird, Co.', + model: 'm', + modelDisplayName: 'M', + category: null, + topCategory: 'feature', + topCategoryShare: 1.0, + inputTokens: 0, + outputTokens: 0, + cacheWriteTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + costUSD: 0, + calls: 0, + }, + ] + const csv = renderCsv(rows) + expect(csv.split('\n')[1]).toContain('"Weird, Co."') + }) +}) diff --git a/tests/models.test.ts b/tests/models.test.ts index e30e5c6..41ccb5e 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -158,6 +158,18 @@ describe('calculateCost - OMP names produce non-zero cost', () => { }) }) +describe('calculateCost - Claude cache write durations', () => { + it('prices 1-hour cache writes at 1.6x the 5-minute cache write rate', () => { + const fiveMinute = calculateCost('claude-opus-4-7', 0, 0, 1_000_000, 0, 0) + const oneHour = calculateCost('claude-opus-4-7', 0, 0, 1_000_000, 0, 0, 'standard', 1_000_000) + const mixed = calculateCost('claude-opus-4-7', 0, 0, 100_000, 0, 0, 'standard', 60_000) + + expect(fiveMinute).toBeCloseTo(6.25, 6) + expect(oneHour).toBeCloseTo(10, 6) + expect(mixed).toBeCloseTo(0.85, 6) + }) +}) + describe('existing model names still resolve', () => { it('canonical claude-opus-4-6', () => { expect(getModelCosts('claude-opus-4-6')).not.toBeNull() @@ -179,3 +191,72 @@ describe('existing model names still resolve', () => { expect(getModelCosts('anthropic/claude-opus-4-6')).not.toBeNull() }) }) + +// Issue #159: every model name Cursor emits in its SQLite database must +// resolve to a non-zero pricing entry, otherwise the dashboard shows $0 for +// that model. Each case asserts the resolved pricing identity matches the +// pricing of the expected canonical key, so an accidental alias swap (e.g. +// `claude-4.6-opus` aliased to a haiku entry) fails the test even though +// haiku also has positive pricing. +describe('Cursor model variants resolve to pricing', () => { + const cases: Array<[string, string]> = [ + // Sonnet family + ['claude-4-sonnet', 'claude-sonnet-4'], + ['claude-4-sonnet-1m', 'claude-sonnet-4'], + ['claude-4-sonnet-thinking', 'claude-sonnet-4-5'], + ['claude-4.5-sonnet', 'claude-sonnet-4-5'], + ['claude-4.5-sonnet-thinking', 'claude-sonnet-4-5'], + ['claude-4.6-sonnet', 'claude-sonnet-4-6'], + ['claude-4.6-sonnet-high', 'claude-sonnet-4-6'], + ['claude-4.6-sonnet-low', 'claude-sonnet-4-6'], + ['claude-4.6-sonnet-thinking', 'claude-sonnet-4-6'], + ['claude-4.6-sonnet-high-thinking', 'claude-sonnet-4-6'], + // Opus family + ['claude-4-opus', 'claude-opus-4'], + ['claude-4.5-opus', 'claude-opus-4-5'], + ['claude-4.5-opus-high', 'claude-opus-4-5'], + ['claude-4.5-opus-low', 'claude-opus-4-5'], + ['claude-4.5-opus-medium', 'claude-opus-4-5'], + ['claude-4.5-opus-high-thinking', 'claude-opus-4-5'], + ['claude-4.6-opus', 'claude-opus-4-6'], + ['claude-4.6-opus-fast-mode', 'claude-opus-4-6'], + ['claude-4.6-opus-high', 'claude-opus-4-6'], + ['claude-4.6-opus-low', 'claude-opus-4-6'], + ['claude-4.6-opus-medium', 'claude-opus-4-6'], + ['claude-4.6-opus-high-thinking', 'claude-opus-4-6'], + ['claude-4.7-opus', 'claude-opus-4-7'], + ['claude-opus-4-7-thinking-high', 'claude-opus-4-7'], + // Haiku family + ['claude-4.5-haiku', 'claude-haiku-4-5'], + ['claude-4.6-haiku', 'claude-haiku-4-5'], + // Cursor house models + ['composer-1', 'claude-sonnet-4-5'], + ['composer-1.5', 'claude-sonnet-4-5'], + ['composer-2', 'claude-sonnet-4-6'], + ['cursor-auto', 'claude-sonnet-4-5'], + // OpenAI variants Cursor emits + ['gpt-5', 'gpt-5'], + ['gpt-5-fast', 'gpt-5'], + ['gpt-5.2', 'gpt-5.2'], + ['gpt-5.2-low', 'gpt-5'], + // Direct LiteLLM hits where no alias is required + ['grok-code-fast-1', 'grok-code-fast-1'], + ['gemini-3-pro', 'gemini-3-pro-preview'], + ] + + for (const [input, expectedAlias] of cases) { + it(`${input} resolves to ${expectedAlias} pricing`, () => { + const costs = getModelCosts(input) + expect(costs, `${input} should resolve to pricing (and not produce $0 in the dashboard)`).not.toBeNull() + expect(costs!.inputCostPerToken).toBeGreaterThan(0) + expect(costs!.outputCostPerToken).toBeGreaterThan(0) + const expected = getModelCosts(expectedAlias) + expect(expected, `expected target ${expectedAlias} should itself resolve`).not.toBeNull() + // Identity check: the alias must produce the SAME pricing object as + // the canonical key, not just any non-zero pricing. Catches drift + // where a future edit re-points an alias at a wrong-but-positive entry. + expect(costs!.inputCostPerToken).toBe(expected!.inputCostPerToken) + expect(costs!.outputCostPerToken).toBe(expected!.outputCostPerToken) + }) + } +}) diff --git a/tests/optimize-fs.test.ts b/tests/optimize-fs.test.ts index 4ec41de..29d583e 100644 --- a/tests/optimize-fs.test.ts +++ b/tests/optimize-fs.test.ts @@ -325,7 +325,7 @@ describe('scanJsonlFile', () => { message: { content: [{ type: 'tool_use', name: 'Bash', input: {} }] }, })) await scanJsonlFile(filePath, 'p1', undefined) - expect(readSessionLinesSpy).toHaveBeenCalledWith(filePath) + expect(readSessionLinesSpy).toHaveBeenCalledWith(filePath, undefined, { largeLineAsBuffer: true }) expect(readSessionFileSpy).not.toHaveBeenCalled() readSessionLinesSpy.mockRestore() readSessionFileSpy.mockRestore() diff --git a/tests/optimize.test.ts b/tests/optimize.test.ts index 698a18f..52643f9 100644 --- a/tests/optimize.test.ts +++ b/tests/optimize.test.ts @@ -6,6 +6,9 @@ import { detectLowReadEditRatio, detectCacheBloat, detectBloatedClaudeMd, + detectContextBloat, + detectLowWorthSessions, + detectSessionOutliers, computeHealth, computeTrend, type ToolCall, @@ -22,6 +25,78 @@ function emptyProjects(): ProjectSummary[] { return [] } +function projectWithSessions(costs: number[], project = 'app'): ProjectSummary { + const sessions = costs.map((cost, i) => { + const tokens = Math.round(cost * 1000) + return { + sessionId: `s${i + 1}`, + project, + firstTimestamp: `2026-05-${String(i + 1).padStart(2, '0')}T10:00:00Z`, + lastTimestamp: `2026-05-${String(i + 1).padStart(2, '0')}T10:30:00Z`, + totalCostUSD: cost, + totalInputTokens: tokens, + totalOutputTokens: tokens, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: 1, + turns: [], + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: {} as ProjectSummary['sessions'][number]['categoryBreakdown'], + skillBreakdown: {}, + } + }) + + return { + project, + projectPath: `/tmp/${project}`, + sessions, + totalCostUSD: costs.reduce((sum, cost) => sum + cost, 0), + totalApiCalls: sessions.length, + } +} + +type TestSession = ProjectSummary['sessions'][number] + +function contextSession( + i: number, + overrides: Partial, + project = 'app', +): TestSession { + return { + sessionId: `s${i + 1}`, + project, + firstTimestamp: `2026-05-${String(i + 1).padStart(2, '0')}T10:00:00Z`, + lastTimestamp: `2026-05-${String(i + 1).padStart(2, '0')}T10:30:00Z`, + totalCostUSD: 1, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: 1, + turns: [], + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: {} as TestSession['categoryBreakdown'], + skillBreakdown: {}, + ...overrides, + } +} + +function projectWithContextSessions(sessions: TestSession[], project = 'app'): ProjectSummary { + return { + project, + projectPath: `/tmp/${project}`, + sessions, + totalCostUSD: sessions.reduce((sum, session) => sum + session.totalCostUSD, 0), + totalApiCalls: sessions.reduce((sum, session) => sum + session.apiCalls, 0), + } +} + describe('detectJunkReads', () => { it('returns null below minimum threshold', () => { const calls = [ @@ -82,6 +157,10 @@ describe('detectJunkReads', () => { expect(finding.fix.type).toBe('paste') if (finding.fix.type === 'paste') { expect(finding.fix.text).toContain('node_modules') + // Issue #277: every paste-style fix should declare its destination so + // users can tell a permanent CLAUDE.md rule from a one-time session + // opener at a glance. + expect(finding.fix.destination).toBe('claude-md') } expect(finding.fix.label).toContain('CLAUDE.md') }) @@ -207,6 +286,547 @@ describe('detectBloatedClaudeMd', () => { }) }) +describe('detectContextBloat', () => { + it('returns null below the input/context token floor', () => { + const project = projectWithContextSessions([ + contextSession(0, { + totalInputTokens: 74_999, + totalOutputTokens: 100, + }), + ]) + + expect(detectContextBloat([project])).toBeNull() + }) + + it('returns null when output is proportionate to input/context tokens', () => { + const project = projectWithContextSessions([ + contextSession(0, { + totalInputTokens: 100_000, + totalOutputTokens: 5_000, + }), + ]) + + expect(detectContextBloat([project])).toBeNull() + }) + + it('discounts cache reads when estimating context pressure', () => { + const project = projectWithContextSessions([ + contextSession(0, { + totalInputTokens: 5_000, + totalCacheReadTokens: 700_000, + totalOutputTokens: 5_000, + }), + ]) + + expect(detectContextBloat([project])).toBeNull() + }) + + it('weights cache writes when estimating context pressure', () => { + const project = projectWithContextSessions([ + contextSession(0, { + totalInputTokens: 10_000, + totalCacheWriteTokens: 80_000, + totalOutputTokens: 3_000, + }), + ]) + + const finding = detectContextBloat([project]) + expect(finding).not.toBeNull() + expect(finding!.explanation).toContain('110.0K effective input/cache') + expect(finding!.tokensSaved).toBe(65_000) + }) + + it('flags sessions where input/cache tokens swamp output', () => { + const project = projectWithContextSessions([ + contextSession(0, { + totalInputTokens: 90_000, + totalCacheReadTokens: 30_000, + totalOutputTokens: 2_000, + }), + ]) + + const finding = detectContextBloat([project]) + expect(finding).not.toBeNull() + expect(finding!.title).toContain('context-heavy session') + expect(finding!.explanation).toContain('app/s1') + expect(finding!.explanation).toContain('93.0K effective input/cache') + expect(finding!.explanation).toContain('46.5:1') + expect(finding!.impact).toBe('low') + expect(finding!.tokensSaved).toBe(63_000) + }) + + it('uses medium impact between the low and high tiers', () => { + const project = projectWithContextSessions( + Array.from({ length: 4 }, (_, i) => contextSession(i, { + totalInputTokens: 80_000, + totalOutputTokens: 1_000, + })), + ) + + const finding = detectContextBloat([project]) + expect(finding).not.toBeNull() + expect(finding!.impact).toBe('medium') + }) + + it('uses high impact at 10 or more candidates regardless of total size', () => { + const project = projectWithContextSessions( + Array.from({ length: 10 }, (_, i) => contextSession(i, { + totalInputTokens: 80_000, + totalOutputTokens: 1_000, + })), + ) + + const finding = detectContextBloat([project]) + expect(finding).not.toBeNull() + expect(finding!.impact).toBe('high') + }) + + it('includes context growth from the previous session when it is large', () => { + const project = projectWithContextSessions([ + contextSession(0, { + totalInputTokens: 20_000, + totalOutputTokens: 1_000, + }), + contextSession(1, { + totalInputTokens: 100_000, + totalOutputTokens: 2_000, + }), + ]) + + const finding = detectContextBloat([project]) + expect(finding).not.toBeNull() + expect(finding!.explanation).toContain('5.0x previous session input') + }) + + it('calculates context growth within each project only', () => { + const finding = detectContextBloat([ + projectWithContextSessions([ + contextSession(0, { + totalInputTokens: 20_000, + totalOutputTokens: 1_000, + }), + contextSession(1, { + totalInputTokens: 100_000, + totalOutputTokens: 2_000, + }), + ], 'app'), + projectWithContextSessions([ + contextSession(0, { + totalInputTokens: 100_000, + totalOutputTokens: 2_000, + }, 'api'), + ], 'api'), + ]) + + expect(finding).not.toBeNull() + expect(finding!.explanation.match(/previous session input/g)).toHaveLength(1) + }) + + it('summarizes additional candidates after the preview limit', () => { + const project = projectWithContextSessions( + Array.from({ length: 6 }, (_, i) => contextSession(i, { + totalInputTokens: 80_000 + i * 10_000, + totalOutputTokens: 1_000, + })), + ) + + const finding = detectContextBloat([project]) + expect(finding).not.toBeNull() + expect(finding!.explanation).toContain('app/s6') + expect(finding!.explanation).toContain('; +1 more') + expect(finding!.impact).toBe('high') + }) + + it('uses high impact for one very large context-heavy session', () => { + const project = projectWithContextSessions([ + contextSession(0, { + totalInputTokens: 600_000, + totalOutputTokens: 10_000, + }), + ]) + + const finding = detectContextBloat([project]) + expect(finding).not.toBeNull() + expect(finding!.impact).toBe('high') + }) + + it('handles zero-output sessions without dividing by zero', () => { + const project = projectWithContextSessions([ + contextSession(0, { + totalInputTokens: 80_000, + totalOutputTokens: 0, + }), + ]) + + const finding = detectContextBloat([project]) + expect(finding).not.toBeNull() + expect(finding!.explanation).toContain('1000+:1') + expect(finding!.tokensSaved).toBe(80_000) + }) + + it('caps display ratio at 1000+:1 for non-zero-output sessions too', () => { + const project = projectWithContextSessions([ + contextSession(0, { + totalInputTokens: 5_000_000, + totalOutputTokens: 100, + }), + ]) + + const finding = detectContextBloat([project]) + expect(finding).not.toBeNull() + expect(finding!.explanation).toContain('1000+:1') + }) + + it('suppresses the growth ratio when the previous session is more than 7 days back', () => { + const project = projectWithContextSessions([ + { + ...contextSession(0, { totalInputTokens: 20_000, totalOutputTokens: 1_000 }), + firstTimestamp: '2026-05-01T10:00:00Z', + lastTimestamp: '2026-05-01T10:30:00Z', + }, + { + ...contextSession(1, { totalInputTokens: 100_000, totalOutputTokens: 2_000 }), + firstTimestamp: '2026-05-15T10:00:00Z', + lastTimestamp: '2026-05-15T10:30:00Z', + }, + ]) + + const finding = detectContextBloat([project]) + expect(finding).not.toBeNull() + expect(finding!.explanation).not.toContain('previous session input') + }) + + it('anchors growth even when the previous session is below the reporting threshold', () => { + const project = projectWithContextSessions([ + contextSession(0, { totalInputTokens: 20_000, totalOutputTokens: 1_000 }), + contextSession(1, { totalInputTokens: 100_000, totalOutputTokens: 2_000 }), + ]) + + const finding = detectContextBloat([project]) + expect(finding).not.toBeNull() + // The first session sits below CONTEXT_BLOAT_MIN_INPUT_TOKENS (75K) and + // is not itself a candidate, but the growth-from-previous comparison for + // the second session must still anchor against it. + expect(finding!.explanation).toContain('5.0x previous session input') + }) + + it('honors excludedSessionIds passed by the orchestrator', () => { + const project = projectWithContextSessions([ + contextSession(0, { + totalInputTokens: 90_000, + totalCacheReadTokens: 30_000, + totalOutputTokens: 2_000, + }), + ]) + + const finding = detectContextBloat([project], new Set(['s1'])) + expect(finding).toBeNull() + }) +}) + +type LowWorthTurn = TestSession['turns'][number] + +function lowWorthTurn(overrides: Partial = {}): LowWorthTurn { + return { + userMessage: 'do the work', + assistantCalls: [], + timestamp: '2026-05-01T10:00:00Z', + sessionId: 's1', + category: 'coding', + retries: 0, + hasEdits: false, + ...overrides, + } +} + +function lowWorthSession(cost: number, i: number, overrides: Partial = {}, project = 'app'): TestSession { + const tokens = Math.round(cost * 1000) + return { + sessionId: `s${i + 1}`, + project, + firstTimestamp: `2026-05-${String(i + 1).padStart(2, '0')}T10:00:00Z`, + lastTimestamp: `2026-05-${String(i + 1).padStart(2, '0')}T10:30:00Z`, + totalCostUSD: cost, + totalInputTokens: tokens, + totalOutputTokens: tokens, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: 1, + turns: [], + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: {} as TestSession['categoryBreakdown'], + skillBreakdown: {}, + ...overrides, + } +} + +function projectWithLowWorthSessions(sessions: TestSession[], project = 'app'): ProjectSummary { + return { + project, + projectPath: `/tmp/${project}`, + sessions, + totalCostUSD: sessions.reduce((sum, s) => sum + s.totalCostUSD, 0), + totalApiCalls: sessions.reduce((sum, s) => sum + s.apiCalls, 0), + } +} + +describe('detectLowWorthSessions', () => { + it('returns null for cheap sessions', () => { + const project = projectWithLowWorthSessions([ + lowWorthSession(1.99, 0, { turns: [lowWorthTurn({ hasEdits: false })] }), + ]) + expect(detectLowWorthSessions([project])).toBeNull() + }) + + it('does not flag the no-edit cost boundary', () => { + const project = projectWithLowWorthSessions([ + lowWorthSession(2.99, 0, { turns: [lowWorthTurn({ hasEdits: false })] }), + ]) + expect(detectLowWorthSessions([project])).toBeNull() + }) + + it('flags expensive sessions with no edit turns', () => { + const project = projectWithLowWorthSessions([ + lowWorthSession(4, 0, { turns: [lowWorthTurn({ hasEdits: false })] }), + ]) + const finding = detectLowWorthSessions([project]) + expect(finding).not.toBeNull() + expect(finding!.title).toContain('possibly low-worth') + expect(finding!.explanation).toContain('app/s1') + expect(finding!.explanation).toContain('no edit turns') + // sessionTokenTotal = input + output + cache. The lowWorthSession helper + // sets input=output=cost*1000, so the savings ceiling is 2x cost*1000. + expect(finding!.tokensSaved).toBe(8_000) + }) + + it('flags retry-heavy sessions', () => { + const project = projectWithLowWorthSessions([ + lowWorthSession(2.5, 0, { + turns: [ + lowWorthTurn({ hasEdits: true, retries: 1 }), + lowWorthTurn({ hasEdits: true, retries: 2 }), + ], + }), + ]) + const finding = detectLowWorthSessions([project]) + expect(finding).not.toBeNull() + expect(finding!.explanation).toContain('3 retries') + }) + + it('estimates recoverable tokens by retry fraction for sessions with edits', () => { + // 4 turns, 2 retries spread across 2 edits, 0 one-shot edits → trips the + // 'no one-shot edit turns' reason. totalTurns=4, fraction=2/4=0.5, + // sessionTokenTotal=8K, so recoverable savings ceiling is 4K — half the + // session, not the full ceiling that no-edit sessions get. + const project = projectWithLowWorthSessions([ + lowWorthSession(4, 0, { + turns: [ + lowWorthTurn({ hasEdits: true, retries: 1 }), + lowWorthTurn({ hasEdits: true, retries: 1 }), + lowWorthTurn({ hasEdits: false }), + lowWorthTurn({ hasEdits: false }), + ], + }), + ]) + const finding = detectLowWorthSessions([project]) + expect(finding).not.toBeNull() + expect(finding!.tokensSaved).toBe(4_000) + }) + + it('uses full session tokens as the savings ceiling for no-edit sessions', () => { + const project = projectWithLowWorthSessions([ + lowWorthSession(4, 0, { turns: [lowWorthTurn({ hasEdits: false })] }), + ]) + const finding = detectLowWorthSessions([project]) + // No edits at all -> entire session is at risk. sessionTokenTotal = 8K. + expect(finding!.tokensSaved).toBe(8_000) + }) + + it('keeps all reasons that apply to the same session', () => { + const project = projectWithLowWorthSessions([ + lowWorthSession(4, 0, { + turns: [ + lowWorthTurn({ hasEdits: false, retries: 1 }), + lowWorthTurn({ hasEdits: false, retries: 2 }), + ], + }), + ]) + const finding = detectLowWorthSessions([project]) + expect(finding).not.toBeNull() + expect(finding!.explanation).toContain('no edit turns') + expect(finding!.explanation).toContain('3 retries') + }) + + it('flags edit sessions with retries but no one-shot edit turns via categoryBreakdown', () => { + const project = projectWithLowWorthSessions([ + lowWorthSession(2.25, 0, { + categoryBreakdown: { + coding: { turns: 2, costUSD: 2.25, retries: 2, editTurns: 2, oneShotTurns: 0 }, + } as TestSession['categoryBreakdown'], + }), + ]) + const finding = detectLowWorthSessions([project]) + expect(finding).not.toBeNull() + expect(finding!.explanation).toContain('no one-shot edit turns') + }) + + it('skips sessions with a git delivery command', () => { + const project = projectWithLowWorthSessions([ + lowWorthSession(8, 0, { + turns: [lowWorthTurn({ hasEdits: false })], + bashBreakdown: { 'cd /tmp/app && git commit -m "ship fix"': { calls: 1 } }, + }), + ]) + expect(detectLowWorthSessions([project])).toBeNull() + }) + + it('skips sessions with gh pr create', () => { + const project = projectWithLowWorthSessions([ + lowWorthSession(8, 0, { + turns: [lowWorthTurn({ hasEdits: false })], + bashBreakdown: { 'gh pr create --fill': { calls: 1 } }, + }), + ]) + expect(detectLowWorthSessions([project])).toBeNull() + }) + + it('does not treat read-only git commands as delivery', () => { + const project = projectWithLowWorthSessions([ + lowWorthSession(8, 0, { + turns: [lowWorthTurn({ hasEdits: false })], + bashBreakdown: { 'git tag -l': { calls: 1 } }, + }), + ]) + expect(detectLowWorthSessions([project])).not.toBeNull() + }) + + it('does not treat dry-run git commands as delivery', () => { + const project = projectWithLowWorthSessions([ + lowWorthSession(8, 0, { + turns: [lowWorthTurn({ hasEdits: false })], + bashBreakdown: { 'git push --dry-run origin main': { calls: 1 } }, + }), + ]) + expect(detectLowWorthSessions([project])).not.toBeNull() + }) + + it('does not treat git commit-tree as a delivery command', () => { + // Regex must match `git commit` only, not `git commit-tree` / + // `git commit-graph`. Without the (?:\s|$|--) lookahead this would be a + // false positive and the session would silently skip detection. + const project = projectWithLowWorthSessions([ + lowWorthSession(8, 0, { + turns: [lowWorthTurn({ hasEdits: false })], + bashBreakdown: { 'git commit-tree HEAD^{tree}': { calls: 1 } }, + }), + ]) + expect(detectLowWorthSessions([project])).not.toBeNull() + }) + + it('still treats `git commit --amend` as a delivery command', () => { + const project = projectWithLowWorthSessions([ + lowWorthSession(8, 0, { + turns: [lowWorthTurn({ hasEdits: false })], + bashBreakdown: { 'git commit --amend --no-edit': { calls: 1 } }, + }), + ]) + expect(detectLowWorthSessions([project])).toBeNull() + }) + + it('uses low impact for a single small candidate', () => { + const project = projectWithLowWorthSessions([ + lowWorthSession(4, 0, { turns: [lowWorthTurn({ hasEdits: false })] }), + ]) + const finding = detectLowWorthSessions([project]) + expect(finding!.impact).toBe('low') + }) + + it('uses medium impact between low and high tiers', () => { + const project = projectWithLowWorthSessions( + Array.from({ length: 3 }, (_, i) => lowWorthSession(4, i, { + turns: [lowWorthTurn({ hasEdits: false })], + })), + ) + const finding = detectLowWorthSessions([project]) + expect(finding!.impact).toBe('medium') + }) + + it('uses high impact at 10 or more candidates', () => { + const project = projectWithLowWorthSessions( + Array.from({ length: 10 }, (_, i) => lowWorthSession(3, i, { + turns: [lowWorthTurn({ hasEdits: false })], + })), + ) + const finding = detectLowWorthSessions([project]) + expect(finding!.impact).toBe('high') + }) + + it('summarizes additional candidates after the preview limit', () => { + const project = projectWithLowWorthSessions( + Array.from({ length: 6 }, (_, i) => lowWorthSession(4 + i, i, { + turns: [lowWorthTurn({ hasEdits: false })], + })), + ) + const finding = detectLowWorthSessions([project]) + expect(finding!.explanation).toContain('; +1 more') + }) +}) + +describe('detectSessionOutliers', () => { + it('returns null when there are too few sessions for a project baseline', () => { + expect(detectSessionOutliers([projectWithSessions([0.5, 4])])).toBeNull() + }) + + it('returns null when no session exceeds twice the project average', () => { + expect(detectSessionOutliers([projectWithSessions([1, 1.2, 1.4, 1.6])])).toBeNull() + }) + + it('does not flag the exact 2x boundary', () => { + expect(detectSessionOutliers([projectWithSessions([1, 1, 2])])).toBeNull() + }) + + it('flags sessions costing more than twice their project average', () => { + const finding = detectSessionOutliers([projectWithSessions([1, 1, 1, 10])]) + expect(finding).not.toBeNull() + expect(finding!.title).toContain('high-cost session outlier') + expect(finding!.explanation).toContain('app/s4') + expect(finding!.impact).toBe('medium') + expect(finding!.tokensSaved).toBeGreaterThan(0) + }) + + it('ignores tiny absolute-cost outliers', () => { + expect(detectSessionOutliers([projectWithSessions([0.01, 0.01, 0.01, 0.2])])).toBeNull() + }) + + it('isolates baselines per project', () => { + const finding = detectSessionOutliers([ + projectWithSessions([8, 9, 10], 'web'), + projectWithSessions([1, 1, 1, 12], 'api'), + ]) + + expect(finding).not.toBeNull() + expect(finding!.explanation).toContain('api/s4') + expect(finding!.explanation).not.toContain('web/') + }) + + it('excludes sessions already flagged by detectContextBloat', () => { + const project = projectWithSessions([1, 1, 1, 10]) + const excluded = new Set(['s4']) + expect(detectSessionOutliers([project], excluded)).toBeNull() + }) + + it('still flags cost outliers that are not context-bloat candidates', () => { + const project = projectWithSessions([1, 1, 1, 10]) + const excluded = new Set(['some-other-session']) + const finding = detectSessionOutliers([project], excluded) + expect(finding).not.toBeNull() + expect(finding!.explanation).toContain('app/s4') + }) +}) + describe('computeHealth', () => { it('returns A with 100 for no findings', () => { const { score, grade } = computeHealth([]) @@ -299,3 +919,48 @@ describe('computeTrend', () => { expect(trend).toBe('active') }) }) + +describe('paste-fix destination tagging (issue #277)', () => { + // Walks every emitted finding's fix and asserts that `paste`-type actions + // declare a destination. Future detectors that ship a paste fix without a + // destination get caught here so users never see an unlabeled "here's a + // suggestion" block again. + function checkAllPasteFixesHaveDestination(findings: WasteFinding[]) { + for (const f of findings) { + if (f.fix.type === 'paste') { + expect( + f.fix.destination, + `finding "${f.title}" has paste fix without destination — pick one of: claude-md / session-opener / prompt / shell-config` + ).toBeDefined() + expect(['claude-md', 'session-opener', 'prompt', 'shell-config']) + .toContain(f.fix.destination) + } + } + } + + it('detectJunkReads emits a tagged paste fix', () => { + const calls = Array.from({ length: 5 }, () => call('Read', { file_path: '/x/node_modules/a.js' })) + checkAllPasteFixesHaveDestination([detectJunkReads(calls)!]) + }) + + it('detectDuplicateReads emits a tagged paste fix', () => { + const calls = [ + ...Array.from({ length: 6 }, () => call('Read', { file_path: '/src/a.ts' }, 's1')), + ...Array.from({ length: 6 }, () => call('Read', { file_path: '/src/b.ts' }, 's1')), + ...Array.from({ length: 6 }, () => call('Read', { file_path: '/src/c.ts' }, 's1')), + ] + checkAllPasteFixesHaveDestination([detectDuplicateReads(calls)!]) + }) + + it('detectLowReadEditRatio emits a tagged paste fix', () => { + const calls = [ + ...Array.from({ length: 5 }, () => call('Edit', { file_path: '/src/a.ts' })), + ...Array.from({ length: 5 }, () => call('Edit', { file_path: '/src/b.ts' })), + ...Array.from({ length: 5 }, () => call('Edit', { file_path: '/src/c.ts' })), + ...Array.from({ length: 5 }, () => call('Edit', { file_path: '/src/d.ts' })), + ...Array.from({ length: 5 }, () => call('Edit', { file_path: '/src/e.ts' })), + ] + const finding = detectLowReadEditRatio(calls) + if (finding) checkAllPasteFixesHaveDestination([finding]) + }) +}) diff --git a/tests/parser-claude-cwd.test.ts b/tests/parser-claude-cwd.test.ts new file mode 100644 index 0000000..179ad7c --- /dev/null +++ b/tests/parser-claude-cwd.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm, utimes } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { parseAllSessions } from '../src/parser.js' +import type { DateRange } from '../src/types.js' + +let tmpDir: string +let originalConfigDir: string | undefined + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'claude-cwd-test-')) + originalConfigDir = process.env['CLAUDE_CONFIG_DIR'] + process.env['CLAUDE_CONFIG_DIR'] = tmpDir +}) + +afterEach(async () => { + if (originalConfigDir === undefined) { + delete process.env['CLAUDE_CONFIG_DIR'] + } else { + process.env['CLAUDE_CONFIG_DIR'] = originalConfigDir + } + await rm(tmpDir, { recursive: true, force: true }) +}) + +function dayRange(day: string): DateRange { + return { + start: new Date(`${day}T00:00:00.000Z`), + end: new Date(`${day}T23:59:59.999Z`), + } +} + +async function writeClaudeSession( + projectSlug: string, + sessionId: string, + cwd: string, + timestamp: string, + usage: Record = { input_tokens: 100, output_tokens: 50 }, + model = 'claude-sonnet-4-5', +): Promise { + const projectDir = join(tmpDir, 'projects', projectSlug) + await mkdir(projectDir, { recursive: true }) + const filePath = join(projectDir, `${sessionId}.jsonl`) + await writeFile(filePath, JSON.stringify({ + type: 'assistant', + sessionId, + timestamp, + cwd, + message: { + id: `msg-${sessionId}`, + type: 'message', + role: 'assistant', + model, + content: [], + usage, + }, + }) + '\n') + + const mtime = new Date(timestamp) + await utimes(filePath, mtime, mtime) +} + +describe('Claude cwd project paths', () => { + it('uses the JSONL cwd as the canonical project path instead of the lossy directory slug', async () => { + await writeClaudeSession( + 'c--AI-LAB-OPENCLAW', + 'windows-session', + 'C:\\AI_LAB\\OPENCLAW', + '2099-05-01T12:00:00.000Z', + ) + + const projects = await parseAllSessions(dayRange('2099-05-01'), 'claude') + + expect(projects).toHaveLength(1) + expect(projects[0]!.projectPath).toBe('C:\\AI_LAB\\OPENCLAW') + expect(projects[0]!.projectPath).not.toBe('c//AI/LAB/OPENCLAW') + expect(projects[0]!.totalApiCalls).toBe(1) + }) + + it('groups Windows cwd case and slash variants into one project', async () => { + await writeClaudeSession( + 'windows-openclaw-a', + 'upper-backslash', + 'C:\\AI_LAB\\OPENCLAW', + '2099-05-02T10:00:00.000Z', + ) + await writeClaudeSession( + 'windows-openclaw-b', + 'lower-forward-slash', + 'c:/AI_LAB/OPENCLAW/', + '2099-05-02T11:00:00.000Z', + ) + + const projects = await parseAllSessions(dayRange('2099-05-02'), 'claude') + + expect(projects).toHaveLength(1) + expect(projects[0]!.sessions).toHaveLength(2) + expect(projects[0]!.totalApiCalls).toBe(2) + expect(projects[0]!.sessions.map(s => s.sessionId).sort()).toEqual([ + 'lower-forward-slash', + 'upper-backslash', + ]) + }) + + it('prefers the canonical cwd path even when mixed with slug-only sessions in the same directory', async () => { + const slug = 'c--AI-LAB-OPENCLAW' + const projectDir = join(tmpDir, 'projects', slug) + await mkdir(projectDir, { recursive: true }) + const noCwdPath = join(projectDir, 'a-no-cwd.jsonl') + await writeFile(noCwdPath, JSON.stringify({ + type: 'assistant', + sessionId: 'no-cwd', + timestamp: '2099-05-03T10:00:00.000Z', + message: { + id: 'msg-no-cwd', type: 'message', role: 'assistant', + model: 'claude-sonnet-4-5', content: [], + usage: { input_tokens: 100, output_tokens: 50 }, + }, + }) + '\n') + await utimes(noCwdPath, new Date('2099-05-03T10:00:00.000Z'), new Date('2099-05-03T10:00:00.000Z')) + + await writeClaudeSession(slug, 'b-with-cwd', 'C:\\AI_LAB\\OPENCLAW', '2099-05-03T11:00:00.000Z') + + const projects = await parseAllSessions(dayRange('2099-05-03'), 'claude') + + expect(projects).toHaveLength(1) + expect(projects[0]!.sessions).toHaveLength(2) + expect(projects[0]!.projectPath).toBe('C:\\AI_LAB\\OPENCLAW') + expect(projects[0]!.projectPath).not.toBe('c//AI/LAB/OPENCLAW') + }) + + it('falls back to the slug-derived path when cwd is null, missing, or empty', async () => { + const slug = 'fallback-slug' + const projectDir = join(tmpDir, 'projects', slug) + await mkdir(projectDir, { recursive: true }) + + async function writeWith(name: string, sessionId: string, cwdField: unknown, ts: string) { + const filePath = join(projectDir, `${name}.jsonl`) + const obj: Record = { + type: 'assistant', sessionId, timestamp: ts, + message: { + id: `msg-${sessionId}`, type: 'message', role: 'assistant', + model: 'claude-sonnet-4-5', content: [], + usage: { input_tokens: 100, output_tokens: 50 }, + }, + } + if (cwdField !== undefined) obj.cwd = cwdField + await writeFile(filePath, JSON.stringify(obj) + '\n') + await utimes(filePath, new Date(ts), new Date(ts)) + } + + await writeWith('null-cwd', 's-null', null, '2099-05-04T10:00:00.000Z') + await writeWith('empty-cwd', 's-empty', '', '2099-05-04T10:30:00.000Z') + await writeWith('whitespace-cwd', 's-ws', ' ', '2099-05-04T11:00:00.000Z') + await writeWith('missing-cwd', 's-miss', undefined, '2099-05-04T11:30:00.000Z') + + const projects = await parseAllSessions(dayRange('2099-05-04'), 'claude') + + expect(projects).toHaveLength(1) + expect(projects[0]!.sessions).toHaveLength(4) + expect(projects[0]!.projectPath).toBe('fallback/slug') + }) +}) + +describe('Claude cache creation pricing', () => { + it('prices 1-hour cache writes from usage.cache_creation at the 2x input rate', async () => { + await writeClaudeSession( + 'cache-pricing', + 'one-hour-cache', + '/tmp/cache-pricing', + '2099-05-05T10:00:00.000Z', + { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 60_120, + cache_creation: { + ephemeral_5m_input_tokens: 0, + ephemeral_1h_input_tokens: 60_120, + }, + }, + 'claude-opus-4-7', + ) + + const projects = await parseAllSessions(dayRange('2099-05-05'), 'claude') + + expect(projects).toHaveLength(1) + expect(projects[0]!.sessions[0]!.totalCacheWriteTokens).toBe(60_120) + expect(projects[0]!.totalCostUSD).toBeCloseTo(0.6012, 6) + }) + + it('falls back to the legacy 5-minute cache write rate when split fields are absent', async () => { + await writeClaudeSession( + 'legacy-cache-pricing', + 'legacy-cache', + '/tmp/legacy-cache-pricing', + '2099-05-06T10:00:00.000Z', + { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 60_120, + }, + 'claude-opus-4-7', + ) + + const projects = await parseAllSessions(dayRange('2099-05-06'), 'claude') + + expect(projects).toHaveLength(1) + expect(projects[0]!.sessions[0]!.totalCacheWriteTokens).toBe(60_120) + expect(projects[0]!.totalCostUSD).toBeCloseTo(0.37575, 6) + }) +}) diff --git a/tests/parser-compact-entry.test.ts b/tests/parser-compact-entry.test.ts new file mode 100644 index 0000000..6777d94 --- /dev/null +++ b/tests/parser-compact-entry.test.ts @@ -0,0 +1,434 @@ +import { describe, it, expect } from 'vitest' + +import { compactEntry } from '../src/parser.js' +import type { JournalEntry } from '../src/types.js' + +function entry(overrides: Partial & Record): JournalEntry { + return { type: 'user', ...overrides } as JournalEntry +} + +describe('compactEntry', () => { + it('preserves type, timestamp, sessionId, cwd', () => { + const raw = entry({ type: 'user', timestamp: 't1', sessionId: 's1', cwd: '/foo' }) + const c = compactEntry(raw) + expect(c.type).toBe('user') + expect(c.timestamp).toBe('t1') + expect(c.sessionId).toBe('s1') + expect(c.cwd).toBe('/foo') + }) + + it('strips unknown catch-all fields', () => { + const raw = entry({ + type: 'assistant', + toolResult: { type: 'tool_result', content: 'x'.repeat(10_000) }, + someHugeField: 'y'.repeat(10_000), + }) + const c = compactEntry(raw) + expect((c as Record)['toolResult']).toBeUndefined() + expect((c as Record)['someHugeField']).toBeUndefined() + }) + + it('preserves deferred_tools_delta attachment with copied names', () => { + const raw = entry({ + type: 'attachment', + attachment: { + type: 'deferred_tools_delta', + addedNames: ['mcp__svc__t1', 'Bash'], + extraData: 'should be dropped', + }, + }) + const c = compactEntry(raw) + const att = (c as Record)['attachment'] as Record + expect(att['type']).toBe('deferred_tools_delta') + expect(att['addedNames']).toEqual(['mcp__svc__t1', 'Bash']) + expect(att['extraData']).toBeUndefined() + }) + + it('copies addedNames into a new array (not by reference)', () => { + const originalNames = ['mcp__a__b', 'Bash'] + const raw = entry({ + type: 'attachment', + attachment: { type: 'deferred_tools_delta', addedNames: originalNames }, + }) + const c = compactEntry(raw) + const att = (c as Record)['attachment'] as { addedNames: string[] } + expect(att.addedNames).not.toBe(originalNames) + expect(att.addedNames).toEqual(originalNames) + }) + + it('caps addedNames at 1000 entries', () => { + const names = Array.from({ length: 2000 }, (_, i) => `mcp__svc__t${i}`) + const raw = entry({ + type: 'attachment', + attachment: { type: 'deferred_tools_delta', addedNames: names }, + }) + const c = compactEntry(raw) + const att = (c as Record)['attachment'] as { addedNames: string[] } + expect(att.addedNames).toHaveLength(1000) + }) + + it('filters non-string entries from addedNames', () => { + const raw = entry({ + type: 'attachment', + attachment: { type: 'deferred_tools_delta', addedNames: [42, null, 'mcp__a__b', undefined] }, + }) + const c = compactEntry(raw) + const att = (c as Record)['attachment'] as { addedNames: string[] } + expect(att.addedNames).toEqual(['mcp__a__b']) + }) + + it('drops non-deferred_tools_delta attachments', () => { + const raw = entry({ + type: 'attachment', + attachment: { type: 'other', data: 'x'.repeat(10_000) }, + }) + const c = compactEntry(raw) + expect((c as Record)['attachment']).toBeUndefined() + }) + + it('caps user message string content at 2000', () => { + const longText = 'a'.repeat(5000) + const raw = entry({ + type: 'user', + message: { role: 'user' as const, content: longText }, + }) + const c = compactEntry(raw) + expect(c.message!.role).toBe('user') + const content = (c.message as { content: string }).content + expect(content.length).toBe(2000) + }) + + it('caps total user text across all blocks at 2000', () => { + const raw = entry({ + type: 'user', + message: { + role: 'user' as const, + content: [ + { type: 'text' as const, text: 'a'.repeat(1500) }, + { type: 'text' as const, text: 'b'.repeat(1500) }, + { type: 'text' as const, text: 'c'.repeat(1500) }, + { type: 'image' as const, source: 'big data' }, + ], + }, + }) + const c = compactEntry(raw) + const content = (c.message as { content: Array<{ type: string; text: string }> }).content + expect(content).toHaveLength(2) + expect(content[0]!.text.length).toBe(1500) + expect(content[1]!.text.length).toBe(500) + }) + + it('compacts assistant tool_use blocks, dropping text and thinking, preserving id', () => { + const raw = entry({ + type: 'assistant', + timestamp: 't1', + message: { + type: 'message' as const, + role: 'assistant' as const, + model: 'claude-opus-4-6', + id: 'msg_123', + usage: { input_tokens: 100, output_tokens: 200 }, + content: [ + { type: 'text', text: 'x'.repeat(50_000) }, + { type: 'thinking', thinking: 'y'.repeat(50_000) }, + { type: 'tool_use', id: 'tu1', name: 'Read', input: { file_path: '/foo', huge: 'z'.repeat(10_000) } }, + { type: 'tool_use', id: 'tu2', name: 'Edit', input: { old_string: 'a'.repeat(5000), new_string: 'b'.repeat(5000) } }, + ], + }, + }) + const c = compactEntry(raw) + const msg = c.message as { content: Array<{ type: string; id?: string; name?: string; input?: Record }> } + expect(msg.content).toHaveLength(2) + expect(msg.content[0]!.name).toBe('Read') + expect(msg.content[0]!.id).toBe('tu1') + expect(msg.content[0]!.input).toEqual({ file_path: '/foo' }) + expect(msg.content[1]!.name).toBe('Edit') + expect(msg.content[1]!.id).toBe('tu2') + expect(msg.content[1]!.input).toEqual({}) + }) + + it('caps tool_use blocks at 500 per message', () => { + const blocks = Array.from({ length: 600 }, (_, i) => ({ + type: 'tool_use' as const, + id: `tu${i}`, + name: `Tool${i}`, + input: {}, + })) + const raw = entry({ + type: 'assistant', + message: { + type: 'message' as const, + role: 'assistant' as const, + model: 'claude-opus-4-6', + usage: { input_tokens: 10, output_tokens: 10 }, + content: blocks, + }, + }) + const c = compactEntry(raw) + const msg = c.message as { content: unknown[] } + expect(msg.content).toHaveLength(500) + }) + + it('preserves model, usage (destructured), and id on assistant messages', () => { + const raw = entry({ + type: 'assistant', + message: { + type: 'message' as const, + role: 'assistant' as const, + model: 'claude-opus-4-6', + id: 'msg_abc', + usage: { + input_tokens: 50, + output_tokens: 100, + cache_read_input_tokens: 25, + extraGarbage: 'should not survive', + }, + content: [], + }, + }) + const c = compactEntry(raw) + const msg = c.message as { model: string; id: string; usage: Record } + expect(msg.model).toBe('claude-opus-4-6') + expect(msg.id).toBe('msg_abc') + expect(msg.usage['input_tokens']).toBe(50) + expect(msg.usage['output_tokens']).toBe(100) + expect(msg.usage['cache_read_input_tokens']).toBe(25) + expect(msg.usage['extraGarbage']).toBeUndefined() + }) + + it('deep-copies usage nested objects, stripping extra keys', () => { + const cacheCreation = { ephemeral_5m_input_tokens: 100, ephemeral_1h_input_tokens: 200, extraJunk: 'big' } + const serverToolUse = { web_search_requests: 3, web_fetch_requests: 1, extraJunk: 'big' } + const raw = entry({ + type: 'assistant', + message: { + type: 'message' as const, + role: 'assistant' as const, + model: 'claude-opus-4-6', + usage: { + input_tokens: 10, + output_tokens: 10, + speed: 'fast', + cache_creation: cacheCreation, + server_tool_use: serverToolUse, + }, + content: [], + }, + }) + const c = compactEntry(raw) + const msg = c.message as { usage: Record } + expect(msg.usage['speed']).toBe('fast') + const cc = msg.usage['cache_creation'] as Record + expect(cc['ephemeral_5m_input_tokens']).toBe(100) + expect(cc['ephemeral_1h_input_tokens']).toBe(200) + expect(cc['extraJunk']).toBeUndefined() + expect(cc).not.toBe(cacheCreation) + const stu = msg.usage['server_tool_use'] as Record + expect(stu['web_search_requests']).toBe(3) + expect(stu['web_fetch_requests']).toBe(1) + expect(stu['extraJunk']).toBeUndefined() + expect(stu).not.toBe(serverToolUse) + }) + + it('keeps Skill input.skill and input.name, type-checked and capped', () => { + const raw = entry({ + type: 'assistant', + message: { + type: 'message' as const, + role: 'assistant' as const, + model: 'claude-opus-4-6', + usage: { input_tokens: 10, output_tokens: 10 }, + content: [ + { type: 'tool_use', id: 'tu', name: 'Skill', input: { skill: 'graphify', args: 'huge arg data' } }, + ], + }, + }) + const c = compactEntry(raw) + const msg = c.message as { content: Array<{ input: Record }> } + expect(msg.content[0]!.input['skill']).toBe('graphify') + expect(msg.content[0]!.input['args']).toBeUndefined() + }) + + it('rejects non-string Skill input.skill and caps long names', () => { + const raw = entry({ + type: 'assistant', + message: { + type: 'message' as const, + role: 'assistant' as const, + model: 'claude-opus-4-6', + usage: { input_tokens: 10, output_tokens: 10 }, + content: [ + { type: 'tool_use', id: 'tu1', name: 'Skill', input: { skill: { malicious: 'x'.repeat(10_000) } } }, + { type: 'tool_use', id: 'tu2', name: 'Skill', input: { skill: 'a'.repeat(500) } }, + ], + }, + }) + const c = compactEntry(raw) + const msg = c.message as { content: Array<{ input: Record }> } + expect(msg.content[0]!.input['skill']).toBeUndefined() + expect((msg.content[1]!.input['skill'] as string).length).toBe(200) + }) + + it('keeps Bash input.command capped at 2000 for bash command extraction', () => { + const longCmd = 'npm run build && '.repeat(200) + const raw = entry({ + type: 'assistant', + message: { + type: 'message' as const, + role: 'assistant' as const, + model: 'claude-opus-4-6', + usage: { input_tokens: 10, output_tokens: 10 }, + content: [ + { type: 'tool_use', id: 'tu', name: 'Bash', input: { command: longCmd, description: 'big desc' } }, + ], + }, + }) + const c = compactEntry(raw) + const msg = c.message as { content: Array<{ input: Record }> } + const cmd = msg.content[0]!.input['command'] as string + expect(cmd.length).toBe(2000) + expect(msg.content[0]!.input['description']).toBeUndefined() + }) + + it('keeps Read file_path capped and drops unrelated input fields', () => { + const raw = entry({ + type: 'assistant', + message: { + type: 'message' as const, + role: 'assistant' as const, + model: 'claude-opus-4-6', + usage: { input_tokens: 10, output_tokens: 10 }, + content: [ + { type: 'tool_use', id: 'tu', name: 'Read', input: { file_path: '/tmp/' + 'x'.repeat(3000), content: 'big' } }, + ], + }, + }) + const c = compactEntry(raw) + const msg = c.message as { content: Array<{ input: Record }> } + expect((msg.content[0]!.input['file_path'] as string).length).toBe(2000) + expect(msg.content[0]!.input['content']).toBeUndefined() + }) + + it('keeps Agent subagent_type capped and drops prompt text', () => { + const raw = entry({ + type: 'assistant', + message: { + type: 'message' as const, + role: 'assistant' as const, + model: 'claude-opus-4-6', + usage: { input_tokens: 10, output_tokens: 10 }, + content: [ + { type: 'tool_use', id: 'tu', name: 'Agent', input: { subagent_type: 'reviewer'.repeat(50), prompt: 'big' } }, + ], + }, + }) + const c = compactEntry(raw) + const msg = c.message as { content: Array<{ input: Record }> } + expect((msg.content[0]!.input['subagent_type'] as string).length).toBe(200) + expect(msg.content[0]!.input['prompt']).toBeUndefined() + }) + + it('handles entry with no message field', () => { + const raw = entry({ type: 'system', timestamp: 't1', cwd: '/x' }) + const c = compactEntry(raw) + expect(c.type).toBe('system') + expect(c.timestamp).toBe('t1') + expect(c.message).toBeUndefined() + }) + + it('handles assistant message with no usage (non-standard)', () => { + const raw = entry({ + type: 'assistant', + message: { + type: 'message' as const, + role: 'assistant' as const, + model: 'claude-opus-4-6', + content: [{ type: 'text', text: 'response' }], + }, + }) + const c = compactEntry(raw) + expect(c.message).toBeUndefined() + }) + + it('handles unexpected message role (neither user nor assistant)', () => { + const raw = entry({ + type: 'system', + message: { role: 'system' as never, content: 'sys prompt' }, + }) + const c = compactEntry(raw) + expect(c.message).toBeUndefined() + }) + + it('tolerates null elements in user content array', () => { + const raw = entry({ + type: 'user', + message: { + role: 'user' as const, + content: [null, undefined, { type: 'text', text: 'ok' }, 42, { type: 'text' }] as never, + }, + }) + const c = compactEntry(raw) + const content = (c.message as { content: Array<{ text: string }> }).content + expect(content).toHaveLength(1) + expect(content[0]!.text).toBe('ok') + }) + + it('tolerates assistant content that is not an array', () => { + const raw = entry({ + type: 'assistant', + message: { + type: 'message' as const, + role: 'assistant' as const, + model: 'claude-opus-4-6', + usage: { input_tokens: 10, output_tokens: 10 }, + content: 'not an array' as never, + }, + }) + const c = compactEntry(raw) + const msg = c.message as { content: unknown[] } + expect(msg.content).toEqual([]) + }) + + it('tolerates null elements in assistant content array', () => { + const raw = entry({ + type: 'assistant', + message: { + type: 'message' as const, + role: 'assistant' as const, + model: 'claude-opus-4-6', + usage: { input_tokens: 10, output_tokens: 10 }, + content: [null, { type: 'tool_use', id: 'tu1', name: 'Read', input: {} }, undefined] as never, + }, + }) + const c = compactEntry(raw) + const msg = c.message as { content: Array<{ name: string }> } + expect(msg.content).toHaveLength(1) + expect(msg.content[0]!.name).toBe('Read') + }) + + it('memory reduction: compacted entry is much smaller than raw', () => { + const hugeContent = Array.from({ length: 20 }, (_, i) => ({ + type: i % 2 === 0 ? 'text' : 'tool_result', + text: 'x'.repeat(100_000), + content: 'y'.repeat(100_000), + })) + const raw = entry({ + type: 'assistant', + timestamp: '2026-01-01T00:00:00', + message: { + type: 'message' as const, + role: 'assistant' as const, + model: 'claude-opus-4-6', + id: 'msg_1', + usage: { input_tokens: 1000, output_tokens: 500 }, + content: hugeContent as never, + }, + toolResult: { content: 'z'.repeat(500_000) }, + }) + const rawSize = JSON.stringify(raw).length + const compacted = compactEntry(raw) + const compactedSize = JSON.stringify(compacted).length + expect(rawSize).toBeGreaterThan(2_000_000) + expect(compactedSize).toBeLessThan(500) + }) +}) diff --git a/tests/parser-large-json-scanner.test.ts b/tests/parser-large-json-scanner.test.ts new file mode 100644 index 0000000..af0668b --- /dev/null +++ b/tests/parser-large-json-scanner.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' + +import { parseJsonlLine } from '../src/parser.js' + +function largeUserLine(): string { + return JSON.stringify({ + type: 'user', + sessionId: 's1', + timestamp: '2026-05-01T00:00:00Z', + cwd: '/repo', + message: { + role: 'user', + content: [ + { type: 'image', source: { data: 'x'.repeat(40_000) } }, + { type: 'text', text: 'hello ' + 'a'.repeat(3000) }, + ], + }, + }) +} + +function largeAssistantLine(): string { + return JSON.stringify({ + type: 'assistant', + sessionId: 's1', + timestamp: '2026-05-01T00:00:01Z', + cwd: '/repo', + message: { + id: 'm1', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [ + { type: 'text', text: 'x'.repeat(40_000) }, + { type: 'tool_use', id: 'read1', name: 'Read', input: { file_path: '/tmp/file.ts', content: 'drop me' } }, + { type: 'tool_use', id: 'agent1', name: 'Agent', input: { subagent_type: 'reviewer', prompt: 'drop me' } }, + ], + usage: { + input_tokens: 100, + output_tokens: 20, + cache_read_input_tokens: 300, + }, + }, + }) +} + +describe('large JSONL compact scanner', () => { + it('extracts user text from array content without full JSON.parse', () => { + const parsed = parseJsonlLine(largeUserLine()) + expect(parsed?.type).toBe('user') + const content = parsed?.message?.role === 'user' ? parsed.message.content : '' + expect(content).toBeTypeOf('string') + expect((content as string).startsWith('hello ')).toBe(true) + expect((content as string).length).toBe(2000) + }) + + it('extracts capped tool inputs needed by optimize', () => { + const parsed = parseJsonlLine(Buffer.from(largeAssistantLine())) + const msg = parsed?.message + expect(msg?.role).toBe('assistant') + if (msg?.role !== 'assistant') return + expect(msg.usage.input_tokens).toBe(100) + expect(msg.usage.output_tokens).toBe(20) + expect(msg.usage.cache_read_input_tokens).toBe(300) + expect(msg.content).toEqual([ + { type: 'tool_use', id: 'read1', name: 'Read', input: { file_path: '/tmp/file.ts' } }, + { type: 'tool_use', id: 'agent1', name: 'Agent', input: { subagent_type: 'reviewer' } }, + ]) + }) + + it('extracts deferred MCP inventory from large attachment lines', () => { + const line = JSON.stringify({ + type: 'attachment', + sessionId: 's1', + timestamp: '2026-05-01T00:00:02Z', + padding: 'x'.repeat(40_000), + attachment: { + type: 'deferred_tools_delta', + addedNames: ['Bash', 'mcp__svc__tool'], + }, + }) + const parsed = parseJsonlLine(Buffer.from(line)) as Record + expect(parsed['attachment']).toEqual({ + type: 'deferred_tools_delta', + addedNames: ['Bash', 'mcp__svc__tool'], + }) + }) +}) diff --git a/tests/parser-large-session.test.ts b/tests/parser-large-session.test.ts new file mode 100644 index 0000000..44d3d7b --- /dev/null +++ b/tests/parser-large-session.test.ts @@ -0,0 +1,180 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { describe, expect, it, beforeEach, afterEach } from 'vitest' + +import { parseAllSessions, clearSessionCache } from '../src/parser.js' +import type { DateRange } from '../src/types.js' + +let home: string + +beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'codeburn-large-')) + process.env['CLAUDE_CONFIG_DIR'] = join(home, '.claude') +}) + +afterEach(async () => { + clearSessionCache() + delete process.env['CLAUDE_CONFIG_DIR'] + await rm(home, { recursive: true, force: true }) +}) + +function userLine(sessionId: string, timestamp: string, textSize = 100): string { + return JSON.stringify({ + type: 'user', + sessionId, + timestamp, + cwd: '/projects/app', + message: { role: 'user', content: 'x'.repeat(textSize) }, + }) +} + +function assistantLine(sessionId: string, timestamp: string, messageId: string, opts?: { + contentSize?: number + toolCount?: number +}): string { + const contentSize = opts?.contentSize ?? 0 + const toolCount = opts?.toolCount ?? 1 + const content: unknown[] = [] + if (contentSize > 0) { + content.push({ type: 'text', text: 'y'.repeat(contentSize) }) + content.push({ type: 'thinking', thinking: 'z'.repeat(contentSize) }) + } + for (let i = 0; i < toolCount; i++) { + content.push({ + type: 'tool_use', + id: `tu-${i}`, + name: i === 0 ? 'Edit' : 'Read', + input: { file_path: '/tmp/x', big: 'w'.repeat(contentSize) }, + }) + } + return JSON.stringify({ + type: 'assistant', + sessionId, + timestamp, + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content, + usage: { input_tokens: 1000, output_tokens: 100 }, + }, + }) +} + +function messageFirstLargeAssistantLine(sessionId: string, timestamp: string, messageId: string): string { + const hugeText = 'y'.repeat(3_000_000) + return `{"parentUuid":"u1","isSidechain":false,"message":{"model":"claude-sonnet-4-5","id":"${messageId}","type":"message","role":"assistant","content":[{"type":"text","text":"${hugeText}"},{"type":"tool_use","id":"tu-large","name":"Edit","input":{"file_path":"/tmp/x","old_string":"a","new_string":"b"}}],"usage":{"input_tokens":1000,"output_tokens":100,"cache_read_input_tokens":5000}},"uuid":"a1","timestamp":"${timestamp}","type":"assistant","sessionId":"${sessionId}","cwd":"/projects/app"}` +} + +function attachmentLine(sessionId: string, timestamp: string): string { + return JSON.stringify({ + type: 'attachment', + sessionId, + timestamp, + attachment: { + type: 'deferred_tools_delta', + addedNames: ['Bash', 'Edit', 'Read', 'mcp__hf__hub_search'], + }, + }) +} + +describe('parseAllSessions with large Claude fixture', () => { + it('correctly parses sessions with bulky text/thinking/tool_result blocks', async () => { + const projectDir = join(home, '.claude', 'projects', 'bigapp') + await mkdir(projectDir, { recursive: true }) + + const lines: string[] = [] + lines.push(attachmentLine('s1', '2026-04-10T09:00:00Z')) + for (let i = 0; i < 50; i++) { + const ts = `2026-04-10T${String(9 + Math.floor(i / 10)).padStart(2, '0')}:${String((i % 10) * 5).padStart(2, '0')}:00Z` + lines.push(userLine('s1', ts, 5000)) + lines.push(assistantLine('s1', ts.replace(':00Z', ':30Z'), `msg-${i}`, { + contentSize: 50_000, + toolCount: 3, + })) + } + + await writeFile(join(projectDir, 'session.jsonl'), lines.join('\n')) + + const range: DateRange = { + start: new Date('2026-04-10T00:00:00Z'), + end: new Date('2026-04-10T23:59:59Z'), + } + + const projects = await parseAllSessions(range, 'claude') + + expect(projects.length).toBeGreaterThan(0) + const proj = projects[0]! + expect(proj.totalApiCalls).toBe(50) + expect(proj.totalCostUSD).toBeGreaterThan(0) + + const sess = proj.sessions[0]! + expect(sess.turns.length).toBe(50) + + for (const turn of sess.turns) { + expect(turn.userMessage.length).toBeLessThanOrEqual(2000) + expect(turn.assistantCalls.length).toBe(1) + const call = turn.assistantCalls[0]! + expect(call.tools).toContain('Edit') + expect(call.tools).toContain('Read') + expect(call.model).toBe('claude-sonnet-4-5') + } + + expect(sess.mcpInventory).toContain('mcp__hf__hub_search') + }) + + it('handles malformed JSONL lines without crashing', async () => { + const projectDir = join(home, '.claude', 'projects', 'baddata') + await mkdir(projectDir, { recursive: true }) + + const lines = [ + 'not json at all', + '{"type": "user", "sessionId": "s1", "timestamp": "2026-04-10T10:00:00Z", "message": {"role": "user", "content": [null, {"type": "text", "text": "hello"}, 42]}}', + '{"type": "assistant", "sessionId": "s1", "timestamp": "2026-04-10T10:01:00Z", "message": {"id": "m1", "type": "message", "role": "assistant", "model": "claude-sonnet-4-5", "content": "not-an-array", "usage": {"input_tokens": 100, "output_tokens": 50}}}', + '{"type": "assistant", "sessionId": "s1", "timestamp": "2026-04-10T10:02:00Z", "message": {"id": "m2", "type": "message", "role": "assistant", "model": "claude-sonnet-4-5", "content": [null, {"type": "tool_use", "id": "t1", "name": "Read", "input": {}}], "usage": {"input_tokens": 100, "output_tokens": 50}}}', + ] + + await writeFile(join(projectDir, 'session.jsonl'), lines.join('\n')) + + const range: DateRange = { + start: new Date('2026-04-10T00:00:00Z'), + end: new Date('2026-04-10T23:59:59Z'), + } + + const projects = await parseAllSessions(range, 'claude') + expect(projects.length).toBeGreaterThan(0) + + const sess = projects[0]!.sessions[0]! + expect(sess.apiCalls).toBeGreaterThanOrEqual(1) + }) + + it('parses huge message-first assistant lines without full JSON.parse expansion', async () => { + const projectDir = join(home, '.claude', 'projects', 'messagefirst') + await mkdir(projectDir, { recursive: true }) + + const lines = [ + userLine('s1', '2026-04-10T10:00:00Z', 100), + messageFirstLargeAssistantLine('s1', '2026-04-10T10:00:01Z', 'msg-large'), + ] + + await writeFile(join(projectDir, 'session.jsonl'), lines.join('\n')) + + const range: DateRange = { + start: new Date('2026-04-10T00:00:00Z'), + end: new Date('2026-04-10T23:59:59Z'), + } + + const projects = await parseAllSessions(range, 'claude') + expect(projects.length).toBeGreaterThan(0) + + const sess = projects[0]!.sessions[0]! + expect(sess.apiCalls).toBe(1) + expect(sess.totalInputTokens).toBe(1000) + expect(sess.totalOutputTokens).toBe(100) + expect(sess.totalCacheReadTokens).toBe(5000) + expect(sess.toolBreakdown['Edit']?.calls).toBe(1) + }) +}) diff --git a/tests/parser-mcp-inventory.test.ts b/tests/parser-mcp-inventory.test.ts new file mode 100644 index 0000000..cbbe34c --- /dev/null +++ b/tests/parser-mcp-inventory.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from 'vitest' + +import { extractMcpInventory } from '../src/parser.js' +import type { JournalEntry } from '../src/types.js' + +function entry(overrides: Partial & Record): JournalEntry { + return { type: 'attachment', ...overrides } as JournalEntry +} + +describe('extractMcpInventory', () => { + it('returns empty array when no entries have an attachment', () => { + expect(extractMcpInventory([entry({ type: 'user' })])).toEqual([]) + }) + + it('returns empty array when no deferred_tools_delta is present', () => { + expect(extractMcpInventory([ + entry({ attachment: { type: 'something_else', addedNames: ['mcp__a__b'] } }), + ])).toEqual([]) + }) + + it('extracts mcp__server__tool names from a single delta', () => { + const result = extractMcpInventory([ + entry({ + attachment: { + type: 'deferred_tools_delta', + addedNames: ['Bash', 'Edit', 'mcp__hf__hub_repo_search', 'mcp__hf__paper_search'], + }, + }), + ]) + expect(result).toEqual(['mcp__hf__hub_repo_search', 'mcp__hf__paper_search']) + }) + + it('filters out built-in tools (no mcp__ prefix)', () => { + const result = extractMcpInventory([ + entry({ + attachment: { + type: 'deferred_tools_delta', + addedNames: ['Bash', 'Edit', 'WebFetch', 'mcp__svc__t1'], + }, + }), + ]) + expect(result).toEqual(['mcp__svc__t1']) + }) + + it('rejects malformed names: empty server segment', () => { + const result = extractMcpInventory([ + entry({ + attachment: { + type: 'deferred_tools_delta', + addedNames: ['mcp____tool', 'mcp__svc__t1'], + }, + }), + ]) + expect(result).toEqual(['mcp__svc__t1']) + }) + + it('rejects malformed names: missing tool segment (no second `__`)', () => { + const result = extractMcpInventory([ + entry({ + attachment: { + type: 'deferred_tools_delta', + addedNames: ['mcp__server', 'mcp__svc__t1'], + }, + }), + ]) + expect(result).toEqual(['mcp__svc__t1']) + }) + + it('rejects malformed names: empty tool segment (trailing `__`)', () => { + const result = extractMcpInventory([ + entry({ + attachment: { + type: 'deferred_tools_delta', + addedNames: ['mcp__server__', 'mcp__svc__t1'], + }, + }), + ]) + expect(result).toEqual(['mcp__svc__t1']) + }) + + it('unions across multiple delta entries (incremental adds)', () => { + const result = extractMcpInventory([ + entry({ attachment: { type: 'deferred_tools_delta', addedNames: ['mcp__a__t1'] } }), + entry({ attachment: { type: 'deferred_tools_delta', addedNames: ['mcp__a__t2', 'mcp__b__t1'] } }), + ]) + expect(result).toEqual(['mcp__a__t1', 'mcp__a__t2', 'mcp__b__t1']) + }) + + it('deduplicates names seen in multiple deltas', () => { + const result = extractMcpInventory([ + entry({ attachment: { type: 'deferred_tools_delta', addedNames: ['mcp__a__t1', 'mcp__a__t1'] } }), + entry({ attachment: { type: 'deferred_tools_delta', addedNames: ['mcp__a__t1'] } }), + ]) + expect(result).toEqual(['mcp__a__t1']) + }) + + it('tolerates missing or non-string addedNames', () => { + const result = extractMcpInventory([ + entry({ attachment: { type: 'deferred_tools_delta' } }), + entry({ attachment: { type: 'deferred_tools_delta', addedNames: 'not-an-array' } }), + entry({ attachment: { type: 'deferred_tools_delta', addedNames: [42, null, 'mcp__svc__t1', undefined] } }), + ]) + expect(result).toEqual(['mcp__svc__t1']) + }) + + it('tolerates malformed attachment object', () => { + const result = extractMcpInventory([ + entry({ attachment: null }), + entry({ attachment: 'string-not-object' }), + entry({ attachment: { type: 'deferred_tools_delta', addedNames: ['mcp__svc__t1'] } }), + ]) + expect(result).toEqual(['mcp__svc__t1']) + }) + + it('returns names in sorted order', () => { + const result = extractMcpInventory([ + entry({ + attachment: { + type: 'deferred_tools_delta', + addedNames: ['mcp__zzz__a', 'mcp__aaa__z', 'mcp__mmm__m'], + }, + }), + ]) + expect(result).toEqual(['mcp__aaa__z', 'mcp__mmm__m', 'mcp__zzz__a']) + }) +}) diff --git a/tests/parser-skip-line.test.ts b/tests/parser-skip-line.test.ts new file mode 100644 index 0000000..f023f46 --- /dev/null +++ b/tests/parser-skip-line.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest' + +import { shouldSkipLine } from '../src/parser.js' + +const threshold = '2026-04-01T00:00:00.000Z' + +function makeLine(type: string, timestamp: string, payloadSize = 0): string { + const payload = payloadSize > 0 ? `,"content":"${'x'.repeat(payloadSize)}"` : '' + return `{"type":"${type}","sessionId":"s1","timestamp":"${timestamp}"${payload}}` +} + +function makeLineWithLongCwd(type: string, timestamp: string, cwdLength: number): string { + const cwd = '/projects/' + 'a'.repeat(cwdLength) + return `{"type":"${type}","sessionId":"s1","cwd":"${cwd}","timestamp":"${timestamp}","message":{"role":"user","content":"hi"}}` +} + +describe('shouldSkipLine', () => { + it('skips old user lines', () => { + expect(shouldSkipLine(makeLine('user', '2026-03-01T10:00:00Z'), threshold)).toBe(true) + }) + + it('skips old assistant lines', () => { + expect(shouldSkipLine(makeLine('assistant', '2026-03-15T10:00:00Z'), threshold)).toBe(true) + }) + + it('does not skip in-range user lines', () => { + expect(shouldSkipLine(makeLine('user', '2026-04-05T10:00:00Z'), threshold)).toBe(false) + }) + + it('does not skip in-range assistant lines', () => { + expect(shouldSkipLine(makeLine('assistant', '2026-04-10T10:00:00Z'), threshold)).toBe(false) + }) + + it('never skips attachment lines regardless of timestamp', () => { + expect(shouldSkipLine(makeLine('attachment', '2026-01-01T00:00:00Z'), threshold)).toBe(false) + }) + + it('never skips system lines regardless of timestamp', () => { + expect(shouldSkipLine(makeLine('system', '2026-01-01T00:00:00Z'), threshold)).toBe(false) + }) + + it('never skips summary lines regardless of timestamp', () => { + expect(shouldSkipLine(makeLine('summary', '2026-01-01T00:00:00Z'), threshold)).toBe(false) + }) + + it('does not skip lines with no timestamp field', () => { + expect(shouldSkipLine('{"type":"user","sessionId":"s1"}', threshold)).toBe(false) + }) + + it('does not skip lines with unparseable timestamp', () => { + expect(shouldSkipLine('{"type":"user","timestamp":"bad"}', threshold)).toBe(false) + }) + + it('does not skip malformed JSON', () => { + expect(shouldSkipLine('not json at all', threshold)).toBe(false) + }) + + it('only reads top-level type and timestamp fields', () => { + const line = '{"message":{"type":"assistant","timestamp":"2026-03-01T10:00:00Z"},"type":"user","timestamp":"2026-04-05T10:00:00Z"}' + expect(shouldSkipLine(line, threshold)).toBe(false) + }) + + it('handles timestamp pushed past 200 chars by long cwd', () => { + const line = makeLineWithLongCwd('user', '2026-03-01T10:00:00Z', 300) + expect(line.indexOf('"timestamp"')).toBeGreaterThan(200) + expect(shouldSkipLine(line, threshold)).toBe(true) + }) + + it('handles timestamp at the edge of the 2048 head window', () => { + const line = makeLineWithLongCwd('user', '2026-03-01T10:00:00Z', 1900) + expect(line.indexOf('"timestamp"')).toBeGreaterThan(1900) + expect(shouldSkipLine(line, threshold)).toBe(true) + }) + + it('returns false when timestamp is beyond the head window', () => { + const line = makeLineWithLongCwd('user', '2026-03-01T10:00:00Z', 2100) + expect(line.indexOf('"timestamp"')).toBeGreaterThan(2048) + expect(shouldSkipLine(line, threshold)).toBe(false) + }) + + it('skips old assistant line with large payload without parsing it', () => { + const line = makeLine('assistant', '2026-02-01T10:00:00Z', 50_000_000) + expect(line.length).toBeGreaterThan(50_000_000) + expect(shouldSkipLine(line, threshold)).toBe(true) + }) +}) diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts index 46f1fd3..2a82867 100644 --- a/tests/provider-registry.test.ts +++ b/tests/provider-registry.test.ts @@ -3,7 +3,7 @@ import { providers, getAllProviders } from '../src/providers/index.js' describe('provider registry', () => { it('has core providers registered synchronously', () => { - expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'codebuff', 'copilot', 'droid', 'gemini', 'kilo-code', 'kiro', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code']) + expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codebuff', 'codex', 'copilot', 'droid', 'gemini', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'mistral-vibe', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code']) }) it('codebuff tool display names normalize codebuff-native names to canonical set', () => { @@ -69,6 +69,7 @@ describe('provider registry', () => { expect(codex.modelDisplayName('gpt-5.4')).toBe('GPT-5.4') expect(codex.modelDisplayName('gpt-5.4-mini')).toBe('GPT-5.4 Mini') expect(codex.modelDisplayName('gpt-5.3-codex')).toBe('GPT-5.3 Codex') + expect(codex.modelDisplayName('gpt-5.5')).toBe('GPT-5.5') }) it('claude model display names are human-readable', () => { @@ -77,6 +78,14 @@ describe('provider registry', () => { expect(claude.modelDisplayName('claude-sonnet-4-6')).toBe('Sonnet 4.6') }) + it('kimi model and tool display names are normalized', () => { + const kimi = providers.find(p => p.name === 'kimi')! + expect(kimi.modelDisplayName('kimi-auto')).toBe('Kimi (auto)') + expect(kimi.modelDisplayName('kimi-k2-thinking-turbo')).toBe('Kimi K2 Thinking Turbo') + expect(kimi.toolDisplayName('Shell')).toBe('Bash') + expect(kimi.toolDisplayName('WriteFile')).toBe('Write') + }) + it('cursor model display names handle auto mode', async () => { const all = await getAllProviders() const cursor = all.find(p => p.name === 'cursor')! diff --git a/tests/providers/antigravity.test.ts b/tests/providers/antigravity.test.ts new file mode 100644 index 0000000..9396c37 --- /dev/null +++ b/tests/providers/antigravity.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest' + +import { + extractAntigravityGeneratorMetadata, + extractAntigravityModelMap, + parseAntigravityServerInfo, + parseAntigravityServerInfoFromLine, +} from '../../src/providers/antigravity.js' + +describe('antigravity provider helpers', () => { + it('parses legacy https server flags from POSIX process args', () => { + const server = parseAntigravityServerInfoFromLine( + '/Applications/Antigravity.app/language_server_macos_arm --app_data_dir antigravity --https_server_port 57101 --csrf_token 01234567-89ab-cdef-0123-456789abcdef', + ) + + expect(server).toEqual({ + port: 57101, + csrfToken: '01234567-89ab-cdef-0123-456789abcdef', + }) + }) + + it('parses Windows extension server flags and equals syntax', () => { + const server = parseAntigravityServerInfoFromLine( + 'C:\\Users\\Admin\\AppData\\Local\\Programs\\Antigravity\\resources\\app\\extensions\\antigravity\\bin\\language_server_windows_x64.exe --extension_server_port=62225 --extension_server_csrf_token=abcdef01-2345-6789-abcd-ef0123456789', + ) + + expect(server).toEqual({ + port: 62225, + csrfToken: 'abcdef01-2345-6789-abcd-ef0123456789', + }) + }) + + it('parses Windows extension server flags and space syntax', () => { + const server = parseAntigravityServerInfo([ + 'node something-unrelated', + 'language_server_windows_x64.exe --app_data_dir C:\\Users\\Admin\\.gemini\\antigravity --extension_server_port 62300 --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543210', + ]) + + expect(server).toEqual({ + port: 62300, + csrfToken: 'fedcba98-7654-3210-fedc-ba9876543210', + }) + }) + + it('parses quoted flag values', () => { + const server = parseAntigravityServerInfoFromLine( + 'Antigravity language_server_windows_x64.exe --extension_server_port "62301" --extension_server_csrf_token "fedcba98-7654-3210-fedc-ba9876543211"', + ) + + expect(server).toEqual({ + port: 62301, + csrfToken: 'fedcba98-7654-3210-fedc-ba9876543211', + }) + }) + + it('matches language-server and antigravity markers case-insensitively', () => { + const server = parseAntigravityServerInfoFromLine( + 'ANTIGRAVITY LANGUAGE_SERVER_WINDOWS_X64.EXE --extension_server_port 62302 --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543212', + ) + + expect(server).toEqual({ + port: 62302, + csrfToken: 'fedcba98-7654-3210-fedc-ba9876543212', + }) + }) + + it('ignores process args without an antigravity marker', () => { + expect(parseAntigravityServerInfoFromLine( + 'language_server --extension_server_port 62300 --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543210', + )).toBeNull() + }) + + it('ignores invalid ports', () => { + expect(parseAntigravityServerInfoFromLine( + 'antigravity language_server --extension_server_port 99999 --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543210', + )).toBeNull() + }) + + it('ignores chained flag names as values', () => { + expect(parseAntigravityServerInfoFromLine( + 'antigravity language_server --extension_server_port=--extension_server_csrf_token --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543210', + )).toBeNull() + }) + + it('ignores implausibly short CSRF tokens', () => { + expect(parseAntigravityServerInfoFromLine( + 'antigravity language_server --extension_server_port 62300 --extension_server_csrf_token short', + )).toBeNull() + }) + + it('extracts model maps from wrapped and unwrapped RPC responses', () => { + expect(extractAntigravityModelMap({ + response: { models: { high: { model: 'MODEL_PLACEHOLDER_M7' } } }, + })).toEqual({ MODEL_PLACEHOLDER_M7: 'high' }) + + expect(extractAntigravityModelMap({ + models: { low: { model: 'MODEL_PLACEHOLDER_M8' } }, + })).toEqual({ MODEL_PLACEHOLDER_M8: 'low' }) + expect(extractAntigravityModelMap({ + models: { bad: null, good: { model: 'MODEL_PLACEHOLDER_M9' } }, + })).toEqual({ MODEL_PLACEHOLDER_M9: 'good' }) + expect(extractAntigravityModelMap(null)).toEqual({}) + }) + + it('extracts generator metadata from wrapped and unwrapped RPC responses', () => { + const metadata = [{ + chatModel: { + model: 'gemini-3-pro', + usage: { + model: 'gemini-3-pro', + inputTokens: '10', + outputTokens: '4', + apiProvider: 'google', + }, + }, + }] + + expect(extractAntigravityGeneratorMetadata({ response: { generatorMetadata: metadata } })).toEqual(metadata) + expect(extractAntigravityGeneratorMetadata({ generatorMetadata: metadata })).toEqual(metadata) + expect(extractAntigravityGeneratorMetadata({ response: { generatorMetadata: null } })).toEqual([]) + expect(extractAntigravityGeneratorMetadata(null)).toEqual([]) + }) +}) diff --git a/tests/providers/claude-config-dirs.test.ts b/tests/providers/claude-config-dirs.test.ts new file mode 100644 index 0000000..a5561ad --- /dev/null +++ b/tests/providers/claude-config-dirs.test.ts @@ -0,0 +1,262 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { delimiter as pathDelimiter, join } from 'path' +import { tmpdir, homedir } from 'os' + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' + +import { claude } from '../../src/providers/claude.js' +import { parseAllSessions } from '../../src/parser.js' + +let tmpRoot: string +const savedEnv = { + CLAUDE_CONFIG_DIR: process.env['CLAUDE_CONFIG_DIR'], + CLAUDE_CONFIG_DIRS: process.env['CLAUDE_CONFIG_DIRS'], + HOME: process.env['HOME'], +} + +beforeEach(async () => { + tmpRoot = await mkdtemp(join(tmpdir(), 'codeburn-claude-multi-')) + // Point HOME at a scratch dir so the default `~/.claude` fallback resolves + // somewhere we control. Without this, a stray `~/.claude` on the test + // machine could leak into discovery. + process.env['HOME'] = join(tmpRoot, 'home') + await mkdir(process.env['HOME'], { recursive: true }) + delete process.env['CLAUDE_CONFIG_DIR'] + delete process.env['CLAUDE_CONFIG_DIRS'] +}) + +afterEach(async () => { + for (const [k, v] of Object.entries(savedEnv)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + await rm(tmpRoot, { recursive: true, force: true }) +}) + +async function makeConfigDir(name: string, projectSlugs: string[]): Promise { + const dir = join(tmpRoot, name) + for (const slug of projectSlugs) { + const projectDir = join(dir, 'projects', slug) + await mkdir(projectDir, { recursive: true }) + // Discovery only checks for the project subdirectory. A real session + // file is not required; the parser is exercised separately below. + } + return dir +} + +async function writeSession(configDir: string, slug: string, sessionId: string, lines: string[]): Promise { + const dir = join(configDir, 'projects', slug) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, `${sessionId}.jsonl`), lines.join('\n')) +} + +function summaryLine(sessionId: string, cwd: string): string { + return JSON.stringify({ + type: 'summary', + summary: 'test', + leafUuid: 'l', + sessionId, + cwd, + timestamp: '2026-05-09T00:00:00.000Z', + }) +} + +function userLine(uuid: string, sessionId: string, cwd: string, text: string): string { + return JSON.stringify({ + type: 'user', + uuid, + sessionId, + cwd, + timestamp: '2026-05-09T00:00:01.000Z', + message: { role: 'user', content: text }, + }) +} + +function assistantLine(uuid: string, parentUuid: string, sessionId: string, cwd: string): string { + return JSON.stringify({ + type: 'assistant', + uuid, + parentUuid, + sessionId, + cwd, + timestamp: '2026-05-09T00:00:02.000Z', + message: { + id: `msg_${uuid}`, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-6', + content: [{ type: 'text', text: 'reply' }], + usage: { input_tokens: 100, output_tokens: 50 }, + }, + }) +} + +describe('claude provider — CLAUDE_CONFIG_DIRS discovery', () => { + it('falls back to ~/.claude when no env var is set', async () => { + const homeDir = process.env['HOME']! + await mkdir(join(homeDir, '.claude', 'projects', '-Users-you-app'), { recursive: true }) + + const sources = await claude.discoverSessions() + const projectDirs = sources.map(s => s.path) + expect(projectDirs).toContain(join(homeDir, '.claude', 'projects', '-Users-you-app')) + }) + + it('honors CLAUDE_CONFIG_DIR for a single override', async () => { + const dir = await makeConfigDir('claude-work', ['-Users-you-app']) + process.env['CLAUDE_CONFIG_DIR'] = dir + + const sources = await claude.discoverSessions() + expect(sources.some(s => s.path === join(dir, 'projects', '-Users-you-app'))).toBe(true) + // The default `~/.claude` should NOT also be scanned when the override is set. + expect(sources.every(s => !s.path.startsWith(join(process.env['HOME']!, '.claude')))).toBe(true) + }) + + it('CLAUDE_CONFIG_DIRS overrides CLAUDE_CONFIG_DIR and walks every dir in the list', async () => { + const work = await makeConfigDir('claude-work', ['-Users-you-app']) + const personal = await makeConfigDir('claude-personal', ['-Users-you-app']) + const single = await makeConfigDir('claude-other', ['-Users-you-other']) + + process.env['CLAUDE_CONFIG_DIR'] = single + process.env['CLAUDE_CONFIG_DIRS'] = [work, personal].join(pathDelimiter) + + const sources = await claude.discoverSessions() + const paths = sources.map(s => s.path) + expect(paths).toContain(join(work, 'projects', '-Users-you-app')) + expect(paths).toContain(join(personal, 'projects', '-Users-you-app')) + // CLAUDE_CONFIG_DIR should be ignored once CLAUDE_CONFIG_DIRS is non-empty. + expect(paths.some(p => p.startsWith(single))).toBe(false) + }) + + it('emits the same project name for the same slug across dirs (so parser merges)', async () => { + const work = await makeConfigDir('claude-work', ['-Users-you-app']) + const personal = await makeConfigDir('claude-personal', ['-Users-you-app']) + process.env['CLAUDE_CONFIG_DIRS'] = [work, personal].join(pathDelimiter) + + const sources = await claude.discoverSessions() + const ourSources = sources.filter(s => + s.path === join(work, 'projects', '-Users-you-app') || + s.path === join(personal, 'projects', '-Users-you-app'), + ) + expect(ourSources).toHaveLength(2) + expect(new Set(ourSources.map(s => s.project))).toEqual(new Set(['-Users-you-app'])) + }) + + it('tolerates a non-existent dir in the list without dropping the real ones', async () => { + const real = await makeConfigDir('claude-real', ['-Users-you-app']) + const fake = join(tmpRoot, 'does-not-exist') + process.env['CLAUDE_CONFIG_DIRS'] = [real, fake].join(pathDelimiter) + + const sources = await claude.discoverSessions() + expect(sources.some(s => s.path === join(real, 'projects', '-Users-you-app'))).toBe(true) + }) + + it('dedupes when the same dir appears twice in CLAUDE_CONFIG_DIRS', async () => { + const dir = await makeConfigDir('claude-once', ['-Users-you-app']) + process.env['CLAUDE_CONFIG_DIRS'] = [dir, dir].join(pathDelimiter) + + const sources = await claude.discoverSessions() + const ourSources = sources.filter(s => s.path === join(dir, 'projects', '-Users-you-app')) + expect(ourSources).toHaveLength(1) + }) + + it('skips empty entries (leading, trailing, doubled delimiters)', async () => { + const dir = await makeConfigDir('claude-only', ['-Users-you-app']) + process.env['CLAUDE_CONFIG_DIRS'] = `${pathDelimiter}${dir}${pathDelimiter}${pathDelimiter}` + + const sources = await claude.discoverSessions() + expect(sources.some(s => s.path === join(dir, 'projects', '-Users-you-app'))).toBe(true) + }) + + it('expands ~ in CLAUDE_CONFIG_DIR', async () => { + const homeDir = process.env['HOME']! + await mkdir(join(homeDir, 'custom-claude', 'projects', '-Users-you-app'), { recursive: true }) + process.env['CLAUDE_CONFIG_DIR'] = '~/custom-claude' + + const sources = await claude.discoverSessions() + expect(sources.some(s => s.path === join(homeDir, 'custom-claude', 'projects', '-Users-you-app'))).toBe(true) + }) + + it('falls back to CLAUDE_CONFIG_DIR when CLAUDE_CONFIG_DIRS is set but empty', async () => { + const single = await makeConfigDir('claude-fallback', ['-Users-you-app']) + process.env['CLAUDE_CONFIG_DIR'] = single + process.env['CLAUDE_CONFIG_DIRS'] = '' + + const sources = await claude.discoverSessions() + expect(sources.some(s => s.path === join(single, 'projects', '-Users-you-app'))).toBe(true) + }) + + it('skips entries that point at a file rather than a directory', async () => { + const real = await makeConfigDir('claude-real', ['-Users-you-app']) + const filePath = join(tmpRoot, 'not-a-dir.txt') + await writeFile(filePath, 'this is not a config dir') + process.env['CLAUDE_CONFIG_DIRS'] = [real, filePath].join(pathDelimiter) + + const sources = await claude.discoverSessions() + expect(sources.some(s => s.path === join(real, 'projects', '-Users-you-app'))).toBe(true) + expect(sources.every(s => !s.path.startsWith(filePath))).toBe(true) + }) +}) + +describe('claude parser — multi-dir aggregation (issue #208 option 1)', () => { + it('merges sessions from two config dirs into a single ProjectSummary when the canonical cwd matches', async () => { + const work = await makeConfigDir('claude-work', []) + const personal = await makeConfigDir('claude-personal', []) + process.env['CLAUDE_CONFIG_DIRS'] = [work, personal].join(pathDelimiter) + + // Both accounts touch the same real project path. Same cwd -> same merge key. + const slug = '-Users-you-shared-app' + const cwd = '/Users/you/shared-app' + await writeSession(work, slug, 'sess-work', [ + summaryLine('sess-work', cwd), + userLine('u1', 'sess-work', cwd, 'hi from work'), + assistantLine('a1', 'u1', 'sess-work', cwd), + ]) + await writeSession(personal, slug, 'sess-personal', [ + summaryLine('sess-personal', cwd), + userLine('u2', 'sess-personal', cwd, 'hi from personal'), + assistantLine('a2', 'u2', 'sess-personal', cwd), + ]) + + const projects = await parseAllSessions(undefined, 'claude') + const matches = projects.filter(p => p.project === slug) + expect(matches).toHaveLength(1) + expect(matches[0]!.totalApiCalls).toBe(2) + // Two sessions, one from each dir, both rolled up. + expect(matches[0]!.sessions.map(s => s.sessionId).sort()).toEqual(['sess-personal', 'sess-work']) + // No `account` or `accountPath` field should appear on the ProjectSummary + // — option 1 explicitly avoids attribution. + expect((matches[0]! as Record)['account']).toBeUndefined() + expect((matches[0]! as Record)['accountPath']).toBeUndefined() + }) + + // Documents the option-1 behavior at the project-merge layer: the final + // mergedMap in parseAllSessions keys by the sanitized project slug. If two + // dirs both contain a slug `-Users-you-app/` whose underlying canonical + // cwds differ, the slug-level merge collapses them into one row. In real + // Claude usage this is unreachable because Claude derives the slug from + // the cwd, so different cwds always produce different slugs. The test + // pins the behavior so a future refactor cannot quietly swap to cwd-aware + // merging without explicitly opting in. + it('merges by sanitized slug even when sessions carry different canonical cwds', async () => { + const work = await makeConfigDir('claude-work', []) + const personal = await makeConfigDir('claude-personal', []) + process.env['CLAUDE_CONFIG_DIRS'] = [work, personal].join(pathDelimiter) + + const slug = '-Users-you-app' + await writeSession(work, slug, 'sess-work', [ + summaryLine('sess-work', '/Users/you/work-app'), + userLine('u1', 'sess-work', '/Users/you/work-app', 'work'), + assistantLine('a1', 'u1', 'sess-work', '/Users/you/work-app'), + ]) + await writeSession(personal, slug, 'sess-personal', [ + summaryLine('sess-personal', '/Users/you/personal-app'), + userLine('u2', 'sess-personal', '/Users/you/personal-app', 'personal'), + assistantLine('a2', 'u2', 'sess-personal', '/Users/you/personal-app'), + ]) + + const projects = await parseAllSessions(undefined, 'claude') + const matches = projects.filter(p => p.project === slug) + expect(matches).toHaveLength(1) + expect(matches[0]!.totalApiCalls).toBe(2) + }) +}) diff --git a/tests/providers/cline.test.ts b/tests/providers/cline.test.ts new file mode 100644 index 0000000..d739b96 --- /dev/null +++ b/tests/providers/cline.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm, utimes } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { cline, createClineProvider } from '../../src/providers/cline.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +async function writeTask(baseDir: string, taskId: string, opts?: { + tokensIn?: number + tokensOut?: number + model?: string + userMessage?: string + cost?: number +}): Promise { + const taskDir = join(baseDir, 'tasks', taskId) + await mkdir(taskDir, { recursive: true }) + + const messages: unknown[] = [] + if (opts?.userMessage) { + messages.push({ type: 'say', say: 'user_feedback', text: opts.userMessage, ts: 1700000000000 }) + } + const usage: Record = { + tokensIn: opts?.tokensIn ?? 100, + tokensOut: opts?.tokensOut ?? 50, + } + if (opts?.cost !== undefined) usage.cost = opts.cost + messages.push({ type: 'say', say: 'api_req_started', text: JSON.stringify(usage), ts: 1700000001000 }) + + const modelTag = opts?.model ? `${opts.model}` : '' + const history = [ + { role: 'user', content: [{ type: 'text', text: `hello\n\n${modelTag}\n` }] }, + ] + + await writeFile(join(taskDir, 'ui_messages.json'), JSON.stringify(messages)) + await writeFile(join(taskDir, 'api_conversation_history.json'), JSON.stringify(history)) + + return taskDir +} + +describe('cline provider - discovery', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'cline-test-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('discovers Cline tasks from VS Code globalStorage and home data roots', async () => { + const vscodeDir = join(tmpDir, 'globalStorage') + const homeDataDir = join(tmpDir, 'cline-data') + await writeTask(vscodeDir, 'task-vscode') + await writeTask(homeDataDir, 'task-home') + + const provider = createClineProvider([vscodeDir, homeDataDir]) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(2) + expect(sessions.map(s => s.provider)).toEqual(['cline', 'cline']) + expect(sessions.map(s => s.project)).toEqual(['Cline', 'Cline']) + expect(sessions.map(s => s.path).sort()).toEqual([ + join(homeDataDir, 'tasks', 'task-home'), + join(vscodeDir, 'tasks', 'task-vscode'), + ].sort()) + }) + + it('deduplicates the same task id across roots by keeping the newest task directory', async () => { + const vscodeDir = join(tmpDir, 'globalStorage') + const homeDataDir = join(tmpDir, 'cline-data') + const oldTask = await writeTask(vscodeDir, 'task-same') + const newTask = await writeTask(homeDataDir, 'task-same') + await utimes(join(oldTask, 'ui_messages.json'), new Date('2026-01-01T00:00:00Z'), new Date('2026-01-01T00:00:00Z')) + await utimes(join(newTask, 'ui_messages.json'), new Date('2026-02-01T00:00:00Z'), new Date('2026-02-01T00:00:00Z')) + + const provider = createClineProvider([vscodeDir, homeDataDir]) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.path).toBe(newTask) + }) + + it('skips task directories without ui_messages.json', async () => { + const vscodeDir = join(tmpDir, 'globalStorage') + await mkdir(join(vscodeDir, 'tasks', 'task-no-ui'), { recursive: true }) + + const provider = createClineProvider(vscodeDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(0) + }) +}) + +describe('cline provider - parsing', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'cline-test-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('parses Cline usage with cline provider identity', async () => { + const taskDir = await writeTask(tmpDir, 'task-parse', { + tokensIn: 200, + tokensOut: 100, + model: 'anthropic/claude-sonnet-4-5', + userMessage: 'build the feature', + cost: 0.07, + }) + + const source = { path: taskDir, project: 'Cline', provider: 'cline' } + const calls: ParsedProviderCall[] = [] + for await (const call of cline.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.provider).toBe('cline') + expect(calls[0]!.model).toBe('claude-sonnet-4-5') + expect(calls[0]!.inputTokens).toBe(200) + expect(calls[0]!.outputTokens).toBe(100) + expect(calls[0]!.costUSD).toBe(0.07) + expect(calls[0]!.userMessage).toBe('build the feature') + expect(calls[0]!.deduplicationKey).toMatch(/^cline:task-parse:/) + }) +}) + +describe('cline provider - metadata', () => { + it('has correct name and displayName', () => { + expect(cline.name).toBe('cline') + expect(cline.displayName).toBe('Cline') + }) + + it('passes through model and tool display names', () => { + expect(cline.modelDisplayName('claude-sonnet-4-5')).toBe('claude-sonnet-4-5') + expect(cline.toolDisplayName('read_file')).toBe('read_file') + }) +}) diff --git a/tests/providers/codex.test.ts b/tests/providers/codex.test.ts index 9208811..223fe04 100644 --- a/tests/providers/codex.test.ts +++ b/tests/providers/codex.test.ts @@ -123,6 +123,108 @@ describe('codex provider - session discovery', () => { expect(sessions).toHaveLength(1) }) + it('accepts session_meta lines larger than 16 KB (Codex CLI 0.128+)', async () => { + // Codex CLI 0.128+ embeds the full base_instructions / system prompt in the + // first session_meta line, often pushing it past 20 KB. Regression guard + // against a fixed-size buffer in readFirstLine. + const bigPayload = JSON.stringify({ + type: 'session_meta', + timestamp: '2026-05-02T00:00:00Z', + payload: { + cwd: '/Users/test/big', + originator: 'codex-tui', + session_id: 'sess-big', + model: 'gpt-5.5', + base_instructions: { text: 'x'.repeat(40_000) }, + }, + }) + await writeSession(tmpDir, '2026-05-02', 'rollout-big.jsonl', [ + bigPayload, + tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }), + ]) + + const provider = createCodexProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(1) + expect(sessions[0]!.path).toContain('rollout-big.jsonl') + // Confirm the large meta line was actually parsed (cwd extracted), + // not just that some path was registered. + expect(sessions[0]!.project).toBe('Users-test-big') + }) + + it('handles a session_meta line without trailing newline', async () => { + const [year, month, day] = '2026-05-02'.split('-') + const sessionDir = join(tmpDir, 'sessions', year!, month!, day!) + await mkdir(sessionDir, { recursive: true }) + // Write a single session_meta line, deliberately without a trailing \n. + await writeFile( + join(sessionDir, 'rollout-no-nl.jsonl'), + JSON.stringify({ + type: 'session_meta', + timestamp: '2026-05-02T00:00:00Z', + payload: { + cwd: '/Users/test/nonl', + originator: 'codex-tui', + session_id: 'sess-nonl', + model: 'gpt-5.5', + }, + }), + ) + const provider = createCodexProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(1) + expect(sessions[0]!.project).toBe('Users-test-nonl') + }) + + it('handles a session_meta line that spans multiple stream chunks', async () => { + // createReadStream defaults to a 64 KiB highWaterMark, so a >64 KiB first + // line forces readline to assemble the line across chunk boundaries. + const bigPayload = JSON.stringify({ + type: 'session_meta', + timestamp: '2026-05-02T00:00:00Z', + payload: { + cwd: '/Users/test/multichunk', + originator: 'codex-tui', + session_id: 'sess-multichunk', + model: 'gpt-5.5', + base_instructions: { text: 'y'.repeat(120_000) }, + }, + }) + await writeSession(tmpDir, '2026-05-02', 'rollout-multichunk.jsonl', [ + bigPayload, + tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }), + ]) + const provider = createCodexProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(1) + expect(sessions[0]!.project).toBe('Users-test-multichunk') + }) + + it('rejects truncated/torn first-line writes without throwing', async () => { + // Simulate a partial write where Codex started the session_meta object + // but hasn't flushed the rest yet (no closing brace, no newline). + const [year, month, day] = '2026-05-02'.split('-') + const sessionDir = join(tmpDir, 'sessions', year!, month!, day!) + await mkdir(sessionDir, { recursive: true }) + await writeFile( + join(sessionDir, 'rollout-torn.jsonl'), + '{"type":"session_meta","timestamp":"2026-05-02T00:00:00Z","payload":{"cwd":"/x","originator":"codex-tui","session_id":"s","model":"gpt', + ) + const provider = createCodexProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(0) + }) + + it('returns no sessions for an empty rollout file', async () => { + const [year, month, day] = '2026-05-02'.split('-') + const sessionDir = join(tmpDir, 'sessions', year!, month!, day!) + await mkdir(sessionDir, { recursive: true }) + await writeFile(join(sessionDir, 'rollout-empty.jsonl'), '') + const provider = createCodexProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(0) + }) + it('skips files without codex session_meta', async () => { const [year, month, day] = '2026-04-14'.split('-') const sessionDir = join(tmpDir, 'sessions', year!, month!, day!) @@ -208,4 +310,65 @@ describe('codex provider - JSONL parsing', () => { expect(calls[0]!.inputTokens).toBe(500) expect(calls[1]!.inputTokens).toBe(300) }) + + it('does not drop the first event when total_token_usage is omitted (cumulativeTotal=0)', async () => { + // Regression for the prevCumulativeTotal-initialized-to-0 bug. Sessions + // that emit only last_token_usage (no total_token_usage) report + // cumulativeTotal=0 on every event. With a 0-initialized prev, the first + // event matched the dedup guard and was silently dropped, losing the + // session's opening turn. The null sentinel fixes this. + const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-zero-total.jsonl', [ + sessionMeta(), + tokenCount({ + timestamp: '2026-04-14T10:01:00Z', + last: { input: 500, output: 200 }, + // No `total` — info.total_token_usage will be undefined. + }), + tokenCount({ + timestamp: '2026-04-14T10:01:01Z', + last: { input: 100, output: 50 }, + }), + ]) + + const provider = createCodexProvider(tmpDir) + const source = { path: filePath, project: 'test', provider: 'codex' } + const parser = provider.createSessionParser(source, new Set()) + const calls: ParsedProviderCall[] = [] + for await (const call of parser.parse()) { + calls.push(call) + } + + // Both events should produce calls — the first with input=500, second + // with input=100. With the buggy 0-init, only the second would survive + // (or neither, depending on equality timing). + expect(calls.length).toBeGreaterThanOrEqual(1) + expect(calls[0]!.inputTokens).toBe(500) + }) + + it('still dedups consecutive zero-cumulative duplicates', async () => { + // The other half of the regression: two consecutive events with the + // same cumulativeTotal (here both 0 because total_token_usage is + // omitted) and identical last_token_usage must NOT both ingest. The + // second is a duplicate. + const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-zero-dup.jsonl', [ + sessionMeta(), + tokenCount({ + timestamp: '2026-04-14T10:01:00Z', + last: { input: 500, output: 200 }, + }), + tokenCount({ + timestamp: '2026-04-14T10:01:01Z', + last: { input: 500, output: 200 }, + }), + ]) + + const provider = createCodexProvider(tmpDir) + const source = { path: filePath, project: 'test', provider: 'codex' } + const parser = provider.createSessionParser(source, new Set()) + const calls: ParsedProviderCall[] = [] + for await (const call of parser.parse()) { + calls.push(call) + } + expect(calls).toHaveLength(1) + }) }) diff --git a/tests/providers/copilot.test.ts b/tests/providers/copilot.test.ts index f1bc8fa..16cb6fd 100644 --- a/tests/providers/copilot.test.ts +++ b/tests/providers/copilot.test.ts @@ -126,6 +126,47 @@ describe('copilot provider - JSONL parsing', () => { expect(calls[0]!.tools).toEqual(['Bash', 'Read', 'Edit']) }) + it('does not crash on malformed toolRequests (string / null / missing)', async () => { + // Regression guard: a corrupt session previously aborted the whole file's + // parse loop because .map was called on a non-array. The fix coerces any + // non-array shape (string, null, missing) to []. We mix one corrupt event + // between two healthy events and assert both healthy events still parse. + const corruptToolRequestsString = JSON.stringify({ + type: 'assistant.message', + timestamp: '2026-04-15T10:00:15Z', + data: { messageId: 'corrupt-string', outputTokens: 50, toolRequests: 'not an array' }, + }) + const corruptToolRequestsNull = JSON.stringify({ + type: 'assistant.message', + timestamp: '2026-04-15T10:00:16Z', + data: { messageId: 'corrupt-null', outputTokens: 50, toolRequests: null }, + }) + const eventsPath = await createSessionDir('sess-corrupt', [ + modelChange('gpt-4.1'), + assistantMessage({ messageId: 'msg-before', outputTokens: 100 }), + corruptToolRequestsString, + corruptToolRequestsNull, + assistantMessage({ messageId: 'msg-after', outputTokens: 200 }), + ]) + + const source = { path: eventsPath, project: 'test', provider: 'copilot' } + const calls: ParsedProviderCall[] = [] + for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call) + + // The healthy messages BEFORE and AFTER the corrupt events both parse — + // proving that the corrupt event no longer aborts the per-file parse loop. + // Pre-fix, .map on a non-array threw and we'd see < 4 calls. + expect(calls).toHaveLength(4) + expect(calls.find(c => c.outputTokens === 100)).toBeDefined() // msg-before + expect(calls.find(c => c.outputTokens === 200)).toBeDefined() // msg-after + // Corrupt events produce calls with empty tools, not crashes. + const corruptCalls = calls.filter(c => c.outputTokens === 50) + expect(corruptCalls.length).toBe(2) + for (const c of corruptCalls) { + expect(c.tools).toEqual([]) + } + }) + it('skips assistant messages with zero outputTokens', async () => { const eventsPath = await createSessionDir('sess-004', [ modelChange('gpt-4.1'), diff --git a/tests/providers/crush.test.ts b/tests/providers/crush.test.ts new file mode 100644 index 0000000..4835f6f --- /dev/null +++ b/tests/providers/crush.test.ts @@ -0,0 +1,324 @@ +import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises' +import { mkdirSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { createRequire } from 'node:module' + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { isSqliteAvailable } from '../../src/sqlite.js' +import { createCrushProvider } from '../../src/providers/crush.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +const requireForTest = createRequire(import.meta.url) + +type TestDb = { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +} + +let tmpRoot: string +let originalEnv: string | undefined + +beforeEach(async () => { + tmpRoot = await mkdtemp(join(tmpdir(), 'crush-test-')) + originalEnv = process.env['CRUSH_GLOBAL_DATA'] +}) + +afterEach(async () => { + if (originalEnv === undefined) { + delete process.env['CRUSH_GLOBAL_DATA'] + } else { + process.env['CRUSH_GLOBAL_DATA'] = originalEnv + } + await rm(tmpRoot, { recursive: true, force: true }) +}) + +// CREATE TABLE statements taken verbatim from charmbracelet/crush@v0.66.1 +// internal/db/migrations/20250424200609_initial.sql, with subsequent ALTERs +// folded in (summary_message_id, provider on messages, is_summary_message, +// todos on sessions). Keeping the literal upstream column ordering and +// constraints makes drift easy to spot. +function createCrushDb(dir: string): string { + mkdirSync(dir, { recursive: true }) + const dbPath = join(dir, 'crush.db') + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + db.exec(` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + parent_session_id TEXT, + title TEXT NOT NULL, + message_count INTEGER NOT NULL DEFAULT 0 CHECK (message_count >= 0), + prompt_tokens INTEGER NOT NULL DEFAULT 0 CHECK (prompt_tokens >= 0), + completion_tokens INTEGER NOT NULL DEFAULT 0 CHECK (completion_tokens >= 0), + cost REAL NOT NULL DEFAULT 0.0 CHECK (cost >= 0.0), + updated_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + summary_message_id TEXT, + todos TEXT + ) + `) + db.exec(` + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + parts TEXT NOT NULL DEFAULT '[]', + model TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + finished_at INTEGER, + provider TEXT, + is_summary_message INTEGER DEFAULT 0 NOT NULL, + FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE + ) + `) + db.close() + return dbPath +} + +function withTestDb(dbPath: string, fn: (db: TestDb) => void): void { + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + try { + fn(db) + } finally { + db.close() + } +} + +type SessionFixture = { + id: string + parentId?: string | null + promptTokens?: number + completionTokens?: number + cost?: number + createdAt?: number + updatedAt?: number + messageCount?: number +} + +function insertSession(db: TestDb, s: SessionFixture): void { + db.prepare(` + INSERT INTO sessions (id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + s.id, + s.parentId ?? null, + 'test session', + s.messageCount ?? 0, + s.promptTokens ?? 0, + s.completionTokens ?? 0, + s.cost ?? 0, + s.createdAt ?? 1_700_000_000, + s.updatedAt ?? s.createdAt ?? 1_700_000_000, + ) +} + +function insertMessage(db: TestDb, sessionId: string, role: string, model: string | null, id: string): void { + db.prepare(` + INSERT INTO messages (id, session_id, role, parts, model, created_at, updated_at) + VALUES (?, ?, ?, '[]', ?, ?, ?) + `).run(id, sessionId, role, model, 1_700_000_000, 1_700_000_000) +} + +async function writeRegistry(globalDataDir: string, entries: Record): Promise { + await mkdir(globalDataDir, { recursive: true }) + await writeFile(join(globalDataDir, 'projects.json'), JSON.stringify(entries)) +} + +async function collect(parser: { parse(): AsyncGenerator }): Promise { + const out: ParsedProviderCall[] = [] + for await (const call of parser.parse()) out.push(call) + return out +} + +describe('crush provider', () => { + it('reports correct identity', () => { + const p = createCrushProvider() + expect(p.name).toBe('crush') + expect(p.displayName).toBe('Crush') + expect(p.modelDisplayName('gpt-5')).toBe('gpt-5') + }) + + it('returns no sessions when registry is missing', async () => { + const globalData = join(tmpRoot, 'crush-global') + process.env['CRUSH_GLOBAL_DATA'] = globalData + const p = createCrushProvider() + const sessions = await p.discoverSessions() + expect(sessions).toEqual([]) + }) + + it('returns no sessions when registry is malformed JSON', async () => { + const globalData = join(tmpRoot, 'crush-global') + await mkdir(globalData, { recursive: true }) + await writeFile(join(globalData, 'projects.json'), '{ not json') + process.env['CRUSH_GLOBAL_DATA'] = globalData + const p = createCrushProvider() + const sessions = await p.discoverSessions() + expect(sessions).toEqual([]) + }) + + it('discovers root sessions with cost or tokens, skipping zero rows and child sessions', async () => { + if (!isSqliteAvailable()) return + + const projectDir = join(tmpRoot, 'project-a') + const dbPath = createCrushDb(join(projectDir, '.crush')) + withTestDb(dbPath, db => { + insertSession(db, { id: 'root-with-cost', cost: 0.42, promptTokens: 100, completionTokens: 50, createdAt: 1_700_000_001 }) + insertSession(db, { id: 'root-no-spend', cost: 0, promptTokens: 0, completionTokens: 0, createdAt: 1_700_000_002 }) + insertSession(db, { id: 'child', parentId: 'root-with-cost', cost: 0.01, createdAt: 1_700_000_003 }) + insertSession(db, { id: 'root-tokens-only', cost: 0, promptTokens: 5, completionTokens: 5, createdAt: 1_700_000_004 }) + }) + + const globalData = join(tmpRoot, 'crush-global') + await writeRegistry(globalData, { + 'proj-a': { path: projectDir, data_dir: '.crush' }, + }) + process.env['CRUSH_GLOBAL_DATA'] = globalData + + const p = createCrushProvider() + const sessions = await p.discoverSessions() + const ids = sessions.map(s => s.path.split(':').pop()).sort() + expect(ids).toEqual(['root-tokens-only', 'root-with-cost']) + expect(sessions.every(s => s.provider === 'crush')).toBe(true) + }) + + it('parses a session into a ParsedProviderCall with real tokens, cost, and dominant model', async () => { + if (!isSqliteAvailable()) return + + const projectDir = join(tmpRoot, 'project-b') + const dbPath = createCrushDb(join(projectDir, '.crush')) + withTestDb(dbPath, db => { + insertSession(db, { + id: 'sess-1', + promptTokens: 1234, + completionTokens: 567, + cost: 0.0789, + createdAt: 1_700_000_010, + updatedAt: 1_700_000_999, + }) + // Most-used model wins. + insertMessage(db, 'sess-1', 'assistant', 'claude-sonnet-4-6', 'm1') + insertMessage(db, 'sess-1', 'assistant', 'claude-sonnet-4-6', 'm2') + insertMessage(db, 'sess-1', 'assistant', 'gpt-5', 'm3') + }) + + const globalData = join(tmpRoot, 'crush-global') + await writeRegistry(globalData, { + 'proj-b': { path: projectDir, data_dir: '.crush' }, + }) + process.env['CRUSH_GLOBAL_DATA'] = globalData + + const p = createCrushProvider() + const sources = await p.discoverSessions() + expect(sources).toHaveLength(1) + + const calls = await collect(p.createSessionParser(sources[0]!, new Set())) + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.provider).toBe('crush') + expect(call.model).toBe('claude-sonnet-4-6') + expect(call.inputTokens).toBe(1234) + expect(call.outputTokens).toBe(567) + expect(call.costUSD).toBeCloseTo(0.0789, 6) + expect(call.sessionId).toBe('sess-1') + expect(call.deduplicationKey).toBe('crush:sess-1') + // Crush stores epoch seconds; 1_700_000_999 sec → 2023-11-14T22:29:59.000Z. + expect(call.timestamp).toBe(new Date(1_700_000_999 * 1000).toISOString()) + }) + + it('falls back to "unknown" when no message has a model', async () => { + if (!isSqliteAvailable()) return + + const projectDir = join(tmpRoot, 'project-c') + const dbPath = createCrushDb(join(projectDir, '.crush')) + withTestDb(dbPath, db => { + insertSession(db, { id: 'sess-no-model', cost: 0.05, promptTokens: 10, completionTokens: 5, createdAt: 1_700_000_500 }) + insertMessage(db, 'sess-no-model', 'user', null, 'm1') + insertMessage(db, 'sess-no-model', 'assistant', null, 'm2') + }) + + const globalData = join(tmpRoot, 'crush-global') + await writeRegistry(globalData, { + 'proj-c': { path: projectDir, data_dir: '.crush' }, + }) + process.env['CRUSH_GLOBAL_DATA'] = globalData + + const p = createCrushProvider() + const sources = await p.discoverSessions() + const calls = await collect(p.createSessionParser(sources[0]!, new Set())) + expect(calls[0]!.model).toBe('unknown') + }) + + it('respects seenKeys for deduplication', async () => { + if (!isSqliteAvailable()) return + + const projectDir = join(tmpRoot, 'project-d') + const dbPath = createCrushDb(join(projectDir, '.crush')) + withTestDb(dbPath, db => { + insertSession(db, { id: 'sess-dup', cost: 0.10, promptTokens: 100, completionTokens: 50, createdAt: 1_700_000_700 }) + }) + + const globalData = join(tmpRoot, 'crush-global') + await writeRegistry(globalData, { + 'proj-d': { path: projectDir, data_dir: '.crush' }, + }) + process.env['CRUSH_GLOBAL_DATA'] = globalData + + const p = createCrushProvider() + const sources = await p.discoverSessions() + const seen = new Set() + const first = await collect(p.createSessionParser(sources[0]!, seen)) + expect(first).toHaveLength(1) + + const second = await collect(p.createSessionParser(sources[0]!, seen)) + expect(second).toHaveLength(0) + }) + + it('accepts an array-shaped projects.json (legacy format)', async () => { + if (!isSqliteAvailable()) return + + const projectDir = join(tmpRoot, 'project-e') + const dbPath = createCrushDb(join(projectDir, '.crush')) + withTestDb(dbPath, db => { + insertSession(db, { id: 'sess-arr', cost: 0.01, promptTokens: 1, completionTokens: 1, createdAt: 1_700_000_800 }) + }) + + const globalData = join(tmpRoot, 'crush-global') + await mkdir(globalData, { recursive: true }) + await writeFile( + join(globalData, 'projects.json'), + JSON.stringify([{ path: projectDir, data_dir: '.crush' }]), + ) + process.env['CRUSH_GLOBAL_DATA'] = globalData + + const p = createCrushProvider() + const sources = await p.discoverSessions() + expect(sources).toHaveLength(1) + }) + + it('ignores registry entries whose db is missing', async () => { + if (!isSqliteAvailable()) return + + const globalData = join(tmpRoot, 'crush-global') + await writeRegistry(globalData, { + 'ghost': { path: join(tmpRoot, 'does-not-exist'), data_dir: '.crush' }, + }) + process.env['CRUSH_GLOBAL_DATA'] = globalData + + const p = createCrushProvider() + const sources = await p.discoverSessions() + expect(sources).toEqual([]) + }) + + it('is registered via getAllProviders', async () => { + if (!isSqliteAvailable()) return + const { getAllProviders } = await import('../../src/providers/index.js') + const providers = await getAllProviders() + const found = providers.find(p => p.name === 'crush') + expect(found).toBeDefined() + expect(found!.displayName).toBe('Crush') + }) +}) diff --git a/tests/providers/cursor-bubble-dedup.test.ts b/tests/providers/cursor-bubble-dedup.test.ts new file mode 100644 index 0000000..a164eeb --- /dev/null +++ b/tests/providers/cursor-bubble-dedup.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' + +import { isSqliteAvailable, openDatabase } from '../../src/sqlite.js' +import { getAllProviders } from '../../src/providers/index.js' +import type { Provider, ParsedProviderCall } from '../../src/providers/types.js' + +/// Pinned regression for the v3 bubble-dedup fix. The previous (v2) code used +/// the bubble row's mutable token counts as part of the deduplication key, so +/// the same bubble was counted twice once Cursor wrote the streaming-complete +/// final token totals on top of the streaming-in-progress row. v3 switched to +/// the SQLite primary `key` column (which is the stable bubbleId:: +/// path) so re-parsing the same DB after token updates produces zero new +/// calls. This test: +/// 1. Builds a tmp SQLite DB with the cursorDiskKV schema and one bubble row +/// with low token counts (the streaming-in-progress shape). +/// 2. Parses it through the cursor provider. Asserts one call. +/// 3. Mutates the row in place to higher token counts (the streaming-complete +/// shape) without changing the SQLite key. +/// 4. Re-parses with the SAME seenKeys set. Asserts zero new calls. +/// If a future refactor brings back token-count-based dedup, the second parse +/// will produce a duplicate call and this test will fail. + +const skipReason = isSqliteAvailable() + ? null + : 'node:sqlite not available — needs Node 22+; skipping' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'cursor-dedup-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +function buildBubbleValue(opts: { + conversationId: string + text: string + inputTokens: number + outputTokens: number + type: 1 | 2 + createdAt?: string +}): string { + return JSON.stringify({ + type: opts.type, + conversationId: opts.conversationId, + text: opts.text, + tokenCount: { + inputTokens: opts.inputTokens, + outputTokens: opts.outputTokens, + }, + createdAt: opts.createdAt ?? new Date().toISOString(), + modelId: 'gpt-5', + capabilityType: 'composer', + }) +} + +async function createCursorTestDb(): Promise { + // Cursor uses a non-extension state DB filename (state.vscdb in the real app); + // any path works for openDatabase as long as we set up the schema and the + // directory layout the parser expects. The parser only checks the DB + // contents — discovery is bypassed because we hand it the path directly. + const dbPath = join(tmpDir, 'state.vscdb') + await writeFile(dbPath, '') + // Use the underlying node:sqlite to create the schema. + // We need cursorDiskKV with key + value columns. + const Module = await import('node:module') + const requireForSqlite = Module.createRequire(import.meta.url) + const { DatabaseSync } = requireForSqlite('node:sqlite') as { + DatabaseSync: new (path: string) => { + exec(sql: string): void + prepare(sql: string): { run(...p: unknown[]): unknown } + close(): void + } + } + const db = new DatabaseSync(dbPath) + db.exec('CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT)') + + // Single assistant bubble (type=2). The parser yields one ParsedProviderCall + // per bubbleId:% row, so a multi-row fixture would muddy the dedup count; + // we keep the test surface minimal — one bubble through one parse, then + // the same bubble again after token mutation. + const bubbleKey = 'bubbleId:abc-123:bubble-xyz' + db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)').run( + bubbleKey, + buildBubbleValue({ + conversationId: 'abc-123', + text: 'def hello(): pass', + inputTokens: 100, + outputTokens: 20, + type: 2, + }) + ) + + db.close() + return dbPath +} + +async function updateAssistantBubbleTokens(dbPath: string, inputTokens: number, outputTokens: number): Promise { + const Module = await import('node:module') + const requireForSqlite = Module.createRequire(import.meta.url) + const { DatabaseSync } = requireForSqlite('node:sqlite') as { + DatabaseSync: new (path: string) => { + prepare(sql: string): { run(...p: unknown[]): unknown } + close(): void + } + } + const db = new DatabaseSync(dbPath) + db.prepare('UPDATE cursorDiskKV SET value = ? WHERE key = ?').run( + buildBubbleValue({ + conversationId: 'abc-123', + text: 'def hello(): pass', + inputTokens, + outputTokens, + type: 2, + }), + 'bubbleId:abc-123:bubble-xyz' + ) + db.close() +} + +async function getCursorProvider(): Promise { + const all = await getAllProviders() + const p = all.find(p => p.name === 'cursor') + if (!p) throw new Error('cursor provider not registered') + return p +} + +describe.skipIf(skipReason !== null)('cursor bubble dedup (regression for v3 fix)', () => { + it('does not double-count when bubble token counts mutate between parses', async () => { + const dbPath = await createCursorTestDb() + const provider = await getCursorProvider() + + // First parse: streaming-in-progress shape. + const seenKeys = new Set() + const source = { path: dbPath, project: 'test-project', provider: 'cursor' } + const firstRunCalls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) { + firstRunCalls.push(call) + } + expect(firstRunCalls.length).toBe(1) + + // Cursor mutates the same bubble row to its final token totals when the + // stream completes. Simulate by updating in place. The SQLite primary + // key stays the same. + await updateAssistantBubbleTokens(dbPath, 250, 80) + + // Second parse with the SAME seenKeys: must yield zero new calls. If the + // dedup key were derived from token counts (the v2 bug), this would + // produce a duplicate. + const secondRunCalls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) { + secondRunCalls.push(call) + } + expect(secondRunCalls.length).toBe(0) + }) + + it('does not yield the same bubble twice within a single parser run', async () => { + const dbPath = await createCursorTestDb() + const provider = await getCursorProvider() + const seenKeys = new Set() + const source = { path: dbPath, project: 'test-project', provider: 'cursor' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) { + calls.push(call) + } + // One bubble in the DB → one call. (The user message row at type=1 is + // not surfaced as a separate ParsedProviderCall; it's threaded into the + // assistant call's userMessage field.) + expect(calls.length).toBe(1) + }) +}) diff --git a/tests/providers/cursor-workspace-breakdown.test.ts b/tests/providers/cursor-workspace-breakdown.test.ts new file mode 100644 index 0000000..8e666b4 --- /dev/null +++ b/tests/providers/cursor-workspace-breakdown.test.ts @@ -0,0 +1,330 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { mkdirSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { createRequire } from 'node:module' + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' + +import { + createCursorProvider, + clearCursorWorkspaceMapCache, +} from '../../src/providers/cursor.js' +import { isSqliteAvailable } from '../../src/sqlite.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +const requireForTest = createRequire(import.meta.url) + +let userDir: string + +beforeEach(async () => { + userDir = await mkdtemp(join(tmpdir(), 'cursor-ws-test-')) + // Layout matches Cursor's: /{globalStorage,workspaceStorage}/. + await mkdir(join(userDir, 'globalStorage'), { recursive: true }) + await mkdir(join(userDir, 'workspaceStorage'), { recursive: true }) + clearCursorWorkspaceMapCache() +}) + +afterEach(async () => { + clearCursorWorkspaceMapCache() + await rm(userDir, { recursive: true, force: true }) +}) + +function globalDbPath(): string { + return join(userDir, 'globalStorage', 'state.vscdb') +} + +/// Builds a global state.vscdb with the cursorDiskKV table and a small set of +/// bubbles for the requested composer ids. Each bubble carries enough fields +/// to satisfy parseBubbles() — created_at, tokenCount, conversationId, type. +function createGlobalDb(composerIds: string[]): string { + const dbPath = globalDbPath() + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + db.exec(`CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value BLOB)`) + // ItemTable is unused by the global parser but creating it mirrors the + // real schema so a stray query against it does not error. + db.exec(`CREATE TABLE ItemTable (key TEXT UNIQUE, value BLOB)`) + + const insert = db.prepare(`INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)`) + const baseTime = Date.now() - 24 * 3600 * 1000 + + for (const composerId of composerIds) { + // Exactly one assistant bubble per composer so the test math is + // "one composer == one call". User bubbles also produce calls in the + // real parser (text-length token estimation), but they are not + // necessary to exercise the workspace routing logic. + const bubbleId = `bubbleId:${composerId}:bubble-${composerId.slice(0, 6)}` + const bubble = { + type: 2, // assistant + conversationId: composerId, + createdAt: new Date(baseTime).toISOString(), + tokenCount: { inputTokens: 100, outputTokens: 50 }, + modelInfo: { modelName: 'claude-4.6-sonnet' }, + text: 'assistant reply for ' + composerId, + codeBlocks: '[]', + } + insert.run(bubbleId, JSON.stringify(bubble)) + } + + db.close() + return dbPath +} + +/// Creates one workspaceStorage// subdir with workspace.json (folder URI) +/// and state.vscdb (composer.composerData listing the supplied composerIds). +function createWorkspaceDir(hash: string, folderUri: string, composerIds: string[]): void { + const dir = join(userDir, 'workspaceStorage', hash) + mkdirSync(dir, { recursive: true }) + + const wsJsonPath = join(dir, 'workspace.json') + // We cannot do a top-level await in a sync helper; the caller writes via + // mkdirSync above and the JSON via Node's sync writeFile shim through the + // require'd 'fs'. Using readFileSync-friendly imports to keep this test + // helper sync. + const fs = requireForTest('fs') as typeof import('fs') + fs.writeFileSync(wsJsonPath, JSON.stringify({ folder: folderUri })) + + const wsDbPath = join(dir, 'state.vscdb') + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(wsDbPath) + db.exec(`CREATE TABLE ItemTable (key TEXT UNIQUE, value BLOB)`) + const composerData = { + allComposers: composerIds.map(id => ({ + composerId: id, + name: 'session-' + id.slice(0, 6), + unifiedMode: 'agent', + })), + } + db.prepare(`INSERT INTO ItemTable (key, value) VALUES (?, ?)`).run( + 'composer.composerData', + JSON.stringify(composerData), + ) + db.close() +} + +async function collect(parser: { parse(): AsyncGenerator }): Promise { + const out: ParsedProviderCall[] = [] + for await (const call of parser.parse()) out.push(call) + return out +} + +describe('cursor provider — per-project breakdown (#196)', () => { + it('emits one source per workspace plus an orphan source', async () => { + if (!isSqliteAvailable()) return + + const dbPath = createGlobalDb([ + 'composer-work-1', + 'composer-work-2', + 'composer-personal-1', + 'composer-orphan-1', + ]) + createWorkspaceDir('hash-work', 'file:///Users/me/work-app', ['composer-work-1', 'composer-work-2']) + createWorkspaceDir('hash-personal', 'file:///Users/me/personal-app', ['composer-personal-1']) + + const provider = createCursorProvider(dbPath) + const sources = await provider.discoverSessions() + + const projects = sources.map(s => s.project).sort() + expect(projects).toContain('-Users-me-work-app') + expect(projects).toContain('-Users-me-personal-app') + // Orphan source is labeled 'cursor' so a user with no workspaces + // sees the same project name as before the breakdown change. + expect(projects).toContain('cursor') + }) + + it('routes calls to the right workspace and excludes others', async () => { + if (!isSqliteAvailable()) return + + const dbPath = createGlobalDb([ + 'composer-work-1', + 'composer-work-2', + 'composer-personal-1', + ]) + createWorkspaceDir('hash-work', 'file:///Users/me/work-app', ['composer-work-1', 'composer-work-2']) + createWorkspaceDir('hash-personal', 'file:///Users/me/personal-app', ['composer-personal-1']) + + const provider = createCursorProvider(dbPath) + const sources = await provider.discoverSessions() + const workSource = sources.find(s => s.project === '-Users-me-work-app')! + const personalSource = sources.find(s => s.project === '-Users-me-personal-app')! + + const workCalls = await collect(provider.createSessionParser(workSource, new Set())) + const personalCalls = await collect(provider.createSessionParser(personalSource, new Set())) + + const workComposerIds = new Set(workCalls.map(c => c.sessionId)) + expect(workComposerIds).toEqual(new Set(['composer-work-1', 'composer-work-2'])) + const personalComposerIds = new Set(personalCalls.map(c => c.sessionId)) + expect(personalComposerIds).toEqual(new Set(['composer-personal-1'])) + }) + + it('orphan source captures composers not registered in any workspace', async () => { + if (!isSqliteAvailable()) return + + const dbPath = createGlobalDb([ + 'composer-mapped', + 'composer-orphan-a', + 'composer-orphan-b', + ]) + createWorkspaceDir('hash-only', 'file:///Users/me/only-app', ['composer-mapped']) + + const provider = createCursorProvider(dbPath) + const sources = await provider.discoverSessions() + const orphanSource = sources.find(s => s.project === 'cursor')! + + const orphanCalls = await collect(provider.createSessionParser(orphanSource, new Set())) + const ids = new Set(orphanCalls.map(c => c.sessionId)) + expect(ids).toEqual(new Set(['composer-orphan-a', 'composer-orphan-b'])) + }) + + it('totals across all sources equal totals from the legacy single-source behavior', async () => { + if (!isSqliteAvailable()) return + + const dbPath = createGlobalDb([ + 'composer-work-1', + 'composer-personal-1', + 'composer-orphan-1', + ]) + createWorkspaceDir('hash-work', 'file:///Users/me/work-app', ['composer-work-1']) + createWorkspaceDir('hash-personal', 'file:///Users/me/personal-app', ['composer-personal-1']) + + const provider = createCursorProvider(dbPath) + const sources = await provider.discoverSessions() + + const seen = new Set() + let totalCalls = 0 + let totalCost = 0 + for (const source of sources) { + const calls = await collect(provider.createSessionParser(source, seen)) + totalCalls += calls.length + for (const call of calls) totalCost += call.costUSD + } + // Three composers, one assistant call each => three calls overall. + expect(totalCalls).toBe(3) + expect(totalCost).toBeGreaterThan(0) + }) + + it('emits a single `cursor` source (legacy-equivalent) when no workspace mapping exists', async () => { + if (!isSqliteAvailable()) return + + // No createWorkspaceDir calls -> workspaceStorage exists but is empty. + const dbPath = createGlobalDb(['composer-1', 'composer-2']) + + const provider = createCursorProvider(dbPath) + const sources = await provider.discoverSessions() + expect(sources).toHaveLength(1) + expect(sources[0]!.project).toBe('cursor') + + const calls = await collect(provider.createSessionParser(sources[0]!, new Set())) + // All composers fall through to the orphan/catch-all source, matching + // the pre-PR behavior where every Cursor session showed under one row. + const ids = new Set(calls.map(c => c.sessionId)) + expect(ids).toEqual(new Set(['composer-1', 'composer-2'])) + }) + + it('handles multi-root workspaces (workspace.json without folder) by skipping them', async () => { + if (!isSqliteAvailable()) return + + const dbPath = createGlobalDb(['composer-multi']) + // Multi-root workspace: workspace.json carries `configuration` not `folder`. + const dir = join(userDir, 'workspaceStorage', 'hash-multi') + mkdirSync(dir, { recursive: true }) + await writeFile( + join(dir, 'workspace.json'), + JSON.stringify({ configuration: 'file:///path/to/.code-workspace' }), + ) + // No state.vscdb either — multi-root composer never registers. + + const provider = createCursorProvider(dbPath) + const sources = await provider.discoverSessions() + // Multi-root produces no workspace mapping; only the orphan source + // (labeled 'cursor') remains, and it captures the multi-root composer. + const projects = sources.map(s => s.project) + expect(projects).toEqual(['cursor']) + const calls = await collect(provider.createSessionParser(sources[0]!, new Set())) + expect(calls.map(c => c.sessionId)).toEqual(['composer-multi']) + }) + + it('sanitizes vscode-remote URIs into a slug', async () => { + if (!isSqliteAvailable()) return + + const dbPath = createGlobalDb(['composer-remote']) + createWorkspaceDir( + 'hash-remote', + 'vscode-remote://wsl+Ubuntu/home/me/proj', + ['composer-remote'], + ) + + const provider = createCursorProvider(dbPath) + const sources = await provider.discoverSessions() + const project = sources.find(s => s.project !== 'cursor')!.project + // file:// would yield "-Users-me-proj"; remote URIs get the scheme rewritten. + expect(project).toMatch(/wsl-Ubuntu/) + expect(project).toContain('home') + expect(project).toContain('proj') + }) + + it('drops sub-composer rows whose composer id is not a UUID', async () => { + if (!isSqliteAvailable()) return + + const dbPath = globalDbPath() + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + db.exec(`CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value BLOB)`) + db.exec(`CREATE TABLE ItemTable (key TEXT UNIQUE, value BLOB)`) + const insert = db.prepare(`INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)`) + + // One real composer with one bubble. Real composer ids are UUIDs. + const realComposerId = 'cccc1111-2222-3333-4444-555566667777' + insert.run(`bubbleId:${realComposerId}:bubble-real`, JSON.stringify({ + type: 2, + conversationId: realComposerId, + createdAt: new Date().toISOString(), + tokenCount: { inputTokens: 100, outputTokens: 50 }, + modelInfo: { modelName: 'claude-4.6-sonnet' }, + text: 'real', + codeBlocks: '[]', + })) + // A sub-composer row mirroring the real Cursor shape: the composer + // segment has an embedded newline and is not UUID-shaped. Must be + // dropped, not surfaced as its own session. + insert.run(`bubbleId:task-call_xxx\nfc_yyy:bubble-sub`, JSON.stringify({ + type: 2, + conversationId: '', + createdAt: new Date().toISOString(), + tokenCount: { inputTokens: 10, outputTokens: 5 }, + modelInfo: { modelName: 'claude-4.6-sonnet' }, + text: 'sub', + codeBlocks: '[]', + })) + db.close() + + createWorkspaceDir('hash-only', 'file:///Users/me/only', [realComposerId]) + + const provider = createCursorProvider(dbPath) + const sources = await provider.discoverSessions() + const seen = new Set() + let allCalls = 0 + for (const source of sources) { + const calls = await collect(provider.createSessionParser(source, seen)) + allCalls += calls.length + } + // One real composer -> one call. Sub-composer dropped. Total: 1. + expect(allCalls).toBe(1) + }) + + it('remains backwards-compatible when given a legacy bare DB path', async () => { + if (!isSqliteAvailable()) return + + const dbPath = createGlobalDb(['composer-legacy-1', 'composer-legacy-2']) + createWorkspaceDir('hash-legacy', 'file:///Users/me/legacy', ['composer-legacy-1']) + + const provider = createCursorProvider(dbPath) + // Hand-construct a legacy SessionSource (no workspace tag) and verify + // it still yields every call regardless of workspace mapping. + const legacySource = { path: dbPath, project: 'cursor', provider: 'cursor' } + const calls = await collect(provider.createSessionParser(legacySource, new Set())) + const ids = new Set(calls.map(c => c.sessionId)) + expect(ids).toEqual(new Set(['composer-legacy-1', 'composer-legacy-2'])) + }) +}) diff --git a/tests/providers/ibm-bob.test.ts b/tests/providers/ibm-bob.test.ts new file mode 100644 index 0000000..d61f92e --- /dev/null +++ b/tests/providers/ibm-bob.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { ibmBob, createIBMBobProvider } from '../../src/providers/ibm-bob.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +function makeUiMessages(opts: { + tokensIn?: number + tokensOut?: number + cacheReads?: number + cacheWrites?: number + cost?: number + userMessage?: string + ts?: number +}): string { + const messages: unknown[] = [] + + if (opts.userMessage) { + messages.push({ type: 'say', say: 'user_feedback', text: opts.userMessage, ts: 1_700_000_000_000 }) + } + + const apiData: Record = { + tokensIn: opts.tokensIn ?? 100, + tokensOut: opts.tokensOut ?? 50, + cacheReads: opts.cacheReads ?? 0, + cacheWrites: opts.cacheWrites ?? 0, + } + if (opts.cost !== undefined) apiData.cost = opts.cost + + messages.push({ + type: 'say', + say: 'api_req_started', + text: JSON.stringify(apiData), + ts: opts.ts ?? 1_700_000_001_000, + }) + + return JSON.stringify(messages) +} + +function makeApiHistory(model?: string): string { + const modelTag = model ? `${model}` : '' + return JSON.stringify([ + { role: 'user', content: [{ type: 'text', text: `hello\n\n${modelTag}\n` }] }, + { role: 'assistant', content: [{ type: 'text', text: 'response' }] }, + ]) +} + +describe('ibm-bob provider - discovery and parsing', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'ibm-bob-test-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('discovers IBM Bob task directories with ui_messages.json', async () => { + const task1 = join(tmpDir, 'tasks', 'task-a') + const task2 = join(tmpDir, 'tasks', 'task-b') + await mkdir(task1, { recursive: true }) + await mkdir(task2, { recursive: true }) + await writeFile(join(task1, 'ui_messages.json'), '[]') + await writeFile(join(task2, 'ui_messages.json'), '[]') + + const provider = createIBMBobProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(2) + expect(sessions.every(s => s.provider === 'ibm-bob')).toBe(true) + expect(sessions.every(s => s.project === 'IBM Bob')).toBe(true) + }) + + it('skips tasks without ui_messages.json', async () => { + const task = join(tmpDir, 'tasks', 'task-no-ui') + await mkdir(task, { recursive: true }) + await writeFile(join(task, 'api_conversation_history.json'), '[]') + + const provider = createIBMBobProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(0) + }) + + it('parses token usage and provider cost from Bob ui messages', async () => { + const taskDir = join(tmpDir, 'tasks', 'task-001') + await mkdir(taskDir, { recursive: true }) + await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({ + tokensIn: 250, + tokensOut: 125, + cacheReads: 60, + cacheWrites: 30, + cost: 0.08, + userMessage: 'modernize this class', + })) + await writeFile(join(taskDir, 'api_conversation_history.json'), makeApiHistory('anthropic/claude-sonnet-4-6')) + + const source = { path: taskDir, project: 'IBM Bob', provider: 'ibm-bob' } + const calls: ParsedProviderCall[] = [] + for await (const call of ibmBob.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!).toMatchObject({ + provider: 'ibm-bob', + model: 'claude-sonnet-4-6', + inputTokens: 250, + outputTokens: 125, + cacheReadInputTokens: 60, + cacheCreationInputTokens: 30, + costUSD: 0.08, + userMessage: 'modernize this class', + sessionId: 'task-001', + }) + expect(calls[0]!.deduplicationKey).toBe('ibm-bob:task-001:0') + }) + + it('falls back to IBM Bob auto model when history has no model tag', async () => { + const taskDir = join(tmpDir, 'tasks', 'task-002') + await mkdir(taskDir, { recursive: true }) + await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({ tokensIn: 100, tokensOut: 50 })) + await writeFile(join(taskDir, 'api_conversation_history.json'), makeApiHistory()) + + const source = { path: taskDir, project: 'IBM Bob', provider: 'ibm-bob' } + const calls: ParsedProviderCall[] = [] + for await (const call of ibmBob.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('ibm-bob-auto') + expect(calls[0]!.costUSD).toBeGreaterThan(0) + }) + + it('deduplicates across parser runs', async () => { + const taskDir = join(tmpDir, 'tasks', 'task-003') + await mkdir(taskDir, { recursive: true }) + await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({ tokensIn: 100, tokensOut: 50 })) + + const source = { path: taskDir, project: 'IBM Bob', provider: 'ibm-bob' } + const seenKeys = new Set() + + const calls1: ParsedProviderCall[] = [] + for await (const call of ibmBob.createSessionParser(source, seenKeys).parse()) calls1.push(call) + + const calls2: ParsedProviderCall[] = [] + for await (const call of ibmBob.createSessionParser(source, seenKeys).parse()) calls2.push(call) + + expect(calls1).toHaveLength(1) + expect(calls2).toHaveLength(0) + }) +}) + +describe('ibm-bob provider - metadata', () => { + it('has correct name and displayName', () => { + expect(ibmBob.name).toBe('ibm-bob') + expect(ibmBob.displayName).toBe('IBM Bob') + }) + + it('uses shared short model display names', () => { + expect(ibmBob.modelDisplayName('ibm-bob-auto')).toBe('IBM Bob (auto)') + expect(ibmBob.modelDisplayName('claude-sonnet-4-6')).toBe('Sonnet 4.6') + }) +}) diff --git a/tests/providers/kimi.test.ts b/tests/providers/kimi.test.ts new file mode 100644 index 0000000..486a03e --- /dev/null +++ b/tests/providers/kimi.test.ts @@ -0,0 +1,192 @@ +import { createHash } from 'crypto' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { createKimiProvider } from '../../src/providers/kimi.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'kimi-test-')) +}) + +afterEach(async () => { + delete process.env.KIMI_MODEL_NAME + await rm(tmpDir, { recursive: true, force: true }) +}) + +function md5(value: string): string { + return createHash('md5').update(value, 'utf-8').digest('hex') +} + +function record(timestamp: number, type: string, payload: Record): string { + return JSON.stringify({ + timestamp, + message: { type, payload }, + }) +} + +async function writeSession(workDir: string, sessionId: string, lines: string[]): Promise { + const hash = md5(workDir) + const sessionDir = join(tmpDir, 'sessions', hash, sessionId) + await mkdir(sessionDir, { recursive: true }) + const wirePath = join(sessionDir, 'wire.jsonl') + await writeFile(wirePath, [ + JSON.stringify({ type: 'metadata', protocol_version: '2' }), + ...lines, + ].join('\n') + '\n') + return wirePath +} + +async function collect(provider: ReturnType, path: string, seen = new Set()): Promise { + const parser = provider.createSessionParser({ path, project: 'app', provider: 'kimi' }, seen) + const calls: ParsedProviderCall[] = [] + for await (const call of parser.parse()) calls.push(call) + return calls +} + +describe('Kimi provider', () => { + it('discovers session and subagent wire logs under KIMI_SHARE_DIR layout', async () => { + const workDir = '/Users/test/work/app' + const hash = md5(workDir) + await writeFile(join(tmpDir, 'kimi.json'), JSON.stringify({ + work_dirs: [{ path: workDir, kaos: 'local', last_session_id: 'sess-1' }], + })) + + const sessionDir = join(tmpDir, 'sessions', hash, 'sess-1') + const subagentDir = join(sessionDir, 'subagents', 'agent-1') + await mkdir(subagentDir, { recursive: true }) + await writeFile(join(sessionDir, 'wire.jsonl'), '\n') + await writeFile(join(subagentDir, 'wire.jsonl'), '\n') + + const sources = await createKimiProvider(tmpDir).discoverSessions() + + expect(sources).toHaveLength(2) + expect(sources.map(s => s.project)).toEqual(['app', 'app']) + expect(sources.map(s => s.provider)).toEqual(['kimi', 'kimi']) + expect(sources.map(s => s.path).sort()).toEqual([ + join(sessionDir, 'subagents', 'agent-1', 'wire.jsonl'), + join(sessionDir, 'wire.jsonl'), + ].sort()) + }) + + it('parses Kimi wire StatusUpdate usage, tools, bash commands, and configured model', async () => { + await writeFile(join(tmpDir, 'config.toml'), [ + 'default_model = "kimi-code/k2"', + '', + '[models."kimi-code/k2"]', + 'model = "kimi-k2-thinking-turbo"', + ].join('\n')) + + const wirePath = await writeSession('/Users/test/work/app', 'sess-1', [ + record(1776162400, 'TurnBegin', { user_input: 'add status endpoint' }), + record(1776162401, 'ToolCall', { + type: 'function', + id: 'call-shell', + function: { name: 'Shell', arguments: JSON.stringify({ command: 'git status && npm test' }) }, + }), + record(1776162402, 'ToolCall', { + type: 'function', + id: 'call-read', + function: { name: 'ReadFile', arguments: JSON.stringify({ path: 'src/index.ts' }) }, + }), + record(1776162403, 'StatusUpdate', { + message_id: 'msg-1', + token_usage: { + input_other: 100, + input_cache_read: 25, + input_cache_creation: 10, + output: 40, + }, + }), + ]) + + const calls = await collect(createKimiProvider(tmpDir), wirePath) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + provider: 'kimi', + model: 'kimi-k2-thinking-turbo', + inputTokens: 100, + outputTokens: 40, + cacheReadInputTokens: 25, + cacheCreationInputTokens: 10, + cachedInputTokens: 25, + tools: ['Bash', 'Read'], + bashCommands: ['git', 'npm'], + timestamp: '2026-04-14T10:26:43.000Z', + deduplicationKey: 'kimi:sess-1:msg-1', + userMessage: 'add status endpoint', + sessionId: 'sess-1', + }) + expect(calls[0]!.costUSD).toBeGreaterThan(0) + }) + + it('uses content parts, model payload overrides, and message-id deduplication', async () => { + process.env.KIMI_MODEL_NAME = 'kimi-k2-thinking' + const wirePath = await writeSession('/Users/test/work/app', 'sess-2', [ + record(1776023300, 'TurnBegin', { + user_input: [ + { type: 'text', text: 'refactor parser' }, + { type: 'image_url', image_url: { url: 'file://diagram.png' } }, + { type: 'text', text: 'carefully' }, + ], + }), + record(1776023301, 'ToolCallRequest', { + id: 'call-write', + name: 'WriteFile', + arguments: JSON.stringify({ path: 'src/parser.ts', content: 'x' }), + }), + record(1776023302, 'StatusUpdate', { + message_id: 'msg-2', + model_name: 'kimi-k2.6', + token_usage: { input_other: 5, output: 7 }, + }), + record(1776023303, 'StatusUpdate', { + message_id: 'msg-2', + model_name: 'kimi-k2.6', + token_usage: { input_other: 5, output: 7 }, + }), + ]) + + const calls = await collect(createKimiProvider(tmpDir), wirePath) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + model: 'kimi-k2.6', + userMessage: 'refactor parser carefully', + tools: ['Write'], + deduplicationKey: 'kimi:sess-2:msg-2', + }) + }) + + it('skips non-usage updates and supports legacy input total fields defensively', async () => { + const wirePath = await writeSession('/Users/test/work/app', 'sess-3', [ + record(1776023400, 'TurnBegin', { user_input: 'summarize' }), + record(1776023401, 'StatusUpdate', { context_usage: 0.5 }), + record(1776023402, 'StatusUpdate', { + message_id: 'msg-3', + token_usage: { + input: 120, + input_cache_read: 30, + input_cache_creation: 10, + output_tokens: 20, + }, + }), + ]) + + const calls = await collect(createKimiProvider(tmpDir), wirePath) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + inputTokens: 80, + cacheReadInputTokens: 30, + cacheCreationInputTokens: 10, + outputTokens: 20, + model: 'kimi-auto', + }) + }) +}) diff --git a/tests/providers/mistral-vibe.test.ts b/tests/providers/mistral-vibe.test.ts new file mode 100644 index 0000000..51bc03c --- /dev/null +++ b/tests/providers/mistral-vibe.test.ts @@ -0,0 +1,305 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { createMistralVibeProvider } from '../../src/providers/mistral-vibe.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string +let originalVibeHome: string | undefined + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'mistral-vibe-test-')) + originalVibeHome = process.env['VIBE_HOME'] + delete process.env['VIBE_HOME'] +}) + +afterEach(async () => { + if (originalVibeHome === undefined) { + delete process.env['VIBE_HOME'] + } else { + process.env['VIBE_HOME'] = originalVibeHome + } + await rm(tmpDir, { recursive: true, force: true }) +}) + +function metadata(opts: { + sessionId?: string + cwd?: string + input?: number + output?: number + inputPrice?: number + outputPrice?: number + activeModel?: string + modelName?: string + configInputPrice?: number + configOutputPrice?: number + endTime?: string | null + title?: string +} = {}) { + const activeModel = opts.activeModel ?? 'mistral-medium-3.5' + return { + session_id: opts.sessionId ?? 'session-abc123', + start_time: '2026-05-11T10:00:00+00:00', + end_time: Object.hasOwn(opts, 'endTime') ? opts.endTime : '2026-05-11T10:05:00+00:00', + environment: { + working_directory: opts.cwd ?? '/Users/test/mistral-project', + }, + stats: { + session_prompt_tokens: opts.input ?? 2000, + session_completion_tokens: opts.output ?? 3000, + input_price_per_million: opts.inputPrice ?? 1.5, + output_price_per_million: opts.outputPrice ?? 7.5, + tokens_per_second: 42, + }, + config: { + active_model: activeModel, + models: [ + { + alias: activeModel, + name: opts.modelName ?? 'mistral-vibe-cli-latest', + provider: 'mistral', + input_price: opts.configInputPrice ?? 1.5, + output_price: opts.configOutputPrice ?? 7.5, + }, + ], + }, + title: opts.title ?? 'implement mistral support', + total_messages: 2, + } +} + +function userMessage(content: unknown = 'implement mistral support') { + return { + role: 'user', + content, + message_id: 'msg-user-1', + } +} + +function assistantMessage(toolCalls: Array<{ name: string; args?: Record | string }> = []) { + return { + role: 'assistant', + content: 'Done', + message_id: 'msg-assistant-1', + tool_calls: toolCalls.map((call, idx) => ({ + id: `tool-${idx}`, + type: 'function', + function: { + name: call.name, + arguments: typeof call.args === 'string' ? call.args : JSON.stringify(call.args ?? {}), + }, + })), + } +} + +async function writeSession( + name: string, + meta: Record, + messages = [userMessage(), assistantMessage()], + root = tmpDir, +) { + const sessionDir = join(root, name) + await mkdir(sessionDir, { recursive: true }) + await writeFile(join(sessionDir, 'meta.json'), JSON.stringify(meta, null, 2)) + await writeFile(join(sessionDir, 'messages.jsonl'), messages.map(m => JSON.stringify(m)).join('\n') + '\n') + return sessionDir +} + +async function collect(sourcePath: string, provider = createMistralVibeProvider(tmpDir)): Promise { + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser({ + path: sourcePath, + project: 'mistral-project', + provider: 'mistral-vibe', + }, new Set()).parse()) { + calls.push(call) + } + return calls +} + +describe('mistral-vibe provider - session discovery', () => { + it('discovers Vibe session folders and derives project from metadata cwd', async () => { + const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata({ + sessionId: 'session-a', + cwd: '/Users/test/project-a', + })) + await mkdir(join(tmpDir, 'not-a-session'), { recursive: true }) + + const provider = createMistralVibeProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]).toEqual({ + path: sessionDir, + project: 'project-a', + provider: 'mistral-vibe', + }) + }) + + it('discovers subagent session folders nested under agents', async () => { + const parentDir = await writeSession('session_20260511_100000_parent', metadata({ + sessionId: 'parent-session', + cwd: '/Users/test/parent-project', + })) + const childDir = await writeSession('session_20260511_100001_child', metadata({ + sessionId: 'child-session', + cwd: '/Users/test/child-project', + }), [userMessage('child task'), assistantMessage()], join(parentDir, 'agents')) + + const provider = createMistralVibeProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions.map(s => s.path).sort()).toEqual([childDir, parentDir].sort()) + expect(sessions.map(s => s.project).sort()).toEqual(['child-project', 'parent-project']) + }) + + it('returns empty for a missing Vibe sessions directory', async () => { + const provider = createMistralVibeProvider('/missing/vibe/logs/session') + await expect(provider.discoverSessions()).resolves.toEqual([]) + }) + + it('uses VIBE_HOME when no override directory is provided', async () => { + const vibeHome = join(tmpDir, 'vibe-home') + process.env['VIBE_HOME'] = vibeHome + const sessionsDir = join(vibeHome, 'logs', 'session') + await writeSession('session_20260511_100000_sessiona', metadata({ + sessionId: 'env-session', + cwd: '/Users/test/env-project', + }), [userMessage(), assistantMessage()], sessionsDir) + + const provider = createMistralVibeProvider() + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.project).toBe('env-project') + }) +}) + +describe('mistral-vibe provider - parsing', () => { + it('parses cumulative session usage, tools, bash commands, and first user message', async () => { + const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata(), [ + userMessage([{ type: 'text', text: 'track Mistral Vibe usage' }]), + assistantMessage([ + { name: 'read_file', args: { path: 'src/index.ts' } }, + { name: 'search_replace', args: { file_path: 'src/index.ts', content: 'patch' } }, + { name: 'bash', args: { command: 'npm test && git status' } }, + ]), + ]) + + const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir)) + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.provider).toBe('mistral-vibe') + expect(call.model).toBe('mistral-medium-3.5') + expect(call.inputTokens).toBe(2000) + expect(call.outputTokens).toBe(3000) + expect(call.costUSD).toBeCloseTo(0.0255, 8) + expect(call.tools).toEqual(['Read', 'Edit', 'Bash']) + expect(call.bashCommands).toEqual(['npm', 'git']) + expect(call.timestamp).toBe('2026-05-11T10:05:00+00:00') + expect(call.userMessage).toBe('track Mistral Vibe usage') + expect(call.sessionId).toBe('session-abc123') + expect(call.deduplicationKey).toBe('mistral-vibe:session-abc123') + }) + + it('uses configured model prices when stats omit prices', async () => { + const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata({ + inputPrice: 0, + outputPrice: 0, + input: 1000, + output: 1000, + })) + + const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir)) + + expect(calls).toHaveLength(1) + expect(calls[0]!.costUSD).toBeCloseTo(0.009, 8) + }) + + it('falls back to LiteLLM pricing when Vibe does not provide prices', async () => { + const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata({ + activeModel: 'claude-sonnet-4-6', + modelName: 'claude-sonnet-4-6', + input: 1000, + output: 1000, + inputPrice: 0, + outputPrice: 0, + configInputPrice: 0, + configOutputPrice: 0, + })) + + const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir)) + + expect(calls).toHaveLength(1) + expect(calls[0]!.costUSD).toBeCloseTo(0.018, 8) + }) + + it('falls back to start_time when end_time is missing', async () => { + const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata({ + endTime: null, + })) + + const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir)) + + expect(calls[0]!.timestamp).toBe('2026-05-11T10:00:00+00:00') + }) + + it('deduplicates by session id', async () => { + const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata()) + const provider = createMistralVibeProvider(tmpDir) + const source = { path: sessionDir, project: 'mistral-project', provider: 'mistral-vibe' } + const seen = new Set() + + const first: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seen).parse()) first.push(call) + const second: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seen).parse()) second.push(call) + + expect(first).toHaveLength(1) + expect(second).toHaveLength(0) + }) + + it('skips sessions without cumulative token usage', async () => { + const sessionDir = await writeSession('session_20260511_100000_empty', metadata({ + input: 0, + output: 0, + })) + + const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir)) + + expect(calls).toEqual([]) + }) + + it('skips sessions with malformed meta.json', async () => { + const sessionDir = join(tmpDir, 'session_20260511_100000_bad') + await mkdir(sessionDir, { recursive: true }) + await writeFile(join(sessionDir, 'meta.json'), '{{not json') + await writeFile(join(sessionDir, 'messages.jsonl'), JSON.stringify(userMessage()) + '\n') + + const provider = createMistralVibeProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(0) + }) + + it('returns empty calls when messages.jsonl is malformed', async () => { + const sessionDir = await writeSession('session_20260511_100000_badjsonl', metadata()) + await writeFile(join(sessionDir, 'messages.jsonl'), '{{not json\n{{also bad\n') + + const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir)) + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual([]) + expect(calls[0]!.bashCommands).toEqual([]) + }) + + it('formats model and tool display names', () => { + const provider = createMistralVibeProvider(tmpDir) + + expect(provider.modelDisplayName('mistral-medium-3.5')).toBe('Mistral Medium 3.5') + expect(provider.modelDisplayName('devstral-small-latest')).toBe('Devstral Small') + expect(provider.toolDisplayName('search_replace')).toBe('Edit') + expect(provider.toolDisplayName('unknown_tool')).toBe('unknown_tool') + }) +}) diff --git a/tests/providers/opencode.test.ts b/tests/providers/opencode.test.ts index bd715be..3637b79 100644 --- a/tests/providers/opencode.test.ts +++ b/tests/providers/opencode.test.ts @@ -337,6 +337,124 @@ skipUnlessSqlite('opencode provider - session parsing', () => { expect(call.deduplicationKey).toBe('opencode:sess-1:msg-2') }) + it('normalizes opencode MCP tool names for shared MCP reporting', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + + insertMessage(db, 'msg-1', 'sess-1', 1700000000000, { role: 'user' }) + insertPart(db, 'part-1', 'msg-1', 'sess-1', { type: 'text', text: 'look up the ClickUp task' }) + + insertMessage(db, 'msg-2', 'sess-1', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.05, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + insertPart(db, 'part-2', 'msg-2', 'sess-1', { + type: 'tool', + tool: 'clickup_clickup_get_task', + state: { status: 'completed', input: {} }, + }) + insertPart(db, 'part-3', 'msg-2', 'sess-1', { + type: 'tool', + tool: 'figma_get_file', + state: { status: 'completed', input: {} }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual([ + 'mcp__clickup__clickup_get_task', + 'mcp__figma__get_file', + ]) + }) + + it('preserves already-normalized MCP tool names', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.05, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + insertPart(db, 'part-1', 'msg-1', 'sess-1', { + type: 'tool', + tool: 'mcp__github__search_code', + state: { status: 'completed', input: {} }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual(['mcp__github__search_code']) + }) + + it('keeps extension tool names without a server prefix as regular tools', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.05, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + insertPart(db, 'part-1', 'msg-1', 'sess-1', { + type: 'tool', + tool: 'customtool', + state: { status: 'completed', input: {} }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual(['customtool']) + }) + + it('keeps malformed server-prefixed tool names as regular tools', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.05, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + insertPart(db, 'part-1', 'msg-1', 'sess-1', { + type: 'tool', + tool: '_missing_server', + state: { status: 'completed', input: {} }, + }) + insertPart(db, 'part-2', 'msg-1', 'sess-1', { + type: 'tool', + tool: 'missing_', + state: { status: 'completed', input: {} }, + }) + insertPart(db, 'part-3', 'msg-1', 'sess-1', { + type: 'tool', + tool: '_', + state: { status: 'completed', input: {} }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual([ + '_missing_server', + 'missing_', + '_', + ]) + }) + it('skips zero-token messages with zero cost', async () => { const dbPath = createTestDb(tmpDir) withTestDb(dbPath, (db) => { diff --git a/tests/session-cache.test.ts b/tests/session-cache.test.ts new file mode 100644 index 0000000..8da5153 --- /dev/null +++ b/tests/session-cache.test.ts @@ -0,0 +1,509 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { readFile, rm, writeFile, mkdir } from 'fs/promises' +import { existsSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +import { + CACHE_VERSION, + type CachedCall, + type CachedFile, + type CachedTurn, + type FileFingerprint, + type SessionCache, + cleanupOrphanedTempFiles, + computeEnvFingerprint, + emptyCache, + fingerprintFile, + loadCache, + mergeCallByDedupKey, + reconcileFile, + saveCache, +} from '../src/session-cache.js' + +const TMP_DIR = join(tmpdir(), `codeburn-scache-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`) + +beforeEach(() => { + process.env['CODEBURN_CACHE_DIR'] = TMP_DIR +}) + +afterEach(async () => { + delete process.env['CODEBURN_CACHE_DIR'] + if (existsSync(TMP_DIR)) await rm(TMP_DIR, { recursive: true }) +}) + +function makeCall(overrides: Partial = {}): CachedCall { + return { + provider: 'claude', + model: 'claude-sonnet-4-20250514', + usage: { + inputTokens: 1000, + outputTokens: 500, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + cacheCreationOneHourTokens: 0, + }, + speed: 'standard', + timestamp: '2026-05-15T10:00:00Z', + tools: ['Read', 'Edit'], + bashCommands: [], + skills: [], + deduplicationKey: 'msg-abc123', + ...overrides, + } +} + +function makeTurn(overrides: Partial = {}): CachedTurn { + return { + timestamp: '2026-05-15T10:00:00Z', + sessionId: 'sess-1', + userMessage: 'fix the bug', + calls: [makeCall()], + ...overrides, + } +} + +function makeCachedFile(overrides: Partial = {}): CachedFile { + return { + fingerprint: { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 }, + mcpInventory: [], + turns: [makeTurn()], + ...overrides, + } +} + +// ── emptyCache ───────────────────────────────────────────────────────── + +describe('emptyCache', () => { + it('returns a valid empty cache', () => { + const cache = emptyCache() + expect(cache.version).toBe(CACHE_VERSION) + expect(cache.providers).toEqual({}) + }) +}) + +// ── loadCache / saveCache ────────────────────────────────────────────── + +describe('loadCache / saveCache', () => { + it('returns empty cache when no file exists', async () => { + const cache = await loadCache() + expect(cache.version).toBe(CACHE_VERSION) + expect(cache.providers).toEqual({}) + }) + + it('round-trips a cache through save and load', async () => { + const cache: SessionCache = { + version: CACHE_VERSION, + providers: { + claude: { + envFingerprint: 'abc123', + files: { + '/path/to/session.jsonl': makeCachedFile(), + }, + }, + }, + } + + await saveCache(cache) + const loaded = await loadCache() + expect(loaded).toEqual(cache) + }) + + it('returns empty cache on version mismatch', async () => { + const bad: SessionCache = { version: 999, providers: { claude: { envFingerprint: 'x', files: {} } } } + await mkdir(TMP_DIR, { recursive: true }) + await writeFile(join(TMP_DIR, 'session-cache.json'), JSON.stringify(bad)) + + const loaded = await loadCache() + expect(loaded.version).toBe(CACHE_VERSION) + expect(loaded.providers).toEqual({}) + }) + + it('returns empty cache on corrupt JSON', async () => { + await mkdir(TMP_DIR, { recursive: true }) + await writeFile(join(TMP_DIR, 'session-cache.json'), '{broken') + + const loaded = await loadCache() + expect(loaded.version).toBe(CACHE_VERSION) + expect(loaded.providers).toEqual({}) + }) + + it('atomic write does not leave partial file on error', async () => { + await saveCache(emptyCache()) + const raw = await readFile(join(TMP_DIR, 'session-cache.json'), 'utf-8') + expect(JSON.parse(raw)).toEqual(emptyCache()) + }) +}) + +// ── computeEnvFingerprint ────────────────────────────────────────────── + +describe('computeEnvFingerprint', () => { + it('returns stable hash for same env', () => { + const a = computeEnvFingerprint('claude') + const b = computeEnvFingerprint('claude') + expect(a).toBe(b) + expect(a).toHaveLength(16) + }) + + it('changes when env var changes', () => { + const before = computeEnvFingerprint('claude') + const orig = process.env['CLAUDE_CONFIG_DIR'] + process.env['CLAUDE_CONFIG_DIR'] = '/tmp/different' + const after = computeEnvFingerprint('claude') + if (orig === undefined) delete process.env['CLAUDE_CONFIG_DIR'] + else process.env['CLAUDE_CONFIG_DIR'] = orig + expect(before).not.toBe(after) + }) + + it('returns stable hash for unknown provider (no env vars)', () => { + const a = computeEnvFingerprint('unknown-provider') + const b = computeEnvFingerprint('unknown-provider') + expect(a).toBe(b) + }) +}) + +// ── fingerprintFile ──────────────────────────────────────────────────── + +describe('fingerprintFile', () => { + it('returns fingerprint for existing file', async () => { + await mkdir(TMP_DIR, { recursive: true }) + const filePath = join(TMP_DIR, 'test.jsonl') + await writeFile(filePath, 'line1\nline2\n') + + const fp = await fingerprintFile(filePath) + expect(fp).not.toBeNull() + expect(fp!.sizeBytes).toBe(12) + expect(fp!.dev).toBeGreaterThan(0) + expect(fp!.ino).toBeGreaterThan(0) + expect(fp!.mtimeMs).toBeGreaterThan(0) + }) + + it('returns null for non-existent file', async () => { + const fp = await fingerprintFile('/no/such/file') + expect(fp).toBeNull() + }) +}) + +// ── reconcileFile ────────────────────────────────────────────────────── + +describe('reconcileFile', () => { + it('returns "new" when no cached entry', () => { + const fp: FileFingerprint = { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 } + expect(reconcileFile(fp, undefined)).toEqual({ action: 'new' }) + }) + + it('returns "unchanged" when all fields match', () => { + const fp: FileFingerprint = { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 } + const cached = makeCachedFile({ fingerprint: { ...fp } }) + expect(reconcileFile(fp, cached)).toEqual({ action: 'unchanged' }) + }) + + it('returns "appended" when ino same, size grew, and has lastCompleteLineOffset', () => { + const cached = makeCachedFile({ + fingerprint: { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 }, + lastCompleteLineOffset: 4500, + }) + const current: FileFingerprint = { dev: 1, ino: 100, mtimeMs: 2000, sizeBytes: 8000 } + const result = reconcileFile(current, cached) + expect(result).toEqual({ action: 'appended', readFromOffset: 4500 }) + }) + + it('returns "modified" when ino changed', () => { + const cached = makeCachedFile({ + fingerprint: { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 }, + }) + const current: FileFingerprint = { dev: 1, ino: 200, mtimeMs: 2000, sizeBytes: 5000 } + expect(reconcileFile(current, cached)).toEqual({ action: 'modified' }) + }) + + it('returns "modified" when size shrank', () => { + const cached = makeCachedFile({ + fingerprint: { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 }, + lastCompleteLineOffset: 4500, + }) + const current: FileFingerprint = { dev: 1, ino: 100, mtimeMs: 2000, sizeBytes: 3000 } + expect(reconcileFile(current, cached)).toEqual({ action: 'modified' }) + }) + + it('returns "modified" when same size but different mtime', () => { + const cached = makeCachedFile({ + fingerprint: { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 }, + }) + const current: FileFingerprint = { dev: 1, ino: 100, mtimeMs: 2000, sizeBytes: 5000 } + expect(reconcileFile(current, cached)).toEqual({ action: 'modified' }) + }) + + it('returns "modified" for DB provider (no lastCompleteLineOffset) on any fingerprint change', () => { + const cached = makeCachedFile({ + fingerprint: { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 }, + }) + const current: FileFingerprint = { dev: 1, ino: 100, mtimeMs: 2000, sizeBytes: 8000 } + expect(reconcileFile(current, cached)).toEqual({ action: 'modified' }) + }) + + it('returns "modified" when dev changed even if ino same and size grew', () => { + const cached = makeCachedFile({ + fingerprint: { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 }, + lastCompleteLineOffset: 4500, + }) + const current: FileFingerprint = { dev: 2, ino: 100, mtimeMs: 2000, sizeBytes: 8000 } + expect(reconcileFile(current, cached)).toEqual({ action: 'modified' }) + }) +}) + +// ── mergeCallByDedupKey ──────────────────────────────────────────────── + +describe('mergeCallByDedupKey', () => { + it('keeps earlier timestamp', () => { + const existing = makeCall({ timestamp: '2026-05-15T10:00:00Z' }) + const incoming = makeCall({ timestamp: '2026-05-15T10:01:00Z' }) + const merged = mergeCallByDedupKey(existing, incoming) + expect(merged.timestamp).toBe('2026-05-15T10:00:00Z') + }) + + it('takes incoming usage (latest wins)', () => { + const existing = makeCall({ usage: { ...makeCall().usage, outputTokens: 100 } }) + const incoming = makeCall({ usage: { ...makeCall().usage, outputTokens: 999 } }) + const merged = mergeCallByDedupKey(existing, incoming) + expect(merged.usage.outputTokens).toBe(999) + }) + + it('takes incoming tools (latest wins)', () => { + const existing = makeCall({ tools: ['Read'] }) + const incoming = makeCall({ tools: ['Read', 'Edit', 'Bash'] }) + const merged = mergeCallByDedupKey(existing, incoming) + expect(merged.tools).toEqual(['Read', 'Edit', 'Bash']) + }) +}) + +// ── deep validation (loadCache) ──────────────────────────────────────── + +describe('loadCache validation', () => { + async function writeRawCache(data: unknown): Promise { + await mkdir(TMP_DIR, { recursive: true }) + await writeFile(join(TMP_DIR, 'session-cache.json'), JSON.stringify(data)) + } + + it('rejects providers as array', async () => { + await writeRawCache({ version: CACHE_VERSION, providers: [] }) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects provider section missing envFingerprint', async () => { + await writeRawCache({ version: CACHE_VERSION, providers: { claude: { files: {} } } }) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects provider section with files as array', async () => { + await writeRawCache({ version: CACHE_VERSION, providers: { claude: { envFingerprint: 'x', files: [] } } }) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects file with invalid fingerprint (missing ino)', async () => { + await writeRawCache({ + version: CACHE_VERSION, + providers: { claude: { envFingerprint: 'x', files: { + '/f': { fingerprint: { dev: 1, mtimeMs: 1, sizeBytes: 1 }, mcpInventory: [], turns: [] }, + } } }, + }) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects file with non-numeric fingerprint field', async () => { + await writeRawCache({ + version: CACHE_VERSION, + providers: { claude: { envFingerprint: 'x', files: { + '/f': { fingerprint: { dev: 1, ino: 'bad', mtimeMs: 1, sizeBytes: 1 }, mcpInventory: [], turns: [] }, + } } }, + }) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects turn with missing sessionId', async () => { + const badTurn = { timestamp: 'x', userMessage: 'y', calls: [] } + await writeRawCache({ + version: CACHE_VERSION, + providers: { claude: { envFingerprint: 'x', files: { + '/f': { fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 }, mcpInventory: [], turns: [badTurn] }, + } } }, + }) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects call with missing usage object', async () => { + const badCall = { provider: 'claude', model: 'm', deduplicationKey: 'k', timestamp: 't', tools: [], bashCommands: [], skills: [] } + const turn = { timestamp: 'x', sessionId: 's', userMessage: 'y', calls: [badCall] } + await writeRawCache({ + version: CACHE_VERSION, + providers: { claude: { envFingerprint: 'x', files: { + '/f': { fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 }, mcpInventory: [], turns: [turn] }, + } } }, + }) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects call with NaN in usage', async () => { + const badUsage = { inputTokens: NaN, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, cacheCreationOneHourTokens: 0 } + const call = { provider: 'claude', model: 'm', usage: badUsage, deduplicationKey: 'k', timestamp: 't', tools: [], bashCommands: [], skills: [], speed: 'standard' } + const turn = { timestamp: 'x', sessionId: 's', userMessage: 'y', calls: [call] } + await writeRawCache({ + version: CACHE_VERSION, + providers: { claude: { envFingerprint: 'x', files: { + '/f': { fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 }, mcpInventory: [], turns: [turn] }, + } } }, + }) + expect((await loadCache()).providers).toEqual({}) + }) + + function validCallJson() { + return { + provider: 'claude', model: 'm', deduplicationKey: 'k', timestamp: 't', speed: 'standard', + tools: ['Read'], bashCommands: ['ls'], skills: [], + usage: { inputTokens: 1, outputTokens: 1, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, cacheCreationOneHourTokens: 0 }, + } + } + + function wrapCall(callOverride: Record) { + return { + version: CACHE_VERSION, + providers: { claude: { envFingerprint: 'x', files: { + '/f': { fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 }, mcpInventory: [], turns: [ + { timestamp: 'x', sessionId: 's', userMessage: 'y', calls: [{ ...validCallJson(), ...callOverride }] }, + ] }, + } } }, + } + } + + function wrapFile(fileOverride: Record) { + return { + version: CACHE_VERSION, + providers: { claude: { envFingerprint: 'x', files: { + '/f': { fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 }, mcpInventory: [], turns: [], ...fileOverride }, + } } }, + } + } + + it('rejects tools containing non-string element', async () => { + await writeRawCache(wrapCall({ tools: ['Read', 42] })) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects bashCommands containing object element', async () => { + await writeRawCache(wrapCall({ bashCommands: [{}] })) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects skills containing null element', async () => { + await writeRawCache(wrapCall({ skills: [null] })) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects invalid speed value', async () => { + await writeRawCache(wrapCall({ speed: 'turbo' })) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects non-string project', async () => { + await writeRawCache(wrapCall({ project: 123 })) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects non-string projectPath', async () => { + await writeRawCache(wrapCall({ projectPath: true })) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects mcpInventory containing non-string element', async () => { + await writeRawCache(wrapFile({ mcpInventory: ['valid', 99] })) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects non-numeric lastCompleteLineOffset', async () => { + await writeRawCache(wrapFile({ lastCompleteLineOffset: 'bad' })) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects NaN lastCompleteLineOffset', async () => { + await writeRawCache(wrapFile({ lastCompleteLineOffset: null })) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects non-string canonicalCwd', async () => { + await writeRawCache(wrapFile({ canonicalCwd: 42 })) + expect((await loadCache()).providers).toEqual({}) + }) + + it('accepts optional fields when absent', async () => { + const cache: SessionCache = { + version: CACHE_VERSION, + providers: { claude: { envFingerprint: 'x', files: { + '/f': { fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 }, mcpInventory: [], turns: [] }, + } } }, + } + await writeRawCache(cache) + expect((await loadCache())).toEqual(cache) + }) + + it('accepts a fully valid cache with all fields populated', async () => { + const cache: SessionCache = { + version: CACHE_VERSION, + providers: { + claude: { + envFingerprint: 'abc', + files: { '/f': makeCachedFile() }, + }, + }, + } + await writeRawCache(cache) + const loaded = await loadCache() + expect(loaded).toEqual(cache) + }) +}) + +// ── cleanupOrphanedTempFiles ─────────────────────────────────────────── + +describe('cleanupOrphanedTempFiles', () => { + it('removes .tmp files older than 5 minutes', async () => { + await mkdir(TMP_DIR, { recursive: true }) + + const oldTmp = join(TMP_DIR, 'session-cache.json.abc123.tmp') + await writeFile(oldTmp, 'stale') + const { utimes } = await import('fs/promises') + const oldTime = new Date(Date.now() - 10 * 60 * 1000) + await utimes(oldTmp, oldTime, oldTime) + + await cleanupOrphanedTempFiles() + expect(existsSync(oldTmp)).toBe(false) + }) + + it('preserves recent .tmp files', async () => { + await mkdir(TMP_DIR, { recursive: true }) + + const recentTmp = join(TMP_DIR, 'session-cache.json.def456.tmp') + await writeFile(recentTmp, 'recent') + + await cleanupOrphanedTempFiles() + expect(existsSync(recentTmp)).toBe(true) + }) + + it('ignores .tmp files from other caches', async () => { + await mkdir(TMP_DIR, { recursive: true }) + + const otherTmp = join(TMP_DIR, 'codex-results.json.abc123.tmp') + await writeFile(otherTmp, 'other cache temp') + const { utimes } = await import('fs/promises') + const oldTime = new Date(Date.now() - 10 * 60 * 1000) + await utimes(otherTmp, oldTime, oldTime) + + await cleanupOrphanedTempFiles() + expect(existsSync(otherTmp)).toBe(true) + }) + + it('does not fail when cache dir does not exist', async () => { + process.env['CODEBURN_CACHE_DIR'] = '/no/such/dir' + await cleanupOrphanedTempFiles() + }) +}) diff --git a/tsup.config.ts b/tsup.config.ts index 2ba26c5..957fdce 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsup' export default defineConfig({ - entry: ['src/cli.ts'], + entry: ['src/main.ts'], format: ['esm'], target: 'node20', outDir: 'dist', @@ -9,7 +9,4 @@ export default defineConfig({ splitting: false, sourcemap: true, dts: false, - banner: { - js: '#!/usr/bin/env node', - }, })