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 +4 -2
- package/extensions/index.ts +54 -19
- package/package.json +2 -2
- package/src/bedrouter.ts +4 -0
- package/src/footer.ts +57 -5
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
|
|
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
|
|
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
|
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
-
// -
|
|
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:
|
|
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
|
|
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, "
|
|
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, "
|
|
137
|
+
} else if (!h?.ok) { setStatus(ctx, downLine("/bedrouter start", paint(ctx))); return; }
|
|
126
138
|
await autoSelect(ctx);
|
|
127
|
-
if (isOurs(ctx)) setStatus(ctx,
|
|
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
|
|
148
|
-
|
|
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,
|
|
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, "
|
|
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) ? "
|
|
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.
|
|
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.
|
|
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
|
-
|
|
17
|
-
|
|
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 =
|
|
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
|
}
|