token-larper 1.8.1 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,11 +21,11 @@ Token Larper passively reads session files, SQLite databases, and telemetry logs
21
21
  It consolidates all sessions into a real-time, interactive local dashboard running on [`http://localhost:4269`](http://localhost:4269) on **Windows, macOS, and Linux**. On Windows, it also lives quietly in your notification area as a lightweight, DPI-aware system tray icon.
22
22
 
23
23
  * **Powered by `ccusage`**: Uses `ccusage` v20+ for cross-platform log parsing, token counting, and verified frontier model pricing.
24
- * **100% Private & Offline**: No API keys required, no external proxies to configure, and zero analytics or telemetry sent to the cloud. The one outbound request is an optional update check that asks npm for the latest version number; turn it off in **Settings → Updates**.
24
+ * **100% Private & Offline**: No API keys required, no external proxies to configure, and zero analytics or telemetry sent to the cloud. The only outbound requests are two optional ones that send nothing about your usage: an update check that asks npm for the latest version number (turn it off in **Settings → Updates**), and a once-a-day download of OpenRouter's public model price list for cost estimates (set `TOKEN_LARPER_OFFLINE=1` to skip it).
25
25
  * **Cross-Platform Core**: The telemetry engine, 18 harness scrapers, and React dashboard run identically across Windows, macOS (Apple Silicon & Intel), and Linux.
26
26
  * **Gamified Hall of Larp**: Track your lifetime token burn, unlock 14 achievements, climb an 11-tier ranking ladder past rival personas, and export a shareable 1200×630 flex badge.
27
27
  * **Six Styles, Your Colors**: Grove, Terminal, Paper, Brutal, Soft, and Mono each change fonts, corner radius, spacing, borders, and shadows, not just colors. Pick a base color that tints backgrounds, borders, and text, and an accent for highlights, from presets or any custom color. Or import a tweakcn/shadcn CSS theme on top.
28
- * **Subscription-Aware**: Distinguishes between strict verified API billing and estimated **"LARP Value"** (the theoretical API cost of tokens consumed through flat-rate subscriptions like Claude Pro/Max, Copilot, or Antigravity).
28
+ * **Subscription-Aware**: Distinguishes between strict verified API billing and estimated **"LARP Value"** (the theoretical API cost of tokens consumed through flat-rate subscriptions like Claude Pro/Max, Copilot, or Antigravity). Estimates use [OpenRouter's](https://openrouter.ai/models) current list prices, so new models are priced without waiting for an update.
29
29
 
30
30
  ---
31
31
 
@@ -247,6 +247,11 @@ Set `TOKEN_LARPER_DATA_DIR` to use another folder. Versions before 1.5.0 kept th
247
247
  ### Updates
248
248
  When a new version is on npm, the dashboard shows a banner. **Update now** starts `bunx token-larper@latest` in the background; the new version replaces the running one on the same port and the dashboard reloads. You can also check from **Settings → Updates**, or turn automatic checks off there. A copy cloned from git updates with `git pull`.
249
249
 
250
+ ### Estimated Prices
251
+ When ccusage has no price for a model (a brand-new one, or usage through a flat-rate subscription), Token Larper estimates the cost from list prices. It downloads OpenRouter's public model price list once a day, keeps it in the data folder as `openrouter-pricing.json`, and prices any model on that list from it. Other models use rates built into the app. Only the price list is downloaded; nothing about your usage is sent, and if the download fails the last saved list is used.
252
+
253
+ Estimates do not include OpenRouter's long-context surcharges, and a model name that OpenRouter spells differently may fall back to the built-in rates. Verified costs from ccusage are never changed. Set `TOKEN_LARPER_OFFLINE=1` to skip the download and use only the saved list (if any) and the built-in rates.
254
+
250
255
  ### Headless Server Mode
251
256
  If running on a remote headless server or Docker container, you can explicitly disable the tray icon (automatically disabled on macOS/Linux):
252
257
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "token-larper",
3
- "version": "1.8.1",
3
+ "version": "1.10.0",
4
4
  "description": "Token Larper — Multi-Harness AI Coding Usage & Cost Telemetry Dashboard powered by ccusage",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/ccusage.ts CHANGED
@@ -14,6 +14,7 @@ import type {
14
14
  import { isoWeek, localDateKey } from "./client/utils.ts";
15
15
  import { opencodeSessionUpdated, resolveSessionProject, saveSessionProjects } from "./projects.ts";
16
16
  import { APP_ROOT, DATA_DIR } from "./paths.ts";
17
+ import { pricing, type Rates } from "./pricing.ts";
17
18
 
18
19
  const ROOT_DIR = APP_ROOT;
19
20
  const CACHE_DIR = DATA_DIR;
@@ -260,12 +261,6 @@ function resolveCcusageBinary(): { cmd: string[]; version: string } {
260
261
  return { cmd: ["bunx", "ccusage"], version };
261
262
  }
262
263
 
263
- /**
264
- * Estimates realistic frontier API cost in USD when ccusage reports missingPricing: true or 0 cost
265
- * (e.g. for bleeding-edge models like claude-opus-5-5, gpt-6-sol, minimax-m3-free, etc.).
266
- */
267
- type Rates = [input: number, output: number, cacheWrite: number, cacheRead: number];
268
-
269
264
  /**
270
265
  * Anthropic list prices per 1M tokens, by family and version. Opus dropped from $15/$75
271
266
  * at 4.5, so a single "opus" rate overstated newer models about threefold. Names come in
@@ -295,6 +290,11 @@ export function claudeRates(model: string): Rates | null {
295
290
  return [0.25, 1.25, 0.3, 0.03];
296
291
  }
297
292
 
293
+ /**
294
+ * Estimates realistic frontier API cost in USD when ccusage reports missingPricing: true or 0 cost
295
+ * (e.g. for bleeding-edge models like claude-opus-5-5, gpt-6-sol, minimax-m3-free, etc.).
296
+ * Rates come from OpenRouter's price list when it lists the model, otherwise the built-in ones below.
297
+ */
298
298
  export function estimateFrontierCost(params: {
299
299
  modelName: string;
300
300
  inputTokens: number;
@@ -309,9 +309,9 @@ export function estimateFrontierCost(params: {
309
309
  // Pricing in USD per 1M tokens: [input, output, cacheWrite, cacheRead]
310
310
  let rates: Rates = [2.0, 8.0, 2.5, 0.2];
311
311
 
312
- const claude = claudeRates(m);
313
- if (claude) {
314
- rates = claude;
312
+ const known = pricing.lookup(m) ?? claudeRates(m);
313
+ if (known) {
314
+ rates = known;
315
315
  } else if (m.includes("gpt-6") || m.includes("gpt-5") || m.includes("o3") || m.includes("o4")) {
316
316
  rates = [2.5, 10.0, 2.5, 0.25];
317
317
  } else if (m.includes("gemini") && m.includes("pro")) {
@@ -583,6 +583,10 @@ async function syncAntigravityInBackground(): Promise<void> {
583
583
  async function runHarnessRefresh(options?: { forceDeepScan?: boolean }): Promise<DashboardPayload> {
584
584
  mkdirSync(CACHE_DIR, { recursive: true });
585
585
 
586
+ // Starts now so the price download overlaps the ccusage runs below; estimates are
587
+ // built after they finish, and ensure() never throws or blocks for long.
588
+ const pricesReady = pricing.ensure();
589
+
586
590
  // Seed antigravity from disk cache immediately if available
587
591
  const diskAg = loadAntigravityFromDiskCache();
588
592
  if (diskAg && !inMemoryHarnessStore.has("antigravity")) {
@@ -624,6 +628,7 @@ async function runHarnessRefresh(options?: { forceDeepScan?: boolean }): Promise
624
628
  }
625
629
  }
626
630
 
631
+ await pricesReady;
627
632
  cachedDashboardPayload = buildDashboardPayloadFromStore();
628
633
  try {
629
634
  writeFileSync(DASHBOARD_CACHE_FILE, JSON.stringify(cachedDashboardPayload), "utf8");
@@ -1,9 +1,10 @@
1
1
  import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
- import { AlertTriangle, Heart, Moon, Power, RefreshCw, Search, Settings, Sun } from "lucide-react";
2
+ import { AlertTriangle, Moon, Power, RefreshCw, Search, Settings, Sun } from "lucide-react";
3
3
  import type { DashboardPayload, HarnessId, StartupConfig } from "../types.ts";
4
4
  import { OTHER_SERIES, SERIES_SLOTS, seriesColor, type SeriesInfo } from "./charts.tsx";
5
5
  import { DashboardProvider, type Dashboard } from "./context.tsx";
6
6
  import { DateRangePicker } from "./components/DateRangePicker.tsx";
7
+ import { AppFooter } from "./components/AppFooter.tsx";
7
8
  import { Logo } from "./components/Logo.tsx";
8
9
  import { RankChip } from "./components/RankChip.tsx";
9
10
  import { SettingsDialog } from "./components/SettingsDialog.tsx";
@@ -553,14 +554,7 @@ export function App() {
553
554
  {view.id === "rank" && <Rank />}
554
555
  </main>
555
556
 
556
- <footer className="credit">
557
- <span>
558
- Made with <Heart size={12} aria-label="love" className="credit-heart" /> by{" "}
559
- <a href="https://github.com/YarooqH" target="_blank" rel="noreferrer">qray</a>
560
- </span>
561
- <span aria-hidden="true">·</span>
562
- <a href="https://github.com/YarooqH/token-larper" target="_blank" rel="noreferrer">Source on GitHub</a>
563
- </footer>
557
+ <AppFooter />
564
558
 
565
559
  {showSettings && (
566
560
  <SettingsDialog
@@ -0,0 +1,47 @@
1
+ import React from "react";
2
+
3
+ const JOKES = [
4
+ "Tokens were harmed in the making.",
5
+ "The context window remembers.",
6
+ "One more prompt. Famous last words.",
7
+ "Your cache misses have been noted.",
8
+ "The bill has entered the chat.",
9
+ "This footer also cost tokens.",
10
+ "The tokens know what they did.",
11
+ ] as const;
12
+
13
+ const LAST_JOKE_KEY = "token-larper-last-footer-joke";
14
+
15
+ function pickJoke(): string {
16
+ let previous = -1;
17
+ try {
18
+ const saved = sessionStorage.getItem(LAST_JOKE_KEY);
19
+ if (saved !== null) previous = Number(saved);
20
+ } catch {
21
+ // The footer still works when storage is unavailable.
22
+ }
23
+
24
+ const choices = JOKES.map((_, index) => index).filter((index) => index !== previous);
25
+ const index = choices[Math.floor(Math.random() * choices.length)]!;
26
+ try { sessionStorage.setItem(LAST_JOKE_KEY, String(index)); } catch {}
27
+ return JOKES[index];
28
+ }
29
+
30
+ const joke = pickJoke();
31
+
32
+ export function AppFooter() {
33
+ return (
34
+ <footer className="app-footer">
35
+ <span>Made by <a href="https://github.com/YarooqH" target="_blank" rel="noopener noreferrer">qray</a></span>
36
+ <span className="footer-separator" aria-hidden="true">·</span>
37
+ <span>{joke}</span>
38
+ <span className="footer-separator" aria-hidden="true">·</span>
39
+ <a className="footer-source" href="https://github.com/YarooqH/token-larper" target="_blank" rel="noopener noreferrer">
40
+ <svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
41
+ <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.23.48-2.69-1.08-2.69-1.08-.36-.92-.89-1.16-.89-1.16-.73-.5.06-.49.06-.49.81.06 1.24.83 1.24.83.72 1.23 1.88.88 2.34.67.07-.52.28-.88.51-1.08-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.22 2.2.82a7.65 7.65 0 0 1 4.01 0c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.28.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.08-.01 1.95-.01 2.21 0 .21.15.46.55.38A8.02 8.02 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
42
+ </svg>
43
+ <span>Source on GitHub</span>
44
+ </a>
45
+ </footer>
46
+ );
47
+ }
@@ -310,9 +310,32 @@ img { display: block; }
310
310
 
311
311
  .app {
312
312
  max-width: 1240px;
313
+ min-height: 100vh;
314
+ display: flex;
315
+ flex-direction: column;
313
316
  margin: 0 auto;
314
- padding: 0 32px 72px;
317
+ padding: 0 32px 24px;
318
+ }
319
+
320
+ .app-footer {
321
+ display: flex;
322
+ align-items: center;
323
+ justify-content: center;
324
+ flex-wrap: wrap;
325
+ column-gap: 8px;
326
+ row-gap: 4px;
327
+ margin-top: auto;
328
+ padding-top: 64px;
329
+ color: var(--text-3);
330
+ font-size: 11px;
331
+ line-height: 1.5;
332
+ text-align: center;
315
333
  }
334
+ .app-footer a { color: var(--text-2); text-decoration: none; }
335
+ .app-footer a:hover { color: var(--text); text-decoration: underline; text-underline-offset: 3px; }
336
+ .app-footer a:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; border-radius: 2px; }
337
+ .footer-source { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
338
+ .footer-separator { color: var(--text-3); }
316
339
 
317
340
  .topbar {
318
341
  display: flex;
@@ -1161,10 +1184,6 @@ html[data-theme="dark"] .toggle.on .toggle-knob { background: var(--accent-ink);
1161
1184
 
1162
1185
  /* ---------- Credit ---------- */
1163
1186
 
1164
- .credit { display: flex; justify-content: center; flex-wrap: wrap; gap: 6px 10px; margin-top: 48px; color: var(--text-3); font-size: 12px; }
1165
- .credit a { color: inherit; text-decoration: underline; text-decoration-color: var(--border-strong); text-underline-offset: 3px; }
1166
- .credit a:hover { color: var(--text); text-decoration-color: currentColor; }
1167
- .credit-heart { vertical-align: -1px; color: var(--accent); fill: currentColor; }
1168
1187
 
1169
1188
  /* ---------- Splash screens ---------- */
1170
1189
 
@@ -1329,7 +1348,8 @@ html[data-theme="dark"] .toggle.on .toggle-knob { background: var(--accent-ink);
1329
1348
  @media (max-width: 700px) {
1330
1349
  .update-banner { flex-wrap: wrap; }
1331
1350
  .update-banner-actions { width: 100%; justify-content: flex-end; }
1332
- .app { padding: 0 16px 48px; }
1351
+ .app { padding: 0 16px 24px; }
1352
+ .app-footer { padding-top: 48px; }
1333
1353
  .topbar { margin: 0 -16px; padding: 12px 16px; }
1334
1354
  .updated { display: none; }
1335
1355
  .filters { margin: 0 -16px 16px; padding: 8px 16px 10px; }
package/src/pricing.ts ADDED
@@ -0,0 +1,210 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { dataPath } from "./paths.ts";
4
+
5
+ // Model prices for the estimated cost. OpenRouter publishes a public price list that
6
+ // tracks new models faster than a table kept in this repo. The one request goes to its
7
+ // models endpoint (no key, no usage data sent) at most once a day; the result is cached
8
+ // in the data folder and the built-in rates in ccusage.ts cover whatever it does not list.
9
+ // Set TOKEN_LARPER_OFFLINE=1 to skip the request and use only the cache and built-in rates.
10
+
11
+ /** USD per 1M tokens: input, output, cache write, cache read. */
12
+ export type Rates = [input: number, output: number, cacheWrite: number, cacheRead: number];
13
+
14
+ export type PriceTable = Map<string, Rates>;
15
+
16
+ export interface OpenRouterModel {
17
+ id: string;
18
+ pricing?: Record<string, unknown>;
19
+ }
20
+
21
+ const MODELS_URL = "https://openrouter.ai/api/v1/models";
22
+ const REFRESH_MS = 24 * 60 * 60 * 1000;
23
+ const RETRY_MS = 15 * 60 * 1000;
24
+ const TIMEOUT_MS = 8000;
25
+
26
+ /**
27
+ * Reduces a model name to the form both ccusage and OpenRouter can be compared in:
28
+ * lower case, without the harness tag ("[pi] "), provider ("anthropic/") or variant
29
+ * (":free"), and with dashed versions dotted ("4-5" becomes "4.5").
30
+ */
31
+ export function normalizeModelName(name: string): string {
32
+ const slug = name
33
+ .toLowerCase()
34
+ .trim()
35
+ .replace(/^\[[^\]]*\]\s*/, "")
36
+ .replace(/^.*\//, "")
37
+ .replace(/:.*$/, "");
38
+
39
+ const parts = slug.split("-");
40
+ const out: string[] = [];
41
+ for (let i = 0; i < parts.length; i++) {
42
+ const part = parts[i]!;
43
+ const next = parts[i + 1];
44
+ // A pair of short numbers is a version ("opus-4-5"), unless it follows a year
45
+ // ("2024-11-20" is a date).
46
+ const followsYear = /^\d{4}$/.test(parts[i - 1] ?? "");
47
+ if (next !== undefined && !followsYear && /^\d{1,2}$/.test(part) && /^\d{1,2}$/.test(next)) {
48
+ out.push(`${part}.${next}`);
49
+ i++;
50
+ } else {
51
+ out.push(part);
52
+ }
53
+ }
54
+ return out.join("-");
55
+ }
56
+
57
+ // Multiplying a per-token price by 1e6 leaves float noise (0.0000002 * 1e6 is
58
+ // 0.19999999999999998).
59
+ const perMillion = (perToken: number) => Number((perToken * 1e6).toPrecision(10));
60
+
61
+ /** Absent or invalid is undefined; zero is a real price. */
62
+ function cachePrice(value: unknown): number | undefined {
63
+ if (value === undefined || value === null || value === "") return undefined;
64
+ const n = Number(value);
65
+ return Number.isFinite(n) && n >= 0 ? n : undefined;
66
+ }
67
+
68
+ /**
69
+ * Builds the rate table from OpenRouter's model list. Variants (":free" is $0, ":batch"
70
+ * is half price) and "~...-latest" aliases would misprice a model, and a price of 0 or
71
+ * -1 means free or variable, so none of those are kept. When two ids normalize to the
72
+ * same name, the first listed wins.
73
+ */
74
+ export function buildPriceTable(models: readonly OpenRouterModel[]): PriceTable {
75
+ const table: PriceTable = new Map();
76
+ for (const model of models) {
77
+ if (typeof model?.id !== "string" || model.id.startsWith("~") || model.id.includes(":")) continue;
78
+ const input = Number(model.pricing?.prompt);
79
+ const output = Number(model.pricing?.completion);
80
+ if (!(input > 0) || !(output > 0)) continue;
81
+
82
+ const key = normalizeModelName(model.id);
83
+ if (!key || table.has(key)) continue;
84
+
85
+ // Anthropic bills cache writes at 1.25x input and reads at 0.1x. Elsewhere a missing
86
+ // cache price means no discount is known, so cached tokens cost the input price.
87
+ const anthropic = model.id.startsWith("anthropic/");
88
+ const cacheWrite = cachePrice(model.pricing?.input_cache_write) ?? (anthropic ? input * 1.25 : input);
89
+ const cacheRead = cachePrice(model.pricing?.input_cache_read) ?? (anthropic ? input * 0.1 : input);
90
+ table.set(key, [perMillion(input), perMillion(output), perMillion(cacheWrite), perMillion(cacheRead)]);
91
+ }
92
+ return table;
93
+ }
94
+
95
+ /**
96
+ * The rates for a model name, or null if the table has none. An exact match wins;
97
+ * otherwise the longest listed name that prefixes it at a dash ("gpt-5.5-codex" finds
98
+ * "gpt-5.5", and "claude-opus-4-20250514" finds "claude-opus-4"). A prefix must end at
99
+ * a dash so "claude-opus-4.9" never borrows the price of "claude-opus-4".
100
+ */
101
+ export function lookupRates(modelName: string, table: PriceTable): Rates | null {
102
+ const key = normalizeModelName(modelName);
103
+ if (!key) return null;
104
+
105
+ const exact = table.get(key);
106
+ if (exact) return exact;
107
+
108
+ let best: string | undefined;
109
+ for (const listed of table.keys()) {
110
+ if (key.startsWith(`${listed}-`) && (!best || listed.length > best.length)) best = listed;
111
+ }
112
+ return best ? table.get(best)! : null;
113
+ }
114
+
115
+ const isRates = (value: unknown): value is Rates =>
116
+ Array.isArray(value) && value.length === 4 && value.every((n) => typeof n === "number" && Number.isFinite(n) && n >= 0);
117
+
118
+ export interface PricingOptions {
119
+ cacheFile?: string;
120
+ fetchImpl?: (url: string, init?: RequestInit) => Promise<Response>;
121
+ now?: () => number;
122
+ }
123
+
124
+ export function createPricing(options: PricingOptions = {}) {
125
+ const cacheFile = options.cacheFile ?? dataPath("openrouter-pricing.json");
126
+ const doFetch = options.fetchImpl ?? fetch;
127
+ const now = options.now ?? Date.now;
128
+
129
+ let table: PriceTable = new Map();
130
+ // The same few model names repeat across every daily row, and an unlisted name scans
131
+ // the whole table, so answers are remembered until the table changes.
132
+ let answers = new Map<string, Rates | null>();
133
+ let fetchedAt = 0;
134
+ let nextAttempt = 0;
135
+ let cacheRead = false;
136
+ let inFlight: Promise<void> | null = null;
137
+
138
+ function setTable(next: PriceTable): void {
139
+ table = next;
140
+ answers = new Map();
141
+ }
142
+
143
+ function readCache(): void {
144
+ try {
145
+ const stored = JSON.parse(readFileSync(cacheFile, "utf8")) as { fetchedAt?: unknown; rates?: Record<string, unknown> };
146
+ const rates: PriceTable = new Map();
147
+ for (const [key, value] of Object.entries(stored.rates ?? {})) if (isRates(value)) rates.set(key, value);
148
+ if (typeof stored.fetchedAt === "number" && rates.size > 0) {
149
+ setTable(rates);
150
+ fetchedAt = stored.fetchedAt;
151
+ }
152
+ } catch {
153
+ // No cache yet, or it is unreadable; the next download replaces it.
154
+ }
155
+ }
156
+
157
+ async function download(): Promise<void> {
158
+ try {
159
+ const res = await doFetch(MODELS_URL, { signal: AbortSignal.timeout(TIMEOUT_MS), headers: { Accept: "application/json" } });
160
+ if (!res.ok) throw new Error(`OpenRouter responded ${res.status}`);
161
+ const body = (await res.json()) as { data?: unknown };
162
+ if (!Array.isArray(body.data)) throw new Error("OpenRouter response had no model list");
163
+
164
+ const fresh = buildPriceTable(body.data);
165
+ // An empty list would wipe good prices, so treat it as a failed download.
166
+ if (fresh.size === 0) throw new Error("OpenRouter response had no usable prices");
167
+
168
+ setTable(fresh);
169
+ fetchedAt = now();
170
+ try {
171
+ mkdirSync(dirname(cacheFile), { recursive: true });
172
+ writeFileSync(cacheFile, JSON.stringify({ fetchedAt, rates: Object.fromEntries(fresh) }), "utf8");
173
+ } catch {
174
+ // The in-memory prices still work when the disk is not writable.
175
+ }
176
+ } catch {
177
+ // Offline or the endpoint changed: keep what we have and try again later.
178
+ nextAttempt = now() + RETRY_MS;
179
+ }
180
+ }
181
+
182
+ return {
183
+ /** Rates from the OpenRouter table, or null when it has no price for this model. */
184
+ lookup(modelName: string): Rates | null {
185
+ let rates = answers.get(modelName);
186
+ if (rates === undefined) {
187
+ rates = lookupRates(modelName, table);
188
+ answers.set(modelName, rates);
189
+ }
190
+ return rates;
191
+ },
192
+
193
+ /** Replaces the in-memory table. */
194
+ use: setTable,
195
+
196
+ /** Loads the cached table, and downloads a new one if it is over a day old. Never throws. */
197
+ async ensure(): Promise<void> {
198
+ if (!cacheRead) {
199
+ cacheRead = true;
200
+ readCache();
201
+ }
202
+ if (process.env.TOKEN_LARPER_OFFLINE === "1") return;
203
+ if (now() - fetchedAt < REFRESH_MS || now() < nextAttempt) return;
204
+ inFlight ??= download().finally(() => (inFlight = null));
205
+ await inFlight;
206
+ },
207
+ };
208
+ }
209
+
210
+ export const pricing = createPricing();