auto-model-router 0.2.31 → 0.3.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.
Files changed (68) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +211 -29
  3. package/docs/review-2026-09-05.md +267 -0
  4. package/omp-extension/configure-logic.ts +71 -15
  5. package/omp-extension/pi-coding-agent.d.ts +79 -2
  6. package/omp-extension/report-hub.ts +376 -0
  7. package/omp-extension/report-logic.ts +115 -0
  8. package/omp-extension/router-configure.ts +203 -51
  9. package/omp-extension/router-url.ts +52 -0
  10. package/omp-extension/toast-logic.ts +14 -2
  11. package/package.json +1 -1
  12. package/src/catalog/composite.ts +97 -0
  13. package/src/catalog/ollama-catalog.ts +309 -0
  14. package/src/catalog/ollama-prices.ts +85 -0
  15. package/src/catalog/openrouter-catalog.ts +39 -1
  16. package/src/catalog/types.ts +31 -1
  17. package/src/cli/args.ts +1 -0
  18. package/src/cli/config-wizard.ts +189 -28
  19. package/src/cli/explain.ts +2 -4
  20. package/src/cli/models.ts +2 -4
  21. package/src/cli/report.ts +37 -0
  22. package/src/config/defaults.ts +43 -2
  23. package/src/config/load.ts +25 -1
  24. package/src/config/omp-credentials.ts +31 -7
  25. package/src/config/schema.ts +28 -0
  26. package/src/config/types.ts +127 -2
  27. package/src/cost/ledger.ts +73 -4
  28. package/src/cost/report.ts +340 -0
  29. package/src/cost/types.ts +33 -1
  30. package/src/index.ts +5 -8
  31. package/src/router/candidates.ts +90 -11
  32. package/src/router/classify.ts +33 -6
  33. package/src/router/features.ts +13 -1
  34. package/src/router/select.ts +55 -8
  35. package/src/router/state.ts +6 -2
  36. package/src/router/tier-plan.ts +49 -11
  37. package/src/router/types.ts +11 -0
  38. package/src/server/http.ts +47 -6
  39. package/src/server/providers.ts +54 -0
  40. package/src/server/turn.ts +122 -34
  41. package/src/tokens/estimate.ts +16 -0
  42. package/src/upstream/multi.ts +26 -0
  43. package/src/upstream/ollama-usage.ts +157 -0
  44. package/src/upstream/ollama.ts +275 -0
  45. package/src/upstream/openrouter.ts +19 -1
  46. package/src/upstream/types.ts +2 -0
  47. package/src/util/sqlite.ts +25 -1
  48. package/test/catalog.test.ts +44 -0
  49. package/test/classify.test.ts +41 -5
  50. package/test/compaction.test.ts +1 -0
  51. package/test/config-wizard.test.ts +77 -1
  52. package/test/configure-logic.test.ts +129 -33
  53. package/test/embed-lifecycle.test.ts +1 -0
  54. package/test/failover.test.ts +148 -3
  55. package/test/features.test.ts +35 -0
  56. package/test/http-resilience.test.ts +24 -0
  57. package/test/ollama.test.ts +506 -0
  58. package/test/omp-credentials.test.ts +43 -1
  59. package/test/report-hub.test.ts +341 -0
  60. package/test/report-logic.test.ts +92 -0
  61. package/test/report.test.ts +217 -0
  62. package/test/select.test.ts +176 -1
  63. package/test/tier-plan.test.ts +159 -1
  64. package/test/toast-logic.test.ts +11 -2
  65. package/test/tokens.test.ts +71 -1
  66. package/test/trust-attribution.test.ts +2 -2
  67. package/test/turn.test.ts +124 -7
  68. package/tools/build-site.ts +1 -0
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Fullscreen report hub for `/router report`, drawn the way omp's `/models`
3
+ * hub is: a titled two-column frame on the alternate screen, a sidebar of
4
+ * views on the left, the selected view's table on the right, a divider and
5
+ * a footer hint row. The sidebar holds the views, a Window
6
+ * selector (24h / 7d / 30d / 90d) and, when the session has a harness id, a
7
+ * scope toggle. Keys: ↑/↓ or j/k move, Enter applies a window or scope entry,
8
+ * ←/→ also cycle the window, PgUp/PgDn scroll a long table, r reloads, Esc/q
9
+ * close.
10
+ *
11
+ * The component is pure: styling comes through the `HubTheme` seam (omp's
12
+ * `Theme` in production, identity functions in tests), data through
13
+ * `HubSource`, and terminal size through `rows()`. Nothing here imports omp.
14
+ */
15
+
16
+ import { formatTable, type ReportView, reportView, type UsageReport } from "../src/cost/report.ts";
17
+ import type { ReportRequest } from "./report-logic.ts";
18
+
19
+ /** The slice of omp's Theme the hub paints with. */
20
+ export interface HubTheme {
21
+ fg(color: "accent" | "border" | "dim" | "muted" | "success" | "warning" | "error" | "text", text: string): string;
22
+ bg(color: "selectedBg", text: string): string;
23
+ bold(text: string): string;
24
+ boxRound: {
25
+ topLeft: string;
26
+ topRight: string;
27
+ bottomLeft: string;
28
+ bottomRight: string;
29
+ horizontal: string;
30
+ vertical: string;
31
+ teeDown: string;
32
+ teeUp: string;
33
+ teeLeft: string;
34
+ teeRight: string;
35
+ };
36
+ nav: { cursor: string };
37
+ }
38
+
39
+ /** Terminal text helpers (pi-tui's in production; ASCII stand-ins in tests). */
40
+ export interface HubText {
41
+ visibleWidth(text: string): number;
42
+ truncateToWidth(text: string, width: number): string;
43
+ }
44
+
45
+ /** Key matching against omp's keybinding ids, so user remaps are honoured. */
46
+ export interface HubKeys {
47
+ up(data: string): boolean;
48
+ down(data: string): boolean;
49
+ left(data: string): boolean;
50
+ right(data: string): boolean;
51
+ pageUp(data: string): boolean;
52
+ pageDown(data: string): boolean;
53
+ cancel(data: string): boolean;
54
+ confirm(data: string): boolean;
55
+ }
56
+
57
+ export interface HubSource {
58
+ report(req: ReportRequest): Promise<UsageReport>;
59
+ status(): Promise<string>;
60
+ }
61
+
62
+ export interface HubOptions {
63
+ theme: HubTheme;
64
+ text: HubText;
65
+ keys: HubKeys;
66
+ source: HubSource;
67
+ /** Terminal height in rows at render time. */
68
+ rows(): number;
69
+ /** Ask the host to repaint (data arrived). */
70
+ requestRender(): void;
71
+ /** Close the overlay. */
72
+ close(): void;
73
+ initial: ReportRequest;
74
+ /** The harness this session belongs to; empty ⇒ no scope toggle. */
75
+ harnessId: string;
76
+ }
77
+
78
+ export const WINDOWS: readonly number[] = [1, 7, 30, 90];
79
+
80
+ type ViewId = "overview" | "providers" | "models" | "tiers" | "days" | "status";
81
+
82
+ type SidebarEntry =
83
+ | { kind: "view"; id: ViewId; label: string; icon: string }
84
+ | { kind: "window"; days: number; label: string }
85
+ | { kind: "scope" }
86
+ | { kind: "label"; label: string }
87
+ | { kind: "sep" };
88
+
89
+ const VIEWS: readonly Extract<SidebarEntry, { kind: "view" }>[] = [
90
+ { kind: "view", id: "overview", label: "Overview", icon: "◎" },
91
+ { kind: "view", id: "providers", label: "Providers", icon: "◈" },
92
+ { kind: "view", id: "models", label: "Models", icon: "◇" },
93
+ { kind: "view", id: "tiers", label: "Tiers", icon: "≡" },
94
+ { kind: "view", id: "days", label: "By day", icon: "▤" },
95
+ ];
96
+
97
+ const WINDOW_LABELS: Record<number, string> = { 1: "24 hours", 7: "7 days", 30: "30 days", 90: "90 days" };
98
+
99
+ /** Sidebar in order: views, Window selector, scope toggle (when scoped), Status. */
100
+ function buildEntries(hasHarness: boolean): SidebarEntry[] {
101
+ const entries: SidebarEntry[] = [...VIEWS, { kind: "sep" }, { kind: "label", label: "Window" }];
102
+ for (const days of WINDOWS) entries.push({ kind: "window", days, label: WINDOW_LABELS[days] ?? `${days}d` });
103
+ if (hasHarness) entries.push({ kind: "sep" }, { kind: "label", label: "Scope" }, { kind: "scope" });
104
+ entries.push({ kind: "sep" }, { kind: "view", id: "status", label: "Status", icon: "●" });
105
+ return entries;
106
+ }
107
+
108
+ const SIDEBAR_WIDTH = 18;
109
+
110
+ /** Pad or truncate a (possibly styled) string to exactly `width` columns. */
111
+ function fit(text: string, width: number, t: HubText): string {
112
+ if (width <= 0) return "";
113
+ const w = t.visibleWidth(text);
114
+ if (w === width) return text;
115
+ if (w < width) return text + " ".repeat(width - w);
116
+ const cut = t.truncateToWidth(text, width);
117
+ const cw = t.visibleWidth(cut);
118
+ return cw < width ? cut + " ".repeat(width - cw) : cut;
119
+ }
120
+
121
+ export class ReportHub {
122
+ #o: HubOptions;
123
+ #req: ReportRequest;
124
+ #entries: SidebarEntry[];
125
+ /** Sidebar cursor (index into #entries). */
126
+ #cursor = 0;
127
+ #view: ViewId = "overview";
128
+ #scroll = 0;
129
+ #report: UsageReport | null = null;
130
+ #data: ReportView | null = null;
131
+ #status: string | null = null;
132
+ #loading = false;
133
+ #error: string | null = null;
134
+ #generation = 0;
135
+ #disposed = false;
136
+ #bodyRows = 10;
137
+
138
+ constructor(o: HubOptions) {
139
+ this.#o = o;
140
+ this.#req = { ...o.initial };
141
+ this.#entries = buildEntries(o.harnessId !== "");
142
+ void this.#load();
143
+ }
144
+
145
+ get request(): ReportRequest {
146
+ return this.#req;
147
+ }
148
+
149
+ get activeView(): ViewId {
150
+ return this.#view;
151
+ }
152
+
153
+ /** The sidebar entry under the cursor. */
154
+ get cursorEntry(): SidebarEntry {
155
+ return this.#entries[this.#cursor] ?? { kind: "sep" };
156
+ }
157
+
158
+ async #load(): Promise<void> {
159
+ const gen = ++this.#generation;
160
+ this.#loading = true;
161
+ this.#error = null;
162
+ this.#o.requestRender();
163
+ try {
164
+ const [report, status] = await Promise.all([
165
+ this.#o.source.report(this.#req),
166
+ this.#o.source.status().catch((err: unknown) => `status unavailable: ${err instanceof Error ? err.message : String(err)}`),
167
+ ]);
168
+ if (gen !== this.#generation || this.#disposed) return;
169
+ this.#report = report;
170
+ this.#data = reportView(report);
171
+ this.#status = status;
172
+ } catch (err) {
173
+ if (gen !== this.#generation || this.#disposed) return;
174
+ this.#error = err instanceof Error ? err.message : String(err);
175
+ } finally {
176
+ if (gen === this.#generation) {
177
+ this.#loading = false;
178
+ this.#o.requestRender();
179
+ }
180
+ }
181
+ }
182
+
183
+ #move(delta: number): void {
184
+ let next = this.#cursor;
185
+ for (let i = 0; i < this.#entries.length; i++) {
186
+ next = (next + delta + this.#entries.length) % this.#entries.length;
187
+ const kind = this.#entries[next]?.kind;
188
+ if (kind !== "sep" && kind !== "label") break;
189
+ }
190
+ this.#cursor = next;
191
+ // Landing on a view shows it at once, as /models does for its scopes;
192
+ // window and scope entries wait for Enter.
193
+ const e = this.#entries[next];
194
+ if (e?.kind === "view" && e.id !== this.#view) {
195
+ this.#view = e.id;
196
+ this.#scroll = 0;
197
+ }
198
+ }
199
+
200
+ #setWindow(days: number): void {
201
+ if (days === this.#req.windowDays) return;
202
+ this.#req = { ...this.#req, windowDays: days };
203
+ this.#scroll = 0;
204
+ void this.#load();
205
+ }
206
+
207
+ /** Enter on the cursor entry: apply a window or flip the scope. */
208
+ #activate(): void {
209
+ const e = this.cursorEntry;
210
+ if (e.kind === "window") this.#setWindow(e.days);
211
+ else if (e.kind === "scope") this.#toggleScope();
212
+ }
213
+
214
+ #cycleWindow(delta: number): void {
215
+ const i = WINDOWS.indexOf(this.#req.windowDays);
216
+ const next = i < 0 ? 0 : (i + delta + WINDOWS.length) % WINDOWS.length;
217
+ this.#setWindow(WINDOWS[next] ?? 7);
218
+ }
219
+
220
+ #toggleScope(): void {
221
+ if (this.#o.harnessId === "") return;
222
+ this.#req = { ...this.#req, harnessId: this.#req.harnessId === "" ? this.#o.harnessId : "" };
223
+ this.#scroll = 0;
224
+ void this.#load();
225
+ }
226
+
227
+ handleInput(data: string): void {
228
+ const k = this.#o.keys;
229
+ if (k.cancel(data) || data === "q") {
230
+ this.#o.close();
231
+ return;
232
+ }
233
+ if (k.up(data) || data === "k") this.#move(-1);
234
+ else if (k.down(data) || data === "j") this.#move(1);
235
+ else if (k.confirm(data) || data === " ") this.#activate();
236
+ else if (k.right(data) || data === "w") this.#cycleWindow(1);
237
+ else if (k.left(data)) this.#cycleWindow(-1);
238
+ else if (data === "a") this.#toggleScope();
239
+ else if (data === "r") void this.#load();
240
+ else if (k.pageDown(data)) this.#scroll += Math.max(1, this.#bodyRows - 2);
241
+ else if (k.pageUp(data)) this.#scroll = Math.max(0, this.#scroll - Math.max(1, this.#bodyRows - 2));
242
+ else return;
243
+ this.#o.requestRender();
244
+ }
245
+
246
+ invalidate(): void {}
247
+
248
+ dispose(): void {
249
+ this.#disposed = true;
250
+ }
251
+
252
+ /** Body lines for the active view, unstyled except headers. */
253
+ #bodyLines(width: number): string[] {
254
+ const th = this.#o.theme;
255
+ if (this.#error !== null) return [th.fg("error", `could not load: ${this.#error}`), "", th.fg("dim", "r retries")];
256
+ if (this.activeView === "status") {
257
+ if (this.#status === null) return [th.fg("dim", "loading…")];
258
+ return this.#status.split("\n");
259
+ }
260
+ const v = this.#data;
261
+ const r = this.#report;
262
+ if (v === null || r === null) return [th.fg("dim", "loading…")];
263
+ if (r.totals.dispatches === 0) {
264
+ return [th.fg("dim", `no routed turns in the last ${r.windowDays}d${r.harnessId === "" ? "" : ` for harness ${r.harnessId}`}`)];
265
+ }
266
+ const styledTable = (headers: string[], rows: string[][]): string[] => {
267
+ const lines = formatTable(headers, rows);
268
+ return lines.map((line, i) => (i === 0 ? th.bold(th.fg("accent", line)) : i === 1 ? th.fg("border", line) : line));
269
+ };
270
+ if (this.activeView === "overview") {
271
+ const out: string[] = [...v.summary.map((s) => th.fg("text", s)), ""];
272
+ for (const t of v.tables) {
273
+ if (t.id === "days") continue;
274
+ out.push(th.bold(t.title), ...styledTable(t.headers, t.rows.slice(0, t.id === "models" ? 6 : t.rows.length)), "");
275
+ }
276
+ return out.map((l) => this.#o.text.truncateToWidth(l, width));
277
+ }
278
+ const table = v.tables.find((t) => t.id === this.activeView);
279
+ if (table === undefined) return [th.fg("dim", "nothing in this window")];
280
+ return [th.bold(table.title), ...styledTable(table.headers, table.rows)].map((l) => this.#o.text.truncateToWidth(l, width));
281
+ }
282
+
283
+ #statusRow(width: number): string {
284
+ const th = this.#o.theme;
285
+ const scope = this.#req.harnessId === "" ? "all harnesses" : `harness ${this.#req.harnessId}`;
286
+ const heading = this.#data === null ? `last ${this.#req.windowDays}d · ${scope}` : `${this.#data.heading.replace(/ · harness [^·]+/, "")} · ${scope}`;
287
+ const tail = this.#loading ? th.fg("warning", " · loading…") : "";
288
+ return this.#o.text.truncateToWidth(th.fg("accent", ` ${heading}`) + tail, width);
289
+ }
290
+
291
+ #sidebarLines(width: number, rows: number): string[] {
292
+ const th = this.#o.theme;
293
+ const lines: string[] = [];
294
+ this.#entries.forEach((e, i) => {
295
+ const here = i === this.#cursor;
296
+ const cursor = here ? th.fg("accent", th.nav.cursor) : " ";
297
+ switch (e.kind) {
298
+ case "sep":
299
+ lines.push(th.fg("border", th.boxRound.horizontal.repeat(width)));
300
+ return;
301
+ case "label":
302
+ lines.push(th.fg("dim", ` ${e.label}`));
303
+ return;
304
+ case "view": {
305
+ const active = e.id === this.#view;
306
+ const label = active ? th.bold(th.fg("accent", e.label)) : e.label;
307
+ lines.push(`${cursor} ${th.fg(active ? "accent" : "dim", e.icon)} ${label}`);
308
+ return;
309
+ }
310
+ case "window": {
311
+ const on = e.days === this.#req.windowDays;
312
+ lines.push(`${cursor} ${on ? th.fg("accent", "●") : th.fg("dim", "○")} ${on ? th.bold(e.label) : e.label}`);
313
+ return;
314
+ }
315
+ case "scope": {
316
+ const scoped = this.#req.harnessId !== "";
317
+ const label = scoped ? `this harness` : "all harnesses";
318
+ lines.push(`${cursor} ${th.fg("accent", scoped ? "◉" : "◎")} ${label}`);
319
+ return;
320
+ }
321
+ }
322
+ });
323
+ while (lines.length < rows) lines.push("");
324
+ return lines.slice(0, rows);
325
+ }
326
+
327
+ #footer(width: number): string {
328
+ const th = this.#o.theme;
329
+ const e = this.cursorEntry;
330
+ const enter = e.kind === "window" ? "enter set window · " : e.kind === "scope" ? "enter toggle scope · " : "";
331
+ return this.#o.text.truncateToWidth(th.fg("dim", `↑↓ move · ${enter}←→ window (${this.#req.windowDays}d) · pgup/pgdn scroll · r reload · esc close`), width);
332
+ }
333
+
334
+ render(width: number): string[] {
335
+ const th = this.#o.theme;
336
+ const t = this.#o.text;
337
+ const box = th.boxRound;
338
+ const paint = (s: string): string => th.fg("border", s);
339
+ const height = Math.max(16, this.#o.rows());
340
+ const contentRows = Math.max(10, height - 4);
341
+ const sidebarWidth = SIDEBAR_WIDTH;
342
+ const dividerCol = sidebarWidth + 3;
343
+ const bodyWidth = Math.max(0, width - sidebarWidth - 7);
344
+ this.#bodyRows = contentRows - 1;
345
+
346
+ const all = this.#bodyLines(bodyWidth);
347
+ const maxScroll = Math.max(0, all.length - this.#bodyRows);
348
+ if (this.#scroll > maxScroll) this.#scroll = maxScroll;
349
+ const body = [this.#statusRow(bodyWidth), ...all.slice(this.#scroll, this.#scroll + this.#bodyRows)];
350
+ if (maxScroll > 0 && this.#scroll < maxScroll) {
351
+ body[body.length - 1] = th.fg("dim", `… ${all.length - this.#scroll - this.#bodyRows} more (pgdn)`);
352
+ }
353
+ const side = this.#sidebarLines(sidebarWidth, contentRows);
354
+
355
+ // Frame, matching overlay-box.ts: title inset in the top rule, a ┬ over
356
+ // the column divider, ┴ closing it above the footer.
357
+ const title = " Router report ";
358
+ const leftLen = Math.max(0, dividerCol - 1);
359
+ const rightLen = Math.max(0, width - 2 - dividerCol);
360
+ const fillWidth = Math.max(0, leftLen - 1 - t.visibleWidth(title));
361
+ const out: string[] = [];
362
+ out.push(
363
+ paint(box.topLeft + box.horizontal) +
364
+ th.bold(th.fg("accent", title)) +
365
+ paint(box.horizontal.repeat(fillWidth) + box.teeDown + box.horizontal.repeat(rightLen) + box.topRight),
366
+ );
367
+ const bar = paint(box.vertical);
368
+ for (let i = 0; i < contentRows; i++) {
369
+ out.push(`${bar} ${fit(side[i] ?? "", sidebarWidth, t)} ${bar} ${fit(body[i] ?? "", bodyWidth, t)} ${bar}`);
370
+ }
371
+ out.push(paint(box.teeRight + box.horizontal.repeat(leftLen) + box.teeUp + box.horizontal.repeat(rightLen) + box.teeLeft));
372
+ out.push(`${bar} ${fit(this.#footer(width - 4), Math.max(0, width - 4), t)} ${bar}`);
373
+ out.push(paint(box.bottomLeft + box.horizontal.repeat(Math.max(0, width - 2)) + box.bottomRight));
374
+ return out;
375
+ }
376
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Pure logic behind `/router report` and `/router status`: argument parsing,
3
+ * fetching the report over the router's HTTP API with a direct-ledger
4
+ * fallback, and rendering the health snapshot. Kept free of omp types so it
5
+ * is unit-testable with a fake fetch.
6
+ */
7
+
8
+ import type { UsageReport } from "../src/cost/report.ts";
9
+
10
+ export interface ReportRequest {
11
+ windowDays: number;
12
+ /** Empty ⇒ every harness. */
13
+ harnessId: string;
14
+ }
15
+
16
+ /**
17
+ * Parses the free text after `/router report`: an optional window (`7`,
18
+ * `7d`, `24h`, `2w`) and `--all` to drop the harness scope. Anything
19
+ * unrecognised is ignored rather than failing the command.
20
+ */
21
+ export function parseReportArgs(text: string, defaultHarness: string, defaultDays = 7): ReportRequest {
22
+ let windowDays = defaultDays;
23
+ let harnessId = defaultHarness;
24
+ for (const tok of text.trim().split(/\s+/).filter((t) => t !== "")) {
25
+ const lower = tok.toLowerCase();
26
+ if (lower === "--all" || lower === "all") {
27
+ harnessId = "";
28
+ continue;
29
+ }
30
+ if (lower.startsWith("--harness=")) {
31
+ harnessId = tok.slice("--harness=".length);
32
+ continue;
33
+ }
34
+ const m = /^(\d+)(d|h|w)?$/.exec(lower);
35
+ if (m === null) continue;
36
+ const n = Number.parseInt(m[1] ?? "0", 10);
37
+ if (!Number.isInteger(n) || n <= 0) continue;
38
+ const unit = m[2] ?? "d";
39
+ windowDays = unit === "h" ? Math.max(1, Math.ceil(n / 24)) : unit === "w" ? n * 7 : n;
40
+ }
41
+ return { windowDays: Math.min(windowDays, 365), harnessId };
42
+ }
43
+
44
+ export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
45
+
46
+ /** GETs the report from the running router; throws on any failure. */
47
+ export async function fetchReport(
48
+ baseUrl: string,
49
+ req: ReportRequest,
50
+ headers: Record<string, string>,
51
+ fetchImpl: FetchLike = fetch,
52
+ timeoutMs = 5_000,
53
+ ): Promise<UsageReport> {
54
+ const params = new URLSearchParams({ days: String(req.windowDays) });
55
+ if (req.harnessId !== "") params.set("harness", req.harnessId);
56
+ const res = await fetchImpl(`${baseUrl}/v1/router/report?${params.toString()}`, {
57
+ headers,
58
+ signal: AbortSignal.timeout(timeoutMs),
59
+ });
60
+ if (!res.ok) throw new Error(`router returned ${res.status}`);
61
+ return (await res.json()) as UsageReport;
62
+ }
63
+
64
+ /** The subset of `/health` the status view renders. */
65
+ export interface HealthSnapshot {
66
+ status?: string;
67
+ apiKeyConfigured?: boolean;
68
+ apiKeySource?: string;
69
+ agentdox?: { url?: string; defaultScope?: string; recordTurns?: boolean } | null;
70
+ ollama?: {
71
+ baseUrl?: string;
72
+ apiKeySource?: string;
73
+ models?: number;
74
+ available?: boolean;
75
+ cooldownUntilMs?: number | null;
76
+ lastTrip?: { kind?: string; atMs?: number; message?: string } | null;
77
+ usage?: { monthlyUsedFraction?: number | null; activityCostUsd?: number | null; fetchedAtMs?: number | null } | null;
78
+ costBias?: { configured?: number; effective?: number; biasUntilUsage?: number };
79
+ } | null;
80
+ catalog?: {
81
+ models?: number;
82
+ ageMs?: number;
83
+ keyScoped?: boolean;
84
+ shrink?: { fromModels?: number; toModels?: number; atMs?: number } | null;
85
+ } | null;
86
+ }
87
+
88
+ const mins = (ms: number): string => (ms >= 3_600_000 ? `${(ms / 3_600_000).toFixed(1)}h` : `${Math.round(ms / 60_000)}m`);
89
+
90
+ /** Renders `/health` as a few plain lines for the transcript. */
91
+ export function renderStatus(baseUrl: string, h: HealthSnapshot, nowMs = Date.now()): string {
92
+ const out: string[] = [`auto-model-router at ${baseUrl}: ${h.status ?? "unknown"}`];
93
+ out.push(`openrouter: key ${h.apiKeyConfigured === true ? `configured (${h.apiKeySource ?? "?"})` : "MISSING"}`);
94
+ const c = h.catalog;
95
+ if (c !== undefined && c !== null) {
96
+ const shrink = c.shrink !== undefined && c.shrink !== null ? ` · SHRANK ${c.shrink.fromModels ?? "?"} -> ${c.shrink.toModels ?? "?"}` : "";
97
+ out.push(`catalog: ${c.models ?? 0} models · refreshed ${mins(c.ageMs ?? 0)} ago${c.keyScoped === true ? " · key-scoped" : ""}${shrink}`);
98
+ } else {
99
+ out.push("catalog: not fetched yet");
100
+ }
101
+ const o = h.ollama;
102
+ if (o === undefined || o === null) {
103
+ out.push("ollama cloud: disabled");
104
+ } else {
105
+ const avail = o.available === true ? "available" : `COOLING DOWN${o.cooldownUntilMs ? ` until ${new Date(o.cooldownUntilMs).toLocaleTimeString()}` : ""}`;
106
+ const frac = o.usage?.monthlyUsedFraction;
107
+ const usage = frac === undefined || frac === null ? "plan usage unknown" : `plan usage ${(frac * 100).toFixed(0)}%`;
108
+ const bias = o.costBias === undefined ? "" : ` · cost bias ×${o.costBias.effective ?? o.costBias.configured ?? 1} (until ${((o.costBias.biasUntilUsage ?? 1) * 100).toFixed(0)}%)`;
109
+ const trip = o.lastTrip !== undefined && o.lastTrip !== null ? ` · last trip ${o.lastTrip.kind ?? "?"}${o.lastTrip.atMs ? ` ${mins(nowMs - o.lastTrip.atMs)} ago` : ""}` : "";
110
+ out.push(`ollama cloud: ${o.models ?? 0} models · ${avail} · key ${o.apiKeySource ?? "?"} · ${usage}${bias}${trip}`);
111
+ }
112
+ const a = h.agentdox;
113
+ out.push(a === undefined || a === null ? "agentdox: off" : `agentdox: ${a.url ?? "?"} scope ${a.defaultScope ?? "?"}${a.recordTurns === true ? " · recording turns" : ""}`);
114
+ return out.join("\n");
115
+ }