auto-model-router 0.2.32 → 0.3.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.
Files changed (70) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +225 -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 +117 -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 +190 -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 +46 -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 +120 -2
  27. package/src/cost/cache-estimate.ts +52 -0
  28. package/src/cost/ledger.ts +73 -4
  29. package/src/cost/report.ts +351 -0
  30. package/src/cost/types.ts +39 -1
  31. package/src/index.ts +5 -8
  32. package/src/router/candidates.ts +52 -4
  33. package/src/router/classify.ts +33 -6
  34. package/src/router/features.ts +13 -1
  35. package/src/router/select.ts +55 -8
  36. package/src/router/state.ts +6 -2
  37. package/src/router/tier-plan.ts +49 -11
  38. package/src/router/types.ts +10 -0
  39. package/src/server/http.ts +50 -6
  40. package/src/server/providers.ts +54 -0
  41. package/src/server/turn.ts +138 -34
  42. package/src/tokens/estimate.ts +16 -0
  43. package/src/upstream/multi.ts +26 -0
  44. package/src/upstream/ollama-usage.ts +163 -0
  45. package/src/upstream/ollama.ts +275 -0
  46. package/src/upstream/openrouter.ts +19 -1
  47. package/src/upstream/types.ts +2 -0
  48. package/src/util/sqlite.ts +25 -1
  49. package/test/cache-estimate.test.ts +48 -0
  50. package/test/catalog.test.ts +44 -0
  51. package/test/classify.test.ts +41 -5
  52. package/test/compaction.test.ts +1 -0
  53. package/test/config-wizard.test.ts +77 -1
  54. package/test/configure-logic.test.ts +129 -33
  55. package/test/embed-lifecycle.test.ts +1 -0
  56. package/test/failover.test.ts +148 -3
  57. package/test/features.test.ts +35 -0
  58. package/test/http-resilience.test.ts +24 -0
  59. package/test/ollama.test.ts +521 -0
  60. package/test/omp-credentials.test.ts +43 -1
  61. package/test/report-hub.test.ts +343 -0
  62. package/test/report-logic.test.ts +93 -0
  63. package/test/report.test.ts +233 -0
  64. package/test/select.test.ts +151 -1
  65. package/test/tier-plan.test.ts +159 -1
  66. package/test/toast-logic.test.ts +11 -2
  67. package/test/tokens.test.ts +71 -1
  68. package/test/trust-attribution.test.ts +2 -2
  69. package/test/turn.test.ts +173 -7
  70. package/tools/recompute-ollama-cache.ts +129 -0
@@ -0,0 +1,521 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { createCompositeCatalog } from "../src/catalog/composite.ts";
4
+ import {
5
+ buildOllamaModels,
6
+ createOllamaCatalog,
7
+ isOllamaDotCom,
8
+ mergeSnapshots,
9
+ ollamaApiRoot,
10
+ ollamaModelId,
11
+ ollamaTwinKey,
12
+ parseOllamaListing,
13
+ parseOllamaShow,
14
+ type OllamaListing,
15
+ } from "../src/catalog/ollama-catalog.ts";
16
+ import { bareCloudName, ollamaRateFor } from "../src/catalog/ollama-prices.ts";
17
+ import { normalizeCatalogModel } from "../src/catalog/openrouter-catalog.ts";
18
+ import type { CatalogModel, CatalogSnapshot, CatalogSource } from "../src/catalog/types.ts";
19
+ import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
20
+ import type { OllamaConfig, RouterConfig } from "../src/config/types.ts";
21
+ import { buildCandidates } from "../src/router/candidates.ts";
22
+ import { extractFeatures } from "../src/router/features.ts";
23
+ import { createMultiUpstream } from "../src/upstream/multi.ts";
24
+ import { classifyOllamaStatus, createOllamaClient, toOllamaBody } from "../src/upstream/ollama.ts";
25
+ import { createOllamaUsageSource, effectiveOllamaBias, NO_USAGE, ollamaMeter, parseOllamaUsage, usageFraction } from "../src/upstream/ollama-usage.ts";
26
+ import type { Dispatch, DispatchOptions, UpstreamClient } from "../src/upstream/types.ts";
27
+ import { createLogger } from "../src/util/log.ts";
28
+ import { parseChatRequest } from "../src/wire/openai/request.ts";
29
+
30
+ const BASE: RouterConfig = DEFAULT_CONFIG;
31
+ const OLLAMA: OllamaConfig = { ...BASE.ollama, enabled: true };
32
+ const log = createLogger("silent");
33
+
34
+ /** An OpenRouter-shaped raw record, for twins. */
35
+ function orRaw(id: string, coding: number, prompt: number, ctx = 200_000, image = false): Record<string, unknown> {
36
+ return {
37
+ id,
38
+ canonical_slug: id,
39
+ name: id,
40
+ context_length: ctx,
41
+ pricing: { prompt: String(prompt / 1e6), completion: String((prompt * 4) / 1e6), input_cache_read: String(prompt / 1e7) },
42
+ supported_parameters: ["tools", "reasoning"],
43
+ architecture: { input_modalities: image ? ["text", "image"] : ["text"], tokenizer: "Qwen" },
44
+ benchmarks: { artificial_analysis: { coding_index: coding, intelligence_index: coding - 20, agentic_index: coding - 25 } },
45
+ created: 1_700_000_000,
46
+ };
47
+ }
48
+ const OR_MODELS: CatalogModel[] = [
49
+ orRaw("z-ai/glm-5.3-flash", 71.5, 0.07, 1_048_576, true),
50
+ orRaw("openai/gpt-oss-120b", 30.4, 0.05),
51
+ orRaw("moonshotai/kimi-k3", 76.2, 2.55),
52
+ ]
53
+ .map(normalizeCatalogModel)
54
+ .filter((m): m is CatalogModel => m !== null);
55
+
56
+ /** Daemon-style `/api/tags` records. */
57
+ const DAEMON_TAGS: unknown[] = [
58
+ { name: "glm-5.3-flash:cloud", model: "glm-5.3-flash:cloud", remote_model: "glm-5.3-flash", remote_host: "https://ollama.com", modified_at: "2026-08-29T18:15:35Z", details: { context_length: 1048576 }, capabilities: ["completion", "thinking", "tools", "vision"] },
59
+ { name: "gpt-oss:120b-cloud", model: "gpt-oss:120b-cloud", remote_model: "gpt-oss:120b", remote_host: "https://ollama.com", modified_at: "2026-08-29T18:15:35Z", details: { context_length: 131072 }, capabilities: ["completion", "tools", "thinking"] },
60
+ { name: "deepseek-v4-pro:0813-cloud", model: "deepseek-v4-pro:0813-cloud", remote_model: "deepseek-v4-pro:0813", remote_host: "https://ollama.com", modified_at: "2026-08-29T18:15:35Z", details: { context_length: 1048576 }, capabilities: ["completion", "tools", "thinking"] },
61
+ { name: "mystery-model:cloud", model: "mystery-model:cloud", remote_model: "mystery-model", remote_host: "https://ollama.com", details: { context_length: 65536 }, capabilities: ["completion", "tools"] },
62
+ { name: "nomic-embed-text:latest", model: "nomic-embed-text:latest", details: { context_length: 2048 }, capabilities: ["embedding"] },
63
+ ];
64
+
65
+ function listings(source: "daemon" | "ollama.com" = "daemon"): OllamaListing[] {
66
+ return DAEMON_TAGS.map((r) => parseOllamaListing(r, source)).filter((l): l is OllamaListing => l !== null);
67
+ }
68
+
69
+ describe("ollama prices", () => {
70
+ test("bare cloud name strips the daemon decoration", () => {
71
+ expect(bareCloudName("glm-5.3-flash:cloud")).toBe("glm-5.3-flash");
72
+ expect(bareCloudName("deepseek-v4-pro:0813-cloud")).toBe("deepseek-v4-pro:0813");
73
+ expect(bareCloudName("GPT-OSS:120b")).toBe("gpt-oss:120b");
74
+ });
75
+
76
+ test("tagged rate wins, base rate covers other tags, unknown is null", () => {
77
+ expect(ollamaRateFor("gpt-oss:120b-cloud")?.key).toBe("gpt-oss:120b");
78
+ expect(ollamaRateFor("deepseek-v4-pro:0813")?.key).toBe("deepseek-v4-pro");
79
+ expect(ollamaRateFor("mistral-large-3:675b")?.key).toBe("mistral-large-3");
80
+ expect(ollamaRateFor("mystery-model")).toBeNull();
81
+ });
82
+
83
+ test("config overrides beat the shipped snapshot and can add models", () => {
84
+ const o = { "glm-5.3-flash": { input: 0.1, output: 0.2 }, "mystery-model": { input: 1, output: 2 } };
85
+ expect(ollamaRateFor("glm-5.3-flash:cloud", o)?.rate.input).toBe(0.1);
86
+ expect(ollamaRateFor("mystery-model:cloud", o)?.rate.output).toBe(2);
87
+ });
88
+ });
89
+
90
+ describe("ollama listing + show parsing", () => {
91
+ test("daemon records carry context, capabilities and the remote name", () => {
92
+ const l = parseOllamaListing(DAEMON_TAGS[0], "daemon")!;
93
+ expect(l.id).toBe("glm-5.3-flash:cloud");
94
+ expect(l.remoteModel).toBe("glm-5.3-flash");
95
+ expect(l.isCloud).toBe(true);
96
+ expect(l.contextLength).toBe(1048576);
97
+ expect(l.capabilities).toContain("vision");
98
+ // A local model on the daemon is not a cloud model.
99
+ expect(parseOllamaListing(DAEMON_TAGS[4], "daemon")!.isCloud).toBe(false);
100
+ });
101
+
102
+ test("ollama.com records are all cloud, with the id as the remote name", () => {
103
+ const l = parseOllamaListing({ name: "glm-5.3-flash", model: "glm-5.3-flash", details: {} }, "ollama.com")!;
104
+ expect(l.isCloud).toBe(true);
105
+ expect(l.remoteModel).toBe("glm-5.3-flash");
106
+ expect(l.contextLength).toBeNull();
107
+ });
108
+
109
+ test("show yields the architecture's context length and capabilities", () => {
110
+ const s = parseOllamaShow({ capabilities: ["completion", "tools"], model_info: { "glm5_next.context_length": 1048576, "glm5_next.embedding_length": 4096 } });
111
+ expect(s.contextLength).toBe(1048576);
112
+ expect(s.capabilities).toEqual(["completion", "tools"]);
113
+ });
114
+
115
+ test("url helpers", () => {
116
+ expect(isOllamaDotCom("https://ollama.com/v1")).toBe(true);
117
+ expect(isOllamaDotCom("http://127.0.0.1:11434/v1")).toBe(false);
118
+ expect(ollamaApiRoot("https://ollama.com/v1/")).toBe("https://ollama.com");
119
+ expect(ollamaModelId("ollama/glm-5.3-flash:cloud")).toBe("glm-5.3-flash:cloud");
120
+ expect(ollamaModelId("z-ai/glm-5.3-flash")).toBe("z-ai/glm-5.3-flash");
121
+ });
122
+ });
123
+
124
+ describe("buildOllamaModels", () => {
125
+ test("prices, twins, capabilities and context are assembled; unpriced and local models are dropped", () => {
126
+ const models = buildOllamaModels({ listings: listings(), openrouter: OR_MODELS, cfg: OLLAMA, log });
127
+ const slugs = models.map((m) => m.slug).sort();
128
+ expect(slugs).toEqual(["ollama/deepseek-v4-pro:0813-cloud", "ollama/glm-5.3-flash:cloud", "ollama/gpt-oss:120b-cloud"]);
129
+
130
+ const glm = models.find((m) => m.slug === "ollama/glm-5.3-flash:cloud")!;
131
+ expect(glm.provider).toBe("ollama");
132
+ expect(glm.author).toBe("ollama");
133
+ expect(glm.price.prompt).toBeCloseTo(0.15 / 1e6, 12);
134
+ expect(glm.price.cacheRead).toBeCloseTo(0.03 / 1e6, 12);
135
+ expect(glm.price.cacheWrite).toBeUndefined();
136
+ expect(glm.quality.coding).toBe(71.5); // inherited from z-ai/glm-5.3-flash
137
+ expect(glm.tokenizer).toBe("Qwen"); // twin's tokenizer family
138
+ expect(glm.contextLength).toBe(1048576); // the listing, not the twin
139
+ expect(glm.supportsTools).toBe(true);
140
+ expect(glm.supportsToolChoice).toBe(false);
141
+ expect(glm.inputModalities).toContain("image");
142
+ expect(glm.supportsReasoning).toBe(true);
143
+
144
+ // `gpt-oss:120b` ↔ `openai/gpt-oss-120b`: the tag folds into the key.
145
+ expect(ollamaTwinKey("gpt-oss:120b-cloud")).toBe("gpt-oss-120b");
146
+ const oss = models.find((m) => m.slug === "ollama/gpt-oss:120b-cloud")!;
147
+ expect(oss.quality.coding).toBe(30.4);
148
+
149
+ // No twin ⇒ unscored, but still a model (trivial-eligible).
150
+ const ds = models.find((m) => m.slug === "ollama/deepseek-v4-pro:0813-cloud")!;
151
+ expect(ds.quality).toEqual({});
152
+ });
153
+
154
+ test("a pinned twin beats the name match", () => {
155
+ const cfg: OllamaConfig = { ...OLLAMA, twins: { "deepseek-v4-pro": "moonshotai/kimi-k3" } };
156
+ const ds = buildOllamaModels({ listings: listings(), openrouter: OR_MODELS, cfg, log }).find((m) => m.slug.startsWith("ollama/deepseek"))!;
157
+ expect(ds.quality.coding).toBe(76.2);
158
+ });
159
+
160
+ test("includeLocal admits a daemon-local model only when priced", () => {
161
+ const cfg: OllamaConfig = { ...OLLAMA, includeLocal: true, prices: { "nomic-embed-text": { input: 0, output: 0 } } };
162
+ const models = buildOllamaModels({ listings: listings(), openrouter: OR_MODELS, cfg, log });
163
+ expect(models.some((m) => m.slug === "ollama/nomic-embed-text:latest")).toBe(true);
164
+ });
165
+
166
+ test("ollama.com listings with no metadata fall back to the twin's context and capabilities", () => {
167
+ const l = parseOllamaListing({ name: "glm-5.3-flash", model: "glm-5.3-flash", details: {} }, "ollama.com")!;
168
+ const [m] = buildOllamaModels({ listings: [l], openrouter: OR_MODELS, cfg: OLLAMA, log });
169
+ expect(m!.slug).toBe("ollama/glm-5.3-flash");
170
+ expect(m!.contextLength).toBe(1_048_576);
171
+ expect(m!.supportsTools).toBe(true);
172
+ expect(m!.inputModalities).toContain("image");
173
+ });
174
+ });
175
+
176
+ describe("createOllamaCatalog", () => {
177
+ test("lists via /api/tags, fills ollama.com metadata via /api/show once per id, caches by TTL", async () => {
178
+ const calls: string[] = [];
179
+ const fetchImpl = async (url: string, init?: RequestInit): Promise<Response> => {
180
+ calls.push(`${init?.method ?? "GET"} ${url}`);
181
+ if (url.endsWith("/api/tags")) return Response.json({ models: [{ name: "glm-5.3-flash", model: "glm-5.3-flash", details: {} }] });
182
+ if (url.endsWith("/api/show")) return Response.json({ capabilities: ["completion", "tools"], model_info: { "glm5_next.context_length": 4096 } });
183
+ return new Response("nope", { status: 404 });
184
+ };
185
+ const cfg: OllamaConfig = { ...OLLAMA, baseUrl: "https://ollama.com/v1", apiKey: "k", catalogTtlMs: 60_000 };
186
+ const src = createOllamaCatalog(cfg, log, fetchImpl);
187
+ const first = await src.get(OR_MODELS);
188
+ expect(first.map((m) => m.slug)).toEqual(["ollama/glm-5.3-flash"]);
189
+ expect(first[0]!.contextLength).toBe(4096); // show beats the twin
190
+ await src.get(OR_MODELS); // within TTL: no network
191
+ expect(calls).toEqual(["GET https://ollama.com/api/tags", "POST https://ollama.com/api/show"]);
192
+ src.invalidate();
193
+ await src.get(OR_MODELS);
194
+ expect(calls).toHaveLength(3); // re-listed, show cached
195
+ expect(src.peek()).toHaveLength(1);
196
+ });
197
+
198
+ test("a failed listing keeps the previous set", async () => {
199
+ let fail = false;
200
+ const fetchImpl = async (url: string): Promise<Response> => {
201
+ if (fail) throw new Error("boom");
202
+ return Response.json({ models: url.endsWith("/api/tags") ? DAEMON_TAGS : [] });
203
+ };
204
+ const src = createOllamaCatalog({ ...OLLAMA, catalogTtlMs: 10 }, log, fetchImpl);
205
+ expect((await src.get(OR_MODELS)).length).toBe(3);
206
+ fail = true;
207
+ await new Promise((r) => setTimeout(r, 60)); // well past the 10ms TTL, even on a loaded runner
208
+ expect((await src.get(OR_MODELS)).length).toBe(3);
209
+ });
210
+ });
211
+
212
+ describe("toOllamaBody", () => {
213
+ test("strips OpenRouter-only fields, maps reasoning, drops cache_control, requests usage", () => {
214
+ const body = toOllamaBody({
215
+ model: "ollama/glm-5.3-flash:cloud",
216
+ models: ["ollama/glm-5.3-flash:cloud", "ollama/gpt-oss:120b-cloud"],
217
+ session_id: "s",
218
+ tool_choice: "auto",
219
+ stream: true,
220
+ stream_options: { include_usage: false },
221
+ reasoning: { effort: "xhigh" },
222
+ messages: [
223
+ { role: "system", content: [{ type: "text", text: "sys", cache_control: { type: "ephemeral" } }] },
224
+ { role: "user", content: "hi" },
225
+ ],
226
+ });
227
+ expect(body.model).toBe("glm-5.3-flash:cloud");
228
+ expect(body.models).toBeUndefined();
229
+ expect(body.session_id).toBeUndefined();
230
+ expect(body.tool_choice).toBeUndefined();
231
+ expect(body.reasoning).toBeUndefined();
232
+ expect(body.reasoning_effort).toBe("high");
233
+ expect(body.stream_options).toEqual({ include_usage: true });
234
+ const sys = (body.messages as { content: unknown }[])[0]!.content as Record<string, unknown>[];
235
+ expect(sys[0]).toEqual({ type: "text", text: "sys" });
236
+ });
237
+
238
+ test("reasoning off is simply omitted", () => {
239
+ const body = toOllamaBody({ model: "ollama/x", reasoning: { enabled: false }, stream: false });
240
+ expect(body.reasoning_effort).toBeUndefined();
241
+ expect(body.stream_options).toBeUndefined();
242
+ });
243
+ });
244
+
245
+ describe("classifyOllamaStatus", () => {
246
+ test("402 is quota: retryable and account-level", () => {
247
+ const e = classifyOllamaStatus(402, { error: { message: "out of credits" } });
248
+ expect(e.kind).toBe("quota");
249
+ expect(e.retryable).toBe(true);
250
+ });
251
+ test("429 is a rate limit; 401 is auth; 404 is model_unavailable", () => {
252
+ expect(classifyOllamaStatus(429, {}).kind).toBe("rate_limit");
253
+ expect(classifyOllamaStatus(401, {}).retryable).toBe(false);
254
+ expect(classifyOllamaStatus(404, {}).kind).toBe("model_unavailable");
255
+ });
256
+ test("a 403 about billing is quota, any other 403 is moderation", () => {
257
+ expect(classifyOllamaStatus(403, { error: "plan limit reached" }).kind).toBe("quota");
258
+ expect(classifyOllamaStatus(403, { error: "content blocked" }).kind).toBe("moderation");
259
+ });
260
+ });
261
+
262
+ describe("ollama client", () => {
263
+ function cfgWith(o: Partial<OllamaConfig>): RouterConfig {
264
+ return { ...BASE, ollama: { ...OLLAMA, ...o }, logLevel: "silent" };
265
+ }
266
+ const sse = (lines: string[]): Response =>
267
+ new Response(new ReadableStream({
268
+ start(c) {
269
+ for (const l of lines) c.enqueue(new TextEncoder().encode(`data: ${l}\n\n`));
270
+ c.enqueue(new TextEncoder().encode("data: [DONE]\n\n"));
271
+ c.close();
272
+ },
273
+ }), { status: 200, headers: { "content-type": "text/event-stream" } });
274
+
275
+ test("dispatch strips the slug prefix on the way out and re-adds it on the served model", async () => {
276
+ let sent: Record<string, unknown> | null = null;
277
+ let auth: string | null = null;
278
+ const fetchImpl = (async (_url: string, init?: RequestInit): Promise<Response> => {
279
+ sent = JSON.parse(String(init?.body)) as Record<string, unknown>;
280
+ auth = (init?.headers as Record<string, string>).authorization ?? null;
281
+ return sse([
282
+ JSON.stringify({ id: "g1", model: "glm-5.3-flash:cloud", choices: [{ index: 0, delta: { content: "hi" }, finish_reason: null }] }),
283
+ JSON.stringify({ id: "g1", model: "glm-5.3-flash:cloud", choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 10, completion_tokens: 2 } }),
284
+ ]);
285
+ });
286
+ const client = createOllamaClient(cfgWith({ apiKey: "sk" }), fetchImpl);
287
+ const d = await client.dispatch({ body: { model: "ollama/glm-5.3-flash:cloud", messages: [], stream: true }, sessionId: "s", signal: new AbortController().signal });
288
+ const chunks = [];
289
+ for await (const c of d.chunks) chunks.push(c);
290
+ expect(sent!.model).toBe("glm-5.3-flash:cloud");
291
+ expect(auth as string | null).toBe("Bearer sk"); // assigned inside the fetch closure; TS narrows the declaration
292
+ const start = chunks[0]!.events.find((e) => e.type === "start");
293
+ expect(start && start.type === "start" ? start.servedSlug : null).toBe("ollama/glm-5.3-flash:cloud");
294
+ expect(chunks[0]!.raw.model).toBe("ollama/glm-5.3-flash:cloud");
295
+ expect(await d.generationId()).toBe("g1");
296
+ expect(client.available()).toBe(true);
297
+ });
298
+
299
+ test("a 402 trips the breaker for quotaCooldownMs and surfaces as a retryable quota error", async () => {
300
+ const fetchImpl = (async (): Promise<Response> => Response.json({ error: { message: "credits exhausted" } }, { status: 402 }));
301
+ const client = createOllamaClient(cfgWith({ quotaCooldownMs: 60_000 }), fetchImpl);
302
+ let caught: unknown = null;
303
+ try {
304
+ await client.dispatch({ body: { model: "ollama/x", messages: [] }, sessionId: "s", signal: new AbortController().signal });
305
+ } catch (e) {
306
+ caught = e;
307
+ }
308
+ expect((caught as { kind: string }).kind).toBe("quota");
309
+ expect((caught as { retryable: boolean }).retryable).toBe(true);
310
+ expect(client.available()).toBe(false);
311
+ expect(client.cooldownUntilMs()).toBeGreaterThan(Date.now());
312
+ expect(client.lastTrip()?.kind).toBe("quota");
313
+ });
314
+
315
+ test("a 429 with a zero cooldown does not trip the breaker", async () => {
316
+ const fetchImpl = (async (): Promise<Response> => new Response("slow down", { status: 429 }));
317
+ const client = createOllamaClient(cfgWith({ rateLimitCooldownMs: 0, planCreditsUsd: 0 }), fetchImpl);
318
+ await client.dispatch({ body: { model: "ollama/x", messages: [] }, sessionId: "s", signal: new AbortController().signal }).catch(() => {});
319
+ expect(client.available()).toBe(true);
320
+ });
321
+ });
322
+
323
+ describe("multi upstream + composite catalog", () => {
324
+ const stub = (name: string, calls: string[]): UpstreamClient => ({
325
+ dispatch: async (opts: DispatchOptions): Promise<Dispatch> => {
326
+ calls.push(`${name}:${String(opts.body.model)}`);
327
+ return { chunks: (async function* () {})(), generationId: async () => null };
328
+ },
329
+ complete: async (body) => {
330
+ calls.push(`${name}:complete:${String(body.model)}`);
331
+ return { text: "", costUsd: null };
332
+ },
333
+ fetchModels: async () => {
334
+ calls.push(`${name}:models`);
335
+ return [];
336
+ },
337
+ fetchModelsForUser: async () => [],
338
+ });
339
+
340
+ test("dispatch and complete go to the provider named by the slug prefix; catalog fetches stay on OpenRouter", async () => {
341
+ const calls: string[] = [];
342
+ const multi = createMultiUpstream(stub("or", calls), stub("ol", calls));
343
+ const sig = new AbortController().signal;
344
+ await multi.dispatch({ body: { model: "ollama/glm-5.3-flash:cloud" }, sessionId: "s", signal: sig });
345
+ await multi.dispatch({ body: { model: "z-ai/glm-5.3-flash" }, sessionId: "s", signal: sig });
346
+ await multi.complete({ model: "qwen/qwen3.7-flash" }, sig);
347
+ await multi.fetchModels();
348
+ expect(calls).toEqual(["ol:ollama/glm-5.3-flash:cloud", "or:z-ai/glm-5.3-flash", "or:complete:qwen/qwen3.7-flash", "or:models"]);
349
+ });
350
+
351
+ test("the merged snapshot hides Ollama models while the breaker is open and keeps its identity otherwise", async () => {
352
+ const base: CatalogSnapshot = { models: OR_MODELS, fetchedAtMs: 1, keyScoped: true };
353
+ const openrouter: CatalogSource = { get: async () => base, refresh: async () => base, peek: () => base, find: (s) => OR_MODELS.find((m) => m.slug === s) };
354
+ const ollamaModels = buildOllamaModels({ listings: listings(), openrouter: OR_MODELS, cfg: OLLAMA, log });
355
+ let available = true;
356
+ const source = { get: async () => ollamaModels, peek: () => ollamaModels, invalidate: () => {} };
357
+ const breaker = { available: () => available, cooldownUntilMs: () => null, lastTrip: () => null };
358
+ const catalog = createCompositeCatalog(openrouter, source, breaker);
359
+
360
+ const a = await catalog.get();
361
+ expect(a.models.length).toBe(OR_MODELS.length + 3);
362
+ expect(a.keyScoped).toBe(true);
363
+ expect(await catalog.get()).toBe(a); // same inputs ⇒ same object (tier plan memo holds)
364
+ expect(catalog.find("ollama/glm-5.3-flash:cloud")?.provider).toBe("ollama");
365
+ expect(catalog.find("z-ai/glm-5.3-flash")?.provider).toBe("openrouter");
366
+
367
+ available = false;
368
+ const b = await catalog.get();
369
+ expect(b.models.length).toBe(OR_MODELS.length);
370
+ expect(b).not.toBe(a);
371
+ expect(catalog.ollamaModels()).toHaveLength(3); // still known, just hidden
372
+ expect(mergeSnapshots(base, []).models).toBe(base.models);
373
+ });
374
+ });
375
+
376
+ describe("selection over a mixed catalog", () => {
377
+ const req = parseChatRequest(
378
+ {
379
+ model: "auto",
380
+ tools: [{ type: "function", function: { name: "read", description: "Read", parameters: { type: "object", properties: {} } } }],
381
+ messages: [{ role: "user", content: "rename the helper" }],
382
+ },
383
+ new Headers(),
384
+ );
385
+ const features = extractFeatures(req, 50_000);
386
+ const ollamaModels = buildOllamaModels({ listings: listings(), openrouter: OR_MODELS, cfg: OLLAMA, log });
387
+ const snapshot: CatalogSnapshot = { models: [...OR_MODELS, ...ollamaModels], fetchedAtMs: 1 };
388
+
389
+ function build(costBias: number) {
390
+ return buildCandidates({
391
+ req,
392
+ features,
393
+ tier: "simple",
394
+ task: "coding",
395
+ snapshot,
396
+ ledger: null,
397
+ cfg: { ...BASE, adaptiveTierFloors: false, ollama: { ...OLLAMA, costBias } },
398
+ expectedCompletionTokens: 512,
399
+ warmSlug: null,
400
+ });
401
+ }
402
+
403
+ test("Ollama models rank alongside OpenRouter ones on the same economics", () => {
404
+ const { candidates } = build(1);
405
+ const slugs = candidates.map((c) => c.model.slug);
406
+ expect(slugs).toContain("ollama/glm-5.3-flash:cloud");
407
+ expect(slugs).toContain("z-ai/glm-5.3-flash");
408
+ // $0.07/M on OpenRouter beats $0.15/M on Ollama at list price.
409
+ expect(slugs.indexOf("z-ai/glm-5.3-flash")).toBeLessThan(slugs.indexOf("ollama/glm-5.3-flash:cloud"));
410
+ });
411
+
412
+ test("costBias below 1 tilts the ranking toward Ollama and says so", () => {
413
+ const { candidates } = build(0.25);
414
+ const slugs = candidates.map((c) => c.model.slug);
415
+ expect(slugs.indexOf("ollama/glm-5.3-flash:cloud")).toBeLessThan(slugs.indexOf("z-ai/glm-5.3-flash"));
416
+ expect(candidates.find((c) => c.model.slug === "ollama/glm-5.3-flash:cloud")!.reasons.some((r) => r.startsWith("provider bias"))).toBe(true);
417
+ });
418
+ });
419
+
420
+ describe("ollama plan usage (credit-aware bias)", () => {
421
+ // The shape ollama.com/api/usage returned on 2026-09-06 for a Pro account.
422
+ const PAYLOAD = {
423
+ activity: { cost: "0.00000", period: { type: "last_4_weeks", starting_at: "2026-08-10T00:00:00Z", ending_at: "2026-09-06T04:57:02Z" }, models: [] },
424
+ limits: { monthly: { usage: 0, models: [{ name: "glm-5.3", request_count: 1 }, { name: "nemotron-3-super", request_count: 2 }] } },
425
+ };
426
+
427
+ test("parses the observed payload", () => {
428
+ const u = parseOllamaUsage(PAYLOAD, 5)!;
429
+ expect(u.monthlyUsedFraction).toBe(0);
430
+ expect(u.monthlyUsageRaw).toBe(0);
431
+ expect(u.activityCostUsd).toBe(0);
432
+ expect(u.requestsThisMonth).toBe(3);
433
+ expect(u.fetchedAtMs).toBe(5);
434
+ expect(parseOllamaUsage({ unrelated: true })).toBeNull();
435
+ expect(parseOllamaUsage("nope")).toBeNull();
436
+ });
437
+
438
+ test("infers the usage scale: percent above 1, fraction at or below 1, exactly 1 read as 1%", () => {
439
+ expect(usageFraction(0)).toBe(0);
440
+ expect(usageFraction(37)).toBeCloseTo(0.37, 6);
441
+ expect(usageFraction(250)).toBe(1);
442
+ expect(usageFraction(0.42)).toBeCloseTo(0.42, 6);
443
+ expect(usageFraction(1)).toBeCloseTo(0.01, 6);
444
+ });
445
+
446
+ test("the bias holds under the threshold, switches to list price above it, and stays on when usage is unknown", () => {
447
+ const at = (f: number | null) => (f === null ? null : { monthlyUsedFraction: f, monthlyUsageRaw: f, activityCostUsd: null, requestsThisMonth: 0, fetchedAtMs: 0 });
448
+ expect(effectiveOllamaBias(0.1, 0.9, at(0.5))).toBe(0.1);
449
+ expect(effectiveOllamaBias(0.1, 0.9, at(0.9))).toBe(1);
450
+ expect(effectiveOllamaBias(0.1, 0.9, at(1))).toBe(1);
451
+ expect(effectiveOllamaBias(0.1, 0.9, at(null))).toBe(0.1);
452
+ expect(effectiveOllamaBias(0.1, 0.9, null)).toBe(0.1);
453
+ expect(effectiveOllamaBias(1, 0.9, at(0))).toBe(1);
454
+ });
455
+
456
+ test("the source polls on its interval, keeps the last reading on failure, and is inert without a key", async () => {
457
+ let calls = 0;
458
+ let fail = false;
459
+ const fetchImpl = async (url: string, init?: RequestInit): Promise<Response> => {
460
+ calls++;
461
+ expect(url).toBe("https://ollama.com/api/usage");
462
+ expect((init?.headers as Record<string, string>).authorization).toBe("Bearer k");
463
+ if (fail) return new Response("down", { status: 503 });
464
+ return Response.json({ ...PAYLOAD, limits: { monthly: { usage: 42, models: [] } } });
465
+ };
466
+ const src = createOllamaUsageSource({ apiKey: "k", pollMs: 20, timeoutMs: 1000, log, fetchImpl });
467
+ expect(src.peek()).toBeNull();
468
+ expect((await src.get())?.monthlyUsedFraction).toBeCloseTo(0.42, 6);
469
+ await src.get();
470
+ expect(calls).toBe(1); // within the interval
471
+ fail = true;
472
+ await new Promise((r) => setTimeout(r, 120)); // well past the 20ms poll interval
473
+ expect((await src.get())?.monthlyUsedFraction).toBeCloseTo(0.42, 6); // last good reading survives a 503
474
+ expect(calls).toBe(2);
475
+ expect(createOllamaUsageSource({ apiKey: "", pollMs: 50, timeoutMs: 1000, log, fetchImpl })).toBe(NO_USAGE);
476
+ });
477
+
478
+ test("the composite snapshot carries the live bias and re-merges when it flips", async () => {
479
+ const base: CatalogSnapshot = { models: OR_MODELS, fetchedAtMs: 1, keyScoped: true };
480
+ const openrouter: CatalogSource = { get: async () => base, refresh: async () => base, peek: () => base, find: (s) => OR_MODELS.find((m) => m.slug === s) };
481
+ const ollamaModels = buildOllamaModels({ listings: listings(), openrouter: OR_MODELS, cfg: OLLAMA, log });
482
+ const source = { get: async () => ollamaModels, peek: () => ollamaModels, invalidate: () => {} };
483
+ const breaker = { available: () => true, cooldownUntilMs: () => null, lastTrip: () => null };
484
+ let used = 0.2;
485
+ const usage = { get: async () => ({ monthlyUsedFraction: used, monthlyUsageRaw: used * 100, activityCostUsd: null, requestsThisMonth: 0, fetchedAtMs: 0 }), peek: () => ({ monthlyUsedFraction: used, monthlyUsageRaw: used * 100, activityCostUsd: null, requestsThisMonth: 0, fetchedAtMs: 0 }) };
486
+ const catalog = createCompositeCatalog(openrouter, source, breaker, { costBias: 0.1, biasUntilUsage: 0.9, usage });
487
+
488
+ const a = await catalog.get();
489
+ expect(a.providerBias).toEqual({ ollama: 0.1 });
490
+ expect(catalog.ollamaBias()).toBe(0.1);
491
+ expect(await catalog.get()).toBe(a);
492
+
493
+ // Candidate scoring reads the bias off the snapshot, not the config.
494
+ const req = parseChatRequest({ model: "auto", tools: [{ type: "function", function: { name: "read", description: "Read", parameters: { type: "object", properties: {} } } }], messages: [{ role: "user", content: "rename the helper" }] }, new Headers());
495
+ const features = extractFeatures(req, 50_000);
496
+ const rank = (snap: CatalogSnapshot) => buildCandidates({ req, features, tier: "simple", task: "coding", snapshot: snap, ledger: null, cfg: { ...BASE, adaptiveTierFloors: false, ollama: { ...OLLAMA, costBias: 1 } }, expectedCompletionTokens: 512, warmSlug: null }).candidates.map((c) => c.model.slug);
497
+ expect(rank(a).indexOf("ollama/glm-5.3-flash:cloud")).toBeLessThan(rank(a).indexOf("z-ai/glm-5.3-flash"));
498
+
499
+ used = 0.95; // credits nearly gone: list price
500
+ const b = await catalog.get();
501
+ expect(b).not.toBe(a);
502
+ expect(b.providerBias).toEqual({ ollama: 1 });
503
+ expect(rank(b).indexOf("z-ai/glm-5.3-flash")).toBeLessThan(rank(b).indexOf("ollama/glm-5.3-flash:cloud"));
504
+ });
505
+ });
506
+
507
+
508
+ describe("ollamaMeter", () => {
509
+ test("plan share times credits is the dashboard's dollar figure", () => {
510
+ const usage = { monthlyUsedFraction: 0.104, monthlyUsageRaw: 0.104, activityCostUsd: 0, requestsThisMonth: 1250, fetchedAtMs: 1 };
511
+ // 10.4% of Pro's $60 is the $6.24 ollama.com shows.
512
+ expect(ollamaMeter(usage, 60)).toEqual({ usedUsd: 6.24, creditsUsd: 60 });
513
+ });
514
+
515
+ test("unknown credits or usage yields no meter", () => {
516
+ const usage = { monthlyUsedFraction: 0.5, monthlyUsageRaw: 0.5, activityCostUsd: 0, requestsThisMonth: 1, fetchedAtMs: 1 };
517
+ expect(ollamaMeter(usage, 0)).toBeNull();
518
+ expect(ollamaMeter(null, 60)).toBeNull();
519
+ expect(ollamaMeter({ ...usage, monthlyUsedFraction: null }, 60)).toBeNull();
520
+ });
521
+ });
@@ -4,7 +4,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
 
7
- import { readOmpCredential, resolveOpenRouterKey } from "../src/config/omp-credentials.ts";
7
+ import { readOmpCredential, resolveOllamaKey, resolveOpenRouterKey } from "../src/config/omp-credentials.ts";
8
8
 
9
9
  /**
10
10
  * omp's real `auth_credentials` DDL, copied verbatim from a live
@@ -183,3 +183,45 @@ describe("resolveOpenRouterKey", () => {
183
183
  expect(resolved.source).toBe("omp-auth-store");
184
184
  });
185
185
  });
186
+
187
+ describe("resolveOllamaKey", () => {
188
+ test("explicit configuration wins, and an env-sourced key is attributed to OLLAMA_API_KEY", () => {
189
+ setEnv("OLLAMA_API_KEY", undefined);
190
+ expect(resolveOllamaKey("ok-explicit").source).toBe("config");
191
+ setEnv("OLLAMA_API_KEY", "ok-from-env");
192
+ const resolved = resolveOllamaKey("ok-from-env");
193
+ expect(resolved.source).toBe("env");
194
+ expect(resolved.detail).toBe("OLLAMA_API_KEY");
195
+ });
196
+
197
+ test("borrows omp's ollama-cloud credential when nothing else is configured", () => {
198
+ setEnv("OLLAMA_API_KEY", undefined);
199
+ const dir = mkdtempSync(join(tmpdir(), "ompr-agentdir-"));
200
+ dirs.push(dir);
201
+ const db = new Database(join(dir, "agent.db"));
202
+ db.exec(SCHEMA);
203
+ db.query("INSERT INTO auth_credentials (provider, credential_type, data) VALUES (?, ?, ?)").run(
204
+ "ollama-cloud",
205
+ "api_key",
206
+ JSON.stringify({ key: "ok-borrowed", source: "login" }),
207
+ );
208
+ db.close();
209
+ setEnv("PI_CODING_AGENT_DIR", dir);
210
+ const resolved = resolveOllamaKey("");
211
+ expect(resolved.apiKey).toBe("ok-borrowed");
212
+ expect(resolved.source).toBe("omp-auth-store");
213
+ // The OpenRouter chain must not pick up the Ollama credential, nor vice versa.
214
+ expect(resolveOpenRouterKey("").source).toBe("none");
215
+ });
216
+
217
+ test("points at /login ollama-cloud when nothing resolves", () => {
218
+ setEnv("OLLAMA_API_KEY", undefined);
219
+ const dir = mkdtempSync(join(tmpdir(), "ompr-empty-agent-"));
220
+ dirs.push(dir);
221
+ setEnv("PI_CODING_AGENT_DIR", dir);
222
+ const resolved = resolveOllamaKey("");
223
+ expect(resolved.source).toBe("none");
224
+ expect(resolved.detail).toContain("/login ollama-cloud");
225
+ });
226
+ });
227
+