pi-bedrouter 0.1.0 → 0.2.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 +2 -1
- package/extensions/index.ts +20 -4
- package/package.json +1 -1
- package/src/bedrouter.ts +7 -0
- package/src/settings.ts +7 -1
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 |
|
package/extensions/index.ts
CHANGED
|
@@ -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)
|
|
82
|
-
|
|
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 () => {
|
|
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
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/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");
|