auto-model-router 0.2.32 → 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 (67) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +208 -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 +27 -0
  26. package/src/config/types.ts +114 -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 +52 -4
  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 +10 -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 +151 -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
@@ -183,6 +183,8 @@ export function select(args: SelectArgs): Decision {
183
183
  // Overshooting buys several byte-stable turns per plan change.
184
184
  let compactionPlan: CompactionEdit[] = [];
185
185
  let promptTokensSaved = 0;
186
+ let compactionSavedBytes = 0;
187
+ let compactionPlanTokens = state.compactionPlanTokens ?? 0;
186
188
  let effFeatures = features;
187
189
  if (cfg.compaction.enabled && req.promptBytes > 0 && features.promptTokens > 0) {
188
190
  const bytesPerToken = req.promptBytes / features.promptTokens;
@@ -197,8 +199,19 @@ export function select(args: SelectArgs): Decision {
197
199
  const overBudget = compactedTokens > cfg.compaction.budgetTokens;
198
200
  const overWindow =
199
201
  cfg.compaction.fitToWindow && compactedTokens * headroom + EXPECTED_COMPLETION_TOKENS > profile.contextWindow;
202
+ // Rationing: when the budget is unreachable (the observed case — compacted
203
+ // prompts of 100-160k against a 40k budget) `overBudget` is true on every
204
+ // turn, and a plan gains an edit the moment a tool result ages out of the
205
+ // protected window, so `floorRatio` never gets to hold a plan. Instead,
206
+ // extend an existing plan only once the compacted prompt has grown by
207
+ // `replanGrowthRatio` since it was made. Never rations the window fit.
208
+ const rationed =
209
+ cfg.compaction.replanGrowthRatio > 1 &&
210
+ carried.length > 0 &&
211
+ compactionPlanTokens > 0 &&
212
+ compactedTokens < compactionPlanTokens * cfg.compaction.replanGrowthRatio;
200
213
  let plan: CompactionResult = { edits: carried, savedBytes: carriedSavedBytes };
201
- if (overBudget || overWindow) {
214
+ if (overWindow || (overBudget && !rationed)) {
202
215
  const targets: number[] = [];
203
216
  // Overshoot the budget so the next re-plan is several turns away.
204
217
  if (overBudget) targets.push(Math.max(1, Math.floor(cfg.compaction.budgetTokens * cfg.compaction.floorRatio)));
@@ -208,12 +221,18 @@ export function select(args: SelectArgs): Decision {
208
221
  }
209
222
  if (plan.edits.length > 0) {
210
223
  compactionPlan = [...plan.edits];
224
+ compactionSavedBytes = plan.savedBytes;
211
225
  promptTokensSaved = tokensOf(plan.savedBytes);
212
226
  effFeatures = { ...features, promptTokens: features.promptTokens - promptTokensSaved };
213
227
  const added = plan.edits.length - carried.length;
228
+ // A plan that gained edits was made at THIS compacted size; a carried
229
+ // plan keeps the size it was made at, so growth accrues against it.
230
+ if (added > 0 || compactionPlanTokens === 0) compactionPlanTokens = effFeatures.promptTokens;
214
231
  reasons.push(
215
- `compaction: ${plan.edits.length} tool result(s) shrunk (${carried.length} carried, ${added} new), ~${promptTokensSaved} tokens saved (prompt ${features.promptTokens}→${effFeatures.promptTokens})`,
232
+ `compaction: ${plan.edits.length} tool result(s) shrunk (${carried.length} carried, ${added} new), ~${promptTokensSaved} tokens saved (prompt ${features.promptTokens}→${effFeatures.promptTokens})${rationed ? " [re-plan rationed]" : ""}`,
216
233
  );
234
+ } else {
235
+ compactionPlanTokens = 0;
217
236
  }
218
237
  }
219
238
 
@@ -268,6 +287,19 @@ export function select(args: SelectArgs): Decision {
268
287
  // The task type selects the quality axis and capability filters; the tier
269
288
  // still bounds cost (task selects, tier budgets).
270
289
  const warmSlug = cacheWarm ? state.cacheWarmSlug : null;
290
+ // Pre-fetch trust/latency signals for all candidate slugs in one batch
291
+ // query per signal kind, instead of per-model individual lookups.
292
+ const candidateSignals =
293
+ ledger !== null && snapshot.models.length > 0
294
+ ? ledger.signals?.(snapshot.models.map((m) => m.slug), cfg.filters.trustScopedByHarness ? req.harnessId : undefined)
295
+ : undefined;
296
+ // What an escalated retry has actually been billing per prompt token, for
297
+ // the escalation-cost term in candidate scoring. Read once per turn; null
298
+ // (term inert) when the weight is 0 or the ledger has too few samples.
299
+ const escalationUsdPerPromptToken =
300
+ cfg.filters.escalationCostWeight > 0
301
+ ? (ledger?.escalationCost?.(cfg.ledger.blendWindowDays)?.usdPerPromptToken ?? null)
302
+ : null;
271
303
  const build = (t: Tier, relaxLevel = 0): { candidates: Candidate[]; rejected: Rejection[] } =>
272
304
  buildCandidates({
273
305
  req,
@@ -281,12 +313,13 @@ export function select(args: SelectArgs): Decision {
281
313
  warmSlug,
282
314
  relaxLevel,
283
315
  ...(args.excludeSlugs === undefined ? {} : { excludeSlugs: args.excludeSlugs }),
316
+ ...(candidateSignals === undefined ? {} : { signals: candidateSignals }),
317
+ ...(escalationUsdPerPromptToken === null ? {} : { escalationUsdPerPromptToken }),
284
318
  });
285
319
  let chosenTier = effective;
286
320
  let built: { candidates: Candidate[]; rejected: Rejection[] } | null = null;
287
321
  // Relax level the tier rescue used (0 = no rescue). The budget downgrade
288
322
  // search must rebuild at the same level, or it re-applies the strict config
289
- // that excluded every model and throws instead of downgrading.
290
323
  let rescuedRelax = 0;
291
324
  for (const t of wideningOrder(effective, profile.minTier, profile.maxTier)) {
292
325
  const b = build(t);
@@ -354,17 +387,26 @@ export function select(args: SelectArgs): Decision {
354
387
  if (warm !== undefined) {
355
388
  const warmPrice = priceAt(warm.model, Math.max(1, state.lastPromptTokens));
356
389
  const newPrice = priceAt(chosen.model, Math.max(1, effFeatures.promptTokens));
357
- const stayCost = state.lastPromptTokens * (warmPrice.cacheRead ?? warmPrice.prompt);
358
- const switchCost = effFeatures.promptTokens * (newPrice.prompt + (newPrice.cacheWrite ?? 0));
390
+ const stayWarm = state.lastPromptTokens * (warmPrice.cacheRead ?? warmPrice.prompt);
391
+ const switchCold = effFeatures.promptTokens * (newPrice.prompt + (newPrice.cacheWrite ?? 0));
392
+ const newWarm = effFeatures.promptTokens * (newPrice.cacheRead ?? newPrice.prompt);
393
+ // Amortise over the horizon: H turns of staying warm against one cold
394
+ // switch plus H−1 turns warm on the new model. H = 1 is the one-turn
395
+ // comparison, which kept a 25x-priced model warm for a 33-dispatch run
396
+ // because no single turn could recoup the cold write on its own.
397
+ const horizon = Math.max(1, cfg.hysteresis.switchHorizonTurns);
398
+ const stayCost = horizon * stayWarm;
399
+ const switchCost = switchCold + (horizon - 1) * newWarm;
400
+ const over = horizon > 1 ? ` over ${horizon} turns` : "";
359
401
  if (stayCost > switchCost * cfg.hysteresis.switchMargin) {
360
402
  reasons.push(
361
- `cache: switch ${warmSlug} → ${chosen.model.slug} (stay $${stayCost.toFixed(4)} > switch $${switchCost.toFixed(4)} × ${cfg.hysteresis.switchMargin})`,
403
+ `cache: switch ${warmSlug} → ${chosen.model.slug} (stay $${stayCost.toFixed(4)} > switch $${switchCost.toFixed(4)} × ${cfg.hysteresis.switchMargin}${over})`,
362
404
  );
363
405
  } else {
364
406
  chosen = warm;
365
407
  sticky = true;
366
408
  reasons.push(
367
- `cache: keeping warm ${warmSlug} (stay $${stayCost.toFixed(4)} ≤ switch $${switchCost.toFixed(4)} × ${cfg.hysteresis.switchMargin})`,
409
+ `cache: keeping warm ${warmSlug} (stay $${stayCost.toFixed(4)} ≤ switch $${switchCost.toFixed(4)} × ${cfg.hysteresis.switchMargin}${over})`,
368
410
  );
369
411
  }
370
412
  }
@@ -377,6 +419,7 @@ export function select(args: SelectArgs): Decision {
377
419
  perDayUsd: profile.budget?.perDayUsd ?? cfg.budget.perDayUsd,
378
420
  onExceeded: profile.budget?.onExceeded ?? cfg.budget.onExceeded,
379
421
  };
422
+ const daySpend = budget.perDayUsd !== undefined ? (ledger?.spendSince(nowMs - DAY_MS, req.harnessId) ?? 0) : 0;
380
423
  const breach = (c: Candidate): string | null => {
381
424
  if (budget.perTurnUsd !== undefined && c.forecast.coldUsd > budget.perTurnUsd) {
382
425
  return `cold forecast $${c.forecast.coldUsd.toFixed(4)} > per-turn budget $${budget.perTurnUsd}`;
@@ -388,7 +431,6 @@ export function select(args: SelectArgs): Decision {
388
431
  // Scope the rolling 24h ceiling to the requesting harness when it
389
432
  // identifies itself, so multiple harnesses sharing one router each get
390
433
  // their own daily budget instead of one exhausting it for the others.
391
- const daySpend = ledger?.spendSince(nowMs - DAY_MS, req.harnessId) ?? 0;
392
434
  if (daySpend + c.forecast.coldUsd > budget.perDayUsd) {
393
435
  return `24h spend $${daySpend.toFixed(4)} + cold forecast > per-day budget $${budget.perDayUsd}`;
394
436
  }
@@ -425,6 +467,9 @@ export function select(args: SelectArgs): Decision {
425
467
  const fallbacks: string[] = [];
426
468
  for (const c of candidates) {
427
469
  if (c.model.slug === chosen.model.slug) continue;
470
+ // The cascade is served by ONE upstream: an OpenRouter `models[]` array
471
+ // cannot name an Ollama model and vice versa.
472
+ if (c.model.provider !== chosen.model.provider) continue;
428
473
  fallbacks.push(c.model.slug);
429
474
  if (fallbacks.length >= 2) break;
430
475
  }
@@ -475,6 +520,8 @@ export function select(args: SelectArgs): Decision {
475
520
  cacheBreakpointMessageIndices,
476
521
  compactionPlan,
477
522
  promptTokensSaved,
523
+ compactionSavedBytes,
524
+ compactionPlanTokens,
478
525
  reasoning,
479
526
  maxTokens,
480
527
  stripAssistantReasoning,
@@ -31,6 +31,7 @@ interface Row {
31
31
  context_version: string | null;
32
32
  context_fetched_at_ms: number;
33
33
  compaction_plan: string | null;
34
+ compaction_plan_tokens: number;
34
35
  updated_at_ms: number;
35
36
  }
36
37
 
@@ -51,6 +52,7 @@ function toState(row: Row): ConversationState {
51
52
  contextVersion: row.context_version,
52
53
  contextFetchedAtMs: row.context_fetched_at_ms,
53
54
  compactionPlan: row.compaction_plan === null ? null : (JSON.parse(row.compaction_plan) as CompactionEdit[]),
55
+ compactionPlanTokens: row.compaction_plan_tokens,
54
56
  updatedAtMs: row.updated_at_ms,
55
57
  };
56
58
  }
@@ -69,10 +71,10 @@ export function createConversationStore(db: Database): ConversationStore {
69
71
  INSERT INTO conversations (
70
72
  key, session_id, turn, current_slug, current_tier, sticky_until_turn,
71
73
  last_prompt_tokens, cache_warm_slug, cache_warm_at_ms,
72
- context_version, context_fetched_at_ms, compaction_plan, updated_at_ms
74
+ context_version, context_fetched_at_ms, compaction_plan, compaction_plan_tokens, updated_at_ms
73
75
  ) VALUES ($key, $sessionId, $turn, $currentSlug, $currentTier, $stickyUntilTurn,
74
76
  $lastPromptTokens, $cacheWarmSlug, $cacheWarmAtMs,
75
- $contextVersion, $contextFetchedAtMs, $compactionPlan, $updatedAtMs)
77
+ $contextVersion, $contextFetchedAtMs, $compactionPlan, $compactionPlanTokens, $updatedAtMs)
76
78
  ON CONFLICT(key) DO UPDATE SET
77
79
  session_id = excluded.session_id,
78
80
  turn = excluded.turn,
@@ -85,6 +87,7 @@ export function createConversationStore(db: Database): ConversationStore {
85
87
  context_version = excluded.context_version,
86
88
  context_fetched_at_ms = excluded.context_fetched_at_ms,
87
89
  compaction_plan = excluded.compaction_plan,
90
+ compaction_plan_tokens = excluded.compaction_plan_tokens,
88
91
  updated_at_ms = excluded.updated_at_ms
89
92
  `);
90
93
  // Read-modify-write in JS lost money: an aborted or failed dispatch is still
@@ -133,6 +136,7 @@ export function createConversationStore(db: Database): ConversationStore {
133
136
  $stickyUntilTurn: state.stickyUntilTurn,
134
137
  $lastPromptTokens: state.lastPromptTokens,
135
138
  $compactionPlan: state.compactionPlan === null ? null : JSON.stringify(state.compactionPlan),
139
+ $compactionPlanTokens: state.compactionPlanTokens ?? 0,
136
140
  $cacheWarmSlug: state.cacheWarmSlug,
137
141
  $cacheWarmAtMs: state.cacheWarmAtMs,
138
142
  $contextVersion: state.contextVersion,
@@ -13,17 +13,26 @@
13
13
  *
14
14
  * The fix is to treat the floors as relative when the absolute ones cannot be
15
15
  * met. Rank the available scored models, split them into four quantile bands,
16
- * and take each band's lower bound as that tier's adaptive floor. The effective
17
- * floor is then `min(configured, adaptive)`:
16
+ * and take each band's lower bound as that tier's adaptive floor. The adaptive
17
+ * floor applies ONLY when the configured one leaves the tier thin:
18
18
  *
19
- * - A healthy catalog keeps the configured floors verbatim (the adaptive floor
20
- * sits above them, so `min` picks the configured value) — no behaviour change.
21
- * - A narrowed catalog falls back to the adaptive floor, so `hard` still gets
22
- * the best quartile of what is available instead of nothing at all.
19
+ * - When at least `MIN_FLOOR_ADMITS` rankable models meet the configured
20
+ * floor, the configured floor stands verbatim — no behaviour change.
21
+ * - When fewer do (a narrowed catalog), the effective floor is
22
+ * `min(configured, adaptive)`, so `hard` still gets the best quartile of
23
+ * what is available instead of nothing at all.
23
24
  *
24
- * `min` is deliberate: adaptive floors may only RELAX a floor, never tighten
25
- * one. Tightening would let a rich catalog silently price us out of a tier the
26
- * operator explicitly configured.
25
+ * The thinness gate is load-bearing. An earlier version applied `min` always,
26
+ * on the assumption that a healthy catalog's bands sit above the configured
27
+ * floors. They do not: a wide catalog carries a long tail of weak scored models
28
+ * (measured on 347 key-admitted models: coding p50 = 45.8 against a configured
29
+ * `moderate` floor of 60, p75 = 59.9 against `hard`'s 72), so `min` silently
30
+ * relaxed every tier and a coding-50 model won `moderate` — then escalated 14x
31
+ * more often than the model the configured floor would have picked.
32
+ *
33
+ * Adaptive floors may only RELAX a floor, never tighten one. Tightening would
34
+ * let a rich catalog silently price us out of a tier the operator explicitly
35
+ * configured.
27
36
  *
28
37
  * Unscored models are never imputed a score (see `candidates.ts`), so a catalog
29
38
  * with no benchmarks at all yields all-zero floors: every tier admits every
@@ -46,10 +55,21 @@ export interface TierPlan {
46
55
  floors: Record<QualityAxis, AxisFloors>;
47
56
  /** How many available models carried a score on each axis. */
48
57
  scoredCount: Record<QualityAxis, number>;
58
+ /** Rankable models' scores per axis, ascending — what the floors were cut from. */
59
+ scores: Record<QualityAxis, readonly number[]>;
49
60
  /** Adaptive input-price ceiling ($/Mtok) per tier, from the catalog's price spread. */
50
61
  priceCeilings: Record<Tier, number>;
51
62
  }
52
63
 
64
+ /**
65
+ * Rankable models a configured floor must admit for it to stand as written.
66
+ * Below this the tier is "thin" and the adaptive band may relax the floor.
67
+ * Three, not one: a lone survivor leaves no same-tier failover and no
68
+ * meaningful cost ranking, which is the narrowed-catalog situation this whole
69
+ * module exists to escape.
70
+ */
71
+ export const MIN_FLOOR_ADMITS = 3;
72
+
53
73
  /**
54
74
  * Models that could plausibly serve a turn, for ranking purposes: the built-in
55
75
  * denials (floating aliases, batch endpoints, stealth, meta-routers) and free
@@ -121,6 +141,7 @@ function bandCeilings(ascending: readonly number[]): Record<Tier, number> {
121
141
  export function computeTierPlan(models: readonly CatalogModel[], cfg: RouterConfig): TierPlan {
122
142
  const floors: Record<string, AxisFloors> = {};
123
143
  const scoredCount: Record<string, number> = {};
144
+ const axisScores: Record<string, readonly number[]> = {};
124
145
  const includeFree = cfg.filters.includeFree;
125
146
 
126
147
  for (const axis of AXES) {
@@ -134,6 +155,7 @@ export function computeTierPlan(models: readonly CatalogModel[], cfg: RouterConf
134
155
  scores.sort((a, b) => a - b);
135
156
  floors[axis] = bandFloors(scores);
136
157
  scoredCount[axis] = scores.length;
158
+ axisScores[axis] = scores;
137
159
  }
138
160
  // Input prices ($/Mtok) of the rankable models, ascending, for the ceilings.
139
161
  const prices: number[] = [];
@@ -146,10 +168,24 @@ export function computeTierPlan(models: readonly CatalogModel[], cfg: RouterConf
146
168
  return {
147
169
  floors: floors as Record<QualityAxis, AxisFloors>,
148
170
  scoredCount: scoredCount as Record<QualityAxis, number>,
171
+ scores: axisScores as Record<QualityAxis, readonly number[]>,
149
172
  priceCeilings: bandCeilings(prices),
150
173
  };
151
174
  }
152
175
 
176
+ /** Rankable models scoring at least `floor` on an axis. `scores` is ascending. */
177
+ export function countAdmitted(scores: readonly number[], floor: number): number {
178
+ // Binary search for the first score >= floor; everything after it qualifies.
179
+ let lo = 0;
180
+ let hi = scores.length;
181
+ while (lo < hi) {
182
+ const mid = (lo + hi) >> 1;
183
+ if ((scores[mid] ?? 0) < floor) lo = mid + 1;
184
+ else hi = mid;
185
+ }
186
+ return scores.length - lo;
187
+ }
188
+
153
189
  /**
154
190
  * Memoized per snapshot object AND config object. A catalog refresh installs a
155
191
  * fresh `CatalogSnapshot`, which misses the cache and recomputes the plan — so
@@ -173,8 +209,9 @@ export function tierPlanFor(snapshot: CatalogSnapshot, cfg: RouterConfig): TierP
173
209
  }
174
210
 
175
211
  /**
176
- * The floor to actually enforce for a tier on an axis. Never tightens the
177
- * configured floor; only relaxes it when the available catalog cannot meet it.
212
+ * The floor to actually enforce for a tier on an axis. The configured floor
213
+ * stands whenever at least MIN_FLOOR_ADMITS rankable models meet it; only a
214
+ * thin tier is relaxed to the adaptive band, and never tightened.
178
215
  */
179
216
  export function effectiveQualityFloor(
180
217
  configured: number,
@@ -182,6 +219,7 @@ export function effectiveQualityFloor(
182
219
  axis: QualityAxis,
183
220
  plan: TierPlan,
184
221
  ): number {
222
+ if (countAdmitted(plan.scores[axis], configured) >= MIN_FLOOR_ADMITS) return configured;
185
223
  const adaptive = plan.floors[axis][tier];
186
224
  return Math.min(configured, adaptive);
187
225
  }
@@ -178,6 +178,12 @@ export interface ConversationState {
178
178
  * the prompt cache and un-saves the tokens. Fresh planning only extends it.
179
179
  */
180
180
  compactionPlan: CompactionEdit[] | null;
181
+ /**
182
+ * Compacted prompt size (tokens) at which `compactionPlan` was last made.
183
+ * Re-planning is rationed against growth from this point
184
+ * (`compaction.replanGrowthRatio`). 0 / absent = unknown.
185
+ */
186
+ compactionPlanTokens?: number;
181
187
  /** When that block was fetched, for the staleness TTL. */
182
188
  contextFetchedAtMs: number;
183
189
  updatedAtMs: number;
@@ -247,6 +253,10 @@ export interface Decision {
247
253
  compactionPlan: CompactionEdit[];
248
254
  /** Estimated prompt tokens removed by `compactionPlan`, for the ledger. */
249
255
  promptTokensSaved: number;
256
+ /** Prompt bytes `compactionPlan` removes; the dispatched size is `req.promptBytes` minus this (plus any context block). */
257
+ compactionSavedBytes: number;
258
+ /** Compacted prompt size the plan was (last) made at, persisted for re-plan rationing. */
259
+ compactionPlanTokens: number;
250
260
  reasoning: ReasoningLevel | undefined;
251
261
  maxTokens: number | undefined;
252
262
  stripAssistantReasoning: boolean;
@@ -1,15 +1,15 @@
1
1
  import { mkdirSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import type { Server } from "bun";
4
- import { createCatalog } from "../catalog/openrouter-catalog.ts";
4
+ import { createProviders } from "./providers.ts";
5
5
  import { createBridgeFromConfig } from "../context/index.ts";
6
6
  import { createLedger } from "../cost/ledger.ts";
7
+ import { buildUsageReport } from "../cost/report.ts";
7
8
  import type { Ledger, ModelTrust } from "../cost/types.ts";
8
9
  import { createRouter } from "../router/index.ts";
9
10
  import { createConversationStore } from "../router/state.ts";
10
- import { createOpenRouterClient } from "../upstream/openrouter.ts";
11
11
  import { UpstreamError } from "../upstream/types.ts";
12
- import { apiKeySource } from "../config/load.ts";
12
+ import { apiKeySource, ollamaKeySource } from "../config/load.ts";
13
13
  import { routerConfigPath } from "../cli/config-cmd.ts";
14
14
  import { watchConfig } from "../config/hot-reload.ts";
15
15
  import type { RouterConfig } from "../config/types.ts";
@@ -172,8 +172,7 @@ export function startServer(cfg: RouterConfig): StartedServer {
172
172
  if (cfg.ledger.path !== ":memory:") mkdirSync(dirname(cfg.ledger.path), { recursive: true });
173
173
  const db = openDb(cfg.ledger.path);
174
174
  const ledger = createLedger(db, cfg);
175
- const upstream = createOpenRouterClient(cfg);
176
- const catalog = createCatalog(cfg, upstream, db);
175
+ const { upstream, catalog, ollama, ollamaUsage } = createProviders(cfg, db, log);
177
176
  const conversations = createConversationStore(db);
178
177
  const router = createRouter({ config: cfg, catalog, ledger, conversations, upstream });
179
178
  const context = createBridgeFromConfig(cfg, db);
@@ -189,7 +188,7 @@ export function startServer(cfg: RouterConfig): StartedServer {
189
188
  routerConfigPath(),
190
189
  cfg,
191
190
  pinned,
192
- ["server", "openrouter", "context", "ledger"],
191
+ ["server", "openrouter", "ollama", "context", "ledger"],
193
192
  {
194
193
  onReload: ({ changed }) => {
195
194
  log.info("config reloaded", { changed: changed.join(", ") });
@@ -211,6 +210,14 @@ export function startServer(cfg: RouterConfig): StartedServer {
211
210
  if (cfg.openrouter.apiKey === "") {
212
211
  log.warn("OPENROUTER_API_KEY is not set; /v1/chat/completions will fail at dispatch time");
213
212
  }
213
+ if (ollama !== null) {
214
+ log.info("ollama cloud upstream enabled", {
215
+ baseUrl: cfg.ollama.baseUrl,
216
+ // Provenance only; never the key itself.
217
+ apiKeySource: ollamaKeySource(cfg).source,
218
+ costBias: cfg.ollama.costBias,
219
+ });
220
+ }
214
221
 
215
222
  // Warm the catalog without blocking listen; the first request may race it,
216
223
  // which CatalogSource.get() already serializes.
@@ -281,6 +288,10 @@ export function startServer(cfg: RouterConfig): StartedServer {
281
288
  try {
282
289
  normReq = parseChatRequest(await req.json(), req.headers);
283
290
  } catch (err) {
291
+ // The slot was acquired before parsing; a rejected body never reaches
292
+ // runTurn's `finally`, so it must be released here or every malformed
293
+ // request permanently consumes one of maxConcurrentTurns.
294
+ releaseTurn();
284
295
  if (err instanceof WireErrorException) return wireErrorResponse(err.wireError);
285
296
  return wireErrorResponse({
286
297
  status: 400,
@@ -358,6 +369,15 @@ export function startServer(cfg: RouterConfig): StartedServer {
358
369
  if (req.method === "GET" && url.pathname === "/v1/router/stats") {
359
370
  return json(computeStats(ledger));
360
371
  }
372
+ if (req.method === "GET" && url.pathname === "/v1/router/report") {
373
+ // Usage analytics for `/router report` and the CLI: bounded window,
374
+ // optional harness scope (the X-Omp-Harness header value).
375
+ const rawDays = url.searchParams.get("days");
376
+ const parsedDays = rawDays === null ? 7 : Number.parseInt(rawDays, 10);
377
+ const windowDays = Number.isInteger(parsedDays) ? Math.min(Math.max(parsedDays, 1), 365) : 7;
378
+ const harnessId = url.searchParams.get("harness") ?? "";
379
+ return json(buildUsageReport(db, { windowDays, harnessId }));
380
+ }
361
381
  if (req.method === "GET" && url.pathname === "/v1/router/decisions") {
362
382
  const rawLimit = url.searchParams.get("limit");
363
383
  const parsed = rawLimit === null ? 50 : Number.parseInt(rawLimit, 10);
@@ -375,6 +395,23 @@ export function startServer(cfg: RouterConfig): StartedServer {
375
395
  agentdox: context.enabled
376
396
  ? { url: cfg.context.baseUrl, defaultScope: cfg.context.defaultScope, recordTurns: cfg.context.recordTurns }
377
397
  : null,
398
+ // Never the key. `available` is the circuit breaker: false while a
399
+ // 402/429 cooldown routes every turn around Ollama.
400
+ ollama:
401
+ ollama === null
402
+ ? null
403
+ : {
404
+ baseUrl: cfg.ollama.baseUrl,
405
+ apiKeySource: ollamaKeySource(cfg).source,
406
+ models: catalog.ollamaModels?.().length ?? 0,
407
+ available: ollama.available(),
408
+ cooldownUntilMs: ollama.cooldownUntilMs(),
409
+ lastTrip: ollama.lastTrip(),
410
+ // Plan usage as ollama.com reports it (share of included monthly
411
+ // credits) and the cost multiplier currently in force.
412
+ usage: ollamaUsage.peek(),
413
+ costBias: { configured: cfg.ollama.costBias, effective: catalog.ollamaBias?.() ?? cfg.ollama.costBias, biasUntilUsage: cfg.ollama.biasUntilUsage },
414
+ },
378
415
  catalog: snap === null
379
416
  ? null
380
417
  : {
@@ -382,6 +419,10 @@ export function startServer(cfg: RouterConfig): StartedServer {
382
419
  fetchedAtMs: snap.fetchedAtMs,
383
420
  ageMs: Date.now() - snap.fetchedAtMs,
384
421
  keyScoped: snap.keyScoped === true,
422
+ // Non-null after a refresh kept < half the previous models;
423
+ // the one signal that the key narrowed (or upstream blipped)
424
+ // and routing is now on whatever survived.
425
+ shrink: catalog.lastShrink?.() ?? null,
385
426
  },
386
427
  });
387
428
  }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Upstream + catalog assembly shared by the server and the CLIs, so `models`
3
+ * and `explain` see exactly the catalog a turn would route over — including
4
+ * Ollama Cloud when it is enabled.
5
+ */
6
+
7
+ import type { Database } from "bun:sqlite";
8
+ import { createCompositeCatalog } from "../catalog/composite.ts";
9
+ import { createOllamaCatalog } from "../catalog/ollama-catalog.ts";
10
+ import { createCatalog } from "../catalog/openrouter-catalog.ts";
11
+ import type { CatalogSource } from "../catalog/types.ts";
12
+ import type { RouterConfig } from "../config/types.ts";
13
+ import { createMultiUpstream } from "../upstream/multi.ts";
14
+ import { createOllamaClient, type OllamaClient } from "../upstream/ollama.ts";
15
+ import { createOllamaUsageSource, NO_USAGE, type OllamaUsageSource } from "../upstream/ollama-usage.ts";
16
+ import { createOpenRouterClient } from "../upstream/openrouter.ts";
17
+ import type { UpstreamClient } from "../upstream/types.ts";
18
+ import { createLogger, type Logger } from "../util/log.ts";
19
+
20
+ export interface Providers {
21
+ upstream: UpstreamClient;
22
+ catalog: CatalogSource & { ollamaModels?(): unknown[]; ollamaBias?(): number };
23
+ /** Non-null when Ollama Cloud is enabled; carries the circuit breaker. */
24
+ ollama: OllamaClient | null;
25
+ /** Plan usage reader; inert without a key. */
26
+ ollamaUsage: OllamaUsageSource;
27
+ }
28
+
29
+ export function createProviders(cfg: RouterConfig, db: Database, log: Logger = createLogger(cfg.logLevel)): Providers {
30
+ const openrouter = createOpenRouterClient(cfg);
31
+ const openrouterCatalog = createCatalog(cfg, openrouter, db);
32
+ if (!cfg.ollama.enabled) return { upstream: openrouter, catalog: openrouterCatalog, ollama: null, ollamaUsage: NO_USAGE };
33
+ // Ollama Cloud is a second upstream ranked in the same catalog: `ollama/…`
34
+ // slugs dispatch to it, everything else to OpenRouter.
35
+ const ollama = createOllamaClient(cfg);
36
+ // Plan usage lives on ollama.com whichever base URL dispatches; it needs the
37
+ // key, so the daemon path without `/login ollama-cloud` keeps a static bias.
38
+ const ollamaUsage = createOllamaUsageSource({
39
+ apiKey: cfg.ollama.apiKey,
40
+ pollMs: cfg.ollama.usagePollMs,
41
+ timeoutMs: Math.min(cfg.ollama.timeoutMs, 15_000),
42
+ log,
43
+ });
44
+ return {
45
+ upstream: createMultiUpstream(openrouter, ollama),
46
+ catalog: createCompositeCatalog(openrouterCatalog, createOllamaCatalog(cfg.ollama, log), ollama, {
47
+ costBias: cfg.ollama.costBias,
48
+ biasUntilUsage: cfg.ollama.biasUntilUsage,
49
+ usage: ollamaUsage,
50
+ }),
51
+ ollama,
52
+ ollamaUsage,
53
+ };
54
+ }