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
@@ -1,13 +1,30 @@
1
1
  /**
2
- * omp extension: `/router` — edit auto-model-router's settings through
3
- * omp's native UI dialogs.
2
+ * omp extension: `/router` — configure auto-model-router and pull usage
3
+ * reports without leaving the session.
4
4
  *
5
- * The command walks the same sections and fields as `auto-model-router config`
6
- * (reusing `WIZARD_SECTIONS` / `PROFILE_FIELDS` from the router's CLI) but
7
- * prompts through `ctx.ui` select/input/confirm dialogs instead of stdin.
8
- * Edits are persisted through the router's own validated merge
9
- * (`writeRouterConfig`), so the on-disk config.yml is schema-checked and
10
- * backed up exactly as the CLI wizard does.
5
+ * /router menu: Configure / Report / Status
6
+ * /router config edit any section of the router's config.yml
7
+ * /router report usage analytics in a fullscreen hub styled like
8
+ * /models: views for overview, providers, models,
9
+ * tiers, by day and status, with the time window
10
+ * (24h / 7d / 30d / 90d) and harness scope chosen
11
+ * in the sidebar. `/router report 30d --all` presets
12
+ * them. Headless sessions get the text instead.
13
+ * /router status the router's /health: keys, catalog, Ollama
14
+ * availability and plan usage, agentdox.
15
+ *
16
+ * Configuration walks the same sections and fields as `auto-model-router
17
+ * config` (reusing `WIZARD_SECTIONS` / `PROFILE_FIELDS` from the router's
18
+ * CLI) but prompts through `ctx.ui` select/input dialogs. Edits are persisted
19
+ * through the router's own validated merge (`writeRouterConfig`), so the
20
+ * on-disk config.yml is schema-checked and backed up exactly as the CLI
21
+ * wizard does.
22
+ *
23
+ * Reports and status are fetched from the running router
24
+ * (`GET /v1/router/report`, `GET /health`) and posted into the transcript as
25
+ * a custom message, so they scroll with the conversation and the model can
26
+ * answer questions about them. If the router is unreachable the report falls
27
+ * back to reading the ledger directly.
11
28
  *
12
29
  * Install alongside router-embed.ts:
13
30
  *
@@ -15,64 +32,198 @@
15
32
  * extensions:
16
33
  * - /path/to/auto-model-router/omp-extension/router-embed.ts
17
34
  * - /path/to/auto-model-router/omp-extension/router-configure.ts
18
- *
19
- * Run `/router` in an omp session to pick a section, edit its
20
- * fields, and save.
21
35
  */
22
36
 
23
- import { loadConfig } from "../src/config/load.ts";
37
+ import { existsSync } from "node:fs";
38
+
24
39
  import { applyAnswers, PROFILE_FIELDS, WIZARD_SECTIONS } from "../src/cli/config-wizard.ts";
25
- import { writeRouterConfig, routerConfigPath } from "../src/cli/config-cmd.ts";
40
+ import { routerConfigPath, writeRouterConfig } from "../src/cli/config-cmd.ts";
41
+ import { loadConfig } from "../src/config/load.ts";
26
42
  import type { RouterConfig } from "../src/config/types.ts";
43
+ import { buildUsageReport, renderUsageReport, type UsageReport } from "../src/cost/report.ts";
44
+ import { openDb } from "../src/util/sqlite.ts";
27
45
 
28
- import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
46
+ import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
29
47
 
30
- import { editProfile, sectionTitles, walkSection, type ConfigUi } from "./configure-logic.ts";
48
+ import { matchesKey, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
49
+
50
+ import { editProfile, editSectionMenu, type ConfigUi, type SelectOption } from "./configure-logic.ts";
51
+ import { ReportHub } from "./report-hub.ts";
52
+ import { fetchReport, parseReportArgs, renderStatus, type HealthSnapshot, type ReportRequest } from "./report-logic.ts";
53
+ import { routerAuthHeaders, routerBaseUrl } from "./router-url.ts";
54
+
55
+ // This harness's id, matching the X-Omp-Harness header the router records.
56
+ // Empty ⇒ reports cover every harness (single-harness default).
57
+ const HARNESS_ID = process.env.OMP_HARNESS_ID ?? "";
58
+
59
+ /** Custom message type for report/status output in the transcript. */
60
+ const MESSAGE_TYPE = "auto-model-router";
31
61
 
32
62
  export default function (pi: ExtensionAPI): void {
33
- pi.setLabel("auto-model-router configure");
63
+ pi.setLabel("auto-model-router");
34
64
 
35
65
  pi.registerCommand("router", {
36
- description: "Configure auto-model-router settings through the native UI",
37
- handler: async (_args, ctx) => {
38
- const ui = ctx.ui;
39
- const cfg = loadConfig();
40
- const answers: Record<string, unknown> = {};
41
-
42
- for (;;) {
43
- const options = [...sectionTitles(WIZARD_SECTIONS), "Profiles", "Save and exit", "Quit without saving"];
44
- const chosen = await ui.select("auto-model-router configure", options);
45
- if (chosen === undefined) return;
46
- if (chosen === "Quit without saving") return;
47
- if (chosen === "Save and exit") break;
48
-
49
- if (chosen === "Profiles") {
50
- await editProfiles(ui, cfg, answers);
51
- continue;
52
- }
53
-
54
- const section = WIZARD_SECTIONS.find((s) => s.title === chosen);
55
- if (section === undefined) continue;
56
- await walkSection(ui, section, cfg, answers);
57
- }
58
-
59
- if (Object.keys(answers).length === 0) {
60
- ui.notify("no changes made", "info");
61
- return;
66
+ description: "auto-model-router: configure, usage report, status",
67
+ handler: async (args, ctx) => {
68
+ const [verb = "", ...rest] = args.trim().split(/\s+/).filter((t) => t !== "");
69
+ const tail = rest.join(" ");
70
+ switch (verb.toLowerCase()) {
71
+ case "config":
72
+ case "configure":
73
+ return configure(ctx);
74
+ case "report":
75
+ return report(pi, ctx, tail);
76
+ case "status":
77
+ case "health":
78
+ return status(pi, ctx);
79
+ case "":
80
+ break;
81
+ default:
82
+ ctx.ui.notify(`unknown /router subcommand "${verb}" (config | report [7d] [--all] | status)`, "warn");
83
+ return;
62
84
  }
63
85
 
64
- try {
65
- const target = routerConfigPath();
66
- const partial = applyAnswers(answers);
67
- const backup = writeRouterConfig(target, partial);
68
- ui.notify(`wrote ${target}${backup ? ` (backup: ${backup})` : ""}`, "info");
69
- } catch (err) {
70
- ui.notify(err instanceof Error ? err.message : String(err), "error");
71
- }
86
+ const chosen = await ctx.ui.select("auto-model-router", [
87
+ { label: "Configure", description: "edit any router setting" },
88
+ { label: "Report", description: "usage analytics; window and scope adjustable inside" },
89
+ { label: "Status", description: "keys, catalog, Ollama, agentdox" },
90
+ ]);
91
+ if (chosen === undefined) return;
92
+ if (chosen === "Configure") return configure(ctx);
93
+ if (chosen === "Status") return status(pi, ctx);
94
+ if (chosen === "Report") return report(pi, ctx, "");
72
95
  },
73
96
  });
74
97
  }
75
98
 
99
+ /** Posts a block of text into the transcript without triggering a turn. */
100
+ function post(pi: ExtensionAPI, text: string): void {
101
+ pi.sendMessage({ customType: MESSAGE_TYPE, content: `\`\`\`text\n${text}\n\`\`\``, display: true }, { triggerTurn: false });
102
+ }
103
+
104
+ /** Loads a report: the running router first, the ledger directly if it is down. */
105
+ async function loadReport(req: ReportRequest): Promise<UsageReport> {
106
+ try {
107
+ return await fetchReport(routerBaseUrl(), req, routerAuthHeaders());
108
+ } catch (err) {
109
+ // Router not reachable (standalone `serve` not running, or embed still
110
+ // starting): read the ledger directly so the report still comes back.
111
+ const cfg = loadConfig();
112
+ if (!existsSync(cfg.ledger.path)) {
113
+ throw new Error(`router unreachable (${err instanceof Error ? err.message : String(err)}) and no ledger at ${cfg.ledger.path}`);
114
+ }
115
+ const db = openDb(cfg.ledger.path);
116
+ try {
117
+ return buildUsageReport(db, req);
118
+ } finally {
119
+ db.close();
120
+ }
121
+ }
122
+ }
123
+
124
+ async function loadStatus(): Promise<string> {
125
+ const baseUrl = routerBaseUrl();
126
+ const res = await fetch(`${baseUrl}/health`, { headers: routerAuthHeaders(), signal: AbortSignal.timeout(5_000) });
127
+ if (!res.ok) throw new Error(`router returned ${res.status}`);
128
+ return renderStatus(baseUrl, (await res.json()) as HealthSnapshot);
129
+ }
130
+
131
+ async function report(pi: ExtensionAPI, ctx: ExtensionContext, argText: string): Promise<void> {
132
+ const req = parseReportArgs(argText, HARNESS_ID);
133
+ // Interactive sessions get the fullscreen hub (the /models look); headless
134
+ // and print modes get the text posted into the transcript.
135
+ if (ctx.hasUI && typeof ctx.ui.custom === "function") {
136
+ await ctx.ui.custom<void>(
137
+ (tui, theme, keybindings, done) =>
138
+ new ReportHub({
139
+ theme,
140
+ text: { visibleWidth, truncateToWidth },
141
+ keys: {
142
+ up: (d) => keybindings.matches(d, "tui.select.up"),
143
+ down: (d) => keybindings.matches(d, "tui.select.down"),
144
+ pageUp: (d) => keybindings.matches(d, "tui.select.pageUp"),
145
+ pageDown: (d) => keybindings.matches(d, "tui.select.pageDown"),
146
+ cancel: (d) => keybindings.matches(d, "tui.select.cancel"),
147
+ confirm: (d) => keybindings.matches(d, "tui.select.confirm"),
148
+ left: (d) => matchesKey(d, "left"),
149
+ right: (d) => matchesKey(d, "right"),
150
+ },
151
+ source: { report: loadReport, status: loadStatus },
152
+ rows: () => tui.terminal?.rows ?? process.stdout.rows ?? 40,
153
+ requestRender: () => tui.requestRender(),
154
+ close: () => done(undefined),
155
+ initial: req,
156
+ harnessId: HARNESS_ID,
157
+ }),
158
+ { overlay: true, overlayOptions: { fullscreen: true, width: "100%", maxHeight: "100%", anchor: "center" } },
159
+ );
160
+ return;
161
+ }
162
+ let data: UsageReport;
163
+ try {
164
+ data = await loadReport(req);
165
+ } catch (err) {
166
+ ctx.ui.notify(err instanceof Error ? err.message : String(err), "error");
167
+ return;
168
+ }
169
+ if (data.totals.dispatches === 0) {
170
+ ctx.ui.notify(`no routed turns in the last ${req.windowDays}d${req.harnessId === "" ? "" : ` for harness ${req.harnessId}`}`, "info");
171
+ return;
172
+ }
173
+ post(pi, renderUsageReport(data));
174
+ }
175
+
176
+ async function status(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
177
+ try {
178
+ post(pi, await loadStatus());
179
+ } catch (err) {
180
+ ctx.ui.notify(`router unreachable at ${routerBaseUrl()}: ${err instanceof Error ? err.message : String(err)}`, "error");
181
+ }
182
+ }
183
+
184
+ async function configure(ctx: ExtensionContext): Promise<void> {
185
+ const ui = ctx.ui;
186
+ const cfg = loadConfig();
187
+ const answers: Record<string, unknown> = {};
188
+
189
+ for (;;) {
190
+ // Each section shows how many of its fields have pending edits.
191
+ const options: SelectOption[] = WIZARD_SECTIONS.map((s) => {
192
+ const touched = s.fields.filter((f) => f.path in answers).length;
193
+ return touched > 0 ? { label: s.title, description: `${touched} pending` } : { label: s.title, description: `${s.fields.length} settings` };
194
+ });
195
+ options.push("profiles" in answers ? { label: "Profiles", description: "pending" } : { label: "Profiles", description: `${cfg.profiles.length} profiles` }, "Save and exit", "Quit without saving");
196
+ const pending = Object.keys(answers).length;
197
+ const chosen = await ui.select(`auto-model-router configure${pending > 0 ? ` (${pending} pending)` : ""}`, options);
198
+ if (chosen === undefined) return;
199
+ if (chosen === "Quit without saving") return;
200
+ if (chosen === "Save and exit") break;
201
+
202
+ if (chosen === "Profiles") {
203
+ await editProfiles(ui, cfg, answers);
204
+ continue;
205
+ }
206
+
207
+ const section = WIZARD_SECTIONS.find((s) => s.title === chosen);
208
+ if (section === undefined) continue;
209
+ await editSectionMenu(ui, section, cfg, answers);
210
+ }
211
+
212
+ if (Object.keys(answers).length === 0) {
213
+ ui.notify("no changes made", "info");
214
+ return;
215
+ }
216
+
217
+ try {
218
+ const target = routerConfigPath();
219
+ const partial = applyAnswers(answers);
220
+ const backup = writeRouterConfig(target, partial);
221
+ ui.notify(`wrote ${target}${backup ? ` (backup: ${backup})` : ""} — restart omp for server/upstream/ledger changes`, "info");
222
+ } catch (err) {
223
+ ui.notify(err instanceof Error ? err.message : String(err), "error");
224
+ }
225
+ }
226
+
76
227
  /** Edits the profiles array as whole elements, mirroring the CLI wizard. */
77
228
  async function editProfiles(
78
229
  ui: ConfigUi,
@@ -83,7 +234,8 @@ async function editProfiles(
83
234
  // expects), converting at the boundary to/from ProfileConfig.
84
235
  const list: Record<string, unknown>[] = cfg.profiles.map((p) => ({ ...p }));
85
236
  const names = list.map((p, i) => `${i + 1}) ${p.id} (${p.name})`);
86
- const choice = await ui.select("Profiles", [...names, "+ Add profile", "Back"]);
237
+ const items: SelectOption[] = list.map((p, i) => ({ label: names[i] ?? "", description: `${p.minTier}..${p.maxTier} · ctx ${p.contextWindow} · out ${p.maxTokens}` }));
238
+ const choice = await ui.select("Profiles", [...items, "+ Add profile", "Back"]);
87
239
  if (choice === undefined || choice === "Back") return;
88
240
 
89
241
  if (choice === "+ Add profile") {
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Where the router is listening, resolved fresh on every call.
3
+ *
4
+ * The embedded router binds a free OS-assigned port and writes it to the
5
+ * port file at session start, so nothing can cache the URL: the toast poll,
6
+ * `/router report` and `/router status` all resolve it at the moment of use.
7
+ * Precedence: `AUTO_MODEL_ROUTER_URL`, the embed port file,
8
+ * `AUTO_MODEL_ROUTER_PORT`, then `server.port` in the router's config.yml.
9
+ */
10
+
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ import { parse as parseYaml } from "yaml";
16
+
17
+ import { embedPortPath, readEmbedPort } from "./embed-logic.ts";
18
+ import { resolveRouterUrl } from "./toast-logic.ts";
19
+
20
+ /** `$AUTO_MODEL_ROUTER_HOME` with `~` expanded, default `~/.auto-model-router`. */
21
+ export function routerHome(): string {
22
+ const raw = process.env.AUTO_MODEL_ROUTER_HOME ?? join(homedir(), ".auto-model-router");
23
+ return raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\") ? join(homedir(), raw.slice(1)) : raw;
24
+ }
25
+
26
+ /** Raw router config.yml, or null when there is none to read. */
27
+ export function readRouterConfigText(): string | null {
28
+ const path = join(routerHome(), "config.yml");
29
+ if (!existsSync(path)) return null;
30
+ try {
31
+ return readFileSync(path, "utf8");
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /** Base URL of the router this omp process should talk to. */
38
+ export function routerBaseUrl(): string {
39
+ return resolveRouterUrl(
40
+ process.env.AUTO_MODEL_ROUTER_URL,
41
+ readRouterConfigText(),
42
+ parseYaml,
43
+ process.env.AUTO_MODEL_ROUTER_PORT,
44
+ readEmbedPort(embedPortPath(routerHome())),
45
+ );
46
+ }
47
+
48
+ /** Authorization header for a router configured with `server.apiKey`. */
49
+ export function routerAuthHeaders(): Record<string, string> {
50
+ const key = process.env.AUTO_MODEL_ROUTER_API_KEY;
51
+ return key === undefined || key === "" ? {} : { authorization: `Bearer ${key}` };
52
+ }
@@ -98,10 +98,22 @@ export interface ToastMessage {
98
98
  text: string;
99
99
  }
100
100
 
101
+ /**
102
+ * Provider and model for display. Catalog slugs namespace providers by
103
+ * prefix: `ollama/<id>` is Ollama Cloud, anything else is OpenRouter (whose
104
+ * slugs keep their vendor segment, e.g. `z-ai/glm-5.3-flash`). The Ollama
105
+ * prefix is dropped from the displayed model since the provider label
106
+ * already says it.
107
+ */
108
+ export function providerOf(slug: string): { provider: string; model: string } {
109
+ if (slug.startsWith("ollama/")) return { provider: "ollama", model: slug.slice("ollama/".length) };
110
+ return { provider: "openrouter", model: slug };
111
+ }
112
+
101
113
  export function toToastText(d: ToastDecision): string {
102
- const model = d.servedSlug ?? d.slug;
114
+ const { provider, model } = providerOf(d.servedSlug ?? d.slug);
103
115
  const cost = d.reportedUsd === null ? "" : ` \u00b7 $${d.reportedUsd.toFixed(5)}`;
104
- return `${model} [${d.tier}]${cost}`;
116
+ return `${provider} \u00b7 ${model} [${d.tier}]${cost}`;
105
117
  }
106
118
 
107
119
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.2.31",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -0,0 +1,97 @@
1
+ /**
2
+ * A `CatalogSource` that presents OpenRouter's catalog and Ollama Cloud's as
3
+ * one snapshot, so selection ranks them together.
4
+ *
5
+ * The merged snapshot object is reused until either side actually changes,
6
+ * because `tierPlanFor` memoises per snapshot identity and a fresh object per
7
+ * turn would recompute the tier plan every turn for nothing.
8
+ *
9
+ * While the Ollama breaker is open (plan quota or concurrency limit hit), the
10
+ * Ollama models are left out entirely: a candidate that will 402 or 429 is
11
+ * not a candidate, and hiding it here means the turn routes straight to an
12
+ * OpenRouter model instead of paying a doomed dispatch first.
13
+ */
14
+
15
+ import type { OllamaAvailability } from "../upstream/ollama.ts";
16
+ import { effectiveOllamaBias, NO_USAGE, type OllamaUsageSource } from "../upstream/ollama-usage.ts";
17
+ import { mergeSnapshots, type OllamaCatalogSource } from "./ollama-catalog.ts";
18
+ import type { CatalogModel, CatalogShrink, CatalogSnapshot, CatalogSource } from "./types.ts";
19
+
20
+ export interface CompositeBias {
21
+ /** Static multiplier from config. */
22
+ costBias: number;
23
+ /** Plan usage fraction at which the bias switches off (list price). */
24
+ biasUntilUsage: number;
25
+ usage: OllamaUsageSource;
26
+ }
27
+
28
+ export function createCompositeCatalog(
29
+ openrouter: CatalogSource,
30
+ ollama: OllamaCatalogSource,
31
+ availability: OllamaAvailability,
32
+ bias: CompositeBias = { costBias: 1, biasUntilUsage: 1, usage: NO_USAGE },
33
+ ): CatalogSource & { ollamaModels(): CatalogModel[]; ollamaBias(): number } {
34
+ let lastBase: CatalogSnapshot | null = null;
35
+ let lastOllama: readonly CatalogModel[] = [];
36
+ let lastAvailable = true;
37
+ let lastBias = 1;
38
+ let merged: CatalogSnapshot | null = null;
39
+
40
+ /** The multiplier in force from the latest usage reading (no network). */
41
+ function currentBias(): number {
42
+ return effectiveOllamaBias(bias.costBias, bias.biasUntilUsage, bias.usage.peek());
43
+ }
44
+
45
+ function combine(base: CatalogSnapshot, models: readonly CatalogModel[]): CatalogSnapshot {
46
+ const available = availability.available();
47
+ const providerBias = currentBias();
48
+ if (merged !== null && base === lastBase && models === lastOllama && available === lastAvailable && providerBias === lastBias) return merged;
49
+ lastBase = base;
50
+ lastOllama = models;
51
+ lastAvailable = available;
52
+ lastBias = providerBias;
53
+ merged = mergeSnapshots(base, available ? models : []);
54
+ // A fresh object either way once anything changed; stamp the live bias so
55
+ // candidate scoring reads it off the snapshot it is ranking.
56
+ merged = { ...merged, providerBias: { ollama: providerBias } };
57
+ return merged;
58
+ }
59
+
60
+ function find(slug: string): CatalogModel | undefined {
61
+ const fromBase = openrouter.find(slug);
62
+ if (fromBase !== undefined) return fromBase;
63
+ for (const m of lastOllama) if (m.slug === slug) return m;
64
+ for (const m of ollama.peek()) if (m.slug === slug) return m;
65
+ return undefined;
66
+ }
67
+
68
+ return {
69
+ async get(): Promise<CatalogSnapshot> {
70
+ const base = await openrouter.get();
71
+ const models = await ollama.get(base.models);
72
+ // Refreshes on its own poll interval; a cached reading returns at once.
73
+ await bias.usage.get();
74
+ return combine(base, models);
75
+ },
76
+ async refresh(): Promise<CatalogSnapshot> {
77
+ const base = await openrouter.refresh();
78
+ ollama.invalidate();
79
+ const models = await ollama.get(base.models);
80
+ await bias.usage.get();
81
+ return combine(base, models);
82
+ },
83
+ ollamaBias: currentBias,
84
+ peek(): CatalogSnapshot | null {
85
+ const base = openrouter.peek();
86
+ if (base === null) return null;
87
+ return combine(base, ollama.peek());
88
+ },
89
+ find,
90
+ lastShrink(): CatalogShrink | null {
91
+ return openrouter.lastShrink?.() ?? null;
92
+ },
93
+ ollamaModels(): CatalogModel[] {
94
+ return [...ollama.peek()];
95
+ },
96
+ };
97
+ }