From d2e1db6019cfb7926d91f9f6ebb5e5c95b9fd9a5 Mon Sep 17 00:00:00 2001 From: "itarun.p" Date: Sat, 25 Jul 2026 08:05:21 +0700 Subject: [PATCH 1/3] fix(pricing): stop reporting the "unknown" placeholder as an unpriced model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `unknown` is our own stand-in for a row whose model id could not be determined — written into the queue by sync.js and claude-categorizer.js, and coalesced to again at read time by local-api.js. Pricing it turned a missing *attribution* into a missing *price*. That put the placeholder in `unpriced_models`, a field whose whole purpose is to name models needing an entry in curated-overrides.json — where `unknown` can never match. It also logged that exact advice to the console, and made every first lookup schedule an upstream refresh hunting for a price that cannot exist. The field shipped in 0.39.41, so this is what it looked like on first contact. Resolve placeholder ids to a distinct `unattributed` tier before the lookup, so they never reach the negative cache, the tier map, the warning path or the refresh scheduler. A closed set rather than a substring rule: silently un-pricing a real model would recreate the $0 bug this surface exists to expose (#90). A model id that is only whitespace now folds into the `empty` tier instead of leaking into unpriced_models as " ". The dashboard still flags such a row as unpriced — its tokens count toward the total at $0, so the caveat is honest even though the row does not belong in a list of models awaiting a price. Closes #93 --- dashboard/src/lib/model-breakdown.ts | 9 ++- src/lib/pricing/index.js | 24 +++++++- test/model-breakdown.test.js | 37 ++++++++++++ test/pricing-observability.test.js | 88 ++++++++++++++++++++++++++++ 4 files changed, 156 insertions(+), 2 deletions(-) diff --git a/dashboard/src/lib/model-breakdown.ts b/dashboard/src/lib/model-breakdown.ts index 5c45ea5a..48c2a9a6 100644 --- a/dashboard/src/lib/model-breakdown.ts +++ b/dashboard/src/lib/model-breakdown.ts @@ -25,6 +25,13 @@ function resolveModelName(model: any, fallback: any) { // price is plausible and therefore never looks wrong — worth flagging. const FUZZY_PRICING_TIERS = new Set(["curated:fuzzy", "litellm:fuzzy", "litellm:prefix-strip"]); +// Server tiers that mean "$0 because we have no price", as opposed to a model +// that genuinely costs nothing. "unattributed"/"empty" rows carry no model id at +// all, but their tokens still count toward the total, so the same caveat applies +// — they are excluded from the server's unpriced_models list (which exists to +// name models needing a curated price) but must not silently read as priced. +const UNPRICED_PRICING_TIERS = new Set(["miss", "unattributed", "empty"]); + function isKnownZeroCostModel(name: any) { const lower = String(name || "").toLowerCase(); return lower.includes("free") || lower.includes("hy3-preview") || /^glm-[\d.]+-flash(?![a-z])/.test(lower); @@ -92,7 +99,7 @@ export function buildFleetData(modelBreakdown: any, { copyFn }: AnyRecord = {}) // stays as the fallback for responses from an older server. const pricingTier = typeof model?.pricing_tier === "string" ? model.pricing_tier : null; const pricingMissing = pricingTier - ? pricingTier === "miss" && modelTokens > 0 + ? UNPRICED_PRICING_TIERS.has(pricingTier) && modelTokens > 0 : modelTokens > 0 && (modelCost == null || modelCost <= 0) && !isKnownZeroCostModel(name); const pricingFuzzy = Boolean(pricingTier && FUZZY_PRICING_TIERS.has(pricingTier)); return { diff --git a/src/lib/pricing/index.js b/src/lib/pricing/index.js index b91a2444..d4dc4416 100644 --- a/src/lib/pricing/index.js +++ b/src/lib/pricing/index.js @@ -51,6 +51,11 @@ const RELOAD_COOLDOWN_MS = 5 * 60 * 1000; // wrong, unlike a $0 one. const FUZZY_SOURCES = new Set(["curated:fuzzy", "litellm:fuzzy", "litellm:prefix-strip"]); +// Placeholder ids that stand in for "this row has no model", so they resolve to +// the "unattributed" tier instead of being looked up and recorded as a miss. +// Closed set on purpose: a real model id must never be silently un-priced here. +const UNATTRIBUTED_MODEL_IDS = new Set(["unknown"]); + // `last_refresh_error` is served over HTTP to the dashboard, so it is built // from CLOSED sets, never from an arbitrary value. A previous version accepted // anything symbol-shaped, which a QA pass broke immediately: a 32-character @@ -204,11 +209,28 @@ function resolveLookupSource(opts) { return null; } +// A row whose model id could not be determined is stored and aggregated under +// the literal id "unknown" — persisted into the queue by src/commands/sync.js +// and src/lib/claude-categorizer.js, then coalesced to it again at read time by +// src/lib/local-api.js. That is a placeholder for "no model", not a model, so +// pricing it turned a missing *attribution* into a missing *price*: it recorded +// a permanent miss, listed "unknown" in unpriced_models as though a real model +// needed a curated price, and logged advice to add it to curated-overrides.json +// where it could never match anything. +// Returns the tier to report, or null when this is a real model id to look up. +function resolvePlaceholderTier(model) { + if (!model) return "empty"; + const normalized = String(model).trim().toLowerCase(); + if (!normalized) return "empty"; + return UNATTRIBUTED_MODEL_IDS.has(normalized) ? "unattributed" : null; +} + // Returns the price AND how it was resolved. getModelPricing keeps the old // bare-numbers contract for the many existing callers; anything that wants to // show the user how much to trust the number uses this. function getModelPricingMeta(model, opts = {}) { - if (!model) return { pricing: ZERO_PRICING, tier: "empty" }; + const placeholderTier = resolvePlaceholderTier(model); + if (placeholderTier) return { pricing: ZERO_PRICING, tier: placeholderTier }; const lookupSource = resolveLookupSource(opts); const cacheKey = lookupSource ? `${lookupSource}\0${model}` : model; diff --git a/test/model-breakdown.test.js b/test/model-breakdown.test.js index eb5b716c..1c9782ea 100644 --- a/test/model-breakdown.test.js +++ b/test/model-breakdown.test.js @@ -718,3 +718,40 @@ test("without pricing_tier the cost<=0 heuristic still applies (older server res assert.deepEqual(provider.missingPricingModels.map((m) => m.name), ["brand-new-model"]); assert.deepEqual(provider.fuzzyPricingModels, []); }); + +test("an unattributed row still carries the unpriced caveat", async () => { + const mod = await loadDashboardModule("dashboard/src/lib/model-breakdown.ts"); + const { buildFleetData } = mod; + + // The server excludes the "unknown" placeholder from unpriced_models — that + // list names models needing a curated price, and a placeholder is not one. + // The dashboard chip answers a different question: "are these tokens counted + // at $0?" For an unattributed row they are, so it must still be flagged. On + // this machine every such row currently has 0 tokens and is dropped earlier, + // so only a synthesized row exercises the path. + const [provider] = buildFleetData({ + sources: [ + { + source: "claude", + totals: { billable_total_tokens: 2000, total_cost_usd: "5" }, + models: [ + { + model: "unknown", + model_id: "unknown", + pricing_tier: "unattributed", + totals: { billable_total_tokens: 1000, total_cost_usd: "0" }, + }, + { + model: "acme-1", + model_id: "acme-1", + pricing_tier: "litellm:exact", + totals: { billable_total_tokens: 1000, total_cost_usd: "5" }, + }, + ], + }, + ], + }); + + assert.deepEqual(provider.missingPricingModels.map((m) => m.name), ["unknown"]); + assert.deepEqual(provider.fuzzyPricingModels, []); +}); diff --git a/test/pricing-observability.test.js b/test/pricing-observability.test.js index 2f55404c..f096ddd4 100644 --- a/test/pricing-observability.test.js +++ b/test/pricing-observability.test.js @@ -343,3 +343,91 @@ test("one provider's exact hit cannot hide another provider's miss", async () => const diagnostics = pricing.getPricingDiagnostics(); assert.deepEqual(diagnostics.unpriced_models, ["unknown-only-here"]); }); + +// --- the "unknown" placeholder is not a model ------------------------------- +// Rows whose model id could not be determined are stored under the literal id +// "unknown" (src/commands/sync.js:1250,1675,1768) and coalesced to it again at +// read time (src/lib/local-api.js:210,434,1121,1355). Pricing it turned a +// missing *attribution* into a missing *price*, which is a different problem +// with a different fix — the live dashboard reported unpriced_models: +// ["unknown"] and advised adding it to curated-overrides.json, where it could +// never match anything. + +test("the \"unknown\" placeholder resolves as unattributed, not as an unpriced model", async () => { + const payload = { current: { "acme-1": entry(1e-6, 2e-6) } }; + await loadWith(payload, tmpCachePath("unattributed")); + + const { result: meta, lines } = captureWarnings(() => + pricing.getModelPricingMeta("unknown", { source: "claude" }), + ); + + assert.equal(meta.tier, "unattributed"); + assert.deepEqual(meta.pricing, pricing.ZERO_PRICING, "still costs nothing, as before"); + assert.deepEqual(lines, [], "no advice to add a placeholder to curated-overrides.json"); + + const diagnostics = pricing.getPricingDiagnostics(); + assert.deepEqual( + diagnostics.unpriced_models, + [], + "unpriced_models names models needing a curated price — a placeholder is not one", + ); + assert.deepEqual(diagnostics.fuzzy_priced_models, []); + assert.equal( + pricing.__getStateForTests().reloadPromise, + null, + "a placeholder must not burn an upstream refresh looking for a price it can never have", + ); +}); + +test("a model id that is only whitespace is empty, not a placeholder-shaped miss", async () => { + const payload = { current: { "acme-1": entry(1e-6, 2e-6) } }; + await loadWith(payload, tmpCachePath("blank")); + + // `!model` misses this, so before the trim it reached the lookup and leaked + // into unpriced_models as " " — the same defect wearing different characters. + assert.equal(pricing.getModelPricingMeta(" ").tier, "empty"); + assert.deepEqual(pricing.getPricingDiagnostics().unpriced_models, []); +}); + +test("case and padding do not smuggle the placeholder past the check", async () => { + const payload = { current: { "acme-1": entry(1e-6, 2e-6) } }; + await loadWith(payload, tmpCachePath("placeholder-variants")); + + for (const variant of ["UNKNOWN", " Unknown ", "unknown\n"]) { + assert.equal( + pricing.getModelPricingMeta(variant).tier, + "unattributed", + `${JSON.stringify(variant)} is the same placeholder`, + ); + } + assert.deepEqual(pricing.getPricingDiagnostics().unpriced_models, []); +}); + +test("a real model that merely contains \"unknown\" is still priced or missed normally", async () => { + // The exemption is a closed set, not a substring rule: silently un-pricing a + // real model would recreate the $0 bug this whole surface exists to expose. + const payload = { current: { "unknown-labs-v2": entry(3e-6, 6e-6) } }; + await loadWith(payload, tmpCachePath("real-model")); + + assert.equal(pricing.getModelPricingMeta("unknown-labs-v2").tier, "litellm:exact"); + // Contains the priced key, so it resolves the same way any other model would. + assert.equal(pricing.getModelPricingMeta("unknown-labs-v2-preview").tier, "litellm:fuzzy"); + assert.equal(pricing.getModelPricingMeta("mystery-model").tier, "miss"); + assert.deepEqual(pricing.getPricingDiagnostics().unpriced_models, ["mystery-model"]); +}); + +test("cost for an unattributed row is unchanged by the exemption", async () => { + const payload = { current: { "acme-1": entry(1e-6, 2e-6) } }; + await loadWith(payload, tmpCachePath("unattributed-cost")); + + // The row was already costing $0 (no price to apply). The fix changes how it + // is *reported*, and must not quietly change anyone's total. + const cost = pricing.computeRowCost({ + model: "unknown", + source: "claude", + input_tokens: 1_000_000, + output_tokens: 1_000_000, + }); + assert.equal(cost, 0); + assert.deepEqual(pricing.getModelPricing("unknown"), pricing.ZERO_PRICING); +}); From f33f39c60dcd961344ecd3860a97f9cae0348bee Mon Sep 17 00:00:00 2001 From: "itarun.p" Date: Sat, 25 Jul 2026 08:21:26 +0700 Subject: [PATCH 2/3] docs: make the front-door claims true, and teach CI to keep them that way MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README is well structured, but three of its strongest trust claims were checkable and failed the check — in a product whose entire pitch is "trust this number, trust this privacy". - `tokentracker activate-if-needed`, offered to users whose install is already broken, has never existed in the CLI's dispatch. It throws. The working command is `tokentracker init`. - "local SQLite" described the store; the store is an append-only file at ~/.tokentracker/tracker/queue.jsonl. SQLite is something we only ever *read*, from tools like Cursor. Reframed as the audit path it actually is: you can `cat` the whole thing. - "Nothing ever leaves your laptop" / "no upload path at all" are refutable in five minutes with lsof — pricing, plan quotas, OAuth refresh and avatars all make outbound calls. Replaced with the narrower claim that is actually true ("your usage data never leaves your machine") plus a table naming every outbound call and why. The weaker claim is the stronger trust signal, because it survives being checked. Also: Droid has been wired and tested since sync.js imported it but appeared nowhere in the README; the hard-coded tool count is now "20+", because a number in prose has no validator — the same failure mode as the expired DeepSeek prices. CONTRIBUTING cloned the upstream fork, claimed a stale test count, and pointed at a README table that no longer exists. `uninstall` does not remove the launchd agents, which are installed outside the CLI, so "removes everything" now says so. Value, not just accuracy: added why you'd use this over each provider's own billing page, and a section for the macOS menu-bar app and Windows app — both published as release assets, both invisible in the README, both sitting at zero downloads. The durable part is the last commit's worth: scripts/openwiki-check-facts.cjs already had a check that rejects a documented CLI command which doesn't exist. It would have caught activate-if-needed the day it was written — it was only ever pointed at openwiki/, never at the file users actually copy commands from. It now scans README.md and CONTRIBUTING.md too, while the completeness half stays scoped to openwiki/ (a front door is not a manifest). It earned that immediately: it rejected `tokentracker --version` in my own new prose (also not a real command) and a cautionary example that quoted the fake command verbatim. There is deliberately no way to silence a finding. Finally, documents the pricing-diagnostics surface now that #93 is fixed — including why `unpriced_models` excludes placeholders — and replaces the publish checklist's hard-coded model names with a query against that surface. Refs #93 --- CLAUDE.md | 4 +- CONTRIBUTING.md | 10 +-- README.md | 112 ++++++++++++++++++++++--------- docs/npm-publish-checklist.md | 16 ++++- docs/screenshots/leaderboard.png | Bin 35187 -> 0 bytes openwiki/README.md | 4 +- openwiki/local-api.md | 28 ++++++++ openwiki/quickstart.md | 32 +++++---- scripts/openwiki-check-facts.cjs | 36 +++++++--- test/openwiki-facts.test.js | 46 ++++++++++++- 10 files changed, 221 insertions(+), 67 deletions(-) delete mode 100644 docs/screenshots/leaderboard.png diff --git a/CLAUDE.md b/CLAUDE.md index 6f6512ce..a0492cfa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Guidance for Claude Code working in this repository. Every line here is loaded i Token Tracker is a local-first AI token usage tracker. -- **CLI** (`src/`, CommonJS, Node ≥20) — entry `bin/tracker.js` → `src/cli.js`. `serve` runs a local HTTP server on `:7680`, `sync` parses logs into `~/.tokentracker/queue.jsonl`. +- **CLI** (`src/`, CommonJS, Node ≥20) — entry `bin/tracker.js` → `src/cli.js`. `serve` runs a local HTTP server on `:7680`, `sync` parses logs into `~/.tokentracker/tracker/queue.jsonl`. - **Dashboard** (`dashboard/`, React 18 + Vite 7 + TS strict + Tailwind) — built to `dashboard/dist/`, served by the CLI on localhost. Local-only: there is no hosted deployment. - **macOS app** (`TokenTrackerBar/`, Swift 5.9, XcodeGen) — menu bar + WidgetKit. `EmbeddedServer/` bundles the CLI runtime + built dashboard so the `.app` is self-contained. - **Windows app** (`TokenTrackerWin/`, .NET 8 WinForms + WPF + WebView2) — system-tray counterpart of the macOS app. Launches the bundled CLI `serve` on a dynamic loopback port (avoids the DoSvc-held `:7680`), hosts the dashboard in WebView2, registers the `tokentracker://` deep-link for OAuth. Built `EmbeddedServer/` (Node + CLI + dashboard) is bundled by `scripts/bundle-node.ps1` so the `.exe` is self-contained. Dashboard adaptations are gated behind `isNativeWindowsApp()` (`dashboard/src/lib/native-bridge.js`) so macOS/web paths are untouched. @@ -18,7 +18,7 @@ For the canonical list of supported providers, grep `parse*Incremental` in `src/ ## Frequently used commands ```bash -npm test # node --test test/*.test.js (97 files) +npm test # node --test test/*.test.js node --test test/.test.js # single test file npm run ci:local # tests + validations + builds npm run dashboard:dev # Vite dev server with local API mock (port 5173) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eed73fa4..84f9a71d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ Thanks for considering a contribution! TokenTracker is a small project, so the p ## Setup ```bash -git clone https://github.com/mm7894215/TokenTracker.git +git clone https://github.com/pitimon/TokenTracker.git cd TokenTracker npm install @@ -25,7 +25,7 @@ node bin/tracker.js doctor # Health check ## Tests ```bash -npm test # Full suite (96 test files, node --test) +npm test # Full suite (node --test over test/*.test.js) node --test test/rollout-parser.test.js # A single test file npm run ci:local # Tests + validations + builds (everything CI runs) ``` @@ -50,11 +50,11 @@ npm run validate:copy # Validate copy registry completene This is the most common kind of contribution. The pattern: -1. **Add a parser to `src/lib/rollout.js`** — most tools write JSONL or SQLite logs. The parser should normalize tokens into the canonical shape: `{input_tokens, output_tokens, cached_input_tokens, cache_creation_input_tokens, total_tokens, model, source, hour_start}`. +1. **Add a parser to `src/lib/rollout.js`** — most tools write JSONL or SQLite logs. The parser should normalize tokens into the canonical shape: `{input_tokens, output_tokens, cached_input_tokens, cache_creation_input_tokens, total_tokens, model, source, hour_start}`. This file is large; don't read it top to bottom — find the closest existing tool (`parseDroidIncremental`, `parseZedIncremental`, …) and copy its shape. Each parser exports a `resolve*`/`list*` pair for finding the tool's files and a `parse*Incremental` for reading them. 2. **Add a hook installer in `src/commands/init.js`** — most tools support a config file or hook script you can patch. Make it idempotent (safe to re-run). 3. **Add a status check in `src/commands/status.js`** — show whether the hook is installed and whether data has been collected. -4. **Add a parser test in `test/rollout-parser.test.js`** — use a real (anonymized) sample log fixture. -5. **Update `README.md` Supported AI Tools table** with the new row. +4. **Add a parser test in its own `test/-parser.test.js`** — use a real (anonymized) sample log fixture. Recent tools each got their own file (`droid-parser.test.js`, `zed-parser.test.js`, `goose-parser.test.js`); only the older ones share `rollout-parser.test.js`. +5. **Add the tool to the Supported tools list in `README.md`** — the blockquote under "🔌 Supported tools". Leave the "20+" count alone; a hard number in prose has no validator and goes stale silently. Look at how Claude Code, Codex, or Gemini are wired in for reference — they're the simplest examples. diff --git a/README.md b/README.md index e1ff1d10..df9a0b12 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ### Know exactly what you're spending on AI — across every CLI -Auto-collect token usage from **22 AI coding tools**, aggregate it locally, and read real cost trends in one dashboard. No account or API key required to start — just one command. +Auto-collect token usage from **20+ AI coding tools**, aggregate it locally, and read real cost trends in one dashboard. No account or API key required to start — just one command. [![npm version](https://img.shields.io/npm/v/@ipv9/tokentracker-cli.svg?color=blue)](https://www.npmjs.com/package/@ipv9/tokentracker-cli) [![npm downloads](https://img.shields.io/npm/dm/@ipv9/tokentracker-cli.svg?color=brightgreen)](https://www.npmjs.com/package/@ipv9/tokentracker-cli) @@ -24,6 +24,19 @@ Auto-collect token usage from **22 AI coding tools**, aggregate it locally, and --- +## 🤔 Why not just read each provider's billing page? + +You can — that is the honest alternative, and for a single tool it is enough. TokenTracker earns its place once you use more than one: + +- **One number instead of six tabs.** Claude, Codex, Cursor, Gemini and Copilot each bill in their own dashboard, on their own reset schedule, in their own units. Nobody adds them up for you. +- **Subscriptions hide the number entirely.** A flat monthly plan shows you a quota bar, not what your usage would have cost. TokenTracker prices every token against public model rates, so you can see whether the plan is a bargain or a subsidy you have outgrown. +- **Per-project and per-model, not just per-account.** Billing pages answer "what do I owe this month". This answers "which repo, which model, and which hour" — the resolution you need to actually change something. +- **Quota chips before you hit the wall.** Live plan-limit usage sits on each provider's card, so a 5-hour window running out is something you see rather than something you discover. + +If you only use one tool and never care about per-project cost, the provider's own page is genuinely fine. This is for the rest. + +--- + ## ⚡ Quick Start > **Requires** Node.js **20+**. @@ -48,21 +61,34 @@ tokentracker doctor # health check --- +## 🖥️ Prefer an app? There's a desktop build + +If you'd rather not keep a terminal open, both native apps are on the [Releases page](https://github.com/pitimon/TokenTracker/releases/latest): + +| Platform | Download | What it adds | +|---|---|---| +| **macOS 12+** | `TokenTrackerBar.dmg` | A menu-bar app — live token count in the menu bar, launch at login, sync and update from a click, plus a desktop widget. | +| **Windows** | `TokenTracker-Setup.exe` | The same dashboard as a standalone app. | + +TokenTracker in the macOS menu bar + +Both bundle their own Node runtime, so there is nothing else to install. They share the same local data as the CLI — run either, or both. The desktop builds are cut less often than the npm package, so the latest release tag usually trails the npm version badge above. + +--- + ## ✨ What you get -- 🔒 **Private by design.** Runs entirely on your machine — token counts and timestamps only, never prompts, responses, or file contents. No account, no required API keys, no telemetry, no phone-home. Nothing ever leaves your laptop. -- 📊 **One calm web dashboard.** Your whole picture in the browser at a local URL (no login): total spend, usage trend, per-provider breakdown, context breakdown, and a GitHub-style activity heatmap — light or dark, auto-refreshing while the tab is open. +- 🔒 **Your usage data never leaves your machine.** Token counts and timestamps only — never prompts, responses, or file contents. No account, no telemetry, no analytics, no phone-home. TokenTracker does make a few outbound calls *on your behalf* (model prices, your own plan quotas); they are listed in [Privacy](#-privacy) and none of them carry your usage. +- 📊 **One calm web dashboard.** Your whole picture in the browser at a local URL, no login — light or dark, auto-refreshing while the tab is open. [What's on it ↓](#-the-dashboard) - 📈 **Quota at a glance, on every card.** Live plan-quota usage (used %, e.g. 5h + weekly) as color-coded chips right on each provider's card — see how close you are to your limits without leaving the overview. Full windows + reset countdowns on the Limits page. Covers Claude, Codex, Cursor, Gemini, Kimi, Z.AI, Kiro, Copilot, and Antigravity. -- 💰 **Cost you can trust.** 2,200+ models priced from [LiteLLM](https://github.com/BerriAI/litellm) (refreshed daily) with a bundled offline snapshot, so USD totals are right even without a network. Cross-provider records are de-duplicated to match each provider's own billing. -- 🔌 **22 tools, zero config.** Claude Code, Codex, Cursor, Gemini, Copilot, Antigravity, OpenCode, Kiro, Zed, Goose, and more — auto-detected, hooks auto-install on first run. Zero to dashboard in ~30 seconds. +- 💰 **Cost you can trust — and a price tag when it can't.** 2,200+ models priced from [LiteLLM](https://github.com/BerriAI/litellm) (refreshed daily) with a bundled offline snapshot, so USD totals are right even without a network. A model too new to have a price is badged **pricing missing** rather than quietly counted as $0, and prices refresh in the background instead of waiting for a restart. Cross-provider records are de-duplicated to match each provider's own billing. +- 🔌 **20+ tools, zero config.** Claude Code, Codex, Cursor, Gemini, Copilot, Antigravity, OpenCode, Kiro, Zed, Goose, and more — auto-detected, hooks auto-install on first run. Zero to dashboard in ~30 seconds. - 🧩 **Skills tab.** Syncs 250+ public skills across your tools. --- ## 📊 The dashboard -A calm, single-screen readout — a hero total paired with a usage-trend chart, a provider breakdown stacked above a per-provider context breakdown, and a GitHub-style activity heatmap. - | Dark | Light | |---|---| | Dashboard — dark | Dashboard — light | @@ -83,7 +109,7 @@ Auto-refresh runs only while the tab is visible (`Off` / `30s` / `60s` / `120s`, Auto-detected on first run — no manual plugin or hook wiring: -> **Claude Code · Codex CLI · Cursor · Gemini CLI · GitHub Copilot · Antigravity · Kiro · OpenCode · OpenClaw · Every Code · Hermes · Kimi Code · CodeBuddy · Grok Build · oh-my-pi · pi · Craft Agents · Kilo CLI · Kilo Code · Roo Code · Zed Agent · Goose** +> **Claude Code · Codex CLI · Cursor · Gemini CLI · GitHub Copilot · Antigravity · Kiro · OpenCode · OpenClaw · Every Code · Hermes · Kimi Code · CodeBuddy · Grok Build · Droid · oh-my-pi · pi · Craft Agents · Kilo CLI · Kilo Code · Roo Code · Zed Agent · Goose** Each tool is connected one of three ways, all automatic: a **SessionEnd/notify hook** (Claude Code, Codex, Gemini, Every Code, CodeBuddy, Grok Build), a **bundled plugin** linked via the tool's own CLI (OpenCode, OpenClaw), or a **passive reader** that only reads files the tool already writes — SQLite, JSONL, OTEL exports (Cursor, Kiro, Copilot, Zed, Goose, and the rest). @@ -96,16 +122,16 @@ Rate-limit providers are auto-detected where possible. For Z.AI / GLM Coding Pla ## 🏗️ How it works ``` -AI CLI tools → hooks / passive readers → local SQLite → dashboard - (logs) (token counts only) (30-min buckets) (your browser) +AI CLI tools → hooks / passive readers → local queue file → dashboard + (logs) (token counts only) (30-min buckets) (your browser) ``` 1. Your AI tools write logs during normal use. -2. Lightweight hooks (or passive file readers) pick up token counts locally — never prompt or response content. -3. Counts are aggregated into 30-minute UTC buckets in a local SQLite snapshot. -4. The dashboard reads that snapshot and renders it in your browser's timezone. +2. Lightweight hooks (or passive file readers) pick up token counts locally — never prompt or response content. Some tools keep their logs in SQLite (Cursor, Kiro, Zed and friends); TokenTracker only ever *reads* those. +3. Counts are aggregated into 30-minute UTC buckets and appended to one plain-text file: `~/.tokentracker/tracker/queue.jsonl`. +4. The dashboard reads that file and renders it in your browser's timezone. -Nothing leaves your machine. There is no account, no upload, and no server to sign in to. +No account, no upload of your usage, and no server to sign in to. --- @@ -114,9 +140,20 @@ Nothing leaves your machine. There is no account, no upload, and no server to si | Protection | What it means | |---|---| | **No content** | Only token counts and timestamps. Never prompts, responses, or files. | -| **Local only** | All data stays on your machine. There is no upload path at all. | -| **Auditable** | Open source — read [`src/lib/rollout.js`](src/lib/rollout.js); it's just numbers and timestamps. | -| **No telemetry** | No analytics, no crash reporting, no phone-home. | +| **Your usage stays local** | Every count TokenTracker collects is written to one file on your disk and read back by a server on your own machine. There is no endpoint it uploads usage to. | +| **Auditable in one command** | You don't have to take our word for it — the store is an append-only text file you can open yourself: `cat ~/.tokentracker/tracker/queue.jsonl`. It's numbers and timestamps. | +| **No telemetry** | No analytics, no crash reporting, no phone-home, no account. | + +**Outbound calls, on your behalf only.** TokenTracker is local-first, not network-free. It talks to the internet in exactly these cases, and none of them carry your usage data: + +| When | Where | Why | +|---|---|---| +| Pricing refresh (daily) | `raw.githubusercontent.com` | Downloads the public [LiteLLM](https://github.com/BerriAI/litellm) price list. Anonymous — no credentials, nothing sent. Works offline from a bundled snapshot. | +| Quota chips + Limits page | `api.anthropic.com`, `chatgpt.com`, `cursor.com`, `cloudcode-pa.googleapis.com`, `api.kimi.com`, `api.z.ai`, `api.github.com` | Asks *your* provider about *your* plan limits, using credentials already on your machine. Only for providers you actually use. | +| Token refresh | `auth.openai.com`, `oauth2.googleapis.com`, `auth.kimi.com` | Renews those same provider credentials when they expire. | +| Profile avatars | Allowlisted avatar CDNs | Fetched server-side so your browser doesn't contact them directly. | +| IP check page | `ip.net.coffee` | Only if you open that page. | +| `npx` startup | npm registry | How `npx` works — it downloads the package. A global install avoids it. | --- @@ -147,29 +184,30 @@ Browser auto-open is opt-in: `tokentracker serve --open`. Background services an --- -## 🛠️ Development +## ⏱️ Always-on, without a terminal (macOS) + +If you want the dashboard up all the time but don't want the desktop app, the repo ships a launchd installer. It registers two LaunchAgents — the dashboard on port `7680`, and a periodic background sync — both pinned to a specific published version: ```bash git clone https://github.com/pitimon/TokenTracker.git cd TokenTracker -npm install +./scripts/install-local-service.sh # remove later with ./scripts/uninstall-local-service.sh +``` -# build the dashboard, then run the CLI -npm run dashboard:build -node bin/tracker.js +macOS only; it uses `launchd` directly. On Linux, the same effect is a small systemd user unit running `tokentracker serve --sync --no-open`. -npm test # root tests -npm run ci:local # full local gate (build + tests + validators) -``` +--- + +## 🛠️ Development -## 📚 Code Documentation +```bash +git clone https://github.com/pitimon/TokenTracker.git +cd TokenTracker && npm install +npm run dashboard:build && node bin/tracker.js +npm run ci:local # the full gate: build + tests + validators +``` -Source-backed engineering documentation starts at -[`openwiki/README.md`](openwiki/README.md). Regenerate the local fact -ledger with `npm run docs:openwiki:extract`, validate it with -`npm run docs:openwiki:check`, and use `npm run docs:openwiki:verify` for the -independent read-only review. The model-backed update command expects credentials -from the caller's environment and never reads them from this repository. +Setup details, the test layout, and how to add a new tool integration are in [CONTRIBUTING.md](CONTRIBUTING.md). Source-backed engineering documentation starts at [`openwiki/README.md`](openwiki/README.md). --- @@ -185,7 +223,7 @@ tokentracker status # see each integration's state tokentracker doctor # deeper health check ``` -If a tool you use shows as not configured, run `tokentracker activate-if-needed` to re-run detection. Still missing? [Open an issue](https://github.com/pitimon/TokenTracker/issues/new) with the `doctor` output. +If a tool you use shows as not configured, run `tokentracker init` — it re-runs detection and installs anything missing. Still missing? [Open an issue](https://github.com/pitimon/TokenTracker/issues/new) with the `doctor` output. @@ -261,6 +299,14 @@ tokentracker uninstall Removes every hook TokenTracker installed across all detected tools, plus local config and data. Safe to re-run. +One thing it does **not** touch: if you set up the always-on macOS service yourself with `scripts/install-local-service.sh`, that LaunchAgent is installed outside the CLI and keeps restarting the dashboard. Remove it first: + +```bash +./scripts/uninstall-local-service.sh +``` + +(The CLI never installs a LaunchAgent, so if you have only ever run `npx`/`tokentracker`, there is nothing extra to clean up.) + --- diff --git a/docs/npm-publish-checklist.md b/docs/npm-publish-checklist.md index cc899959..84df7ba5 100644 --- a/docs/npm-publish-checklist.md +++ b/docs/npm-publish-checklist.md @@ -28,8 +28,20 @@ service scripts under `scripts/`. Dashboard pricing/UI release gate: -- `claude-sonnet-5`, `claude-fable-5`, and `claude-opus-4-8` must resolve to - non-zero pricing before publish. Use `node --test test/pricing.test.js`. +- Every model you actually used must resolve to non-zero pricing before publish. + Run `node --test test/pricing.test.js`, then check the live surface rather than + a hard-coded list of model names — a list in prose goes stale the week a new + model ships, which is the failure this gate exists to catch: + + ```bash + curl -s "http://localhost:7680/functions/tokentracker-usage-model-breakdown?from=$(date -v-7d +%F)&to=$(date +%F)" \ + | python3 -c 'import json,sys; p=json.load(sys.stdin)["pricing"]; print(p["unpriced_models"], p["fuzzy_priced_models"])' + ``` + + `unpriced_models` should be empty. Anything listed needs an entry in + `src/lib/pricing/curated-overrides.json` before you publish. Entries in + `fuzzy_priced_models` are priced by partial match — plausible but possibly + wrong, so confirm them rather than assuming. - Collapsed provider cards must show model chips, top-cost signal, and a pricing-missing badge when a non-free model has tokens but zero cost. Use the focused `UsageOverview` and `model-breakdown` tests before relying on a diff --git a/docs/screenshots/leaderboard.png b/docs/screenshots/leaderboard.png deleted file mode 100644 index 86e42129a6988101c373355c67a66336a6a53232..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 35187 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 XI@Ig)zAo<^Po|u-($ic?ec%5B6kOoc diff --git a/openwiki/README.md b/openwiki/README.md index 4125f9cf..2b74a8ac 100644 --- a/openwiki/README.md +++ b/openwiki/README.md @@ -92,8 +92,8 @@ transformed between them, see the [Data flow](architecture/dataflow.md) view. ## Start here -- [Quickstart](quickstart.md): documentation workflow, source ledger, and local - update commands. +- [Working on this documentation](quickstart.md): the source ledger, the + regeneration commands, and what the fact checker enforces. - [Architecture](architecture/overview.md): runtime components and boundaries. - [Data flow](architecture/dataflow.md): how usage data moves from tool logs to the dashboard, with a leveled data-flow diagram. diff --git a/openwiki/local-api.md b/openwiki/local-api.md index ce0c0e83..9b2d8f5a 100644 --- a/openwiki/local-api.md +++ b/openwiki/local-api.md @@ -30,6 +30,34 @@ The mutation endpoint checks local authorization. The skills endpoint has its ow method-specific behavior. Do not expose either endpoint beyond the local server without re-evaluating that security model. +## Pricing diagnostics + +`/functions/tokentracker-usage-model-breakdown` is the one endpoint that reports +how much to trust its own numbers. Each model in `sources[].models[]` carries a +`pricing_tier`, and the response's `pricing` object carries a snapshot of what +the pricing layer knows it guessed at or missed. Both come from +`getPricingDiagnostics()` in `src/lib/pricing/index.js`; read that function +before relying on the exact field set. + +The tier vocabulary is a closed set, and the distinctions matter because three +different situations all produce a `$0` cost: + +| Tier | Means | +| --- | --- | +| `curated:exact`, `litellm:exact` | Matched a model id outright. Trust the price. | +| `curated:fuzzy`, `litellm:fuzzy`, `litellm:prefix-strip` | Priced by partial match. Plausible, possibly a different model's price — and never `$0`, so it cannot be spotted by looking for zeros. | +| `miss` | No price found. Counted as `$0`; needs an entry in `curated-overrides.json`. | +| `unattributed` | The row had no model id and is stored under the placeholder `unknown`. Also `$0`, but nothing to add a price for. | +| `empty` | No model id at all. | + +`unpriced_models` lists only `miss` models — it is a work list of ids needing a +curated price, so placeholders are deliberately excluded from it. +`fuzzy_priced_models` lists the guessed ones. A `miss`, or a snapshot older than +its TTL, triggers a single-flight background refresh; `refreshing`, `stale`, and +`last_refresh_error` report that machinery. A refresh that cannot reach upstream +is discarded rather than installed, so a failed refresh never replaces good +prices with an older snapshot's. + ## Related modules - `src/lib/pricing/` resolves model pricing used by local aggregations. diff --git a/openwiki/quickstart.md b/openwiki/quickstart.md index 08f8f540..35a7f297 100644 --- a/openwiki/quickstart.md +++ b/openwiki/quickstart.md @@ -1,18 +1,8 @@ -# TokenTracker OpenWiki +# Working on this documentation -TokenTracker is a local-first token-usage tracker. The Node CLI parses supported -tool logs into local queue files, serves a dashboard on loopback, and can be -bundled by the macOS and Windows desktop applications. - -Start with [architecture](architecture/overview.md) for the data flow. Use these -pages for change-oriented source maps: - -- [CLI and operations](cli-and-operations.md) -- [Parsers and sync](parsers-and-sync.md) -- [Local API](local-api.md) -- [Dashboard routes](dashboard-routes.md) -- [Native app boundaries](native-app-boundaries.md) -- [Testing and release](testing-and-release.md) +How to keep these pages true: where the authority lives, which commands +regenerate it, and the entry points worth knowing before you edit. For the map +of the pages themselves, start at [the OpenWiki index](README.md). ## Source of truth @@ -26,6 +16,20 @@ npm run docs:openwiki:extract npm run docs:openwiki:check ``` +The check runs two different ways over two different file sets: + +- **Every reference must resolve** — across `openwiki/**`, plus `README.md` and + `CONTRIBUTING.md`. A CLI command, `/functions/*` endpoint, dashboard route, or + `parse*Incremental` symbol named in any of them must exist in the ledger. The + front-door docs are included because that is where readers copy commands from: + `README.md` spent a long time telling users with a broken install to run a + repair command the CLI has never had, while this checker passed every time + without ever looking at that file. There is deliberately no way to silence a + finding — if a page needs to name something that does not exist, describe it + instead of quoting it. +- **Every contract must be documented** — `openwiki/**` only. The README is a + front door, not a manifest; it is not required to list every endpoint. + ## Local flow ```text diff --git a/scripts/openwiki-check-facts.cjs b/scripts/openwiki-check-facts.cjs index fae9d04e..22ef037a 100644 --- a/scripts/openwiki-check-facts.cjs +++ b/scripts/openwiki-check-facts.cjs @@ -21,7 +21,12 @@ function findLine(content, index) { return content.slice(0, index).split("\n").length; } -function collectFindings({ facts, files, root = ROOT }) { +// `files` are scanned for references that must resolve (an unknown command in +// any doc is a defect). `coverageFiles` are the subset that must *also* be +// complete — every real command/endpoint/route documented somewhere. Only +// openwiki/ carries that obligation: the README is a front door, not a manifest, +// and requiring it to list every endpoint would be the wrong kind of pressure. +function collectFindings({ facts, files, coverageFiles = files, root = ROOT }) { const findings = []; const commandNames = new Set(facts.cli.commands.map((command) => command.name)); const endpointNames = new Set(facts.local_api.endpoints.map((endpoint) => endpoint.path)); @@ -30,14 +35,16 @@ function collectFindings({ facts, files, root = ROOT }) { const documentedCommands = new Set(); const documentedEndpoints = new Set(); const documentedRoutes = new Set(); + const coveragePaths = new Set(coverageFiles.map((file) => file.path)); for (const file of files) { const relative = path.relative(root, file.path); + const countsForCoverage = coveragePaths.has(file.path); for (const match of file.content.matchAll(/(?:npx --yes @ipv9\/tokentracker-cli|(? file.content).join("\n"); + const allDocumentation = coverageFiles.map((file) => file.content).join("\n"); for (const route of routeNames) { if (allDocumentation.includes(`\`${route}\``)) documentedRoutes.add(route); } @@ -92,12 +99,25 @@ function checkFacts({ root = ROOT } = {}) { if (JSON.stringify(saved) !== JSON.stringify(current)) { findings.push("openwiki-facts/source-facts.json is stale; run npm run docs:openwiki:extract"); } - const files = readMarkdownFiles(path.join(root, "openwiki")); - if (files.length === 0) { + const coverageFiles = readMarkdownFiles(path.join(root, "openwiki")); + if (coverageFiles.length === 0) { findings.push("openwiki/ contains no Markdown documentation"); return findings; } - return findings.concat(collectFindings({ facts: current, files, root })); + // The front-door docs are checked for unresolvable references too. They are + // what users actually run commands from: README.md told broken installs to run + // `tokentracker activate-if-needed`, a command that has never existed, and + // this check would have caught it on the day it was written had it been + // looking. Scoped to the two root docs a reader is told to follow. + const files = coverageFiles.concat(readRootDocs(root)); + return findings.concat(collectFindings({ facts: current, files, coverageFiles, root })); +} + +function readRootDocs(root) { + return ["README.md", "CONTRIBUTING.md"] + .map((name) => path.join(root, name)) + .filter((filePath) => fs.existsSync(filePath)) + .map((filePath) => ({ path: filePath, content: fs.readFileSync(filePath, "utf8") })); } function main() { diff --git a/test/openwiki-facts.test.js b/test/openwiki-facts.test.js index 655913cb..620f3b7e 100644 --- a/test/openwiki-facts.test.js +++ b/test/openwiki-facts.test.js @@ -1,8 +1,10 @@ const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); const { test } = require("node:test"); const { extractFacts } = require("../scripts/openwiki-extract-facts.cjs"); -const { collectFindings } = require("../scripts/openwiki-check-facts.cjs"); +const { checkFacts, collectFindings } = require("../scripts/openwiki-check-facts.cjs"); test("OpenWiki facts expose TokenTracker's public command, API, route, and parser contracts", () => { const facts = extractFacts(); @@ -24,3 +26,45 @@ test("OpenWiki fact checker rejects unsupported concrete claims", () => { }); assert.equal(findings.filter((finding) => finding.includes("unknown")).length, 4); }); + +test("the fact checker reads the front-door docs, not only openwiki/", () => { + // README.md told users with a broken install to run + // `tokentracker activate-if-needed` — a command that has never been in the + // CLI's dispatch. The checker that would have caught it existed the whole + // time; it just wasn't looking at the file users actually follow. + const findings = checkFacts(); + assert.deepEqual(findings, [], "repo docs are clean as committed"); + + const readme = path.join(process.cwd(), "README.md"); + const original = fs.readFileSync(readme, "utf8"); + try { + fs.writeFileSync(readme, `${original}\nRun \`tokentracker activate-if-needed\` to fix.\n`); + const withFake = checkFacts(); + assert.ok( + withFake.some((f) => f.startsWith("README.md:") && f.includes("activate-if-needed")), + `expected README.md to be scanned, got: ${JSON.stringify(withFake)}`, + ); + } finally { + fs.writeFileSync(readme, original); + } +}); + +test("the front-door docs do not owe openwiki's completeness obligation", () => { + // README is a front door, not a manifest. Documenting a command there must not + // satisfy openwiki's "every command is documented" check, or extending the + // scan would have quietly weakened the coverage half of the same validator. + const facts = extractFacts(); + const findings = collectFindings({ + facts, + root: process.cwd(), + files: [ + { path: `${process.cwd()}/README.md`, content: "`tokentracker doctor`\n" }, + { path: `${process.cwd()}/openwiki/example.md`, content: "nothing documented here\n" }, + ], + coverageFiles: [{ path: `${process.cwd()}/openwiki/example.md`, content: "nothing documented here\n" }], + }); + assert.ok( + findings.includes("openwiki/ missing CLI command 'doctor'"), + "a command documented only in README must still count as undocumented in openwiki", + ); +}); From a4b3c16a6d6229ee004b2626bbd84ba5984197b6 Mon Sep 17 00:00:00 2001 From: "itarun.p" Date: Sat, 25 Jul 2026 08:28:36 +0700 Subject: [PATCH 3/3] docs: complete the tier table from source; stop the test mutating README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up review caught this PR committing the mistake it is about. The pricing-tier table asserted "a closed set" and listed six values. The resolution ladder in src/lib/pricing/matcher.js emits eleven. I had read FUZZY_SOURCES and the tiers my own tests exercise, and written the table from those — an unchecked claim in the one change whose thesis is don't ship an unchecked claim. Enumerated from source and regrouped by what each rung means for trusting the number: exactly-resolved, guessed, and not-priced. The four missing rungs were curated:exact-dot, litellm:exact-dot, curated:alias and litellm:strip. Checked whether the omission hid a live defect, since a guessed tier missing from FUZZY_SOURCES would mean fuzzy_priced_models under-reports — the exact invisibility #90 existed to remove. It does not: litellm:strip removes a reasoning-effort suffix (-high, -xhigh, -fast) and then matches the base model exactly, and reasoning effort changes how many tokens you spend, not the rate. curated:alias is a deliberate mapping. Both belong with the exact tiers. FUZZY_SOURCES is correct as written; matcher.js is now cited as the authority so the table cannot drift from it silently. Also: - The scope test wrote a fake command into the tracked README and restored it in a finally block. That covers a thrown assertion but not a killed process or a CI timeout — and the recovery path is `git add README.md`, which would silently re-commit the exact falsehood this PR removes. Now asserts through an exported readRootDocs plus a synthetic collectFindings call. Same two properties proven, nothing tracked is touched. - The publish checklist used `date -v-7d`, which is BSD-only and would fail for a Linux maintainer with nothing in CI to catch it. - Dropped two in-page anchors to emoji headings. GitHub's slugger handles the variation selector in 🛡️ unintuitively and I could not verify the generated id, so the cross-reference is prose. A dead link in the section arguing you should trust these claims is a poor trade for a saved scroll. --- README.md | 4 ++-- docs/npm-publish-checklist.md | 4 +++- openwiki/local-api.md | 38 ++++++++++++++++++++++------- scripts/openwiki-check-facts.cjs | 2 +- test/openwiki-facts.test.js | 41 +++++++++++++++++--------------- 5 files changed, 58 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index df9a0b12..920a03fd 100644 --- a/README.md +++ b/README.md @@ -78,8 +78,8 @@ Both bundle their own Node runtime, so there is nothing else to install. They sh ## ✨ What you get -- 🔒 **Your usage data never leaves your machine.** Token counts and timestamps only — never prompts, responses, or file contents. No account, no telemetry, no analytics, no phone-home. TokenTracker does make a few outbound calls *on your behalf* (model prices, your own plan quotas); they are listed in [Privacy](#-privacy) and none of them carry your usage. -- 📊 **One calm web dashboard.** Your whole picture in the browser at a local URL, no login — light or dark, auto-refreshing while the tab is open. [What's on it ↓](#-the-dashboard) +- 🔒 **Your usage data never leaves your machine.** Token counts and timestamps only — never prompts, responses, or file contents. No account, no telemetry, no analytics, no phone-home. TokenTracker does make a few outbound calls *on your behalf* (model prices, your own plan quotas); every one is named in the Privacy section below, and none of them carry your usage. +- 📊 **One calm web dashboard.** Your whole picture in the browser at a local URL, no login — light or dark, auto-refreshing while the tab is open. - 📈 **Quota at a glance, on every card.** Live plan-quota usage (used %, e.g. 5h + weekly) as color-coded chips right on each provider's card — see how close you are to your limits without leaving the overview. Full windows + reset countdowns on the Limits page. Covers Claude, Codex, Cursor, Gemini, Kimi, Z.AI, Kiro, Copilot, and Antigravity. - 💰 **Cost you can trust — and a price tag when it can't.** 2,200+ models priced from [LiteLLM](https://github.com/BerriAI/litellm) (refreshed daily) with a bundled offline snapshot, so USD totals are right even without a network. A model too new to have a price is badged **pricing missing** rather than quietly counted as $0, and prices refresh in the background instead of waiting for a restart. Cross-provider records are de-duplicated to match each provider's own billing. - 🔌 **20+ tools, zero config.** Claude Code, Codex, Cursor, Gemini, Copilot, Antigravity, OpenCode, Kiro, Zed, Goose, and more — auto-detected, hooks auto-install on first run. Zero to dashboard in ~30 seconds. diff --git a/docs/npm-publish-checklist.md b/docs/npm-publish-checklist.md index 84df7ba5..d28e5cee 100644 --- a/docs/npm-publish-checklist.md +++ b/docs/npm-publish-checklist.md @@ -34,7 +34,9 @@ Dashboard pricing/UI release gate: model ships, which is the failure this gate exists to catch: ```bash - curl -s "http://localhost:7680/functions/tokentracker-usage-model-breakdown?from=$(date -v-7d +%F)&to=$(date +%F)" \ + FROM=$(python3 -c 'import datetime;print(datetime.date.today()-datetime.timedelta(days=7))') + TO=$(python3 -c 'import datetime;print(datetime.date.today())') + curl -s "http://localhost:7680/functions/tokentracker-usage-model-breakdown?from=$FROM&to=$TO" \ | python3 -c 'import json,sys; p=json.load(sys.stdin)["pricing"]; print(p["unpriced_models"], p["fuzzy_priced_models"])' ``` diff --git a/openwiki/local-api.md b/openwiki/local-api.md index 9b2d8f5a..34053f52 100644 --- a/openwiki/local-api.md +++ b/openwiki/local-api.md @@ -39,16 +39,38 @@ the pricing layer knows it guessed at or missed. Both come from `getPricingDiagnostics()` in `src/lib/pricing/index.js`; read that function before relying on the exact field set. -The tier vocabulary is a closed set, and the distinctions matter because three -different situations all produce a `$0` cost: +The tiers come from the resolution ladder in `src/lib/pricing/matcher.js` — that +function is the authority; the grouping below is what each rung means for +trusting the number. The distinctions matter because three different situations +all produce a `$0` cost, and because a *guessed* price is never `$0` and so +cannot be spotted by looking for zeros. -| Tier | Means | +**Resolved exactly** — the id matched, trust the price: + +| Tier | Rung | +| --- | --- | +| `curated:exact` | Matched a key in `curated-overrides.json`. Curated always wins over LiteLLM. | +| `curated:exact-dot`, `litellm:exact-dot` | Matched exactly after rejoining dash-separated numerics (`glm-5-1` → `glm-5.1`), for providers that dash-normalize version numbers. | +| `litellm:exact` | Matched a LiteLLM key. | +| `curated:alias` | A deliberate curated mapping, e.g. Cursor's `auto` → `composer-1`. Intentional, not inferred. | +| `litellm:strip` | Matched the base model after removing a reasoning-effort suffix (`-high`, `-xhigh`, `-fast`, …). Reasoning effort changes how many tokens you spend, not the per-token rate, so the base price is the right one. | + +**Guessed** — plausible, possibly another model's price. These are what +`fuzzy_priced_models` reports: + +| Tier | Rung | +| --- | --- | +| `curated:fuzzy` | A curated substring rule matched. | +| `litellm:prefix-strip` | A provider-qualified key ended with the bare model name. Where several providers expose the same model, the lexicographically smallest key wins — deterministic, but the chosen provider's rate may not be yours. | +| `litellm:fuzzy` | Reverse substring, longest key first: the model id *contains* a known key. | + +**Not priced** — all cost `$0`, for three different reasons: + +| Tier | Rung | | --- | --- | -| `curated:exact`, `litellm:exact` | Matched a model id outright. Trust the price. | -| `curated:fuzzy`, `litellm:fuzzy`, `litellm:prefix-strip` | Priced by partial match. Plausible, possibly a different model's price — and never `$0`, so it cannot be spotted by looking for zeros. | -| `miss` | No price found. Counted as `$0`; needs an entry in `curated-overrides.json`. | -| `unattributed` | The row had no model id and is stored under the placeholder `unknown`. Also `$0`, but nothing to add a price for. | -| `empty` | No model id at all. | +| `miss` | Nothing matched. Needs an entry in `curated-overrides.json`; this is what `unpriced_models` lists. | +| `unattributed` | The row had no model id and is stored under the placeholder `unknown`. Also `$0`, but there is nothing to add a price for — deliberately excluded from `unpriced_models`. | +| `empty` | No model id passed at all. | `unpriced_models` lists only `miss` models — it is a work list of ids needing a curated price, so placeholders are deliberately excluded from it. diff --git a/scripts/openwiki-check-facts.cjs b/scripts/openwiki-check-facts.cjs index 22ef037a..c2ef502a 100644 --- a/scripts/openwiki-check-facts.cjs +++ b/scripts/openwiki-check-facts.cjs @@ -129,4 +129,4 @@ function main() { if (require.main === module) main(); -module.exports = { checkFacts, collectFindings }; +module.exports = { checkFacts, collectFindings, readRootDocs }; diff --git a/test/openwiki-facts.test.js b/test/openwiki-facts.test.js index 620f3b7e..a7ae317b 100644 --- a/test/openwiki-facts.test.js +++ b/test/openwiki-facts.test.js @@ -4,7 +4,7 @@ const path = require("node:path"); const { test } = require("node:test"); const { extractFacts } = require("../scripts/openwiki-extract-facts.cjs"); -const { checkFacts, collectFindings } = require("../scripts/openwiki-check-facts.cjs"); +const { checkFacts, collectFindings, readRootDocs } = require("../scripts/openwiki-check-facts.cjs"); test("OpenWiki facts expose TokenTracker's public command, API, route, and parser contracts", () => { const facts = extractFacts(); @@ -28,25 +28,28 @@ test("OpenWiki fact checker rejects unsupported concrete claims", () => { }); test("the fact checker reads the front-door docs, not only openwiki/", () => { - // README.md told users with a broken install to run - // `tokentracker activate-if-needed` — a command that has never been in the - // CLI's dispatch. The checker that would have caught it existed the whole - // time; it just wasn't looking at the file users actually follow. - const findings = checkFacts(); - assert.deepEqual(findings, [], "repo docs are clean as committed"); + // README.md told users with a broken install to run a repair command the CLI + // has never had. The checker that would have caught it existed the whole time; + // it just wasn't looking at the file users actually follow. + // + // Asserted through readRootDocs rather than by writing a fake command into the + // real README: a killed process or a CI timeout would skip the restore, and + // the recovery path is `git add README.md` — which would silently re-commit + // the exact falsehood this check exists to prevent. + const scanned = readRootDocs(process.cwd()).map((file) => path.basename(file.path)); + assert.deepEqual(scanned.sort(), ["CONTRIBUTING.md", "README.md"]); + assert.deepEqual(checkFacts(), [], "repo docs are clean as committed"); - const readme = path.join(process.cwd(), "README.md"); - const original = fs.readFileSync(readme, "utf8"); - try { - fs.writeFileSync(readme, `${original}\nRun \`tokentracker activate-if-needed\` to fix.\n`); - const withFake = checkFacts(); - assert.ok( - withFake.some((f) => f.startsWith("README.md:") && f.includes("activate-if-needed")), - `expected README.md to be scanned, got: ${JSON.stringify(withFake)}`, - ); - } finally { - fs.writeFileSync(readme, original); - } + const facts = extractFacts(); + const findings = collectFindings({ + facts, + root: process.cwd(), + files: [{ path: `${process.cwd()}/README.md`, content: "Run `tokentracker activate-if-needed` to fix.\n" }], + }); + assert.ok( + findings.some((f) => f.startsWith("README.md:") && f.includes("activate-if-needed")), + `expected the fake command to be rejected, got: ${JSON.stringify(findings)}`, + ); }); test("the front-door docs do not owe openwiki's completeness obligation", () => {