pi-bedrouter 0.2.1 → 0.4.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
@@ -7,6 +7,7 @@ A [Pi](https://pi.dev) extension for [bedrouter](https://github.com/bmelton/bedr
7
7
  - **registers a `bedrouter` provider** in Pi whose models come straight from `bedrouter.json`: the `auto` aliases (the router decides everything) plus each rung of each ladder
8
8
  - **switches the session to `bedrouter/auto`** when the server is healthy, so nobody has to pick a model
9
9
  - **shows what served each request in the footer**, live: routed model vs requested, class and deciding signal, the classifier's note, and the session's running cost against what the requested model would have cost
10
+ - **totals the whole session**: every request carries Pi's session id (`x-bedrouter-session`), so bedrouter can add up the session across conversation keys, compaction and sub-agent calls; `/bedrouter usage` shows spend, what the asked-for model would have cost, and the breakdown by route
10
11
  - **`/bedrouter`** for everything else: status, start/stop/restart, doctor and the per-rung entitlement probe, the savings report, the last N decisions, pi-agents fit notes
11
12
 
12
13
  ```
@@ -57,7 +58,8 @@ Example for a developer with a checkout:
57
58
  | `/bedrouter install` | `npm install` the dependency, or `npm run build` a checkout that has no `dist/` |
58
59
  | `/bedrouter doctor` | Which credential source resolved, expiry, the loaded ladder |
59
60
  | `/bedrouter probe` | One 1-token request per rung: which models this AWS account can actually invoke |
60
- | `/bedrouter report [--since t] [--json]` | Savings report over the decision log |
61
+ | `/bedrouter usage` | This Pi session so far: requests, tokens, spend (models + classifier), what the asked-for model would have cost, saved/over, and a per-route table. Whole-session totals need bedrouter ≥ 0.3; against an older server it shows the current conversation and says so. `usage all` lists recent sessions on the server (`*` marks this one) |
62
+ | `/bedrouter report [--since t] [--session key] [--json]` | Savings report over the decision log (all clients, all sessions; `--session` narrows it to one) |
61
63
  | `/bedrouter log [n]` | The last n routing decisions, one line each |
62
64
  | `/bedrouter models` | Re-read `bedrouter.json` and re-register the provider (after editing the ladder) |
63
65
  | `/bedrouter fitnotes` | Merge model notes into `~/.pi/agent/workflows.json` so the pi-agents planner defaults to `bedrouter/auto` and only pins premium rungs for planning/review |
@@ -65,7 +67,7 @@ Example for a developer with a checkout:
65
67
 
66
68
  ## How the footer works
67
69
 
68
- Bedrouter echoes every routing decision in response headers (`x-bedrouter-model`, `-requested`, `-class`, `-reason`, `-conversation`, `-classifier`). Pi hands extensions those headers in the `after_provider_response` event, so the status line updates the moment a response starts, before any tokens stream. When the turn ends, the extension asks bedrouter for the conversation's running totals (`GET /v1/conversations/:key`) and appends cost: spend so far, then the routing effect against what the same tokens would have cost on the model the client asked for: `saved $x (n%)`, `same as asked-for`, or `+$x over asked-for (routed up)` when the router chose a stronger rung. The classifier's one-off call is shown separately (`classifier $0.0004`) rather than counted against routing. `↑n` counts escalations in this conversation. The line clears when you switch to a non-bedrouter model. A background health check (every `healthPollS` seconds) keeps it honest between turns: if the server dies the line reads `bedrouter: DOWN` and, with `autoStart` on, the extension restarts it and says so.
70
+ Bedrouter echoes every routing decision in response headers (`x-bedrouter-model`, `-requested`, `-class`, `-reason`, `-conversation`, `-classifier`). Pi hands extensions those headers in the `after_provider_response` event, so the status line updates the moment a response starts, before any tokens stream. When the turn ends, the extension asks bedrouter for the session's running totals (`GET /v1/sessions/:id`, keyed by the Pi session id the extension sends on every request as `x-bedrouter-session`; on a bedrouter older than 0.3 it falls back to the current conversation, `GET /v1/conversations/:key`) and appends cost: spend so far, then the routing effect against what the same tokens would have cost on the model the client asked for: `saved $x (n%)`, `same as asked-for`, or `+$x over asked-for (routed up)` when the router chose a stronger rung. The classifier's one-off call is shown separately (`classifier $0.0004`) rather than counted against routing. `↑n` counts escalations in this session. The line clears when you switch to a non-bedrouter model. A background health check (every `healthPollS` seconds) keeps it honest between turns: if the server dies the line reads `bedrouter: DOWN` and, with `autoStart` on, the extension restarts it and says so.
69
71
 
70
72
  Under a coding agent every request carries tools and a large system prompt, so you will see `execute` and `explore` decided by keywords or the classifier, then `sticky` for the rest of the session, `up:kw:explore` when an explicit design question moves the conversation up, and escalations after failures. `trivial` shows up for bare chat clients, not for Pi.
71
73
 
@@ -6,19 +6,21 @@
6
6
  // - optionally switches the session to bedrouter/auto so nobody has to pick a model
7
7
  // - shows which model served each request in the footer, from bedrouter's x-bedrouter-* response headers,
8
8
  // plus the running session cost against what the requested model would have cost
9
- // - /bedrouter status|start|stop|restart|install|doctor|probe|report|log|models|fitnotes|config
9
+ // - tags every request with Pi's session id (x-bedrouter-session) so bedrouter can total the whole session, not just
10
+ // one conversation key; /bedrouter usage shows those totals
11
+ // - /bedrouter status|start|stop|restart|install|doctor|probe|usage|report|log|models|fitnotes|config
10
12
  import fs from "node:fs";
11
13
  import path from "node:path";
12
14
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
13
15
  import { loadSettings, saveSettings, settingsPath, agentDir, type Settings } from "../src/settings.js";
14
16
  import * as br from "../src/bedrouter.js";
15
17
  import { fitNotes, piModels } from "../src/models.js";
16
- import { fromHeaders, statusLine, type LastDecision } from "../src/footer.js";
18
+ import { downLine, fromHeaders, readyLine, restartingLine, sessionsTable, statusLine, usageReport, type CostTotals, type LastDecision, type Paint } from "../src/footer.js";
17
19
 
18
20
  export default async function (pi: ExtensionAPI) {
19
21
  let settings = loadSettings();
20
22
  let last: LastDecision | null = null;
21
- let stats: br.ConversationStats | null = null;
23
+ let stats: CostTotals | null = null; // footer totals: the session when the server supports it, else the last conversation
22
24
  let registeredModelIds: string[] = [];
23
25
  let lastCtx: ExtensionContext | null = null; // most recent context, for the background poll to update the footer
24
26
  let serverUp: boolean | null = null; // last known health; null = never checked
@@ -27,8 +29,18 @@ export default async function (pi: ExtensionAPI) {
27
29
  const ourConversations = new Set<string>(); // bedrouter conversation keys seen from this session
28
30
 
29
31
  const isOurs = (ctx: ExtensionContext) => ctx.model?.provider === settings.providerName;
32
+ const sessionId = (ctx: ExtensionContext) => { try { return ctx.sessionManager.getSessionId(); } catch { return null; } };
33
+ /** Whole-session totals from bedrouter (>= 0.3); falls back to the last conversation on older servers. */
34
+ async function refreshStats(ctx: ExtensionContext): Promise<{ session: br.SessionStats | null; conversation: br.ConversationStats | null }> {
35
+ const id = sessionId(ctx);
36
+ const session = id ? await br.session(settings, id) : null;
37
+ const conversation = !session && last?.conversation ? await br.conversation(settings, last.conversation) : null;
38
+ stats = session ?? conversation ?? stats;
39
+ return { session, conversation };
40
+ }
30
41
  const setStatus = (ctx: ExtensionContext, text: string | undefined) => { lastCtx = ctx; if (ctx.hasUI && settings.footer) ctx.ui.setStatus("bedrouter", text); };
31
- const readyText = () => statusLine(last, stats);
42
+ const paint = (ctx: ExtensionContext | null): Paint => (ctx?.hasUI && ctx.ui.theme ? (c, t) => ctx.ui.theme.fg(c, t) : (_c, t) => t);
43
+ const readyText = (ctx: ExtensionContext | null = lastCtx) => statusLine(last, stats, paint(ctx));
32
44
  const notify = (ctx: ExtensionContext, msg: string, type: "info" | "warning" | "error" = "info") => { if (ctx.hasUI) ctx.ui.notify(msg, type); };
33
45
  /** Show multi-line output (doctor/report/log) as a visible message without adding it to the model's context. */
34
46
  const show = (title: string, body: string) => pi.sendMessage({ customType: "bedrouter", content: `**${title}**\n\n\`\`\`\n${body}\n\`\`\``, display: true }, { triggerTurn: false });
@@ -112,27 +124,36 @@ export default async function (pi: ExtensionAPI) {
112
124
 
113
125
  pi.on("session_start", async (ev, ctx) => {
114
126
  lastCtx = ctx;
115
- if (ev.reason !== "startup" && ev.reason !== "new") { if (isOurs(ctx)) setStatus(ctx, statusLine(last, stats)); return; }
127
+ if (ev.reason !== "startup" && ev.reason !== "new") { if (isOurs(ctx)) setStatus(ctx, statusLine(last, stats, paint(ctx))); return; }
116
128
  last = null; stats = null;
117
129
  const h = await br.health(settings);
118
130
  serverUp = !!h?.ok;
119
131
  if (!h?.ok && settings.autoStart) {
120
132
  const r = await bringUp(ctx, { install: true, start: true });
121
133
  serverUp = r.ok;
122
- if (!r.ok) { notify(ctx, `bedrouter: ${r.lines[r.lines.length - 1]}`, "warning"); setStatus(ctx, "bedrouter: DOWN · /bedrouter start"); return; }
134
+ if (!r.ok) { notify(ctx, `bedrouter: ${r.lines[r.lines.length - 1]}`, "warning"); setStatus(ctx, downLine("/bedrouter start", paint(ctx))); return; }
123
135
  const created = r.lines.filter((l) => l.startsWith("created "));
124
136
  if (created.length) notify(ctx, created.join("\n"), "warning");
125
- } else if (!h?.ok) { setStatus(ctx, "bedrouter: DOWN · /bedrouter start"); return; }
137
+ } else if (!h?.ok) { setStatus(ctx, downLine("/bedrouter start", paint(ctx))); return; }
126
138
  await autoSelect(ctx);
127
- if (isOurs(ctx)) setStatus(ctx, "bedrouter: ready");
139
+ if (isOurs(ctx)) setStatus(ctx, readyText(ctx));
128
140
  });
129
141
 
130
142
  pi.on("model_select", async (ev, ctx) => {
131
- if (ev.model.provider === settings.providerName) setStatus(ctx, statusLine(last, stats));
143
+ if (ev.model.provider === settings.providerName) setStatus(ctx, statusLine(last, stats, paint(ctx)));
132
144
  else setStatus(ctx, undefined);
133
145
  });
134
146
 
135
147
  // ---- live routing display ------------------------------------------------------------------------------------
148
+ // Tag requests with Pi's session id: bedrouter's conversation key is derived from the system prompt and first user
149
+ // message, so one Pi session becomes several conversations (compaction, sub-agents, prompt changes) and trivial or
150
+ // pinned requests are not tracked at all. The session key is what /bedrouter usage and the footer total against.
151
+ pi.on("before_provider_headers", async (ev, ctx) => {
152
+ if (!isOurs(ctx)) return;
153
+ const id = sessionId(ctx);
154
+ if (id) ev.headers["x-bedrouter-session"] = id;
155
+ });
156
+
136
157
  pi.on("after_provider_response", async (ev, ctx) => {
137
158
  if (!isOurs(ctx)) return;
138
159
  const d = fromHeaders(ev.headers ?? {});
@@ -140,13 +161,13 @@ export default async function (pi: ExtensionAPI) {
140
161
  last = d;
141
162
  serverUp = true;
142
163
  if (d.conversation) ourConversations.add(d.conversation);
143
- setStatus(ctx, statusLine(last, stats));
164
+ setStatus(ctx, statusLine(last, stats, paint(ctx)));
144
165
  });
145
166
 
146
167
  pi.on("agent_end", async (_ev, ctx) => {
147
- if (!isOurs(ctx) || !last?.conversation) return;
148
- stats = await br.conversation(settings, last.conversation);
149
- setStatus(ctx, statusLine(last, stats));
168
+ if (!isOurs(ctx) || !last) return;
169
+ await refreshStats(ctx);
170
+ setStatus(ctx, statusLine(last, stats, paint(ctx)));
150
171
  });
151
172
 
152
173
  // ---- background health poll ----------------------------------------------------------------------------------
@@ -160,14 +181,14 @@ export default async function (pi: ExtensionAPI) {
160
181
  if (up === serverUp) return;
161
182
  serverUp = up;
162
183
  if (up) { setStatus(ctx, readyText()); if (last) notify(ctx, "bedrouter: back up"); return; }
163
- setStatus(ctx, "bedrouter: DOWN" + (settings.autoStart ? " · restarting…" : " · /bedrouter start"));
184
+ setStatus(ctx, settings.autoStart ? restartingLine(paint(ctx)) : downLine("/bedrouter start", paint(ctx)));
164
185
  if (!settings.autoStart || Date.now() - restartAttemptAt < 60_000) return;
165
186
  restartAttemptAt = Date.now();
166
187
  // same path as startup: locate, build/install if needed, start
167
188
  const r = await bringUp(ctx, { install: true, start: true });
168
189
  serverUp = r.ok;
169
190
  if (r.ok) { setStatus(ctx, readyText()); notify(ctx, `bedrouter: restarted`); }
170
- else { setStatus(ctx, "bedrouter: DOWN · /bedrouter start"); notify(ctx, `bedrouter: restart failed — ${r.lines[r.lines.length - 1].split("\n")[0]}`, "warning"); }
191
+ else { setStatus(ctx, downLine("/bedrouter start", paint(ctx))); notify(ctx, `bedrouter: restart failed — ${r.lines[r.lines.length - 1].split("\n")[0]}`, "warning"); }
171
192
  }
172
193
  if (settings.healthPollS > 0) { poll = setInterval(() => void checkHealth(), settings.healthPollS * 1000); poll.unref(); }
173
194
 
@@ -181,9 +202,9 @@ export default async function (pi: ExtensionAPI) {
181
202
  });
182
203
 
183
204
  // ---- /bedrouter ------------------------------------------------------------------------------------------------
184
- const SUB = ["status", "start", "stop", "restart", "install", "doctor", "probe", "report", "log", "models", "fitnotes", "config", "help"];
205
+ const SUB = ["status", "start", "stop", "restart", "install", "doctor", "probe", "usage", "report", "log", "models", "fitnotes", "config", "help"];
185
206
  pi.registerCommand("bedrouter", {
186
- description: "bedrouter router: status | start | stop | restart | install | doctor | probe | report | log [n] | models | fitnotes | config",
207
+ description: "bedrouter router: status | start | stop | restart | install | doctor | probe | usage [all] | report | log [n] | models | fitnotes | config",
187
208
  getArgumentCompletions: (prefix) => SUB.filter((s) => s.startsWith(prefix.trim())).map((value) => ({ value, label: value })),
188
209
  handler: async (args, ctx) => {
189
210
  const [sub = "status", ...rest] = args.trim().split(/\s+/).filter(Boolean);
@@ -212,10 +233,10 @@ export default async function (pi: ExtensionAPI) {
212
233
  const r = await bringUp(ctx, { install: false, start: true });
213
234
  show(`bedrouter ${sub}`, r.lines.join("\n"));
214
235
  serverUp = r.ok;
215
- if (r.ok) { await autoSelect(ctx); setStatus(ctx, isOurs(ctx) ? readyText() : undefined); }
236
+ if (r.ok) { await autoSelect(ctx); setStatus(ctx, isOurs(ctx) ? readyText(ctx) : undefined); }
216
237
  break;
217
238
  }
218
- case "stop": notify(ctx, await br.stop(settings)); serverUp = false; setStatus(ctx, isOurs(ctx) ? "bedrouter: DOWN · /bedrouter start" : undefined); break;
239
+ case "stop": notify(ctx, await br.stop(settings)); serverUp = false; setStatus(ctx, isOurs(ctx) ? downLine("/bedrouter start", paint(ctx)) : undefined); break;
219
240
  case "install": {
220
241
  notify(ctx, "bedrouter: installing/building (this can take a minute)…");
221
242
  const r = await bringUp(ctx, { install: true, start: false });
@@ -229,6 +250,20 @@ export default async function (pi: ExtensionAPI) {
229
250
  show(`bedrouter ${sub}`, r.out);
230
251
  break;
231
252
  }
253
+ case "usage": {
254
+ const h = await br.health(settings);
255
+ if (!h?.ok) { notify(ctx, `bedrouter is down (${br.baseUrl(settings)}); /bedrouter start`, "error"); break; }
256
+ const id = sessionId(ctx);
257
+ if (rest[0] === "all") { show("bedrouter usage: recent sessions on this server", sessionsTable(await br.recentSessions(settings), id ?? undefined)); break; }
258
+ const { session, conversation } = await refreshStats(ctx);
259
+ if (session) { show("bedrouter usage: this session", usageReport(session, { sessionId: id ?? undefined })); }
260
+ else if (conversation) {
261
+ // older bedrouter: no /v1/sessions; present the last conversation in the same shape
262
+ show("bedrouter usage: this conversation", usageReport({ ...conversation, errors: 0, conversations: 1, cacheReadTokens: 0, byRoute: conversation.routedModel ? { [`${conversation.requestedModel ?? "?"} -> ${conversation.routedModel}`]: { requests: conversation.requests, costUsd: conversation.costUsd, requestedCostUsd: conversation.requestedCostUsd, inputTokens: conversation.inputTokens, outputTokens: conversation.outputTokens } } : {}, firstTs: conversation.lastTs }, { sessionId: id ?? undefined, conversationOnly: true }));
263
+ } else show("bedrouter usage", isOurs(ctx) ? `nothing routed yet in this session (${id ?? "no session id"})` : `this session is not using bedrouter (model ${ctx.model?.provider}/${ctx.model?.id}); pick bedrouter/auto with /model`);
264
+ if (isOurs(ctx) && last) setStatus(ctx, statusLine(last, stats, paint(ctx)));
265
+ break;
266
+ }
232
267
  case "report": {
233
268
  if (!need() || !loc) break;
234
269
  const r = br.run(settings, loc, ["report", ...rest]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-bedrouter",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Pi extension for bedrouter: starts the cost-aware Bedrock model router, registers it as a provider, and shows which model served each request",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -43,7 +43,7 @@
43
43
  "prepublishOnly": "npm test && npm run typecheck"
44
44
  },
45
45
  "dependencies": {
46
- "bedrouter": "^0.1.0"
46
+ "bedrouter": "^0.3.0"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@earendil-works/pi-coding-agent": ">=0.85.0"
package/src/bedrouter.ts CHANGED
@@ -8,6 +8,8 @@ import type { Settings } from "./settings.js";
8
8
 
9
9
  export type Health = { ok: boolean; region: string; pid: number; version: string; routing: boolean; classifier: string | null; uptimeS: number };
10
10
  export type ConversationStats = { key: string; requests: number; costUsd: number; requestedCostUsd: number; classifierCostUsd: number; inputTokens: number; outputTokens: number; escalations: number; class: string | null; routedModel: string | null; requestedModel: string | null; lastTs: string };
11
+ /** Per-session totals from GET /v1/sessions/:key (bedrouter >= 0.3): every request this Pi session sent, across conversations. */
12
+ export type SessionStats = { key: string; requests: number; errors: number; conversations: number; costUsd: number; requestedCostUsd: number; classifierCostUsd: number; inputTokens: number; outputTokens: number; cacheReadTokens: number; escalations: number; byRoute: Record<string, { requests: number; costUsd: number; requestedCostUsd: number; inputTokens: number; outputTokens: number }>; firstTs: string; lastTs: string };
11
13
  export type Rung = { alias: string; bedrockId: string; inputPerM: number; outputPerM: number };
12
14
  export type BedrouterConfig = { families: Record<string, Rung[]>; aliases?: Record<string, string>; routing?: { enabled?: boolean; classes?: Record<string, Record<string, string>>; classifier?: { enabled?: boolean; model?: string } } };
13
15
 
@@ -124,6 +126,8 @@ export const FALLBACK_CONFIG: BedrouterConfig = {
124
126
  routing: { classes: { anthropic: { trivial: "haiku", execute: "sonnet", explore: "opus" }, openai: { execute: "gpt-oss-20b", explore: "gpt-oss-120b" } } },
125
127
  };
126
128
  export const conversation = (s: Settings, key: string) => getJson<ConversationStats>(`${baseUrl(s)}/v1/conversations/${key}`);
129
+ export const session = (s: Settings, key: string) => getJson<SessionStats>(`${baseUrl(s)}/v1/sessions/${encodeURIComponent(key)}`);
130
+ export const recentSessions = async (s: Settings) => (await getJson<{ data: SessionStats[] }>(`${baseUrl(s)}/v1/sessions`))?.data ?? [];
127
131
  export const recentConversations = async (s: Settings) => (await getJson<{ data: ConversationStats[] }>(`${baseUrl(s)}/v1/conversations`))?.data ?? [];
128
132
 
129
133
  /** True when a conversation other than `ours` sent a request within `windowMs`: another client is using the server. */
package/src/footer.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  // Footer status text: what served the last request and how the session is doing on cost.
2
- import type { ConversationStats } from "./bedrouter.js";
2
+ import type { ConversationStats, SessionStats } from "./bedrouter.js";
3
+
4
+ /** The footer totals against a session (preferred, bedrouter >= 0.3) or a single conversation (older servers). */
5
+ export type CostTotals = SessionStats | ConversationStats;
3
6
 
4
7
  export type LastDecision = { model: string; requested: string; cls: string; reason: string; conversation?: string; classifier?: string };
5
8
 
@@ -13,10 +16,24 @@ export function fromHeaders(h: Record<string, string>): LastDecision | null {
13
16
  const usd = (n: number) => (n >= 1 ? `$${n.toFixed(2)}` : `$${n.toFixed(4)}`);
14
17
  const short = (reason: string) => reason.replace(/^keyword:/, "kw:").replace(/^classifier:/, "clf:").replace(/^upgrade:keyword:/, "up:kw:").replace(/^shape:/, "");
15
18
 
16
- export function statusLine(d: LastDecision | null, c: ConversationStats | null): string {
17
- if (!d) return "bedrouter: ready";
19
+ /**
20
+ * Pi's footer sorts extension statuses by key, joins them with ONE space, and collapses runs of ASCII spaces, so two
21
+ * packages' lines run into each other. Each status therefore starts with a dim segment bar and ends with non-breaking
22
+ * spaces (which survive the collapse) as a right margin. `paint` is ctx.ui.theme.fg when a UI is present.
23
+ */
24
+ export type Paint = (color: "success" | "error" | "warning" | "dim" | "accent", text: string) => string;
25
+ const plain: Paint = (_c, t) => t;
26
+ const NBSP2 = "\u00a0\u00a0";
27
+ export const segment = (body: string, paint: Paint = plain) => `${paint("dim", "│")} ${body}${NBSP2}`;
28
+
29
+ export function readyLine(paint: Paint = plain): string { return segment(`${paint("success", "✓")} bedrouter ready`, paint); }
30
+ export function downLine(hint: string, paint: Paint = plain): string { return segment(`${paint("error", "✗")} bedrouter down · ${hint}`, paint); }
31
+ export function restartingLine(paint: Paint = plain): string { return segment(`${paint("warning", "↻")} bedrouter restarting…`, paint); }
32
+
33
+ export function statusLine(d: LastDecision | null, c: CostTotals | null, paint: Paint = plain): string {
34
+ if (!d) return readyLine(paint);
18
35
  const arrow = d.model === d.requested ? "=" : "≠";
19
- let s = `⇄ ${d.model} ${arrow} ${d.requested} ${d.cls}·${short(d.reason)}`;
36
+ let s = `${paint("success", "✓")} bedrouter ⇄ ${d.model} ${arrow} ${d.requested} ${d.cls}·${short(d.reason)}`;
20
37
  if (c && c.requests > 0) {
21
38
  const spend = c.costUsd + c.classifierCostUsd;
22
39
  // Routing effect = model cost vs the same tokens on the asked-for model. The classifier's one-off call is overhead
@@ -30,5 +47,40 @@ export function statusLine(d: LastDecision | null, c: ConversationStats | null):
30
47
  else verdict = `+${usd(-diff)} over asked-for (routed up)${clf}`;
31
48
  s += ` ${usd(spend)} · ${verdict}${c.escalations ? ` ↑${c.escalations}` : ""}`;
32
49
  }
33
- return s;
50
+ return segment(s, paint);
51
+ }
52
+
53
+ const usd4 = (n: number) => `$${n.toFixed(4)}`;
54
+ const kTok = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
55
+
56
+ /** The `/bedrouter usage` body: this Pi session's spend so far, what the asked-for model would have cost, and where it went. */
57
+ export function usageReport(st: SessionStats, opts: { sessionId?: string; conversationOnly?: boolean } = {}): string {
58
+ const spend = st.costUsd + st.classifierCostUsd;
59
+ const diff = st.requestedCostUsd - st.costUsd;
60
+ const pct = st.requestedCostUsd > 0 ? (diff / st.requestedCostUsd) * 100 : 0;
61
+ const mins = Math.max(0, Math.round((Date.parse(st.lastTs) - Date.parse(st.firstTs)) / 60_000));
62
+ const out: string[] = [];
63
+ out.push(`session ${opts.sessionId ?? st.key}${opts.conversationOnly ? " (current conversation only: this bedrouter predates per-session totals, upgrade it for whole-session numbers)" : ""}`);
64
+ out.push(`requests ${st.requests}${st.errors ? ` (${st.errors} errors)` : ""}, conversations ${st.conversations}, escalations ${st.escalations}${mins ? `, over ${mins} min` : ""}`);
65
+ out.push(`tokens in ${kTok(st.inputTokens)} out ${kTok(st.outputTokens)}${st.cacheReadTokens ? ` cache-read ${kTok(st.cacheReadTokens)}` : ""}`);
66
+ out.push(`spend ${usd4(spend)}${st.classifierCostUsd > 0 ? ` (models ${usd4(st.costUsd)} + classifier ${usd4(st.classifierCostUsd)})` : ""}`);
67
+ out.push(`asked-for ${usd4(st.requestedCostUsd)} what the same tokens would have cost on the model Pi asked for`);
68
+ if (Math.abs(diff) < 1e-6) out.push(`routing same as asked-for`);
69
+ else if (diff > 0) out.push(`routing saved ${usd4(diff)} (${pct.toFixed(1)}%)`);
70
+ else out.push(`routing +${usd4(-diff)} over asked-for (routed up: explore / escalations)`);
71
+ const routes = Object.entries(st.byRoute).sort((a, b) => b[1].costUsd - a[1].costUsd);
72
+ if (routes.length) {
73
+ out.push("");
74
+ out.push(` ${"requested -> routed".padEnd(28)} ${"reqs".padStart(5)} ${"in tok".padStart(8)} ${"out tok".padStart(8)} ${"cost".padStart(10)} ${"asked-for".padStart(10)}`);
75
+ for (const [k, r] of routes) out.push(` ${k.padEnd(28)} ${String(r.requests).padStart(5)} ${kTok(r.inputTokens).padStart(8)} ${kTok(r.outputTokens).padStart(8)} ${usd4(r.costUsd).padStart(10)} ${usd4(r.requestedCostUsd).padStart(10)}`);
76
+ }
77
+ return out.join("\n");
78
+ }
79
+
80
+ /** One line per session for `/bedrouter usage all`. */
81
+ export function sessionsTable(list: SessionStats[], current?: string): string {
82
+ if (!list.length) return "(no sessions yet: nothing has sent x-bedrouter-session to this server)";
83
+ const rows = [` ${"session".padEnd(38)} ${"reqs".padStart(5)} ${"spend".padStart(10)} ${"asked-for".padStart(10)} ${"saved".padStart(10)} last`];
84
+ for (const st of list) rows.push(`${st.key === current ? "*" : " "} ${st.key.slice(0, 38).padEnd(38)} ${String(st.requests).padStart(5)} ${usd4(st.costUsd + st.classifierCostUsd).padStart(10)} ${usd4(st.requestedCostUsd).padStart(10)} ${usd4(st.requestedCostUsd - st.costUsd).padStart(10)} ${st.lastTs.slice(0, 16).replace("T", " ")}`);
85
+ return rows.join("\n");
34
86
  }