pi-bedrouter 0.2.0 → 0.2.2

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
@@ -65,7 +65,7 @@ Example for a developer with a checkout:
65
65
 
66
66
  ## How the footer works
67
67
 
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 (classifier calls included), what the same tokens would have cost on the model the client asked for, and the difference as a percentage. `↑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.
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.
69
69
 
70
70
  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
71
 
@@ -13,7 +13,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
13
13
  import { loadSettings, saveSettings, settingsPath, agentDir, type Settings } from "../src/settings.js";
14
14
  import * as br from "../src/bedrouter.js";
15
15
  import { fitNotes, piModels } from "../src/models.js";
16
- import { fromHeaders, statusLine, type LastDecision } from "../src/footer.js";
16
+ import { downLine, fromHeaders, readyLine, restartingLine, statusLine, type LastDecision, type Paint } from "../src/footer.js";
17
17
 
18
18
  export default async function (pi: ExtensionAPI) {
19
19
  let settings = loadSettings();
@@ -28,7 +28,8 @@ export default async function (pi: ExtensionAPI) {
28
28
 
29
29
  const isOurs = (ctx: ExtensionContext) => ctx.model?.provider === settings.providerName;
30
30
  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);
31
+ const paint = (ctx: ExtensionContext | null): Paint => (ctx?.hasUI && ctx.ui.theme ? (c, t) => ctx.ui.theme.fg(c, t) : (_c, t) => t);
32
+ const readyText = (ctx: ExtensionContext | null = lastCtx) => statusLine(last, stats, paint(ctx));
32
33
  const notify = (ctx: ExtensionContext, msg: string, type: "info" | "warning" | "error" = "info") => { if (ctx.hasUI) ctx.ui.notify(msg, type); };
33
34
  /** Show multi-line output (doctor/report/log) as a visible message without adding it to the model's context. */
34
35
  const show = (title: string, body: string) => pi.sendMessage({ customType: "bedrouter", content: `**${title}**\n\n\`\`\`\n${body}\n\`\`\``, display: true }, { triggerTurn: false });
@@ -112,23 +113,23 @@ export default async function (pi: ExtensionAPI) {
112
113
 
113
114
  pi.on("session_start", async (ev, ctx) => {
114
115
  lastCtx = ctx;
115
- if (ev.reason !== "startup" && ev.reason !== "new") { if (isOurs(ctx)) setStatus(ctx, statusLine(last, stats)); return; }
116
+ if (ev.reason !== "startup" && ev.reason !== "new") { if (isOurs(ctx)) setStatus(ctx, statusLine(last, stats, paint(ctx))); return; }
116
117
  last = null; stats = null;
117
118
  const h = await br.health(settings);
118
119
  serverUp = !!h?.ok;
119
120
  if (!h?.ok && settings.autoStart) {
120
121
  const r = await bringUp(ctx, { install: true, start: true });
121
122
  serverUp = r.ok;
122
- if (!r.ok) { notify(ctx, `bedrouter: ${r.lines[r.lines.length - 1]}`, "warning"); setStatus(ctx, "bedrouter: DOWN · /bedrouter start"); return; }
123
+ if (!r.ok) { notify(ctx, `bedrouter: ${r.lines[r.lines.length - 1]}`, "warning"); setStatus(ctx, downLine("/bedrouter start", paint(ctx))); return; }
123
124
  const created = r.lines.filter((l) => l.startsWith("created "));
124
125
  if (created.length) notify(ctx, created.join("\n"), "warning");
125
- } else if (!h?.ok) { setStatus(ctx, "bedrouter: DOWN · /bedrouter start"); return; }
126
+ } else if (!h?.ok) { setStatus(ctx, downLine("/bedrouter start", paint(ctx))); return; }
126
127
  await autoSelect(ctx);
127
- if (isOurs(ctx)) setStatus(ctx, "bedrouter: ready");
128
+ if (isOurs(ctx)) setStatus(ctx, readyText(ctx));
128
129
  });
129
130
 
130
131
  pi.on("model_select", async (ev, ctx) => {
131
- if (ev.model.provider === settings.providerName) setStatus(ctx, statusLine(last, stats));
132
+ if (ev.model.provider === settings.providerName) setStatus(ctx, statusLine(last, stats, paint(ctx)));
132
133
  else setStatus(ctx, undefined);
133
134
  });
134
135
 
@@ -140,13 +141,13 @@ export default async function (pi: ExtensionAPI) {
140
141
  last = d;
141
142
  serverUp = true;
142
143
  if (d.conversation) ourConversations.add(d.conversation);
143
- setStatus(ctx, statusLine(last, stats));
144
+ setStatus(ctx, statusLine(last, stats, paint(ctx)));
144
145
  });
145
146
 
146
147
  pi.on("agent_end", async (_ev, ctx) => {
147
148
  if (!isOurs(ctx) || !last?.conversation) return;
148
149
  stats = await br.conversation(settings, last.conversation);
149
- setStatus(ctx, statusLine(last, stats));
150
+ setStatus(ctx, statusLine(last, stats, paint(ctx)));
150
151
  });
151
152
 
152
153
  // ---- background health poll ----------------------------------------------------------------------------------
@@ -160,14 +161,14 @@ export default async function (pi: ExtensionAPI) {
160
161
  if (up === serverUp) return;
161
162
  serverUp = up;
162
163
  if (up) { setStatus(ctx, readyText()); if (last) notify(ctx, "bedrouter: back up"); return; }
163
- setStatus(ctx, "bedrouter: DOWN" + (settings.autoStart ? " · restarting…" : " · /bedrouter start"));
164
+ setStatus(ctx, settings.autoStart ? restartingLine(paint(ctx)) : downLine("/bedrouter start", paint(ctx)));
164
165
  if (!settings.autoStart || Date.now() - restartAttemptAt < 60_000) return;
165
166
  restartAttemptAt = Date.now();
166
167
  // same path as startup: locate, build/install if needed, start
167
168
  const r = await bringUp(ctx, { install: true, start: true });
168
169
  serverUp = r.ok;
169
170
  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"); }
171
+ else { setStatus(ctx, downLine("/bedrouter start", paint(ctx))); notify(ctx, `bedrouter: restart failed — ${r.lines[r.lines.length - 1].split("\n")[0]}`, "warning"); }
171
172
  }
172
173
  if (settings.healthPollS > 0) { poll = setInterval(() => void checkHealth(), settings.healthPollS * 1000); poll.unref(); }
173
174
 
@@ -212,10 +213,10 @@ export default async function (pi: ExtensionAPI) {
212
213
  const r = await bringUp(ctx, { install: false, start: true });
213
214
  show(`bedrouter ${sub}`, r.lines.join("\n"));
214
215
  serverUp = r.ok;
215
- if (r.ok) { await autoSelect(ctx); setStatus(ctx, isOurs(ctx) ? readyText() : undefined); }
216
+ if (r.ok) { await autoSelect(ctx); setStatus(ctx, isOurs(ctx) ? readyText(ctx) : undefined); }
216
217
  break;
217
218
  }
218
- case "stop": notify(ctx, await br.stop(settings)); serverUp = false; setStatus(ctx, isOurs(ctx) ? "bedrouter: DOWN · /bedrouter start" : undefined); break;
219
+ case "stop": notify(ctx, await br.stop(settings)); serverUp = false; setStatus(ctx, isOurs(ctx) ? downLine("/bedrouter start", paint(ctx)) : undefined); break;
219
220
  case "install": {
220
221
  notify(ctx, "bedrouter: installing/building (this can take a minute)…");
221
222
  const r = await bringUp(ctx, { install: true, start: false });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-bedrouter",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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",
package/src/footer.ts CHANGED
@@ -13,15 +13,36 @@ export function fromHeaders(h: Record<string, string>): LastDecision | null {
13
13
  const usd = (n: number) => (n >= 1 ? `$${n.toFixed(2)}` : `$${n.toFixed(4)}`);
14
14
  const short = (reason: string) => reason.replace(/^keyword:/, "kw:").replace(/^classifier:/, "clf:").replace(/^upgrade:keyword:/, "up:kw:").replace(/^shape:/, "");
15
15
 
16
- export function statusLine(d: LastDecision | null, c: ConversationStats | null): string {
17
- if (!d) return "bedrouter: ready";
16
+ /**
17
+ * Pi's footer sorts extension statuses by key, joins them with ONE space, and collapses runs of ASCII spaces, so two
18
+ * packages' lines run into each other. Each status therefore starts with a dim segment bar and ends with non-breaking
19
+ * spaces (which survive the collapse) as a right margin. `paint` is ctx.ui.theme.fg when a UI is present.
20
+ */
21
+ export type Paint = (color: "success" | "error" | "warning" | "dim" | "accent", text: string) => string;
22
+ const plain: Paint = (_c, t) => t;
23
+ const NBSP2 = "\u00a0\u00a0";
24
+ export const segment = (body: string, paint: Paint = plain) => `${paint("dim", "│")} ${body}${NBSP2}`;
25
+
26
+ export function readyLine(paint: Paint = plain): string { return segment(`${paint("success", "✓")} bedrouter ready`, paint); }
27
+ export function downLine(hint: string, paint: Paint = plain): string { return segment(`${paint("error", "✗")} bedrouter down · ${hint}`, paint); }
28
+ export function restartingLine(paint: Paint = plain): string { return segment(`${paint("warning", "↻")} bedrouter restarting…`, paint); }
29
+
30
+ export function statusLine(d: LastDecision | null, c: ConversationStats | null, paint: Paint = plain): string {
31
+ if (!d) return readyLine(paint);
18
32
  const arrow = d.model === d.requested ? "=" : "≠";
19
- let s = `⇄ ${d.model} ${arrow} ${d.requested} ${d.cls}·${short(d.reason)}`;
33
+ let s = `${paint("success", "✓")} bedrouter ⇄ ${d.model} ${arrow} ${d.requested} ${d.cls}·${short(d.reason)}`;
20
34
  if (c && c.requests > 0) {
21
35
  const spend = c.costUsd + c.classifierCostUsd;
22
- const diff = c.requestedCostUsd - spend;
36
+ // Routing effect = model cost vs the same tokens on the asked-for model. The classifier's one-off call is overhead
37
+ // and shown as such, never folded into "over asked-for" (which would flag every break-even session as a loss).
38
+ const diff = c.requestedCostUsd - c.costUsd;
23
39
  const pct = c.requestedCostUsd > 0 ? Math.round((diff / c.requestedCostUsd) * 100) : 0;
24
- s += ` ${usd(spend)}${diff >= 0 ? ` saved ${usd(diff)} (${pct}%)` : ` +${usd(-diff)} over asked-for`}${c.escalations ? ` ↑${c.escalations}` : ""}`;
40
+ const clf = c.classifierCostUsd > 0 ? `, classifier ${usd(c.classifierCostUsd)}` : "";
41
+ let verdict: string;
42
+ if (Math.abs(diff) < 1e-6) verdict = `same as asked-for${clf}`;
43
+ else if (diff > 0) verdict = `saved ${usd(diff)} (${pct}%)${clf}`;
44
+ else verdict = `+${usd(-diff)} over asked-for (routed up)${clf}`;
45
+ s += ` ${usd(spend)} · ${verdict}${c.escalations ? ` ↑${c.escalations}` : ""}`;
25
46
  }
26
- return s;
47
+ return segment(s, paint);
27
48
  }