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,309 @@
1
+ /**
2
+ * Ollama Cloud catalog: turns Ollama's model listing into `CatalogModel`s the
3
+ * router can rank next to OpenRouter's.
4
+ *
5
+ * Ollama publishes far less per model than OpenRouter does, so each model is
6
+ * assembled from three sources:
7
+ *
8
+ * 1. The listing itself (`GET /api/tags`, on the daemon or on ollama.com):
9
+ * ids, and on the daemon the context length + capabilities of every cloud
10
+ * model. ollama.com's listing carries neither, so there `POST /api/show`
11
+ * (unauthenticated) fills them, cached per id for the life of the process.
12
+ * 2. The price table (`ollama-prices.ts` + `ollama.prices` config), since no
13
+ * Ollama endpoint publishes rates. Unpriced models are dropped.
14
+ * 3. The OpenRouter twin — the same model under an OpenRouter slug, matched by
15
+ * normalised name (`glm-5.3-flash` ↔ `z-ai/glm-5.3-flash`) — for the
16
+ * quality scores that put the model above `trivial`, and as a fallback for
17
+ * tokenizer, context and capabilities. `ollama.twins` in config pins a
18
+ * match the normaliser cannot make.
19
+ *
20
+ * Slugs are `ollama/<id as listed>`, so the daemon's `glm-5.3-flash:cloud` and
21
+ * ollama.com's `glm-5.3-flash` are distinct catalog entries with the same
22
+ * price and twin; the dispatch client strips the `ollama/` prefix.
23
+ */
24
+
25
+ import type { OllamaConfig } from "../config/types.ts";
26
+ import type { Logger } from "../util/log.ts";
27
+ import { normalizeModelKey } from "./benchmark-feeds.ts";
28
+ import { bareCloudName, ollamaRateFor, type OllamaRate } from "./ollama-prices.ts";
29
+ import type { CatalogModel, CatalogSnapshot, Modality } from "./types.ts";
30
+
31
+ export const OLLAMA_SLUG_PREFIX = "ollama/";
32
+
33
+ /** One entry of Ollama's `/api/tags` listing, reduced to what routing needs. */
34
+ export interface OllamaListing {
35
+ id: string;
36
+ /** Bare cloud name on ollama.com (`remote_model` on the daemon). */
37
+ remoteModel: string | null;
38
+ /** Present when the daemon proxies this model to ollama.com. */
39
+ isCloud: boolean;
40
+ contextLength: number | null;
41
+ capabilities: string[];
42
+ modifiedAtMs: number;
43
+ }
44
+
45
+ function asRec(v: unknown): Record<string, unknown> | null {
46
+ return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
47
+ }
48
+
49
+ /** Parses one raw `/api/tags` model record. */
50
+ export function parseOllamaListing(raw: unknown, source: "daemon" | "ollama.com"): OllamaListing | null {
51
+ const rec = asRec(raw);
52
+ if (rec === null) return null;
53
+ const id = typeof rec.model === "string" && rec.model !== "" ? rec.model : typeof rec.name === "string" ? rec.name : "";
54
+ if (id === "") return null;
55
+ const details = asRec(rec.details);
56
+ const ctx = details?.context_length;
57
+ const caps = Array.isArray(rec.capabilities) ? rec.capabilities.filter((c): c is string => typeof c === "string") : [];
58
+ const remote = typeof rec.remote_model === "string" && rec.remote_model !== "" ? rec.remote_model : null;
59
+ const modified = typeof rec.modified_at === "string" ? Date.parse(rec.modified_at) : NaN;
60
+ return {
61
+ id,
62
+ remoteModel: remote ?? (source === "ollama.com" ? id : null),
63
+ // On the daemon only proxied entries are cloud models; on ollama.com
64
+ // everything listed is.
65
+ isCloud: source === "ollama.com" || typeof rec.remote_host === "string",
66
+ contextLength: typeof ctx === "number" && Number.isFinite(ctx) && ctx > 0 ? ctx : null,
67
+ capabilities: caps,
68
+ modifiedAtMs: Number.isFinite(modified) ? modified : 0,
69
+ };
70
+ }
71
+
72
+ /** `POST /api/show` reduced to the two fields the listing may lack. */
73
+ export function parseOllamaShow(raw: unknown): { contextLength: number | null; capabilities: string[] } {
74
+ const rec = asRec(raw);
75
+ const caps = rec !== null && Array.isArray(rec.capabilities) ? rec.capabilities.filter((c): c is string => typeof c === "string") : [];
76
+ let contextLength: number | null = null;
77
+ const info = rec === null ? null : asRec(rec.model_info);
78
+ if (info !== null) {
79
+ for (const [k, v] of Object.entries(info)) {
80
+ if (k.endsWith(".context_length") && typeof v === "number" && Number.isFinite(v) && v > 0) {
81
+ contextLength = v;
82
+ break;
83
+ }
84
+ }
85
+ }
86
+ return { contextLength, capabilities: caps };
87
+ }
88
+
89
+ /**
90
+ * Normalised match key for an Ollama name. Ollama separates the tag with `:`
91
+ * (`gpt-oss:120b`) where OpenRouter uses `-` (`openai/gpt-oss-120b`), so the
92
+ * tag is folded in before the shared normaliser runs.
93
+ */
94
+ export function ollamaTwinKey(name: string): string {
95
+ return normalizeModelKey(bareCloudName(name).replace(/:/g, "-"));
96
+ }
97
+
98
+ /** OpenRouter models indexed by normalised name, first slug wins per key. */
99
+ export function twinIndex(openrouter: readonly CatalogModel[]): Map<string, CatalogModel> {
100
+ const out = new Map<string, CatalogModel>();
101
+ for (const m of openrouter) {
102
+ if (m.provider !== "openrouter") continue;
103
+ if (m.slug.startsWith("~") || m.slug.endsWith(":batch") || m.slug.includes(":free")) continue;
104
+ const key = normalizeModelKey(m.slug);
105
+ if (!out.has(key)) out.set(key, m);
106
+ }
107
+ return out;
108
+ }
109
+
110
+ export interface BuildOllamaArgs {
111
+ listings: readonly OllamaListing[];
112
+ openrouter: readonly CatalogModel[];
113
+ cfg: OllamaConfig;
114
+ log?: Logger;
115
+ }
116
+
117
+ /** Builds catalog models from listings + prices + twins. Pure; no I/O. */
118
+ export function buildOllamaModels(args: BuildOllamaArgs): CatalogModel[] {
119
+ const { listings, openrouter, cfg, log } = args;
120
+ const twins = twinIndex(openrouter);
121
+ const bySlug = new Map(openrouter.map((m) => [m.slug, m] as const));
122
+ const out: CatalogModel[] = [];
123
+ const skipped: string[] = [];
124
+ for (const l of listings) {
125
+ if (!l.isCloud && !cfg.includeLocal) continue;
126
+ const priceName = l.remoteModel ?? l.id;
127
+ const rate = ollamaRateFor(priceName, cfg.prices);
128
+ if (rate === null) {
129
+ skipped.push(l.id);
130
+ continue;
131
+ }
132
+ // A pin may name the tagged cloud name, its base, or the listed id.
133
+ const bare = bareCloudName(priceName);
134
+ const base = bare.includes(":") ? bare.slice(0, bare.indexOf(":")) : bare;
135
+ const pinned = cfg.twins[bare] ?? cfg.twins[base] ?? cfg.twins[l.id];
136
+ const twin = (pinned !== undefined ? bySlug.get(pinned) : undefined) ?? twins.get(ollamaTwinKey(priceName)) ?? null;
137
+ const contextLength = l.contextLength ?? twin?.contextLength ?? null;
138
+ if (contextLength === null) {
139
+ skipped.push(`${l.id} (no context length)`);
140
+ continue;
141
+ }
142
+ const caps = new Set(l.capabilities);
143
+ const hasCaps = caps.size > 0;
144
+ const modalities: Modality[] = ["text"];
145
+ if (hasCaps ? caps.has("vision") : (twin?.inputModalities.includes("image") ?? false)) modalities.push("image");
146
+ const model: CatalogModel = {
147
+ slug: `${OLLAMA_SLUG_PREFIX}${l.id}`,
148
+ canonicalSlug: `${OLLAMA_SLUG_PREFIX}${priceName}`,
149
+ name: `${l.id} (Ollama Cloud)`,
150
+ provider: "ollama",
151
+ contextLength,
152
+ supportsTools: hasCaps ? caps.has("tools") : (twin?.supportsTools ?? false),
153
+ supportsReasoning: hasCaps ? caps.has("thinking") : (twin?.supportsReasoning ?? false),
154
+ reasoningMandatory: false,
155
+ // Ollama's OpenAI-compatible endpoint documents `tool_choice` as unsupported.
156
+ supportsToolChoice: false,
157
+ inputModalities: modalities,
158
+ price: toPrice(rate.rate),
159
+ priceTiers: [],
160
+ quality: twin === null ? {} : { ...twin.quality },
161
+ tokenizer: twin?.tokenizer ?? "Other",
162
+ isFree: false,
163
+ createdAtMs: l.modifiedAtMs,
164
+ author: "ollama",
165
+ };
166
+ if (twin?.maxCompletionTokens !== undefined) model.maxCompletionTokens = twin.maxCompletionTokens;
167
+ out.push(model);
168
+ }
169
+ if (skipped.length > 0) log?.debug("ollama models skipped (no price or context)", { skipped: skipped.join(", ") });
170
+ return out;
171
+ }
172
+
173
+ function toPrice(rate: OllamaRate): CatalogModel["price"] {
174
+ const price: CatalogModel["price"] = { prompt: rate.input / 1e6, completion: rate.output / 1e6 };
175
+ if (rate.cachedInput !== undefined) price.cacheRead = rate.cachedInput / 1e6;
176
+ return price;
177
+ }
178
+
179
+ /** Minimal fetch surface, injectable for tests. */
180
+ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
181
+
182
+ export interface OllamaCatalogSource {
183
+ /** Cloud models, refreshed when older than `cfg.catalogTtlMs`; last good set on failure. */
184
+ get(openrouter: readonly CatalogModel[]): Promise<CatalogModel[]>;
185
+ /** Last built set without touching the network. */
186
+ peek(): CatalogModel[];
187
+ /** Forces a re-list on the next `get`. */
188
+ invalidate(): void;
189
+ }
190
+
191
+ /** Whether a base URL points at ollama.com (which needs `/api/show` for metadata). */
192
+ export function isOllamaDotCom(baseUrl: string): boolean {
193
+ try {
194
+ return new URL(baseUrl).hostname.toLowerCase().endsWith("ollama.com");
195
+ } catch {
196
+ return false;
197
+ }
198
+ }
199
+
200
+ /** `https://ollama.com/v1` → `https://ollama.com`; the native API lives beside `/v1`. */
201
+ export function ollamaApiRoot(baseUrl: string): string {
202
+ return baseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
203
+ }
204
+
205
+ export function createOllamaCatalog(cfg: OllamaConfig, log: Logger, fetchImpl: FetchLike = fetch): OllamaCatalogSource {
206
+ const root = ollamaApiRoot(cfg.baseUrl);
207
+ const direct = isOllamaDotCom(cfg.baseUrl);
208
+ const headers: Record<string, string> = {};
209
+ if (cfg.apiKey !== "") headers.authorization = `Bearer ${cfg.apiKey}`;
210
+ let models: CatalogModel[] = [];
211
+ let listedAtMs = 0;
212
+ let inflight: Promise<CatalogModel[]> | null = null;
213
+ // `/api/show` results are stable per id; fetched once per process.
214
+ const shown = new Map<string, { contextLength: number | null; capabilities: string[] }>();
215
+
216
+ async function list(): Promise<OllamaListing[]> {
217
+ const res = await fetchImpl(`${root}/api/tags`, { headers, signal: AbortSignal.timeout(cfg.timeoutMs) });
218
+ if (!res.ok) throw new Error(`ollama /api/tags HTTP ${res.status}`);
219
+ const json = asRec(await res.json());
220
+ const raw = json !== null && Array.isArray(json.models) ? json.models : [];
221
+ const out: OllamaListing[] = [];
222
+ for (const r of raw) {
223
+ const l = parseOllamaListing(r, direct ? "ollama.com" : "daemon");
224
+ if (l !== null) out.push(l);
225
+ }
226
+ // ollama.com's listing has no context/capabilities; ask per model, once.
227
+ if (direct) {
228
+ for (const l of out) {
229
+ if (l.contextLength !== null && l.capabilities.length > 0) continue;
230
+ let s = shown.get(l.id);
231
+ if (s === undefined) {
232
+ try {
233
+ const r = await fetchImpl(`${root}/api/show`, {
234
+ method: "POST",
235
+ headers: { ...headers, "content-type": "application/json" },
236
+ body: JSON.stringify({ model: l.id }),
237
+ signal: AbortSignal.timeout(cfg.timeoutMs),
238
+ });
239
+ s = r.ok ? parseOllamaShow(await r.json()) : { contextLength: null, capabilities: [] };
240
+ } catch {
241
+ s = { contextLength: null, capabilities: [] };
242
+ }
243
+ shown.set(l.id, s);
244
+ }
245
+ if (l.contextLength === null) l.contextLength = s.contextLength;
246
+ if (l.capabilities.length === 0) l.capabilities = s.capabilities;
247
+ }
248
+ }
249
+ return out;
250
+ }
251
+
252
+ async function refresh(openrouter: readonly CatalogModel[]): Promise<CatalogModel[]> {
253
+ try {
254
+ const listings = await list();
255
+ const built = buildOllamaModels({ listings, openrouter, cfg, log });
256
+ if (built.length === 0 && models.length > 0) {
257
+ log.warn("ollama listing yielded no priced cloud models; keeping the previous set", { listed: listings.length });
258
+ } else {
259
+ models = built;
260
+ }
261
+ listedAtMs = Date.now();
262
+ } catch (err) {
263
+ log.warn("ollama catalog refresh failed; keeping the previous set", {
264
+ error: err instanceof Error ? err.message : String(err),
265
+ models: models.length,
266
+ });
267
+ // Back off for a full TTL rather than hammering a dead endpoint each turn.
268
+ listedAtMs = Date.now();
269
+ }
270
+ return models;
271
+ }
272
+
273
+ return {
274
+ async get(openrouter) {
275
+ if (Date.now() - listedAtMs < cfg.catalogTtlMs) return models;
276
+ inflight ??= refresh(openrouter).finally(() => {
277
+ inflight = null;
278
+ });
279
+ return inflight;
280
+ },
281
+ peek() {
282
+ return models;
283
+ },
284
+ invalidate() {
285
+ listedAtMs = 0;
286
+ },
287
+ };
288
+ }
289
+
290
+ /** True when a catalog slug is an Ollama model. */
291
+ export function isOllamaSlug(slug: string): boolean {
292
+ return slug.startsWith(OLLAMA_SLUG_PREFIX);
293
+ }
294
+
295
+ /** The id Ollama expects in the request body. */
296
+ export function ollamaModelId(slug: string): string {
297
+ return isOllamaSlug(slug) ? slug.slice(OLLAMA_SLUG_PREFIX.length) : slug;
298
+ }
299
+
300
+ /** Composite snapshot helper: append Ollama models to an OpenRouter snapshot. */
301
+ export function mergeSnapshots(openrouter: CatalogSnapshot, ollama: readonly CatalogModel[]): CatalogSnapshot {
302
+ if (ollama.length === 0) return openrouter;
303
+ const merged: CatalogSnapshot = {
304
+ ...openrouter,
305
+ models: [...openrouter.models, ...ollama],
306
+ fetchedAtMs: openrouter.fetchedAtMs,
307
+ };
308
+ return merged;
309
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Ollama Cloud per-model token rates, USD per million tokens.
3
+ *
4
+ * Ollama publishes these on https://ollama.com/pricing and nowhere machine-
5
+ * readable: neither `/v1/models` nor `/api/tags` carries a price, so the router
6
+ * ships a snapshot and lets `ollama.prices` in config override or extend it. A
7
+ * model with no rate from either source is dropped from the catalog — routing
8
+ * on an unknown price is how budgets silently blow up (same rule as the
9
+ * OpenRouter `-1` sentinel).
10
+ *
11
+ * Keys are the bare cloud model names as ollama.com lists them. Where Ollama
12
+ * prices a specific tag (`gpt-oss:120b`) the key keeps the tag; otherwise the
13
+ * base name covers every tag (`deepseek-v4-pro:0813` → `deepseek-v4-pro`).
14
+ *
15
+ * Snapshot taken 2026-09-05 from ollama.com/pricing. Cached-input rates are
16
+ * absent for some rows on that page; those models get no cache-read discount
17
+ * rather than an invented one.
18
+ */
19
+
20
+ export interface OllamaRate {
21
+ /** USD per million uncached prompt tokens. */
22
+ input: number;
23
+ /** USD per million cached prompt tokens. Absent ⇒ no published discount. */
24
+ cachedInput?: number;
25
+ /** USD per million completion tokens. */
26
+ output: number;
27
+ }
28
+
29
+ export const OLLAMA_PRICE_SNAPSHOT_DATE = "2026-09-05";
30
+
31
+ export const OLLAMA_BUILTIN_PRICES: Readonly<Record<string, OllamaRate>> = {
32
+ "deepseek-v4-flash": { input: 0.22, cachedInput: 0.007, output: 0.66 },
33
+ "deepseek-v4-pro": { input: 0.66, cachedInput: 0.022, output: 1.98 },
34
+ gemma4: { input: 0.14, cachedInput: 0.05, output: 0.4 },
35
+ "glm-5.3": { input: 1.4, cachedInput: 0.26, output: 4.4 },
36
+ "glm-5.3-flash": { input: 0.15, cachedInput: 0.03, output: 0.5 },
37
+ "glm-5.2": { input: 1.4, cachedInput: 0.26, output: 4.4 },
38
+ "glm-5.1": { input: 1.0, cachedInput: 0.2, output: 3.2 },
39
+ "gpt-oss:120b": { input: 0.15, cachedInput: 0.014, output: 0.6 },
40
+ "gpt-oss:20b": { input: 0.07, cachedInput: 0.035, output: 0.3 },
41
+ "kimi-k3": { input: 3.0, cachedInput: 0.3, output: 15.0 },
42
+ "kimi-k2.7-code": { input: 0.95, cachedInput: 0.19, output: 4.0 },
43
+ "kimi-k2.6": { input: 0.95, cachedInput: 0.16, output: 4.0 },
44
+ "minimax-m3": { input: 0.6, cachedInput: 0.12, output: 2.4 },
45
+ "minimax-m2.7": { input: 0.3, cachedInput: 0.06, output: 1.2 },
46
+ "mistral-large-3": { input: 0.5, output: 1.5 },
47
+ "nemotron-3-nano": { input: 0.06, output: 0.24 },
48
+ "nemotron-3-super": { input: 0.015, cachedInput: 0.015, output: 0.6 },
49
+ "nemotron-3-ultra": { input: 0.1, cachedInput: 0.1, output: 3.0 },
50
+ "qwen3.5:397b": { input: 0.6, output: 3.6 },
51
+ };
52
+
53
+ /**
54
+ * The bare cloud name a listing entry is priced under: the daemon's `:cloud`
55
+ * / `-cloud` decoration stripped, lower-cased. `glm-5.3-flash:cloud` and
56
+ * `deepseek-v4-pro:0813-cloud` become `glm-5.3-flash` and
57
+ * `deepseek-v4-pro:0813`, which is exactly how ollama.com lists them.
58
+ */
59
+ export function bareCloudName(id: string): string {
60
+ let s = id.trim().toLowerCase();
61
+ if (s.endsWith(":cloud")) s = s.slice(0, -":cloud".length);
62
+ else if (s.endsWith("-cloud")) s = s.slice(0, -"-cloud".length);
63
+ return s;
64
+ }
65
+
66
+ /**
67
+ * Rate for a cloud model: the exact tagged name first (`gpt-oss:120b`), then
68
+ * the base name before the tag (`deepseek-v4-pro:0813` → `deepseek-v4-pro`).
69
+ * `overrides` (from config) win over the built-in snapshot at every step.
70
+ */
71
+ export function ollamaRateFor(
72
+ id: string,
73
+ overrides: Readonly<Record<string, OllamaRate>> = {},
74
+ ): { key: string; rate: OllamaRate } | null {
75
+ const bare = bareCloudName(id);
76
+ const colon = bare.indexOf(":");
77
+ const candidates = colon === -1 ? [bare] : [bare, bare.slice(0, colon)];
78
+ for (const key of candidates) {
79
+ const o = overrides[key];
80
+ if (o !== undefined) return { key, rate: o };
81
+ const b = OLLAMA_BUILTIN_PRICES[key];
82
+ if (b !== undefined) return { key, rate: b };
83
+ }
84
+ return null;
85
+ }
@@ -12,7 +12,25 @@ import type { Database } from "bun:sqlite";
12
12
  import type { RouterConfig } from "../config/types.ts";
13
13
  import type { UpstreamClient } from "../upstream/types.ts";
14
14
  import { createLogger } from "../util/log.ts";
15
- import type { CatalogModel, CatalogSnapshot, CatalogSource, Modality, Price, PriceTier, QualityScores } from "./types.ts";
15
+ import type {
16
+ CatalogModel,
17
+ CatalogShrink,
18
+ CatalogSnapshot,
19
+ CatalogSource,
20
+ Modality,
21
+ Price,
22
+ PriceTier,
23
+ QualityScores,
24
+ } from "./types.ts";
25
+
26
+ /**
27
+ * A refresh that keeps fewer than this fraction of the previous snapshot's
28
+ * models is a "sharp shrink": logged at warn and surfaced on /health. Only
29
+ * judged once the previous snapshot is big enough for the ratio to mean
30
+ * anything (SHRINK_MIN_PREVIOUS).
31
+ */
32
+ const SHRINK_KEEP_RATIO = 0.5;
33
+ const SHRINK_MIN_PREVIOUS = 20;
16
34
  import { applyFeedScores, loadLocalScores, refreshFeedScores } from "./benchmark-feeds.ts";
17
35
 
18
36
  function asRecord(value: unknown): Record<string, unknown> | null {
@@ -163,6 +181,7 @@ export function normalizeCatalogModel(raw: unknown): CatalogModel | null {
163
181
 
164
182
  const model: CatalogModel = {
165
183
  slug: id,
184
+ provider: "openrouter",
166
185
  canonicalSlug: typeof canonical === "string" && canonical.length > 0 ? canonical : id,
167
186
  name: typeof name === "string" && name.length > 0 ? name : id,
168
187
  contextLength,
@@ -259,6 +278,7 @@ export function createCatalog(cfg: RouterConfig, upstream: UpstreamClient, db: D
259
278
  let bySlug = new Map<string, CatalogModel>();
260
279
  let hydrated = false;
261
280
  let inflight: Promise<CatalogSnapshot> | null = null;
281
+ let lastShrink: CatalogShrink | null = null;
262
282
  const readCache = db.query("SELECT payload, fetched_at_ms, etag, key_scoped FROM catalog_cache WHERE id = 1");
263
283
  const writeCache = db.query(
264
284
  `INSERT INTO catalog_cache (id, payload, fetched_at_ms, etag, key_scoped) VALUES (1, ?, ?, NULL, ?)
@@ -407,6 +427,21 @@ export function createCatalog(cfg: RouterConfig, upstream: UpstreamClient, db: D
407
427
  return snapshot;
408
428
  }
409
429
  const fetchedAtMs = Date.now();
430
+ // A sharp shrink is adopted — the key's guardrails are authoritative for
431
+ // what may be dispatched — but never silently. Measured once: the
432
+ // admitted set fell 352 → 8 for a day, the cheap default vanished, and a
433
+ // 10x model served every turn (~$32) with nothing in the log to say why.
434
+ if (snapshot !== null && snapshot.models.length >= SHRINK_MIN_PREVIOUS && models.length < snapshot.models.length * SHRINK_KEEP_RATIO) {
435
+ lastShrink = { fromModels: snapshot.models.length, toModels: models.length, atMs: fetchedAtMs };
436
+ log.warn("catalog shrank sharply; routing narrows to what the key now admits", {
437
+ from: snapshot.models.length,
438
+ to: models.length,
439
+ keyScoped,
440
+ });
441
+ } else if (lastShrink !== null && models.length >= lastShrink.fromModels) {
442
+ log.info("catalog recovered its pre-shrink size", { models: models.length, shrunkTo: lastShrink.toModels });
443
+ lastShrink = null;
444
+ }
410
445
  // Persist the RAW payload: normalization improvements apply on the next
411
446
  // boot without a network fetch. The client returns no headers, so no etag.
412
447
  // Only persist the public fallback when keyless: a key-scoped run that fell
@@ -454,5 +489,8 @@ export function createCatalog(cfg: RouterConfig, upstream: UpstreamClient, db: D
454
489
  hydrate();
455
490
  return bySlug.get(slug);
456
491
  },
492
+ lastShrink(): CatalogShrink | null {
493
+ return lastShrink;
494
+ },
457
495
  };
458
496
  }
@@ -45,9 +45,14 @@ export interface QualityScores {
45
45
  agentic?: number;
46
46
  }
47
47
 
48
+ /** Which upstream serves a catalog model. Slugs are namespaced per provider (`ollama/…`). */
49
+ export type CatalogProvider = "openrouter" | "ollama";
50
+
48
51
  export interface CatalogModel {
49
- /** OpenRouter slug, e.g. `anthropic/claude-sonnet-4.5`. Routing identity. */
52
+ /** OpenRouter slug, e.g. `anthropic/claude-sonnet-4.5`, or `ollama/<id>`. Routing identity. */
50
53
  slug: string;
54
+ /** Upstream that dispatches this slug. */
55
+ provider: CatalogProvider;
51
56
  /** Immutable dated slug, e.g. `anthropic/claude-4.5-sonnet-20250929`. */
52
57
  canonicalSlug: string;
53
58
  name: string;
@@ -83,6 +88,12 @@ export interface CatalogSnapshot {
83
88
  models: CatalogModel[];
84
89
  /** When this snapshot was fetched. */
85
90
  fetchedAtMs: number;
91
+ /**
92
+ * Per-provider cost multipliers in force for this snapshot (1 = list price).
93
+ * Set by the composite catalog from live plan usage; absent ⇒ the
94
+ * configured static bias applies.
95
+ */
96
+ providerBias?: Partial<Record<CatalogProvider, number>>;
86
97
  /**
87
98
  * True when the snapshot was fetched via key-scoped `GET /models/user`.
88
99
  * False when fetched from the public `GET /models` endpoint.
@@ -92,6 +103,19 @@ export interface CatalogSnapshot {
92
103
  etag?: string;
93
104
  }
94
105
 
106
+ /**
107
+ * A refresh that dropped most of the catalog. Recorded, not resisted: the
108
+ * router routes over whatever the key admits, but a collapse from hundreds of
109
+ * models to a handful reroutes every turn onto whatever survived — measured
110
+ * once at ~$32 in a day when the cheap default vanished and a 10x model served
111
+ * everything — so it must be visible on `/health` and in the log.
112
+ */
113
+ export interface CatalogShrink {
114
+ fromModels: number;
115
+ toModels: number;
116
+ atMs: number;
117
+ }
118
+
95
119
  export interface CatalogSource {
96
120
  /** Returns a snapshot, refreshing from upstream when the cached one is older than the TTL. */
97
121
  get(): Promise<CatalogSnapshot>;
@@ -101,4 +125,10 @@ export interface CatalogSource {
101
125
  peek(): CatalogSnapshot | null;
102
126
  /** Slug lookup against the current snapshot. */
103
127
  find(slug: string): CatalogModel | undefined;
128
+ /**
129
+ * The most recent sharp shrink this process observed, or null. Cleared once
130
+ * a later refresh restores at least the pre-shrink size. Optional so fakes
131
+ * and older sources need not implement it.
132
+ */
133
+ lastShrink?(): CatalogShrink | null;
104
134
  }
package/src/cli/args.ts CHANGED
@@ -29,6 +29,7 @@ const BOOLEAN_FLAGS: Record<string, true> = {
29
29
  const COMMANDS: Record<string, true> = {
30
30
  serve: true,
31
31
  stats: true,
32
+ report: true,
32
33
  models: true,
33
34
  explain: true,
34
35
  config: true,