token-larper 1.9.0 → 1.10.1
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 +7 -2
- package/bin/cli.js +48 -15
- package/package.json +1 -1
- package/src/ccusage.ts +32 -9
- package/src/logFile.ts +37 -0
- package/src/pricing.ts +210 -0
- package/src/server.ts +37 -12
- package/src/startup.ts +1 -1
- package/src/tray.ts +2 -0
- package/src/updates.ts +33 -9
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
|
|
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/bin/cli.js
CHANGED
|
@@ -32,7 +32,7 @@ function dataDir() {
|
|
|
32
32
|
|
|
33
33
|
function bunPath() {
|
|
34
34
|
if (typeof Bun !== "undefined") return process.execPath;
|
|
35
|
-
if (spawnSync("bun", ["--version"], { stdio: "ignore" }).status === 0) return "bun";
|
|
35
|
+
if (spawnSync("bun", ["--version"], { stdio: "ignore", windowsHide: true }).status === 0) return "bun";
|
|
36
36
|
console.error("🔥 Token Larper requires Bun (v1.1+) to run.");
|
|
37
37
|
console.error("\nInstall Bun with one command:");
|
|
38
38
|
if (process.platform === "win32") {
|
|
@@ -85,26 +85,47 @@ async function foreground() {
|
|
|
85
85
|
process.exit(child.status ?? 0);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Start the server so it outlives this terminal. On Windows it goes through Start-Process:
|
|
90
|
+
* the server then gets a hidden console that everything it starts shares (a process with
|
|
91
|
+
* no console at all makes Windows open a window for each console program it runs), and it
|
|
92
|
+
* inherits no handles, so it can't end up holding an older copy's port when this CLI was
|
|
93
|
+
* itself started by one during an update. The server writes its own log (src/logFile.ts).
|
|
94
|
+
*/
|
|
95
|
+
function startServerProcess(bun, env) {
|
|
96
|
+
if (process.platform === "win32") {
|
|
97
|
+
const quote = (value) => `'${value.replace(/'/g, "''")}'`;
|
|
98
|
+
const command = [
|
|
99
|
+
"Start-Process -WindowStyle Hidden",
|
|
100
|
+
`-FilePath ${quote(bun)}`,
|
|
101
|
+
`-ArgumentList ${quote(`"${serverScript}"`)}`,
|
|
102
|
+
`-WorkingDirectory ${quote(rootDir)}`,
|
|
103
|
+
].join(" ");
|
|
104
|
+
const hop = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], {
|
|
105
|
+
stdio: "ignore",
|
|
106
|
+
windowsHide: true,
|
|
107
|
+
env,
|
|
108
|
+
});
|
|
109
|
+
return hop.status === 0;
|
|
110
|
+
}
|
|
111
|
+
spawn(bun, [serverScript], { cwd: rootDir, detached: true, stdio: "ignore", env }).unref();
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
88
115
|
async function background(open) {
|
|
89
116
|
const bun = bunPath();
|
|
90
117
|
const dir = dataDir();
|
|
91
118
|
mkdirSync(dir, { recursive: true });
|
|
92
119
|
const logFile = join(dir, "server.log");
|
|
93
|
-
|
|
120
|
+
closeSync(openSync(logFile, "w"));
|
|
94
121
|
|
|
95
122
|
// The server moves to the next free port if another program has this one, and takes
|
|
96
123
|
// over from an older Token Larper on the same port (see src/server.ts).
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
stdio: ["ignore", log, log],
|
|
102
|
-
env: { ...process.env, TOKEN_LARPER_NO_BROWSER: "1" },
|
|
124
|
+
const started = startServerProcess(bun, {
|
|
125
|
+
...process.env,
|
|
126
|
+
TOKEN_LARPER_NO_BROWSER: "1",
|
|
127
|
+
TOKEN_LARPER_LOG_FILE: logFile,
|
|
103
128
|
});
|
|
104
|
-
let exited = false;
|
|
105
|
-
child.on("exit", () => (exited = true));
|
|
106
|
-
child.unref();
|
|
107
|
-
closeSync(log);
|
|
108
129
|
|
|
109
130
|
const readLog = () => {
|
|
110
131
|
try {
|
|
@@ -117,10 +138,22 @@ async function background(open) {
|
|
|
117
138
|
// Ready once the server says where it is listening. A copy of the same version that
|
|
118
139
|
// is already running says so too ("already running at ..."), then the new one exits.
|
|
119
140
|
let url = null;
|
|
120
|
-
|
|
141
|
+
let waitingForPort = false;
|
|
142
|
+
for (let i = 0; started && i < 120 && !url; i++) {
|
|
121
143
|
await sleep(250);
|
|
122
|
-
|
|
123
|
-
|
|
144
|
+
const text = readLog();
|
|
145
|
+
url = text.match(/running at (http:\/\/localhost:\d+)/)?.[1] ?? null;
|
|
146
|
+
// Taking over from an older copy can take a minute while its port is released. This
|
|
147
|
+
// CLI may be holding that port itself (when the old copy started it for an update),
|
|
148
|
+
// so leave now instead of waiting.
|
|
149
|
+
waitingForPort = !url && text.includes("Waiting for port");
|
|
150
|
+
if (waitingForPort) break;
|
|
151
|
+
if (/Token Larper stopped:/.test(text)) break;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (waitingForPort) {
|
|
155
|
+
console.log(`🔥 Token Larper is replacing an older copy. It will be at http://localhost:${port} in a minute.`);
|
|
156
|
+
process.exit(0);
|
|
124
157
|
}
|
|
125
158
|
|
|
126
159
|
if (!url) {
|
package/package.json
CHANGED
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
|
|
313
|
-
if (
|
|
314
|
-
rates =
|
|
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")) {
|
|
@@ -337,6 +337,21 @@ export function estimateFrontierCost(params: {
|
|
|
337
337
|
);
|
|
338
338
|
}
|
|
339
339
|
|
|
340
|
+
// Runs still in progress. On Windows a child keeps the server's listening socket open, so
|
|
341
|
+
// shutdown stops them; otherwise a newer copy can't take over the port.
|
|
342
|
+
const activeRuns = new Set<ReturnType<typeof Bun.spawn>>();
|
|
343
|
+
|
|
344
|
+
export function stopCcusageRuns(): void {
|
|
345
|
+
for (const proc of activeRuns) {
|
|
346
|
+
try {
|
|
347
|
+
proc.kill();
|
|
348
|
+
} catch {
|
|
349
|
+
// Already exited.
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
activeRuns.clear();
|
|
353
|
+
}
|
|
354
|
+
|
|
340
355
|
async function runCcusageJson(subcommandArgs: string[], timeoutMs = 25000): Promise<any | null> {
|
|
341
356
|
const { cmd } = resolveCcusageBinary();
|
|
342
357
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -345,7 +360,10 @@ async function runCcusageJson(subcommandArgs: string[], timeoutMs = 25000): Prom
|
|
|
345
360
|
const proc = Bun.spawn([...cmd, ...subcommandArgs, "--json", "-O"], {
|
|
346
361
|
stdout: "pipe",
|
|
347
362
|
stderr: "ignore",
|
|
363
|
+
windowsHide: true,
|
|
348
364
|
});
|
|
365
|
+
activeRuns.add(proc);
|
|
366
|
+
void proc.exited.finally(() => activeRuns.delete(proc));
|
|
349
367
|
|
|
350
368
|
timer = setTimeout(() => {
|
|
351
369
|
try {
|
|
@@ -583,6 +601,10 @@ async function syncAntigravityInBackground(): Promise<void> {
|
|
|
583
601
|
async function runHarnessRefresh(options?: { forceDeepScan?: boolean }): Promise<DashboardPayload> {
|
|
584
602
|
mkdirSync(CACHE_DIR, { recursive: true });
|
|
585
603
|
|
|
604
|
+
// Starts now so the price download overlaps the ccusage runs below; estimates are
|
|
605
|
+
// built after they finish, and ensure() never throws or blocks for long.
|
|
606
|
+
const pricesReady = pricing.ensure();
|
|
607
|
+
|
|
586
608
|
// Seed antigravity from disk cache immediately if available
|
|
587
609
|
const diskAg = loadAntigravityFromDiskCache();
|
|
588
610
|
if (diskAg && !inMemoryHarnessStore.has("antigravity")) {
|
|
@@ -624,6 +646,7 @@ async function runHarnessRefresh(options?: { forceDeepScan?: boolean }): Promise
|
|
|
624
646
|
}
|
|
625
647
|
}
|
|
626
648
|
|
|
649
|
+
await pricesReady;
|
|
627
650
|
cachedDashboardPayload = buildDashboardPayloadFromStore();
|
|
628
651
|
try {
|
|
629
652
|
writeFileSync(DASHBOARD_CACHE_FILE, JSON.stringify(cachedDashboardPayload), "utf8");
|
package/src/logFile.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { appendFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
// When the CLI starts the server in the background it has no terminal, so the server
|
|
4
|
+
// writes its own output to the file named in TOKEN_LARPER_LOG_FILE (the CLI reads it
|
|
5
|
+
// to learn the URL). Imported first by server.ts so nothing is missed.
|
|
6
|
+
|
|
7
|
+
const file = process.env.TOKEN_LARPER_LOG_FILE;
|
|
8
|
+
|
|
9
|
+
if (file) {
|
|
10
|
+
try {
|
|
11
|
+
writeFileSync(file, "");
|
|
12
|
+
} catch {
|
|
13
|
+
// Logging is best effort.
|
|
14
|
+
}
|
|
15
|
+
const write = (args: unknown[]) => {
|
|
16
|
+
try {
|
|
17
|
+
appendFileSync(file, args.map((a) => (a instanceof Error ? a.stack ?? a.message : String(a))).join(" ") + "\n");
|
|
18
|
+
} catch {
|
|
19
|
+
// Logging is best effort.
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
for (const level of ["log", "info", "warn", "error"] as const) {
|
|
23
|
+
const original = console[level].bind(console);
|
|
24
|
+
console[level] = (...args: unknown[]) => {
|
|
25
|
+
write(args);
|
|
26
|
+
original(...args);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
process.on("uncaughtException", (error) => {
|
|
30
|
+
write(["Token Larper stopped:", error]);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
});
|
|
33
|
+
process.on("unhandledRejection", (reason) => {
|
|
34
|
+
write(["Token Larper stopped:", reason]);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
});
|
|
37
|
+
}
|
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();
|
package/src/server.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import "./logFile.ts";
|
|
1
2
|
import { join, resolve } from "node:path";
|
|
2
|
-
import { getDashboardData } from "./ccusage.ts";
|
|
3
|
+
import { getDashboardData, stopCcusageRuns } from "./ccusage.ts";
|
|
3
4
|
import { getStartupStatus, repointStartupIfStale, setStartupStatus } from "./startup.ts";
|
|
4
5
|
import { startSystemTray, stopSystemTray } from "./tray.ts";
|
|
5
6
|
import { buildTrayStatus, parseTheme, saveTheme } from "./trayStatus.ts";
|
|
@@ -60,7 +61,7 @@ function openDashboard(port: number) {
|
|
|
60
61
|
: process.platform === "darwin"
|
|
61
62
|
? `open http://localhost:${port}`
|
|
62
63
|
: `xdg-open http://localhost:${port}`;
|
|
63
|
-
import("node:child_process").then(({ exec }) => exec(cmd));
|
|
64
|
+
import("node:child_process").then(({ exec }) => exec(cmd, { windowsHide: true }));
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
/** The version of a Token Larper already on the port, "0.0.0" for one too old to say, or null if none. */
|
|
@@ -82,6 +83,22 @@ async function runningVersion(port: number): Promise<string | null> {
|
|
|
82
83
|
}
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
async function waitForPort(port: number, timeoutMs: number): Promise<boolean> {
|
|
87
|
+
const deadline = Date.now() + timeoutMs;
|
|
88
|
+
let announced = false;
|
|
89
|
+
while (true) {
|
|
90
|
+
try {
|
|
91
|
+
Bun.serve({ port, hostname: "127.0.0.1", fetch: () => new Response() }).stop(true);
|
|
92
|
+
return true;
|
|
93
|
+
} catch {
|
|
94
|
+
if (Date.now() > deadline) return false;
|
|
95
|
+
if (!announced) console.log(`Waiting for port ${port} to be released...`);
|
|
96
|
+
announced = true;
|
|
97
|
+
await Bun.sleep(500);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
85
102
|
// A newer copy (after an update, or a fresh npx run) replaces an older one on the
|
|
86
103
|
// same port. The same or a newer version already running just gets opened.
|
|
87
104
|
const running = await runningVersion(PORT);
|
|
@@ -98,6 +115,10 @@ if (running !== null && compareVersions(running, APP_VERSION) < 0) {
|
|
|
98
115
|
// It may exit before answering.
|
|
99
116
|
}
|
|
100
117
|
for (let i = 0; i < 40 && (await runningVersion(PORT)) !== null; i++) await Bun.sleep(250);
|
|
118
|
+
// The old copy can stop answering before its port is free: on Windows, processes it
|
|
119
|
+
// started hold the socket until they exit. Wait for the port rather than moving to the
|
|
120
|
+
// next one, which the open dashboard isn't watching.
|
|
121
|
+
if (!(await waitForPort(PORT, 90_000))) console.warn(`Port ${PORT} is still taken after 90 s.`);
|
|
101
122
|
} else if (running !== null) {
|
|
102
123
|
console.log(`🔥 Token Larper is already running at http://localhost:${PORT}`);
|
|
103
124
|
console.log(`🚀 Opening dashboard in your default browser...`);
|
|
@@ -231,8 +252,12 @@ function startServer(preferredPort: number) {
|
|
|
231
252
|
return Response.json({ error: "This copy runs from a git checkout. Update it with git pull." }, { status: 409 });
|
|
232
253
|
}
|
|
233
254
|
try {
|
|
234
|
-
|
|
235
|
-
|
|
255
|
+
const status = await checkForUpdates();
|
|
256
|
+
if (!status.updateAvailable || !status.latest) {
|
|
257
|
+
return Response.json({ error: status.error ?? "Already on the latest version." }, { status: 409 });
|
|
258
|
+
}
|
|
259
|
+
startUpdate(activePort, status.latest);
|
|
260
|
+
return Response.json({ ok: true, version: status.latest });
|
|
236
261
|
} catch (error) {
|
|
237
262
|
return Response.json({ error: error instanceof Error ? error.message : "Could not start the update" }, { status: 500 });
|
|
238
263
|
}
|
|
@@ -240,6 +265,7 @@ function startServer(preferredPort: number) {
|
|
|
240
265
|
|
|
241
266
|
if (url.pathname === "/api/shutdown" && req.method === "POST") {
|
|
242
267
|
setTimeout(() => {
|
|
268
|
+
stopCcusageRuns();
|
|
243
269
|
stopSystemTray();
|
|
244
270
|
server.stop(true);
|
|
245
271
|
process.exit(0);
|
|
@@ -266,13 +292,12 @@ const server = startServer(PORT);
|
|
|
266
292
|
startSystemTray(server.port ?? PORT);
|
|
267
293
|
void repointStartupIfStale();
|
|
268
294
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
process.
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
});
|
|
295
|
+
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
|
296
|
+
process.on(signal, () => {
|
|
297
|
+
stopCcusageRuns();
|
|
298
|
+
stopSystemTray();
|
|
299
|
+
process.exit(0);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
277
302
|
|
|
278
303
|
console.log(`🔥 Token Larper running at http://localhost:${server.port} (System Tray Icon Active)`);
|
package/src/startup.ts
CHANGED
|
@@ -42,7 +42,7 @@ function saveSettings(settings: SavedSettings): void {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
async function runReg(args: string[]): Promise<{ code: number; output: string; error: string }> {
|
|
45
|
-
const proc = Bun.spawn(["reg.exe", ...args], { stdout: "pipe", stderr: "pipe" });
|
|
45
|
+
const proc = Bun.spawn(["reg.exe", ...args], { stdout: "pipe", stderr: "pipe", windowsHide: true });
|
|
46
46
|
const [output, error, code] = await Promise.all([
|
|
47
47
|
new Response(proc.stdout).text(),
|
|
48
48
|
new Response(proc.stderr).text(),
|
package/src/tray.ts
CHANGED
|
@@ -444,6 +444,7 @@ export async function startSystemTray(port: number): Promise<void> {
|
|
|
444
444
|
{
|
|
445
445
|
stdout: "pipe",
|
|
446
446
|
stderr: "ignore",
|
|
447
|
+
windowsHide: true,
|
|
447
448
|
}
|
|
448
449
|
);
|
|
449
450
|
const out = (await new Response(proc.stdout).text()).trim();
|
|
@@ -462,6 +463,7 @@ export function stopSystemTray(): void {
|
|
|
462
463
|
Bun.spawnSync(["taskkill.exe", "/PID", String(trayPid), "/F"], {
|
|
463
464
|
stdout: "ignore",
|
|
464
465
|
stderr: "ignore",
|
|
466
|
+
windowsHide: true,
|
|
465
467
|
});
|
|
466
468
|
} catch {
|
|
467
469
|
// ignore
|
package/src/updates.ts
CHANGED
|
@@ -48,15 +48,39 @@ export async function checkForUpdates(force = false): Promise<UpdateStatus> {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
51
|
+
* Run a program in the background, independent of this server.
|
|
52
|
+
*
|
|
53
|
+
* On Windows every child of this server inherits its listening socket, which keeps the
|
|
54
|
+
* port taken until the child exits. Start-Process launches the program through the shell
|
|
55
|
+
* instead, so it inherits nothing, and gives it a hidden console that anything it starts
|
|
56
|
+
* shares instead of opening windows of their own. Arguments must not contain double quotes.
|
|
53
57
|
*/
|
|
54
|
-
export function
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
export function launchIndependent(program: string, args: string[], env: Record<string, string | undefined>): void {
|
|
59
|
+
if (process.platform === "win32") {
|
|
60
|
+
const quote = (value: string) => `'${value.replace(/'/g, "''")}'`;
|
|
61
|
+
const command = `Start-Process -WindowStyle Hidden -FilePath ${quote(program)} -ArgumentList ${args.map((a) => quote(`"${a}"`)).join(",")}`;
|
|
62
|
+
// Not detached: a PowerShell without any console fails to start the program.
|
|
63
|
+
spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], {
|
|
64
|
+
stdio: "ignore",
|
|
65
|
+
windowsHide: true,
|
|
66
|
+
env,
|
|
67
|
+
}).unref();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
spawn(program, args, { detached: true, stdio: "ignore", env }).unref();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Start the given version in the background. The new copy sees this server running an
|
|
75
|
+
* older version, asks it to shut down, and takes over the same port (src/server.ts).
|
|
76
|
+
* The exact version is installed because `@latest` can resolve from bun's cached
|
|
77
|
+
* package list and bring back the version already running.
|
|
78
|
+
*/
|
|
79
|
+
export function startUpdate(port: number, version: string): void {
|
|
80
|
+
if (!/^\d+\.\d+\.\d+(?:-[\w.]+)?$/.test(version)) throw new Error(`Unexpected version "${version}"`);
|
|
81
|
+
launchIndependent(process.execPath, ["x", `${PACKAGE}@${version}`], {
|
|
82
|
+
...process.env,
|
|
83
|
+
PORT: String(port),
|
|
84
|
+
TOKEN_LARPER_NO_BROWSER: "1",
|
|
60
85
|
});
|
|
61
|
-
child.unref();
|
|
62
86
|
}
|