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,275 @@
1
+ /**
2
+ * Ollama Cloud transport: Ollama's OpenAI-compatible `/v1/chat/completions`,
3
+ * either on ollama.com directly (API key) or through a local daemon that
4
+ * proxies `:cloud` models under the signed-in account.
5
+ *
6
+ * Same shape as the OpenRouter client, with the differences Ollama's
7
+ * compatibility layer imposes applied to the rendered body just before it
8
+ * goes out:
9
+ *
10
+ * - the `ollama/` catalog prefix is stripped from `model`;
11
+ * - `models[]` (OpenRouter's fallback cascade) and `session_id` are removed;
12
+ * - `tool_choice` is removed (documented as unsupported);
13
+ * - OpenRouter's `reasoning: {effort}` object becomes `reasoning_effort`;
14
+ * - `cache_control` markers are stripped from content parts (Anthropic-style
15
+ * breakpoints mean nothing here and could be rejected);
16
+ * - `stream_options.include_usage` is set so the final chunk carries usage.
17
+ *
18
+ * Ollama returns no `usage.cost`, so the ledger's predicted figure stands in
19
+ * as the reported one; the catalog price is the published per-token rate.
20
+ *
21
+ * Plan limits are the other difference. Ollama meters cloud usage against
22
+ * monthly credits and a per-plan concurrency cap (1/3/10), so 402 and 429 are
23
+ * routine, not exceptional. Both trip a circuit breaker: the composite catalog
24
+ * hides every Ollama model while it is open, so a turn falls straight through
25
+ * to OpenRouter instead of paying a doomed dispatch first.
26
+ */
27
+
28
+ import type { RouterConfig } from "../config/types.ts";
29
+ import { OLLAMA_SLUG_PREFIX, ollamaModelId } from "../catalog/ollama-catalog.ts";
30
+ import { createLogger } from "../util/log.ts";
31
+ import type { StreamEvent, UpstreamChunk } from "../wire/types.ts";
32
+ import { parseSse } from "./sse-parse.ts";
33
+ import { UpstreamError, type Dispatch, type DispatchOptions, type UpstreamClient, type UpstreamErrorKind } from "./types.ts";
34
+
35
+ /** Circuit-breaker view the catalog consults. */
36
+ export interface OllamaAvailability {
37
+ /** False while a quota/rate-limit cooldown is in force. */
38
+ available(): boolean;
39
+ /** Epoch ms the cooldown ends, or null when available. */
40
+ cooldownUntilMs(): number | null;
41
+ /** Why the breaker is open, for /health. */
42
+ lastTrip(): { kind: UpstreamErrorKind; atMs: number; message: string } | null;
43
+ }
44
+
45
+ export interface OllamaClient extends UpstreamClient, OllamaAvailability {}
46
+
47
+ function asRec(v: unknown): Record<string, unknown> | null {
48
+ return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
49
+ }
50
+
51
+ const REASONING_EFFORT_MAP: Record<string, string> = {
52
+ minimal: "low",
53
+ low: "low",
54
+ medium: "medium",
55
+ high: "high",
56
+ xhigh: "high",
57
+ max: "high",
58
+ };
59
+
60
+ /** Rewrites an OpenRouter-shaped request body into what Ollama accepts. Pure. */
61
+ export function toOllamaBody(body: Record<string, unknown>): Record<string, unknown> {
62
+ const out: Record<string, unknown> = { ...body };
63
+ if (typeof out.model === "string") out.model = ollamaModelId(out.model);
64
+ delete out.models;
65
+ delete out.session_id;
66
+ delete out.tool_choice;
67
+ delete out.stream_options;
68
+ const reasoning = asRec(out.reasoning);
69
+ delete out.reasoning;
70
+ delete out.reasoning_effort;
71
+ if (reasoning !== null) {
72
+ if (reasoning.enabled === false) {
73
+ // Nothing: omitting the field is "no explicit effort" on Ollama.
74
+ } else if (typeof reasoning.effort === "string") {
75
+ const mapped = REASONING_EFFORT_MAP[reasoning.effort];
76
+ if (mapped !== undefined) out.reasoning_effort = mapped;
77
+ }
78
+ }
79
+ if (Array.isArray(out.messages)) {
80
+ out.messages = out.messages.map((m) => {
81
+ const msg = asRec(m);
82
+ if (msg === null || !Array.isArray(msg.content)) return m;
83
+ return {
84
+ ...msg,
85
+ content: msg.content.map((part) => {
86
+ const p = asRec(part);
87
+ if (p === null || !("cache_control" in p)) return part;
88
+ const { cache_control: _dropped, ...rest } = p;
89
+ return rest;
90
+ }),
91
+ };
92
+ });
93
+ }
94
+ if (out.stream === true) out.stream_options = { include_usage: true };
95
+ return out;
96
+ }
97
+
98
+ /** HTTP status → error kind. 402/429 are plan limits, not model faults. */
99
+ export function classifyOllamaStatus(status: number, body: unknown): UpstreamError {
100
+ const rec = asRec(body);
101
+ const errRec = rec ? asRec(rec.error) : null;
102
+ const msg = errRec?.message ?? rec?.message ?? rec?.error;
103
+ const message = typeof msg === "string" && msg !== "" ? msg : `Ollama HTTP ${status}`;
104
+ const fail = (kind: UpstreamErrorKind, retryable: boolean): UpstreamError => new UpstreamError(kind, status, message, retryable, body);
105
+ if (status === 401) return fail("auth", false);
106
+ // Out of credits (or a plan gate): the account, not the model. Fail over.
107
+ if (status === 402) return fail("quota", true);
108
+ if (status === 403) return /credit|quota|plan|limit|billing/i.test(message) ? fail("quota", true) : fail("moderation", true);
109
+ if (status === 429) return fail("rate_limit", true);
110
+ if (status === 404) return fail("model_unavailable", true);
111
+ if (status === 400) {
112
+ if (/context|too many tokens|token limit/i.test(message)) return fail("context_length", false);
113
+ return fail("invalid_request", /support|unsupported|does not/i.test(message));
114
+ }
115
+ if (status >= 500) return fail("upstream_error", true);
116
+ return fail("upstream_error", status === 408);
117
+ }
118
+
119
+ function transportError(err: unknown): UpstreamError {
120
+ if (err instanceof UpstreamError) return err;
121
+ const name = err instanceof Error ? err.name : "";
122
+ if (name === "TimeoutError") return new UpstreamError("timeout", 0, "Ollama request timed out", true);
123
+ if (name === "AbortError") return new UpstreamError("aborted", 0, "request aborted", false);
124
+ return new UpstreamError("network", 0, err instanceof Error ? err.message : String(err), true);
125
+ }
126
+
127
+ /** Minimal fetch surface, injectable for tests. */
128
+ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
129
+
130
+ export function createOllamaClient(cfg: RouterConfig, fetchImpl: FetchLike = fetch): OllamaClient {
131
+ const o = cfg.ollama;
132
+ const baseUrl = o.baseUrl.replace(/\/+$/, "");
133
+ const log = createLogger(cfg.logLevel);
134
+ let cooldownUntil = 0;
135
+ let lastTrip: { kind: UpstreamErrorKind; atMs: number; message: string } | null = null;
136
+
137
+ const trip = (err: UpstreamError): void => {
138
+ const ms = err.kind === "quota" ? o.quotaCooldownMs : err.kind === "rate_limit" ? o.rateLimitCooldownMs : 0;
139
+ if (ms <= 0) return;
140
+ cooldownUntil = Math.max(cooldownUntil, Date.now() + ms);
141
+ lastTrip = { kind: err.kind, atMs: Date.now(), message: err.message };
142
+ log.warn("ollama cloud unavailable; routing around it", { kind: err.kind, cooldownMs: ms, message: err.message });
143
+ };
144
+
145
+ function headers(extra: Record<string, string> = {}): Record<string, string> {
146
+ const h: Record<string, string> = { "content-type": "application/json", ...extra };
147
+ if (o.apiKey !== "") h.authorization = `Bearer ${o.apiKey}`;
148
+ return h;
149
+ }
150
+
151
+ function composeSignal(caller: AbortSignal | undefined): AbortSignal | null {
152
+ const timeout = o.timeoutMs > 0 ? AbortSignal.timeout(o.timeoutMs) : null;
153
+ if (caller && timeout) return AbortSignal.any([caller, timeout]);
154
+ return caller ?? timeout;
155
+ }
156
+
157
+ async function httpError(res: Response): Promise<UpstreamError> {
158
+ let body: unknown = null;
159
+ try {
160
+ body = await res.json();
161
+ } catch {
162
+ // Status alone drives classification.
163
+ }
164
+ const err = classifyOllamaStatus(res.status, body);
165
+ trip(err);
166
+ return err;
167
+ }
168
+
169
+ /** Re-prefixes the served model so state/ledger keys match the catalog slug. */
170
+ function prefixServed(chunk: UpstreamChunk): UpstreamChunk {
171
+ let events: StreamEvent[] | null = null;
172
+ for (let i = 0; i < chunk.events.length; i++) {
173
+ const ev = chunk.events[i];
174
+ if (ev !== undefined && ev.type === "start" && !ev.servedSlug.startsWith(OLLAMA_SLUG_PREFIX)) {
175
+ events ??= [...chunk.events];
176
+ events[i] = { ...ev, servedSlug: `${OLLAMA_SLUG_PREFIX}${ev.servedSlug}` };
177
+ }
178
+ }
179
+ const raw = typeof chunk.raw.model === "string" && !chunk.raw.model.startsWith(OLLAMA_SLUG_PREFIX) ? { ...chunk.raw, model: `${OLLAMA_SLUG_PREFIX}${chunk.raw.model}` } : chunk.raw;
180
+ return events === null && raw === chunk.raw ? chunk : { raw, events: events ?? chunk.events };
181
+ }
182
+
183
+ return {
184
+ available: () => Date.now() >= cooldownUntil,
185
+ cooldownUntilMs: () => (Date.now() >= cooldownUntil ? null : cooldownUntil),
186
+ lastTrip: () => lastTrip,
187
+
188
+ async dispatch(opts: DispatchOptions): Promise<Dispatch> {
189
+ const body = toOllamaBody({ ...opts.body, stream: true });
190
+ let res: Response;
191
+ try {
192
+ res = await fetchImpl(`${baseUrl}/chat/completions`, {
193
+ method: "POST",
194
+ headers: headers(),
195
+ body: JSON.stringify(body),
196
+ signal: composeSignal(opts.signal),
197
+ });
198
+ } catch (err) {
199
+ throw transportError(err);
200
+ }
201
+ if (!res.ok) throw await httpError(res);
202
+ if (!res.body) throw new UpstreamError("upstream_error", res.status, "response had no body", true);
203
+
204
+ const parsed = parseSse(res.body, (msg, fields) => log.warn(msg, fields));
205
+ let resolveId!: (id: string | null) => void;
206
+ const idPromise = new Promise<string | null>((resolve) => {
207
+ resolveId = resolve;
208
+ });
209
+ let idResolved = false;
210
+ const resolveOnce = (id: string | null): void => {
211
+ if (!idResolved) {
212
+ idResolved = true;
213
+ resolveId(id);
214
+ }
215
+ };
216
+ const chunks = (async function* (): AsyncGenerator<UpstreamChunk> {
217
+ try {
218
+ for await (const chunk of parsed) {
219
+ const errPayload = chunk.raw.error;
220
+ if (errPayload !== undefined && errPayload !== null) {
221
+ const rec = asRec(errPayload) ?? {};
222
+ const message = typeof rec.message === "string" ? rec.message : "Ollama stream error";
223
+ throw new UpstreamError("upstream_error", 0, message, true, errPayload);
224
+ }
225
+ if (!idResolved && typeof chunk.raw.id === "string") resolveOnce(chunk.raw.id);
226
+ yield prefixServed(chunk);
227
+ }
228
+ } catch (err) {
229
+ throw transportError(err);
230
+ } finally {
231
+ resolveOnce(null);
232
+ }
233
+ })();
234
+ return { chunks, generationId: () => idPromise };
235
+ },
236
+
237
+ async complete(body: Record<string, unknown>, signal: AbortSignal): Promise<{ text: string; costUsd: number | null }> {
238
+ let res: Response;
239
+ try {
240
+ res = await fetchImpl(`${baseUrl}/chat/completions`, {
241
+ method: "POST",
242
+ headers: headers(),
243
+ body: JSON.stringify(toOllamaBody({ ...body, stream: false })),
244
+ signal: composeSignal(signal),
245
+ });
246
+ } catch (err) {
247
+ throw transportError(err);
248
+ }
249
+ if (!res.ok) throw await httpError(res);
250
+ const json = asRec(await res.json());
251
+ const choices = json?.choices;
252
+ const choice0 = Array.isArray(choices) && choices.length > 0 ? asRec(choices[0]) : null;
253
+ const message = choice0 ? asRec(choice0.message) : null;
254
+ const content = message?.content;
255
+ return { text: typeof content === "string" ? content : "", costUsd: null };
256
+ },
257
+
258
+ async fetchModels(signal?: AbortSignal): Promise<unknown[]> {
259
+ let res: Response;
260
+ try {
261
+ res = await fetchImpl(`${baseUrl}/models`, { headers: headers(), signal: composeSignal(signal) });
262
+ } catch (err) {
263
+ throw transportError(err);
264
+ }
265
+ if (!res.ok) throw await httpError(res);
266
+ const data = asRec(await res.json())?.data;
267
+ if (!Array.isArray(data)) throw new UpstreamError("upstream_error", res.status, "models payload had no data array", true);
268
+ return data;
269
+ },
270
+
271
+ fetchModelsForUser(signal?: AbortSignal): Promise<unknown[]> {
272
+ return this.fetchModels(signal);
273
+ },
274
+ };
275
+ }
@@ -23,6 +23,21 @@ import {
23
23
  const CONTEXT_LENGTH_RE =
24
24
  /context[ _-]?length|context[ _-]?window|maximum context|too many tokens|reduce (?:the |your )?(?:length|prompt)|prompt is too long|token limit/i;
25
25
 
26
+ // A 400 that names a MODEL limitation rather than a malformed request: the
27
+ // same body is fine on a sibling. Seen live: "This model only supports single
28
+ // tool-calls at once!" from a model handed history with parallel tool calls —
29
+ // the catalog has no reliable flag for that (`parallel_tool_calls` is listed by
30
+ // 8 of 347 models, including none of the ones that handle it), so it can only
31
+ // be learned reactively. Retryable ⇒ same-tier failover with the slug
32
+ // excluded; still an attributable error, so trust demotes a repeat offender.
33
+ const MODEL_CAPABILITY_RE =
34
+ /only supports single tool[ -]?calls?|parallel tool[ -]?calls?|does not support (?:tools?|function|tool[ -]?calls?|images?|vision|system)|(?:tool|function)[ -]?call(?:s|ing)? (?:is|are) not supported|unsupported (?:tool|function|parameter|modality|image)/i;
35
+
36
+ /** Exported for tests: the status/body → UpstreamError mapping. */
37
+ export function classifyUpstreamStatus(status: number, body: unknown): UpstreamError {
38
+ return classifyStatus(status, body);
39
+ }
40
+
26
41
  function asRec(v: unknown): Record<string, unknown> | null {
27
42
  return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
28
43
  }
@@ -46,7 +61,10 @@ function classifyStatus(status: number, body: unknown): UpstreamError {
46
61
  if (status === 403) return fail("moderation", true);
47
62
  if (status === 429) return fail("rate_limit", true);
48
63
  if (status === 400) {
49
- return CONTEXT_LENGTH_RE.test(message) ? fail("context_length", false) : fail("invalid_request", false);
64
+ if (CONTEXT_LENGTH_RE.test(message)) return fail("context_length", false);
65
+ // The model, not the request, is what cannot cope: let failover pick a sibling.
66
+ if (MODEL_CAPABILITY_RE.test(message)) return fail("invalid_request", true);
67
+ return fail("invalid_request", false);
50
68
  }
51
69
  // Provider routing may recover a missing model on the next attempt.
52
70
  if (status === 404) return fail("model_unavailable", true);
@@ -11,6 +11,8 @@ import type { UpstreamChunk, WireError } from "../wire/types.ts";
11
11
  export type UpstreamErrorKind =
12
12
  | "auth"
13
13
  | "rate_limit"
14
+ /** Plan credits or a usage allowance exhausted (Ollama Cloud 402). Account-level, retryable elsewhere. */
15
+ | "quota"
14
16
  | "context_length"
15
17
  | "model_unavailable"
16
18
  | "invalid_request"
@@ -18,7 +18,7 @@ import { mkdirSync } from "node:fs";
18
18
  import { dirname } from "node:path";
19
19
 
20
20
  /** Bump when a migration is added; guarded below so reopening never regresses it. */
21
- const USER_VERSION = 14;
21
+ const USER_VERSION = 16;
22
22
 
23
23
  const MIGRATIONS = `
24
24
  CREATE TABLE IF NOT EXISTS catalog_cache (
@@ -76,6 +76,8 @@ CREATE INDEX IF NOT EXISTS idx_ledger_slug ON ledger (slug);
76
76
  -- slug ever had; measured on a real ledger, the latency statement went from
77
77
  -- 5-10ms and RISING with history to a flat 0.04-0.08ms.
78
78
  CREATE INDEX IF NOT EXISTS idx_ledger_slug_created ON ledger (slug, created_at_ms DESC);
79
+ CREATE INDEX IF NOT EXISTS idx_ledger_harness_created ON ledger (harness_id, created_at_ms DESC);
80
+ CREATE INDEX IF NOT EXISTS idx_ledger_slug_harness_created ON ledger (slug, harness_id, created_at_ms DESC);
79
81
 
80
82
  CREATE TABLE IF NOT EXISTS token_calibration (
81
83
  tokenizer TEXT PRIMARY KEY,
@@ -224,6 +226,23 @@ const MIGRATE_V13 = `
224
226
  ALTER TABLE conversations ADD COLUMN compaction_plan TEXT;
225
227
  `;
226
228
 
229
+ // v16: conversations remember the compacted prompt size the current plan was
230
+ // made at, so re-planning can be rationed to real growth (see
231
+ // CompactionConfig.replanGrowthRatio). 0 = unknown; the next plan sets it.
232
+ //
233
+ // The same migration discards every token_calibration row. Until v16 the
234
+ // calibration paired the PRE-compaction, pre-context-block byte count with the
235
+ // POST-compaction billed token count, so every learned ratio absorbed
236
+ // compaction (measured: actual/compacted-estimate = 1.5), and one provider
237
+ // that reports ~8x prompt tokens had dragged the qwen3 family to 1.6
238
+ // bytes/token. Rows are running sums with thousands of samples, so they would
239
+ // never converge on correctly paired data; relearning from zero costs ~20
240
+ // samples per family at the built-in defaults.
241
+ const MIGRATE_V16 = `
242
+ ALTER TABLE conversations ADD COLUMN compaction_plan_tokens INTEGER NOT NULL DEFAULT 0;
243
+ DELETE FROM token_calibration;
244
+ `;
245
+
227
246
  // v9: benchmark_cache holds the external benchmark feeds (Artificial Analysis,
228
247
  // BenchLM) that backfill quality scores OpenRouter leaves unpublished. It is a
229
248
  // whole new table, created idempotently by the MIGRATIONS block above, so there
@@ -240,6 +259,10 @@ ALTER TABLE conversations ADD COLUMN compaction_plan TEXT;
240
259
  // is set. Created idempotently by the MIGRATIONS block above, so the version bump
241
260
  // alone records it; no ALTER guard needed.
242
261
 
262
+ // v15: idx_ledger_harness_created and idx_ledger_slug_harness_created serve
263
+ // harness-scoped daily spend, trust, and latency windows. Created idempotently
264
+ // by MIGRATIONS; no ALTER guard needed.
265
+
243
266
  export function openDb(path: string): Database {
244
267
  // ":memory:" has no parent directory to create.
245
268
  if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
@@ -264,6 +287,7 @@ export function openDb(path: string): Database {
264
287
  const convCols = db.query("PRAGMA table_info(conversations)").all() as { name: string }[];
265
288
  if (!convCols.some((c) => c.name === "context_version")) db.exec(MIGRATE_V11);
266
289
  if (!convCols.some((c) => c.name === "compaction_plan")) db.exec(MIGRATE_V13);
290
+ if (!convCols.some((c) => c.name === "compaction_plan_tokens")) db.exec(MIGRATE_V16);
267
291
  db.exec(`PRAGMA user_version = ${USER_VERSION}`);
268
292
  }
269
293
  return db;
@@ -0,0 +1,48 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { estimateUnreportedCache } from "../src/cost/cache-estimate.ts";
4
+ import { EMPTY_USAGE, type UsageCounts } from "../src/cost/types.ts";
5
+
6
+ /**
7
+ * Ollama Cloud caches prompt prefixes and bills them at its cached rate but
8
+ * reports no count (measured 2026-09-07: 12 identical 162k-token requests
9
+ * moved the plan meter $0.06 against $0.29 at the full rate). The estimate
10
+ * follows the router's warm-cache rule: same model as the previous turn
11
+ * within the TTL ⇒ the previous prompt is the cached prefix.
12
+ */
13
+
14
+ const base = (over: Partial<UsageCounts> = {}): UsageCounts => ({ ...EMPTY_USAGE, promptTokens: 120_000, completionTokens: 300, ...over });
15
+ const ctx = { previousSlug: "ollama/glm", previousPromptTokens: 100_000, previousAtMs: 1_000_000, servedSlug: "ollama/glm", nowMs: 1_060_000, cacheWarmTtlMs: 300_000 };
16
+
17
+ describe("estimateUnreportedCache", () => {
18
+ test("same model within the TTL: the previous prompt is the cached prefix, flagged as estimated", () => {
19
+ const out = estimateUnreportedCache(base(), ctx);
20
+ expect(out.cachedTokens).toBe(100_000);
21
+ expect(out.cachedEstimated).toBe(true);
22
+ expect(out.promptTokens).toBe(120_000);
23
+ });
24
+
25
+ test("a shorter prompt than the previous one caps the cached count at the prompt", () => {
26
+ expect(estimateUnreportedCache(base({ promptTokens: 40_000 }), ctx).cachedTokens).toBe(40_000);
27
+ });
28
+
29
+ test("first turn, model switch, or idle past the TTL count as cold", () => {
30
+ expect(estimateUnreportedCache(base(), { ...ctx, previousSlug: null }).cachedTokens).toBe(0);
31
+ expect(estimateUnreportedCache(base(), { ...ctx, previousSlug: "ollama/other" }).cachedTokens).toBe(0);
32
+ expect(estimateUnreportedCache(base(), { ...ctx, nowMs: ctx.previousAtMs + 300_001 }).cachedTokens).toBe(0);
33
+ expect(estimateUnreportedCache(base(), { ...ctx, previousPromptTokens: 0 }).cachedTokens).toBe(0);
34
+ expect(estimateUnreportedCache(base(), { ...ctx, cacheWarmTtlMs: 0 }).cachedTokens).toBe(0);
35
+ });
36
+
37
+ test("provider-reported cache counts are never overwritten", () => {
38
+ const reported = base({ cachedTokens: 5_000 });
39
+ expect(estimateUnreportedCache(reported, ctx)).toBe(reported);
40
+ const written = base({ cacheWriteTokens: 5_000 });
41
+ expect(estimateUnreportedCache(written, ctx)).toBe(written);
42
+ });
43
+
44
+ test("no prompt tokens: nothing to estimate", () => {
45
+ const empty = base({ promptTokens: 0 });
46
+ expect(estimateUnreportedCache(empty, ctx)).toBe(empty);
47
+ });
48
+ });
@@ -316,4 +316,48 @@ describe("createCatalog key-scoped availability", () => {
316
316
  expect(hydrated?.keyScoped).toBe(true);
317
317
  db.close();
318
318
  });
319
+
320
+ test("a refresh that keeps under half the models is recorded as a shrink and surfaced", async () => {
321
+ const { createCatalog } = await import("../src/catalog/openrouter-catalog.ts");
322
+ const { openDb } = await import("../src/util/sqlite.ts");
323
+ const { DEFAULT_CONFIG } = await import("../src/config/defaults.ts");
324
+
325
+ // Every usable record, then a 4-model slice of it, then the full set again.
326
+ const usable = RAW.filter((r) => normalizeCatalogModel(r) !== null);
327
+ expect(usable.length).toBeGreaterThanOrEqual(20);
328
+ const responses = [usable, usable.slice(0, 4), usable];
329
+ let calls = 0;
330
+ const upstream: any = {
331
+ dispatch: () => Promise.reject(new Error("unused")),
332
+ complete: () => Promise.reject(new Error("unused")),
333
+ fetchModels: async () => usable,
334
+ fetchModelsForUser: async () => responses[Math.min(calls++, responses.length - 1)],
335
+ };
336
+ const cfg = {
337
+ ...DEFAULT_CONFIG,
338
+ benchmarks: { ...DEFAULT_CONFIG.benchmarks, enabled: false },
339
+ openrouter: { ...DEFAULT_CONFIG.openrouter, apiKey: "sk-or-test" },
340
+ logLevel: "silent" as const,
341
+ };
342
+ const db = openDb(":memory:");
343
+ const catalog = createCatalog(cfg, upstream, db);
344
+
345
+ const first = await catalog.get();
346
+ expect(catalog.lastShrink?.()).toBeNull();
347
+
348
+ // The shrink is ADOPTED (the key decides what may be dispatched) but flagged.
349
+ const second = await catalog.refresh();
350
+ expect(second.models.length).toBe(4);
351
+ const shrink = catalog.lastShrink?.();
352
+ expect(shrink).not.toBeNull();
353
+ expect(shrink!.fromModels).toBe(first.models.length);
354
+ expect(shrink!.toModels).toBe(4);
355
+
356
+ // Recovery clears it.
357
+ const third = await catalog.refresh();
358
+ expect(third.models.length).toBe(first.models.length);
359
+ expect(catalog.lastShrink?.()).toBeNull();
360
+ db.close();
361
+ });
362
+
319
363
  });
@@ -263,15 +263,51 @@ describe("scoreHeuristic", () => {
263
263
  expect(scoreHeuristic(contFeatures(400), BASE).tier).toBe("moderate");
264
264
  });
265
265
 
266
- test("a circular tool call on a deep loop escalates to hard", () => {
267
- // The stuck signal raw depth misses: a prior call re-issued verbatim.
268
- const deepCircular = scoreHeuristic(contFeatures(90, { circularToolCall: true }), BASE);
266
+ test("a circular tool call on a FRESH turn escalates to hard", () => {
267
+ // Off a continuation the stuck signal keeps full weight: the user is
268
+ // watching a live loop and a pricier model may actually break it.
269
+ const deepCircular = scoreHeuristic(
270
+ { ...contFeatures(90, { circularToolCall: true }), isToolResultContinuation: false },
271
+ BASE,
272
+ );
269
273
  expect(deepCircular.tier).toBe("hard");
270
274
  });
271
275
 
272
- test("a failing tool result on a deep loop is at least moderate", () => {
276
+ test("a circular tool call on a mechanical continuation is damped, not hard", () => {
277
+ // Measured: hard escalations on circular calls never shortened the loop
278
+ // (chain means identical, 5.74 turns, hard vs moderate). 22 of 27 such
279
+ // hard turns were mechanical continuations paying up to 6x for nothing.
280
+ const retry = scoreHeuristic(contFeatures(90, { circularToolCall: true }), BASE);
281
+ const plain = scoreHeuristic(contFeatures(90), BASE);
282
+ expect(tierIdx(retry.tier)).toBeLessThanOrEqual(tierIdx("moderate"));
283
+ // Exactly the damped weight, not merely "less than the full one".
284
+ expect(retry.score - plain.score).toBeCloseTo(BASE.classifier.mechanicalRetryFactor * 0.24, 5);
285
+ });
286
+
287
+ test("a failing tool result on a deep loop is at least simple", () => {
288
+ // Was 'at least moderate' before the mechanical-retry damp: the flat
289
+ // +0.26 pushed deep mechanical retry loops into hard. A damped retry
290
+ // still clears trivial.
273
291
  const deepAndFailing = scoreHeuristic(contFeatures(20, { lastToolFailed: true }), BASE);
274
- expect(tierIdx(deepAndFailing.tier)).toBeGreaterThanOrEqual(tierIdx("moderate"));
292
+ expect(tierIdx(deepAndFailing.tier)).toBeGreaterThanOrEqual(tierIdx("simple"));
293
+ });
294
+
295
+ test("a failed-tool retry on a mechanical continuation is damped, not hard", () => {
296
+ // A retry after a failed tool call is the most mechanical turn there is;
297
+ // the flat +0.26 let automated retry loops buy the hard tier ($7.02 of one
298
+ // measured day vs $0.19 for the same rows as moderate picks). The
299
+ // continuation keeps only mechanicalRetryFactor of the weight.
300
+ const retry = scoreHeuristic(contFeatures(20, { lastToolFailed: true }), BASE);
301
+ const quiet = scoreHeuristic(contFeatures(20), BASE);
302
+ expect(tierIdx(retry.tier)).toBeLessThan(tierIdx("hard"));
303
+ expect(retry.score - quiet.score).toBeCloseTo(
304
+ BASE.classifier.mechanicalRetryFactor * 0.26,
305
+ 5,
306
+ );
307
+ // A failure the USER sees (not a tool-result continuation) keeps the full
308
+ // weight: that genuinely changes what the turn needs.
309
+ const userSeen = scoreHeuristic(contFeatures(2, { isToolResultContinuation: false, lastToolFailed: true }), BASE);
310
+ expect(userSeen.score - scoreHeuristic(contFeatures(2, { isToolResultContinuation: false }), BASE).score).toBeCloseTo(0.26, 5);
275
311
  });
276
312
  });
277
313
 
@@ -9,6 +9,7 @@ const CFG: CompactionConfig = {
9
9
  enabled: true,
10
10
  budgetTokens: 1,
11
11
  floorRatio: 1,
12
+ replanGrowthRatio: 1,
12
13
  fitToWindow: false,
13
14
  protectRecentTurns: 2,
14
15
  maxToolResultBytes: 50,
@@ -8,6 +8,7 @@ import { writeRouterConfig } from "../src/cli/config-cmd.ts";
8
8
  import {
9
9
  applyAnswers,
10
10
  CLEAR_TOKEN,
11
+ displayValue,
11
12
  formatValue,
12
13
  getPath,
13
14
  mergeConfigPartial,
@@ -20,6 +21,7 @@ import {
20
21
  type FieldSpec,
21
22
  type WizardIo,
22
23
  } from "../src/cli/config-wizard.ts";
24
+ import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
23
25
  import { loadConfig } from "../src/config/load.ts";
24
26
 
25
27
  const cfg = loadConfig({});
@@ -113,6 +115,78 @@ describe("validateField", () => {
113
115
  expect(validateField(opt, CLEAR_TOKEN)).toEqual({ ok: true, value: null });
114
116
  expect(validateField(num, CLEAR_TOKEN).ok).toBe(false);
115
117
  });
118
+
119
+ test("a restricted string array rejects unknown items", () => {
120
+ const tiers: FieldSpec = { path: "escalation.probeTiers", label: "t", kind: "stringArray", options: ["trivial", "simple"] };
121
+ expect(validateField(tiers, "simple, trivial")).toEqual({ ok: true, value: ["simple", "trivial"] });
122
+ const bad = validateField(tiers, "simple, hard");
123
+ expect(bad.ok).toBe(false);
124
+ if (!bad.ok) expect(bad.error).toContain("hard");
125
+ });
126
+
127
+ test("number arrays parse, bound-check and reject non-numbers", () => {
128
+ const arms: FieldSpec = { path: "exploration.holdTurns.values", label: "a", kind: "numberArray", min: 1 };
129
+ expect(validateField(arms, "2, 3,4")).toEqual({ ok: true, value: [2, 3, 4] });
130
+ expect(validateField(arms, "2, x").ok).toBe(false);
131
+ expect(validateField(arms, "0, 2").ok).toBe(false);
132
+ });
133
+
134
+ test("secrets display as set/unset", () => {
135
+ const key: FieldSpec = { path: "openrouter.apiKey", label: "k", kind: "string", optional: true, secret: true };
136
+ expect(displayValue(key, "sk-abc")).toBe("set");
137
+ expect(displayValue(key, "")).toBe("unset");
138
+ expect(displayValue(key, undefined)).toBe("unset");
139
+ expect(displayValue({ ...key, secret: false }, "sk-abc")).toBe("sk-abc");
140
+ });
141
+ });
142
+
143
+ describe("WIZARD_SECTIONS coverage", () => {
144
+ /** Leaves that are edited as whole records/arrays rather than fields. */
145
+ const RECORD_PATHS = new Set(["ollama.prices", "ollama.twins", "profiles"]);
146
+
147
+ function leaves(obj: unknown, prefix = ""): string[] {
148
+ if (typeof obj !== "object" || obj === null || Array.isArray(obj)) return [prefix];
149
+ const out: string[] = [];
150
+ for (const [k, v] of Object.entries(obj)) {
151
+ const path = prefix === "" ? k : `${prefix}.${k}`;
152
+ if (RECORD_PATHS.has(path)) continue;
153
+ out.push(...leaves(v, path));
154
+ }
155
+ return out;
156
+ }
157
+
158
+ test("every config leaf has a wizard field (so /router can edit all of it)", () => {
159
+ const fields = new Set(WIZARD_SECTIONS.flatMap((s) => s.fields.map((f) => f.path)));
160
+ const missing = leaves(DEFAULT_CONFIG).filter((p) => !fields.has(p));
161
+ expect(missing).toEqual([]);
162
+ });
163
+
164
+ test("every wizard field points at a real config path or a known optional", () => {
165
+ // Optional keys absent from DEFAULT_CONFIG still have to be spelled right;
166
+ // they are listed here so a typo in a new field path fails loudly.
167
+ const KNOWN_OPTIONAL = new Set([
168
+ "server.apiKey", "server.harnessId", "openrouter.referer",
169
+ "budget.perTurnUsd", "budget.perConversationUsd", "budget.perDayUsd", "filters.maxExpectedWaitMs",
170
+ ...["trivial", "simple", "moderate", "hard"].flatMap((t) => [
171
+ `tiers.${t}.maxInputPerMtok`, `tiers.${t}.maxOutputPerMtok`, `tiers.${t}.qualityNormalization`, `tiers.${t}.capabilityFloorUsd`,
172
+ ]),
173
+ ...["coding", "vision", "documentation", "data", "chat"].flatMap((t) => [`tasks.${t}.minQuality`, `tasks.${t}.requireImage`, `tasks.${t}.prefer`]),
174
+ ...["trivial", "simple", "moderate", "hard"].map((t) => `exploration.rates.${t}`),
175
+ ]);
176
+ const known = new Set(leaves(DEFAULT_CONFIG));
177
+ const unknown = WIZARD_SECTIONS.flatMap((s) => s.fields.map((f) => f.path)).filter((p) => !known.has(p) && !KNOWN_OPTIONAL.has(p));
178
+ expect(unknown).toEqual([]);
179
+ });
180
+
181
+ test("field paths are unique across sections", () => {
182
+ const all = WIZARD_SECTIONS.flatMap((s) => s.fields.map((f) => f.path));
183
+ expect(new Set(all).size).toBe(all.length);
184
+ });
185
+
186
+ test("secrets are exactly the credential keys", () => {
187
+ const secrets = WIZARD_SECTIONS.flatMap((s) => s.fields.filter((f) => f.secret === true).map((f) => f.path)).sort();
188
+ expect(secrets).toEqual(["benchmarks.artificialAnalysisApiKey", "context.token", "ollama.apiKey", "openrouter.apiKey", "server.apiKey"]);
189
+ });
116
190
  });
117
191
 
118
192
  describe("applyAnswers", () => {
@@ -198,13 +272,14 @@ describe("runWizard", () => {
198
272
  });
199
273
 
200
274
  test("blank answers keep current values; only edits are written", async () => {
201
- // Server section: host, port, apiKey, harnessId.
275
+ // Server section: host, port, apiKey, harnessId, maxConcurrentTurns.
202
276
  const { partial, changed } = await drive([
203
277
  SECTION.get("Server") ?? "",
204
278
  "", // keep host
205
279
  "9000", // change port
206
280
  "", // keep apiKey
207
281
  "", // keep harnessId
282
+ "", // keep maxConcurrentTurns
208
283
  "s",
209
284
  ]);
210
285
  expect(partial).toEqual({ server: { port: 9000 } });
@@ -248,6 +323,7 @@ describe("runWizard", () => {
248
323
  "n", // injectBreakpoints
249
324
  "", // maxBreakpoints
250
325
  "", // minPromptTokens
326
+ "", // milestoneTokens
251
327
  "s",
252
328
  ]);
253
329
  expect(partial).toEqual({ logLevel: "warn", cache: { injectBreakpoints: false } });