pi-bedrouter 0.1.0 → 0.2.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 CHANGED
@@ -39,6 +39,7 @@ Bedrouter needs AWS credentials that can call Bedrock; see its README for the `a
39
39
  | `debug` | `false` | Start the server with `BEDROUTER_DEBUG=1` (per-request trace in `server.log`) |
40
40
  | `footer` | `true` | Show the routing status line in Pi's footer |
41
41
  | `providerName` | `"bedrouter"` | Provider name registered in Pi |
42
+ | `stopOnExit` | `"if-started-here"` | What happens to the server when Pi **quits** (`/reload` and session switches never stop it). `if-started-here`: stop it if this session started it and no other client sent a request in the last 5 minutes; `always`: stop it whenever this session started it; `never`: leave it running. Pi tears down its UI before extensions are told about the quit, so this cannot be a prompt; the policy is shown when the server is started and in `/bedrouter status` |
42
43
  | `healthPollS` | `15` | Seconds between background health checks. A dead server flips the footer to `bedrouter: DOWN` and, with `autoStart`, is restarted (at most once a minute); `0` disables the poll |
43
44
 
44
45
  Example for a developer with a checkout:
@@ -52,7 +53,7 @@ Example for a developer with a checkout:
52
53
  | Command | Does |
53
54
  | --- | --- |
54
55
  | `/bedrouter` or `/bedrouter status` | Install location, server health (pid, version, region, classifier), registered models, current model, last decision |
55
- | `/bedrouter start` / `stop` / `restart` | Manage the server. It is shared by every Pi session, so `stop` affects all of them |
56
+ | `/bedrouter start` / `stop` / `restart` | Manage the server. It is shared by every Pi session, so `stop` affects all of them; see `stopOnExit` for what happens when Pi quits |
56
57
  | `/bedrouter install` | `npm install` the dependency, or `npm run build` a checkout that has no `dist/` |
57
58
  | `/bedrouter doctor` | Which credential source resolved, expiry, the loaded ladder |
58
59
  | `/bedrouter probe` | One 1-token request per rung: which models this AWS account can actually invoke |
@@ -64,7 +65,7 @@ Example for a developer with a checkout:
64
65
 
65
66
  ## How the footer works
66
67
 
67
- 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.
68
69
 
69
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.
70
71
 
@@ -23,6 +23,8 @@ export default async function (pi: ExtensionAPI) {
23
23
  let lastCtx: ExtensionContext | null = null; // most recent context, for the background poll to update the footer
24
24
  let serverUp: boolean | null = null; // last known health; null = never checked
25
25
  let restartAttemptAt = 0; // throttle auto-restarts to one per 60 s
26
+ let startedHere = false; // this session launched the server (so it may stop it on quit)
27
+ const ourConversations = new Set<string>(); // bedrouter conversation keys seen from this session
26
28
 
27
29
  const isOurs = (ctx: ExtensionContext) => ctx.model?.provider === settings.providerName;
28
30
  const setStatus = (ctx: ExtensionContext, text: string | undefined) => { lastCtx = ctx; if (ctx.hasUI && settings.footer) ctx.ui.setStatus("bedrouter", text); };
@@ -57,6 +59,8 @@ export default async function (pi: ExtensionAPI) {
57
59
  return `registered ${models.length} models from ${source}: ${registeredModelIds.join(", ")}`;
58
60
  }
59
61
 
62
+ const exitPolicyText = () => settings.stopOnExit === "never" ? "left running" : settings.stopOnExit === "always" ? "stopped" : "stopped unless another client is using it";
63
+
60
64
  /** Locate → (install) → start → register. Returns a human summary. Never throws. */
61
65
  async function bringUp(ctx: ExtensionContext | null, opts: { install?: boolean; start?: boolean } = {}): Promise<{ ok: boolean; lines: string[] }> {
62
66
  const lines: string[] = [];
@@ -76,10 +80,13 @@ export default async function (pi: ExtensionAPI) {
76
80
  }
77
81
  lines.push(await registerProvider(loc as br.Found));
78
82
  if (opts.start) {
83
+ const before = await br.health(settings);
79
84
  const r = await br.start(settings, loc as br.Found);
80
85
  for (const f of r.created) lines.push(`created ${f} from the example; edit it for this machine (AWS_PROFILE, ladder)`);
81
- if (r.ok) lines.push(`bedrouter ${r.health.version} up on ${br.baseUrl(settings)} (pid ${r.health.pid}, region ${r.health.region}, classifier ${r.health.classifier ?? "off"})`);
82
- else { lines.push(r.error); return { ok: false, lines }; }
86
+ if (r.ok) {
87
+ if (!before?.ok) startedHere = true;
88
+ lines.push(`bedrouter ${r.health.version} up on ${br.baseUrl(settings)} (pid ${r.health.pid}, region ${r.health.region}, classifier ${r.health.classifier ?? "off"})${!before?.ok ? `; started by this session, on quit: ${exitPolicyText()}` : "; was already running (not started here, left alone on quit)"}`);
89
+ } else { lines.push(r.error); return { ok: false, lines }; }
83
90
  }
84
91
  return { ok: true, lines };
85
92
  }
@@ -132,6 +139,7 @@ export default async function (pi: ExtensionAPI) {
132
139
  if (!d) return;
133
140
  last = d;
134
141
  serverUp = true;
142
+ if (d.conversation) ourConversations.add(d.conversation);
135
143
  setStatus(ctx, statusLine(last, stats));
136
144
  });
137
145
 
@@ -163,7 +171,14 @@ export default async function (pi: ExtensionAPI) {
163
171
  }
164
172
  if (settings.healthPollS > 0) { poll = setInterval(() => void checkHealth(), settings.healthPollS * 1000); poll.unref(); }
165
173
 
166
- pi.on("session_shutdown", async () => { if (poll) clearInterval(poll); /* the server is left running on purpose: other Pi sessions share it */ });
174
+ pi.on("session_shutdown", async (ev) => {
175
+ if (poll) clearInterval(poll);
176
+ // Only a real quit ends the server; /reload and session switches keep it (the next session picks it straight up).
177
+ // The TUI is already gone at this point, so the policy is a setting, not a prompt (see stopOnExit).
178
+ if (ev.reason !== "quit" || !startedHere || settings.stopOnExit === "never") return;
179
+ if (settings.stopOnExit === "if-started-here" && (await br.othersActive(settings, ourConversations))) return;
180
+ await br.stop(settings);
181
+ });
167
182
 
168
183
  // ---- /bedrouter ------------------------------------------------------------------------------------------------
169
184
  const SUB = ["status", "start", "stop", "restart", "install", "doctor", "probe", "report", "log", "models", "fitnotes", "config", "help"];
@@ -186,6 +201,7 @@ export default async function (pi: ExtensionAPI) {
186
201
  `provider: ${settings.providerName} → ${registeredModelIds.length ? registeredModelIds.join(", ") : "(not registered)"}`,
187
202
  `session: ${isOurs(ctx) ? `using ${ctx.model?.id}` : `not using bedrouter (model ${ctx.model?.provider}/${ctx.model?.id})`}`,
188
203
  `last: ${last ? `${last.requested} → ${last.model} ${last.cls} · ${last.reason}${last.classifier ? ` classifier: ${last.classifier}` : ""}` : "-"}`,
204
+ `on quit: ${startedHere ? `server ${exitPolicyText()} (stopOnExit: ${settings.stopOnExit})` : "server was not started by this session; left alone"}`,
189
205
  `settings: ${settingsPath()}${fs.existsSync(settingsPath()) ? "" : " (defaults; /bedrouter config to create)"}`,
190
206
  ];
191
207
  show("bedrouter status", lines.join("\n"));
@@ -246,7 +262,7 @@ export default async function (pi: ExtensionAPI) {
246
262
  }
247
263
  case "config": {
248
264
  if (!fs.existsSync(settingsPath())) saveSettings(settings);
249
- show("bedrouter settings", `${settingsPath()}\n\n${fs.readFileSync(settingsPath(), "utf8")}\nKeys: path, home, port, autoStart, autoSelect (model id or false), debug, footer, providerName. Edit the file, then /reload.`);
265
+ show("bedrouter settings", `${settingsPath()}\n\n${fs.readFileSync(settingsPath(), "utf8")}\nKeys: path, home, port, autoStart, autoSelect (model id or false), debug, footer, providerName, healthPollS, stopOnExit (if-started-here | always | never). Edit the file, then /reload.`);
250
266
  break;
251
267
  }
252
268
  default:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-bedrouter",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",
package/src/bedrouter.ts CHANGED
@@ -124,6 +124,13 @@ export const FALLBACK_CONFIG: BedrouterConfig = {
124
124
  routing: { classes: { anthropic: { trivial: "haiku", execute: "sonnet", explore: "opus" }, openai: { execute: "gpt-oss-20b", explore: "gpt-oss-120b" } } },
125
125
  };
126
126
  export const conversation = (s: Settings, key: string) => getJson<ConversationStats>(`${baseUrl(s)}/v1/conversations/${key}`);
127
+ export const recentConversations = async (s: Settings) => (await getJson<{ data: ConversationStats[] }>(`${baseUrl(s)}/v1/conversations`))?.data ?? [];
128
+
129
+ /** True when a conversation other than `ours` sent a request within `windowMs`: another client is using the server. */
130
+ export async function othersActive(s: Settings, ours: Set<string>, windowMs = 5 * 60_000, now = Date.now()): Promise<boolean> {
131
+ const recent = await recentConversations(s);
132
+ return recent.some((c) => !ours.has(c.key) && now - Date.parse(c.lastTs) < windowMs);
133
+ }
127
134
 
128
135
  export const serverLog = (s: Settings) => path.join(homeDir(s), "server.log");
129
136
  export const decisionLog = (s: Settings) => path.join(homeDir(s), "bedrouter.log.jsonl");
package/src/footer.ts CHANGED
@@ -19,9 +19,16 @@ export function statusLine(d: LastDecision | null, c: ConversationStats | null):
19
19
  let s = `⇄ ${d.model} ${arrow} ${d.requested} ${d.cls}·${short(d.reason)}`;
20
20
  if (c && c.requests > 0) {
21
21
  const spend = c.costUsd + c.classifierCostUsd;
22
- const diff = c.requestedCostUsd - spend;
22
+ // Routing effect = model cost vs the same tokens on the asked-for model. The classifier's one-off call is overhead
23
+ // and shown as such, never folded into "over asked-for" (which would flag every break-even session as a loss).
24
+ const diff = c.requestedCostUsd - c.costUsd;
23
25
  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}` : ""}`;
26
+ const clf = c.classifierCostUsd > 0 ? `, classifier ${usd(c.classifierCostUsd)}` : "";
27
+ let verdict: string;
28
+ if (Math.abs(diff) < 1e-6) verdict = `same as asked-for${clf}`;
29
+ else if (diff > 0) verdict = `saved ${usd(diff)} (${pct}%)${clf}`;
30
+ else verdict = `+${usd(-diff)} over asked-for (routed up)${clf}`;
31
+ s += ` ${usd(spend)} · ${verdict}${c.escalations ? ` ↑${c.escalations}` : ""}`;
25
32
  }
26
33
  return s;
27
34
  }
package/src/settings.ts CHANGED
@@ -22,9 +22,15 @@ export type Settings = {
22
22
  providerName: string;
23
23
  /** Seconds between background health checks that keep the footer honest and restart a dead server (0 disables). */
24
24
  healthPollS: number;
25
+ /**
26
+ * What happens to the server when Pi quits (not on /reload or session switches):
27
+ * "if-started-here": stop it if this session started it and no other client used it in the last few minutes;
28
+ * "always": stop it whenever this session started it; "never": leave it running.
29
+ */
30
+ stopOnExit: "if-started-here" | "always" | "never";
25
31
  };
26
32
 
27
- export const DEFAULTS: Settings = { port: 20129, autoStart: true, autoSelect: "auto", debug: false, footer: true, providerName: "bedrouter", healthPollS: 15 };
33
+ export const DEFAULTS: Settings = { port: 20129, autoStart: true, autoSelect: "auto", debug: false, footer: true, providerName: "bedrouter", healthPollS: 15, stopOnExit: "if-started-here" };
28
34
 
29
35
  export const agentDir = () => process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
30
36
  export const settingsPath = () => path.join(agentDir(), "pi-bedrouter.json");