pi-bedrouter 0.2.2 → 0.4.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 +4 -2
- package/extensions/index.ts +41 -7
- package/package.json +2 -2
- package/src/bedrouter.ts +4 -0
- package/src/footer.ts +40 -2
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 { downLine, fromHeaders, readyLine, restartingLine, statusLine, type LastDecision, type Paint } 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,6 +29,15 @@ 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
42
|
const paint = (ctx: ExtensionContext | null): Paint => (ctx?.hasUI && ctx.ui.theme ? (c, t) => ctx.ui.theme.fg(c, t) : (_c, t) => t);
|
|
32
43
|
const readyText = (ctx: ExtensionContext | null = lastCtx) => statusLine(last, stats, paint(ctx));
|
|
@@ -134,6 +145,15 @@ export default async function (pi: ExtensionAPI) {
|
|
|
134
145
|
});
|
|
135
146
|
|
|
136
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
|
+
|
|
137
157
|
pi.on("after_provider_response", async (ev, ctx) => {
|
|
138
158
|
if (!isOurs(ctx)) return;
|
|
139
159
|
const d = fromHeaders(ev.headers ?? {});
|
|
@@ -145,8 +165,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
145
165
|
});
|
|
146
166
|
|
|
147
167
|
pi.on("agent_end", async (_ev, ctx) => {
|
|
148
|
-
if (!isOurs(ctx) || !last
|
|
149
|
-
|
|
168
|
+
if (!isOurs(ctx) || !last) return;
|
|
169
|
+
await refreshStats(ctx);
|
|
150
170
|
setStatus(ctx, statusLine(last, stats, paint(ctx)));
|
|
151
171
|
});
|
|
152
172
|
|
|
@@ -182,9 +202,9 @@ export default async function (pi: ExtensionAPI) {
|
|
|
182
202
|
});
|
|
183
203
|
|
|
184
204
|
// ---- /bedrouter ------------------------------------------------------------------------------------------------
|
|
185
|
-
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"];
|
|
186
206
|
pi.registerCommand("bedrouter", {
|
|
187
|
-
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",
|
|
188
208
|
getArgumentCompletions: (prefix) => SUB.filter((s) => s.startsWith(prefix.trim())).map((value) => ({ value, label: value })),
|
|
189
209
|
handler: async (args, ctx) => {
|
|
190
210
|
const [sub = "status", ...rest] = args.trim().split(/\s+/).filter(Boolean);
|
|
@@ -230,6 +250,20 @@ export default async function (pi: ExtensionAPI) {
|
|
|
230
250
|
show(`bedrouter ${sub}`, r.out);
|
|
231
251
|
break;
|
|
232
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
|
+
}
|
|
233
267
|
case "report": {
|
|
234
268
|
if (!need() || !loc) break;
|
|
235
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.1",
|
|
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
|
|
46
|
+
"bedrouter": "^0.4.1"
|
|
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
|
|
|
@@ -27,7 +30,7 @@ export function readyLine(paint: Paint = plain): string { return segment(`${pain
|
|
|
27
30
|
export function downLine(hint: string, paint: Paint = plain): string { return segment(`${paint("error", "✗")} bedrouter down · ${hint}`, paint); }
|
|
28
31
|
export function restartingLine(paint: Paint = plain): string { return segment(`${paint("warning", "↻")} bedrouter restarting…`, paint); }
|
|
29
32
|
|
|
30
|
-
export function statusLine(d: LastDecision | null, c:
|
|
33
|
+
export function statusLine(d: LastDecision | null, c: CostTotals | null, paint: Paint = plain): string {
|
|
31
34
|
if (!d) return readyLine(paint);
|
|
32
35
|
const arrow = d.model === d.requested ? "=" : "≠";
|
|
33
36
|
let s = `${paint("success", "✓")} bedrouter ⇄ ${d.model} ${arrow} ${d.requested} ${d.cls}·${short(d.reason)}`;
|
|
@@ -46,3 +49,38 @@ export function statusLine(d: LastDecision | null, c: ConversationStats | null,
|
|
|
46
49
|
}
|
|
47
50
|
return segment(s, paint);
|
|
48
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");
|
|
86
|
+
}
|