pi-cost 0.1.0 → 0.2.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/CHANGELOG.md +29 -0
- package/README.md +13 -1
- package/extensions/cost.ts +53 -83
- package/lib/app.js +113 -0
- package/lib/args.js +17 -0
- package/lib/cli.js +593 -0
- package/lib/cost-dir.js +62 -0
- package/lib/costs.js +799 -0
- package/lib/net-guard.js +178 -0
- package/lib/pi-parsers-cost.js +234 -66
- package/lib/pi-pricing.js +35 -8
- package/lib/themes.js +90 -0
- package/package.json +16 -7
- package/public/app.js +2232 -448
- package/public/fonts/OFL-IBM-Plex.txt +93 -0
- package/public/fonts/OFL-Playfair-Display.txt +93 -0
- package/public/fonts/fonts.css +244 -0
- package/public/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-600.woff2 +0 -0
- package/public/fonts/playfair-display-cyrillic-400.woff2 +0 -0
- package/public/fonts/playfair-display-latin-400.woff2 +0 -0
- package/public/fonts/playfair-display-latin-ext-400.woff2 +0 -0
- package/public/fonts/playfair-display-vietnamese-400.woff2 +0 -0
- package/public/index.html +68 -46
- package/public/style.css +319 -8
- package/public/sw.js +2 -2
- package/server.js +58 -659
- package/themes/ayu-dark.json +21 -0
- package/themes/ayu-light.json +21 -0
- package/themes/catppuccin-dark.json +21 -0
- package/themes/catppuccin-light.json +21 -0
- package/themes/dracula-dark.json +21 -0
- package/themes/dracula-light.json +21 -0
- package/themes/everforest-dark.json +21 -0
- package/themes/everforest-light.json +21 -0
- package/themes/github-dark.json +21 -0
- package/themes/github-light.json +21 -0
- package/themes/gruvbox-dark.json +21 -0
- package/themes/gruvbox-light.json +21 -0
- package/themes/kanagawa-dark.json +21 -0
- package/themes/kanagawa-light.json +21 -0
- package/themes/monokai-dark.json +21 -0
- package/themes/monokai-light.json +21 -0
- package/themes/night-owl-dark.json +21 -0
- package/themes/night-owl-light.json +21 -0
- package/themes/nord-dark.json +21 -0
- package/themes/nord-light.json +21 -0
- package/themes/one-dark-dark.json +21 -0
- package/themes/one-dark-light.json +21 -0
- package/themes/rose-pine-dark.json +21 -0
- package/themes/rose-pine-light.json +21 -0
- package/themes/solarized-dark.json +21 -0
- package/themes/solarized-light.json +21 -0
- package/themes/synthwave-dark.json +21 -0
- package/themes/synthwave-light.json +21 -0
- package/themes/tokyo-night-dark.json +21 -0
- package/themes/tokyo-night-light.json +21 -0
- package/themes/vitesse-dark.json +21 -0
- package/themes/vitesse-light.json +21 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Breaking changes
|
|
6
|
+
|
|
7
|
+
- The server listens on `127.0.0.1` only. To reach it from another machine, start it with `--host 0.0.0.0 --allowed-hosts=<hostname>`.
|
|
8
|
+
- The Projects tab is gone. The Overview lists the projects, and a click opens a project's sessions. `1` and `2` switch between Overview and Insights.
|
|
9
|
+
|
|
10
|
+
### Features
|
|
11
|
+
|
|
12
|
+
- **Date ranges.** Pick a range with the date picker or a brush on the charts. The cost-by-model charts show hourly or daily series.
|
|
13
|
+
- **Insights, sessions and agents.** New pages and APIs: `/api/insights`, `/api/sessions`, and `/api/agents`.
|
|
14
|
+
- **Command line.** `pi-cost overview|insights|projects|sessions|agents|session` prints the same numbers in the terminal, or as JSON with `--json`.
|
|
15
|
+
- **Color themes.** 18 theme families in a theme menu, in light and dark.
|
|
16
|
+
- **pi-kanban pane.** `GET /api/companion` and the embed theme handshake let the pi-kanban board show pi-cost in a pane, in the board's colors.
|
|
17
|
+
- Keyboard help, loading skeletons, and fonts served locally.
|
|
18
|
+
- Follows pi's directory settings: `PI_CODING_AGENT_DIR`, `--session-dir`, `PI_CODING_AGENT_SESSION_DIR`, and `sessionDir` in `settings.json`.
|
|
19
|
+
- Uses the cost that pi records for each message.
|
|
20
|
+
|
|
21
|
+
### Security
|
|
22
|
+
|
|
23
|
+
- The server refuses requests with a non-local `Host` header and cross-site requests that change data.
|
|
24
|
+
- A content security policy lets only local pages frame pi-cost.
|
|
25
|
+
|
|
26
|
+
### Other
|
|
27
|
+
|
|
28
|
+
- Express 5 and current dependencies.
|
|
29
|
+
- A `node:test` suite for the parsers and routes.
|
package/README.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# pi-cost
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/pi-cost)
|
|
4
|
+
[](https://www.npmjs.com/package/pi-cost)
|
|
5
|
+
|
|
3
6
|
Cost dashboard for the [pi coding agent](https://pi.dev) — overview → project → session → message, with actual and estimated spend.
|
|
4
7
|
|
|
5
8
|
[](https://github.com/user-attachments/assets/f9383f3f-4379-4ca6-b920-badb35f5eea8)
|
|
@@ -24,7 +27,15 @@ Or run standalone:
|
|
|
24
27
|
npx pi-cost
|
|
25
28
|
```
|
|
26
29
|
|
|
27
|
-
Open `http://localhost:5461` — `npm run dev` auto-opens the browser.
|
|
30
|
+
Open `http://localhost:5461` — `npm run dev` auto-opens the browser. pi-cost reads the agent dir in `PI_CODING_AGENT_DIR` (default `~/.pi/agent`).
|
|
31
|
+
|
|
32
|
+
The same numbers from the command line:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
npx pi-cost overview --range 30d
|
|
36
|
+
npx pi-cost sessions --match refactor --json
|
|
37
|
+
npx pi-cost help
|
|
38
|
+
```
|
|
28
39
|
|
|
29
40
|
### From source
|
|
30
41
|
|
|
@@ -33,6 +44,7 @@ git clone https://github.com/NikiforovAll/pi-cost
|
|
|
33
44
|
cd pi-cost
|
|
34
45
|
npm install
|
|
35
46
|
npm start # http://localhost:5461
|
|
47
|
+
npm test # node:test suite on synthetic sessions
|
|
36
48
|
pi --add ./extensions # register as a pi extension
|
|
37
49
|
```
|
|
38
50
|
|
package/extensions/cost.ts
CHANGED
|
@@ -1,47 +1,25 @@
|
|
|
1
1
|
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
3
|
import { createConnection } from "node:net";
|
|
4
|
-
import {
|
|
5
|
-
import { join as joinPath, resolve as resolvePath } from "node:path";
|
|
4
|
+
import { resolve as resolvePath } from "node:path";
|
|
6
5
|
import { fileURLToPath } from "node:url";
|
|
7
6
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
7
|
|
|
9
8
|
const extDir = fileURLToPath(new URL('.', import.meta.url));
|
|
9
|
+
const requireLib = createRequire(import.meta.url);
|
|
10
|
+
const parsers = requireLib("../lib/pi-parsers-cost.js");
|
|
11
|
+
const { readServerRecord, isAlive } = requireLib("../lib/cost-dir.js");
|
|
10
12
|
|
|
11
13
|
let child: ChildProcess | null = null;
|
|
12
14
|
let lastStderr = "";
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
const COST_DIR = joinPath(homedir(), ".pi", "agent", "cost");
|
|
16
|
-
const SETTINGS_PATH = joinPath(COST_DIR, "settings.json");
|
|
17
|
-
const DEFAULT_THEME_DIR = joinPath(COST_DIR, "themes");
|
|
18
|
-
|
|
19
|
-
type CostSettings = {
|
|
20
|
-
themes?: { light?: string; dark?: string; dir?: string };
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
function readSettings(): CostSettings {
|
|
24
|
-
try {
|
|
25
|
-
return JSON.parse(readFileSync(SETTINGS_PATH, "utf8")) as CostSettings;
|
|
26
|
-
} catch (e: any) {
|
|
27
|
-
if (e?.code !== "ENOENT") console.warn(`pi-cost: cannot read ${SETTINGS_PATH}: ${e.message}`);
|
|
28
|
-
return {};
|
|
29
|
-
}
|
|
30
|
-
}
|
|
15
|
+
const PREFERRED_PORT = 5461;
|
|
31
16
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
env.COST_LIGHT_THEME = process.env.COST_LIGHT_THEME ?? t.light;
|
|
39
|
-
}
|
|
40
|
-
if (process.env.COST_DARK_THEME ?? t.dark) {
|
|
41
|
-
env.COST_DARK_THEME = process.env.COST_DARK_THEME ?? t.dark;
|
|
42
|
-
}
|
|
43
|
-
return env;
|
|
44
|
-
}
|
|
17
|
+
// The server follows the same agent dir as this pi (PI_CODING_AGENT_DIR, else ~/.pi/agent), so a
|
|
18
|
+
// dev pi and the main pi each get their own dashboard. The server reads theme settings itself.
|
|
19
|
+
const AGENT_DIR: string = parsers.getAgentDir();
|
|
20
|
+
const COST_DIR: string = parsers.getCostDir();
|
|
21
|
+
|
|
22
|
+
type ServerRecord = { pid: number; port: number };
|
|
45
23
|
|
|
46
24
|
const SUBCOMMANDS = ["start", "stop", "restart", "status", "open"] as const;
|
|
47
25
|
type Sub = (typeof SUBCOMMANDS)[number];
|
|
@@ -57,35 +35,25 @@ function probePort(p: number, timeoutMs = 250): Promise<boolean> {
|
|
|
57
35
|
});
|
|
58
36
|
}
|
|
59
37
|
|
|
60
|
-
async function
|
|
38
|
+
async function runningServer(): Promise<ServerRecord | null> {
|
|
39
|
+
const rec: ServerRecord | null = readServerRecord(COST_DIR);
|
|
40
|
+
return rec && (await probePort(rec.port)) ? rec : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function waitForServer(pid: number, totalMs = 8000): Promise<ServerRecord | null> {
|
|
61
44
|
const start = Date.now();
|
|
62
45
|
while (Date.now() - start < totalMs) {
|
|
63
|
-
|
|
46
|
+
const rec = await runningServer();
|
|
47
|
+
if (rec && rec.pid === pid) return rec;
|
|
64
48
|
await new Promise((r) => setTimeout(r, 150));
|
|
65
49
|
}
|
|
66
|
-
return
|
|
50
|
+
return null;
|
|
67
51
|
}
|
|
68
52
|
|
|
69
53
|
function splitArgs(args: string): string[] {
|
|
70
54
|
return args.trim().split(/\s+/).filter(Boolean);
|
|
71
55
|
}
|
|
72
56
|
|
|
73
|
-
function findPidsOnPort(p: number): number[] {
|
|
74
|
-
if (process.platform === "win32") {
|
|
75
|
-
const r = spawnSync("netstat", ["-ano", "-p", "tcp"], { encoding: "utf8" });
|
|
76
|
-
if (r.status !== 0) return [];
|
|
77
|
-
const pids = new Set<number>();
|
|
78
|
-
for (const line of r.stdout.split(/\r?\n/)) {
|
|
79
|
-
const m = line.match(/^\s*TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)/i);
|
|
80
|
-
if (m && Number(m[1]) === p) pids.add(Number(m[2]));
|
|
81
|
-
}
|
|
82
|
-
return [...pids];
|
|
83
|
-
}
|
|
84
|
-
const r = spawnSync("lsof", ["-tiTCP:" + p, "-sTCP:LISTEN"], { encoding: "utf8" });
|
|
85
|
-
if (r.status !== 0) return [];
|
|
86
|
-
return r.stdout.split(/\s+/).map((s) => Number(s)).filter((n) => Number.isFinite(n) && n > 0);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
57
|
function killPid(pid: number): void {
|
|
90
58
|
if (process.platform === "win32") {
|
|
91
59
|
spawnSync("taskkill", ["/F", "/PID", String(pid)], { stdio: "ignore" });
|
|
@@ -96,18 +64,20 @@ function killPid(pid: number): void {
|
|
|
96
64
|
|
|
97
65
|
export default function costExtension(pi: ExtensionAPI) {
|
|
98
66
|
const serverPath = resolvePath(extDir, "..", "server.js");
|
|
99
|
-
const
|
|
67
|
+
const urlOf = (rec: ServerRecord) => `http://localhost:${rec.port}`;
|
|
100
68
|
|
|
101
69
|
type Notify = (m: string, l?: "info" | "error") => void;
|
|
102
70
|
|
|
103
|
-
async function startServer(notify: Notify, opts: { silentSuccess?: boolean } = {}): Promise<
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
71
|
+
async function startServer(notify: Notify, opts: { silentSuccess?: boolean } = {}): Promise<ServerRecord | null> {
|
|
72
|
+
const existing = await runningServer();
|
|
73
|
+
if (existing) {
|
|
74
|
+
if (!opts.silentSuccess) notify(`pi-cost already listening on ${urlOf(existing)} — run /cost open to launch it`);
|
|
75
|
+
return existing;
|
|
107
76
|
}
|
|
108
77
|
lastStderr = "";
|
|
109
|
-
|
|
110
|
-
|
|
78
|
+
// No --port: the server takes PORT, and a random port when that one is in use.
|
|
79
|
+
child = spawn(process.execPath, [serverPath, "--no-open"], {
|
|
80
|
+
env: { ...process.env, PI_CODING_AGENT_DIR: AGENT_DIR, PORT: String(PREFERRED_PORT) },
|
|
111
81
|
stdio: ["ignore", "ignore", "pipe"],
|
|
112
82
|
detached: true,
|
|
113
83
|
windowsHide: true,
|
|
@@ -115,35 +85,34 @@ export default function costExtension(pi: ExtensionAPI) {
|
|
|
115
85
|
child.stderr?.on("data", (b) => { lastStderr += b.toString(); });
|
|
116
86
|
child.on("exit", () => { child = null; });
|
|
117
87
|
|
|
118
|
-
|
|
88
|
+
const rec = child.pid ? await waitForServer(child.pid) : null;
|
|
89
|
+
if (!rec || !child) {
|
|
119
90
|
notify(`pi-cost failed to start.\n${lastStderr.slice(-500) || "(no stderr)"}`, "error");
|
|
120
|
-
return
|
|
91
|
+
return null;
|
|
121
92
|
}
|
|
122
93
|
child.stderr?.removeAllListeners("data");
|
|
123
94
|
child.stderr?.resume();
|
|
124
95
|
child.unref();
|
|
125
|
-
if (!opts.silentSuccess) notify(`pi-cost started → ${
|
|
126
|
-
return
|
|
96
|
+
if (!opts.silentSuccess) notify(`pi-cost started → ${urlOf(rec)} — run /cost open to launch it`);
|
|
97
|
+
return rec;
|
|
127
98
|
}
|
|
128
99
|
|
|
129
100
|
async function stopServer(notify: Notify, opts: { silentSuccess?: boolean } = {}): Promise<boolean> {
|
|
101
|
+
const rec = await runningServer();
|
|
130
102
|
if (child) child.kill("SIGINT");
|
|
131
103
|
child = null;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const pids = findPidsOnPort(port);
|
|
135
|
-
for (const pid of pids) killPid(pid);
|
|
136
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
137
|
-
if (await probePort(port)) {
|
|
138
|
-
notify(`port ${port} still in use after killing pids ${pids.join(",") || "?"}`, "error");
|
|
139
|
-
return false;
|
|
140
|
-
}
|
|
141
|
-
if (!opts.silentSuccess) {
|
|
142
|
-
notify(`pi-cost stopped (killed orphan pid${pids.length > 1 ? "s" : ""} ${pids.join(",")})`);
|
|
143
|
-
}
|
|
104
|
+
if (!rec) {
|
|
105
|
+
if (!opts.silentSuccess) notify("pi-cost stopped");
|
|
144
106
|
return true;
|
|
145
107
|
}
|
|
146
|
-
|
|
108
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
109
|
+
if (isAlive(rec.pid)) killPid(rec.pid);
|
|
110
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
111
|
+
if (await probePort(rec.port)) {
|
|
112
|
+
notify(`port ${rec.port} still in use after killing pid ${rec.pid}`, "error");
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
if (!opts.silentSuccess) notify(`pi-cost stopped (pid ${rec.pid})`);
|
|
147
116
|
return true;
|
|
148
117
|
}
|
|
149
118
|
|
|
@@ -160,19 +129,20 @@ export default function costExtension(pi: ExtensionAPI) {
|
|
|
160
129
|
if (sub === "stop") { await stopServer(notify); return; }
|
|
161
130
|
if (sub === "restart") {
|
|
162
131
|
if (!(await stopServer(notify, { silentSuccess: true }))) return;
|
|
163
|
-
|
|
164
|
-
notify(`pi-cost restarted → ${
|
|
132
|
+
const rec = await startServer(notify, { silentSuccess: true });
|
|
133
|
+
if (rec) notify(`pi-cost restarted → ${urlOf(rec)}`);
|
|
165
134
|
return;
|
|
166
135
|
}
|
|
167
136
|
if (sub === "status") {
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
notify(up ? `running on ${url}${owned}` : "not running");
|
|
137
|
+
const rec = await runningServer();
|
|
138
|
+
notify(rec ? `running on ${urlOf(rec)} (pid ${rec.pid}, agent dir ${AGENT_DIR})` : "not running");
|
|
171
139
|
return;
|
|
172
140
|
}
|
|
173
141
|
if (sub === "open") {
|
|
142
|
+
const rec = (await runningServer()) ?? (await startServer(notify, { silentSuccess: true }));
|
|
143
|
+
if (!rec) return;
|
|
174
144
|
const { default: open } = await import("open");
|
|
175
|
-
await open(
|
|
145
|
+
await open(urlOf(rec));
|
|
176
146
|
return;
|
|
177
147
|
}
|
|
178
148
|
notify(`Usage: /cost ${SUBCOMMANDS.join("|")}`, "error");
|
package/lib/app.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const express = require('express');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const parsers = require('./pi-parsers-cost');
|
|
6
|
+
const pricing = require('./pi-pricing');
|
|
7
|
+
const costs = require('./costs');
|
|
8
|
+
|
|
9
|
+
const VERSION = require('../package.json').version;
|
|
10
|
+
// Served from node_modules, not a CDN: the page must work offline inside the pi-kanban board.
|
|
11
|
+
const CHART_JS = path.join(path.dirname(require.resolve('chart.js')), 'chart.umd.min.js');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {{net: ReturnType<import('./net-guard').createNetGuard>, themes: ReturnType<import('./themes').createThemeStore>}} deps
|
|
15
|
+
*/
|
|
16
|
+
function createApp({ net, themes }) {
|
|
17
|
+
const app = express();
|
|
18
|
+
|
|
19
|
+
// Before express.json() so a rejected request never buffers a body.
|
|
20
|
+
app.use(net.hostGuard);
|
|
21
|
+
app.use(net.frameGuard);
|
|
22
|
+
app.use(net.originGuard);
|
|
23
|
+
app.use(express.json());
|
|
24
|
+
app.use(express.static(path.join(__dirname, '..', 'public')));
|
|
25
|
+
app.get('/vendor/chart.umd.min.js', (_req, res) => res.sendFile(CHART_JS));
|
|
26
|
+
|
|
27
|
+
// A custom from/to wins over `range`. null is the all-time range, so malformed input is rejected
|
|
28
|
+
// here rather than passed on as a sentinel.
|
|
29
|
+
const withRange = (fallback = null) => (req, res, next) => {
|
|
30
|
+
const custom = costs.parseCustomRange(req.query);
|
|
31
|
+
if (custom === null) return res.status(400).json({ error: 'Invalid date range' });
|
|
32
|
+
req.range = custom || costs.parseRange(req.query.range, fallback);
|
|
33
|
+
next();
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const withProject = (req, res, next) => {
|
|
37
|
+
const project = costs.parseProject(req.query.project);
|
|
38
|
+
if (project === null) return res.status(400).json({ error: 'Invalid project' });
|
|
39
|
+
req.project = project;
|
|
40
|
+
next();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const handle = (label, fn) => async (req, res) => {
|
|
44
|
+
try {
|
|
45
|
+
const data = await fn(req);
|
|
46
|
+
if (data == null) return res.status(404).json({ error: `${label} not found` });
|
|
47
|
+
res.json(data);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.error(`[api] ${label}:`, err);
|
|
50
|
+
res.status(500).json({ error: `Failed to load ${label}` });
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
app.get('/api/themes', async (_req, res) => {
|
|
55
|
+
await themes.ready;
|
|
56
|
+
res.json({
|
|
57
|
+
light: themes.active('light'),
|
|
58
|
+
dark: themes.active('dark'),
|
|
59
|
+
families: themes.families(),
|
|
60
|
+
config: { lightId: themes.config.lightId, darkId: themes.config.darkId, themeDir: themes.config.dir },
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// pi-kanban probes this before it frames the page; `session` is the deep-link template.
|
|
65
|
+
app.get('/api/companion', (_req, res) => {
|
|
66
|
+
res.json({ name: 'pi-cost', version: VERSION, session: '/?session={id}' });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
app.get('/pi-config', (req, res) => {
|
|
70
|
+
res.json({
|
|
71
|
+
agentDir: parsers.getAgentDir(),
|
|
72
|
+
sessionsDir: parsers.getSessionsDir(),
|
|
73
|
+
sessionRoots: parsers.getSessionRoots(),
|
|
74
|
+
port: req.socket.localPort,
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
app.get('/api/overview', withRange(30), withProject, handle('overview', (req) => costs.getOverviewData(req.range, req.project)));
|
|
79
|
+
app.get('/api/insights', withRange(30), withProject, handle('insights', (req) => costs.getInsightsData(req.range, req.project)));
|
|
80
|
+
app.get('/api/projects', withRange(), handle('projects', (req) => costs.getProjectsData(req.range)));
|
|
81
|
+
app.get('/api/projects/:encoded/sessions', withRange(), handle('project sessions', (req) => costs.getProjectSessionsData(req.params.encoded, req.range)));
|
|
82
|
+
app.get('/api/sessions', withRange(), withProject, handle('sessions', (req) => costs.getAllSessionsData(req.range, req.project)));
|
|
83
|
+
app.get('/api/agents', withRange(30), withProject, handle('agents', (req) => costs.getAgentsData(req.range, req.project)));
|
|
84
|
+
app.get('/api/sessions/:id', withRange(), handle('session', async (req) => {
|
|
85
|
+
const base = await costs.getSessionDetailData(req.params.id);
|
|
86
|
+
return base && costs.withSessionSlices(base, req.range);
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
app.get('/api/pricing', handle('pricing', async () => {
|
|
90
|
+
const map = await pricing.fetchPricing();
|
|
91
|
+
const models = [];
|
|
92
|
+
for (const [key, info] of map) {
|
|
93
|
+
models.push({
|
|
94
|
+
key,
|
|
95
|
+
input: info.input_cost_per_token,
|
|
96
|
+
output: info.output_cost_per_token,
|
|
97
|
+
cacheRead: info.cache_read_input_token_cost,
|
|
98
|
+
cacheWrite: info.cache_creation_input_token_cost,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return { models, ...pricing.getMeta() };
|
|
102
|
+
}));
|
|
103
|
+
|
|
104
|
+
app.post('/api/refresh', (_req, res) => {
|
|
105
|
+
costs.invalidateAllCache();
|
|
106
|
+
pricing.refresh();
|
|
107
|
+
res.json({ ok: true });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return app;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { createApp, VERSION };
|
package/lib/args.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// `--name value` or `--name=value`. A following flag is not taken as the value.
|
|
4
|
+
function getArg(name, argv = process.argv) {
|
|
5
|
+
const idx = argv.findIndex((a) => a === `--${name}` || a.startsWith(`--${name}=`));
|
|
6
|
+
if (idx === -1) return null;
|
|
7
|
+
const arg = argv[idx];
|
|
8
|
+
if (arg.includes('=')) return arg.slice(arg.indexOf('=') + 1);
|
|
9
|
+
const next = argv[idx + 1];
|
|
10
|
+
return next !== undefined && !next.startsWith('--') ? next : null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function hasFlag(name, argv = process.argv) {
|
|
14
|
+
return argv.includes(`--${name}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
module.exports = { getArg, hasFlag };
|