auto-model-router 0.3.3 → 0.4.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 (43) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +25 -2
  3. package/omp-extension/report-logic.ts +93 -0
  4. package/omp-extension/router-configure.ts +128 -2
  5. package/package.json +1 -1
  6. package/src/catalog/ollama-catalog.ts +30 -2
  7. package/src/cli/config-wizard.ts +9 -0
  8. package/src/cli/report.ts +7 -2
  9. package/src/config/defaults.ts +12 -0
  10. package/src/config/schema.ts +6 -0
  11. package/src/config/types.ts +54 -0
  12. package/src/cost/feedback.ts +81 -0
  13. package/src/cost/ledger.ts +73 -0
  14. package/src/cost/report.ts +106 -2
  15. package/src/cost/types.ts +28 -0
  16. package/src/router/candidates.ts +13 -4
  17. package/src/router/classify.ts +10 -0
  18. package/src/router/features.ts +20 -1
  19. package/src/router/index.ts +12 -1
  20. package/src/router/learned.ts +202 -0
  21. package/src/router/select.ts +107 -8
  22. package/src/router/state.ts +6 -2
  23. package/src/router/types.ts +40 -1
  24. package/src/server/http.ts +82 -5
  25. package/src/server/overrides.ts +83 -0
  26. package/src/server/providers.ts +10 -2
  27. package/src/server/turn.ts +18 -1
  28. package/src/upstream/ollama-usage.ts +79 -2
  29. package/src/util/sqlite.ts +38 -1
  30. package/test/config-wizard.test.ts +2 -1
  31. package/test/controls.test.ts +223 -0
  32. package/test/failover.test.ts +5 -3
  33. package/test/features.test.ts +31 -0
  34. package/test/learned.test.ts +61 -0
  35. package/test/ollama.test.ts +74 -2
  36. package/test/report-hub.test.ts +4 -2
  37. package/test/report-logic.test.ts +3 -0
  38. package/test/report.test.ts +43 -0
  39. package/test/select.test.ts +180 -1
  40. package/test/trust-attribution.test.ts +58 -2
  41. package/test/turn.test.ts +35 -3
  42. package/tools/replay.ts +266 -156
  43. package/tools/train-classifier.ts +111 -0
@@ -0,0 +1,202 @@
1
+ /**
2
+ * A learned escalation-risk model over the recorded classifier features.
3
+ *
4
+ * The heuristic classifier's weights are hand-set. The ledger holds, for
5
+ * every served turn, the exact feature vector the classifier saw and whether
6
+ * the turn escalated (a cheaper attempt was probe-rejected). That is a
7
+ * labelled dataset — ~13k rows, ~1% positive — and this module is the
8
+ * smallest honest learner for it: standardised features, L2-regularised
9
+ * logistic regression by full-batch gradient descent, evaluated by AUC on a
10
+ * time-ordered holdout so the future is never trained on.
11
+ *
12
+ * Advisory by design. `tools/train-classifier.ts` fits and writes a model
13
+ * file; with `classifier.learnedModelPath` set, `classify()` scores each
14
+ * turn and records `learned: p(escalate)=…` in the decision trail so the
15
+ * signal can be judged against outcomes (and replayed) before it is ever
16
+ * allowed to move a tier.
17
+ */
18
+
19
+ import type { Features } from "./types.ts";
20
+
21
+ export const LEARNED_MODEL_VERSION = 1;
22
+
23
+ /** Feature names in vector order. Adding one is a model-version change. */
24
+ export const FEATURE_NAMES: readonly string[] = [
25
+ "log_prompt_tokens",
26
+ "log_new_content_tokens",
27
+ "turn_depth",
28
+ "tool_count",
29
+ "log_tool_schema_bytes",
30
+ "is_tool_result_continuation",
31
+ "tool_loop_depth",
32
+ "distinct_tools_used",
33
+ "last_tool_failed",
34
+ "repeated_tool_call",
35
+ "circular_tool_call",
36
+ "has_images",
37
+ "has_new_image",
38
+ "code_blocks",
39
+ "log_code_bytes",
40
+ "looks_like_diff",
41
+ "complexity_keywords",
42
+ "triviality_keywords",
43
+ "requested_reasoning",
44
+ "question_count",
45
+ "is_terse_instruction",
46
+ ];
47
+
48
+ const REASONING_ORDINAL: Record<string, number> = { off: 0, minimal: 0.5, low: 1, medium: 2, high: 3, xhigh: 4, max: 5 };
49
+
50
+ const log1p = (v: number): number => Math.log1p(Math.max(0, v));
51
+ const b = (v: boolean | undefined): number => (v === true ? 1 : 0);
52
+
53
+ /** The numeric vector for one turn, in FEATURE_NAMES order. Tolerates old rows missing fields. */
54
+ export function learnedVector(f: Partial<Features>): number[] {
55
+ return [
56
+ log1p(f.promptTokens ?? 0),
57
+ log1p(f.newContentTokens ?? 0),
58
+ f.turnDepth ?? 0,
59
+ f.toolCount ?? 0,
60
+ log1p(f.toolSchemaBytes ?? 0),
61
+ b(f.isToolResultContinuation),
62
+ f.toolLoopDepth ?? 0,
63
+ f.distinctToolsUsed ?? 0,
64
+ b(f.lastToolFailed),
65
+ b(f.repeatedToolCall),
66
+ b(f.circularToolCall),
67
+ b(f.hasImages),
68
+ b(f.hasNewImage),
69
+ f.codeBlocks ?? 0,
70
+ log1p(f.codeBytes ?? 0),
71
+ b(f.looksLikeDiff),
72
+ Array.isArray(f.complexityKeywords) ? f.complexityKeywords.length : 0,
73
+ Array.isArray(f.trivialityKeywords) ? f.trivialityKeywords.length : 0,
74
+ REASONING_ORDINAL[f.requestedReasoning ?? "off"] ?? 0,
75
+ f.questionCount ?? 0,
76
+ b(f.isTerseInstruction),
77
+ ];
78
+ }
79
+
80
+ export interface LearnedModel {
81
+ version: number;
82
+ trainedAtMs: number;
83
+ rows: number;
84
+ positives: number;
85
+ names: string[];
86
+ means: number[];
87
+ stds: number[];
88
+ weights: number[];
89
+ bias: number;
90
+ /** Holdout AUC at training time, for the record. */
91
+ auc: number;
92
+ }
93
+
94
+ const sigmoid = (z: number): number => 1 / (1 + Math.exp(-z));
95
+
96
+ /** P(escalate) for one turn under a model. */
97
+ export function predictRisk(model: LearnedModel, f: Partial<Features>): number {
98
+ const x = learnedVector(f);
99
+ let z = model.bias;
100
+ for (let i = 0; i < model.weights.length && i < x.length; i++) {
101
+ const std = model.stds[i] ?? 1;
102
+ z += (model.weights[i] ?? 0) * (((x[i] ?? 0) - (model.means[i] ?? 0)) / (std > 0 ? std : 1));
103
+ }
104
+ return sigmoid(z);
105
+ }
106
+
107
+ export interface TrainOptions {
108
+ epochs?: number;
109
+ learningRate?: number;
110
+ /** L2 strength on the weights (not the bias). */
111
+ l2?: number;
112
+ /** Weight on positive examples, to counter the ~1% base rate. Default: negatives/positives. */
113
+ positiveWeight?: number;
114
+ }
115
+
116
+ /** Fits weights on already-vectorised rows. Pure, deterministic. */
117
+ export function trainLogistic(xs: number[][], ys: number[], opts: TrainOptions = {}): { weights: number[]; bias: number; means: number[]; stds: number[] } {
118
+ const n = xs.length;
119
+ const d = xs[0]?.length ?? 0;
120
+ if (n === 0 || d === 0) throw new Error("no training rows");
121
+ const means = new Array<number>(d).fill(0);
122
+ const stds = new Array<number>(d).fill(0);
123
+ for (const x of xs) for (let j = 0; j < d; j++) means[j]! += (x[j] ?? 0) / n;
124
+ for (const x of xs) for (let j = 0; j < d; j++) stds[j]! += ((x[j] ?? 0) - means[j]!) ** 2 / n;
125
+ for (let j = 0; j < d; j++) stds[j] = Math.sqrt(stds[j]!) || 1;
126
+ const z = xs.map((x) => x.map((v, j) => (v - means[j]!) / stds[j]!));
127
+
128
+ const positives = ys.reduce((s, y) => s + y, 0);
129
+ const posWeight = opts.positiveWeight ?? (positives > 0 ? (n - positives) / positives : 1);
130
+ const epochs = opts.epochs ?? 400;
131
+ const lr = opts.learningRate ?? 0.1;
132
+ const l2 = opts.l2 ?? 0.01;
133
+ const w = new Array<number>(d).fill(0);
134
+ let bias = 0;
135
+ for (let e = 0; e < epochs; e++) {
136
+ const gw = new Array<number>(d).fill(0);
137
+ let gb = 0;
138
+ let totalWeight = 0;
139
+ for (let i = 0; i < n; i++) {
140
+ const row = z[i]!;
141
+ let s = bias;
142
+ for (let j = 0; j < d; j++) s += w[j]! * row[j]!;
143
+ const y = ys[i]!;
144
+ const sw = y === 1 ? posWeight : 1;
145
+ const err = (sigmoid(s) - y) * sw;
146
+ for (let j = 0; j < d; j++) gw[j]! += err * row[j]!;
147
+ gb += err;
148
+ totalWeight += sw;
149
+ }
150
+ for (let j = 0; j < d; j++) w[j] = w[j]! - lr * (gw[j]! / totalWeight + l2 * w[j]!);
151
+ bias -= lr * (gb / totalWeight);
152
+ }
153
+ return { weights: w, bias, means, stds };
154
+ }
155
+
156
+ /** Area under the ROC curve by rank (Mann-Whitney), ties counted half. */
157
+ export function auc(scores: number[], labels: number[]): number {
158
+ const pos: number[] = [];
159
+ const neg: number[] = [];
160
+ scores.forEach((s, i) => (labels[i] === 1 ? pos : neg).push(s));
161
+ if (pos.length === 0 || neg.length === 0) return 0.5;
162
+ const sortedNeg = [...neg].sort((a, b) => a - b);
163
+ let sum = 0;
164
+ for (const p of pos) {
165
+ // count negatives below p, plus half of ties
166
+ let lo = 0;
167
+ let hi = sortedNeg.length;
168
+ while (lo < hi) {
169
+ const mid = (lo + hi) >> 1;
170
+ if (sortedNeg[mid]! < p) lo = mid + 1;
171
+ else hi = mid;
172
+ }
173
+ let ties = 0;
174
+ for (let k = lo; k < sortedNeg.length && sortedNeg[k] === p; k++) ties++;
175
+ sum += lo + ties / 2;
176
+ }
177
+ return sum / (pos.length * neg.length);
178
+ }
179
+
180
+ /** Loads a model file once per path; null when absent or unreadable. */
181
+ const modelCache = new Map<string, LearnedModel | null>();
182
+ export async function loadLearnedModel(path: string): Promise<LearnedModel | null> {
183
+ if (path === "") return null;
184
+ const cached = modelCache.get(path);
185
+ if (cached !== undefined) return cached;
186
+ let model: LearnedModel | null = null;
187
+ try {
188
+ const parsed = (await Bun.file(path).json()) as Partial<LearnedModel>;
189
+ if (parsed.version === LEARNED_MODEL_VERSION && Array.isArray(parsed.weights) && Array.isArray(parsed.means) && Array.isArray(parsed.stds)) {
190
+ model = parsed as LearnedModel;
191
+ }
192
+ } catch {
193
+ model = null;
194
+ }
195
+ modelCache.set(path, model);
196
+ return model;
197
+ }
198
+
199
+ /** Test seam: forget loaded models. */
200
+ export function resetLearnedModels(): void {
201
+ modelCache.clear();
202
+ }
@@ -43,6 +43,12 @@ export interface SelectArgs {
43
43
  * `buildCandidates` so a failover retry lands on a different model.
44
44
  */
45
45
  excludeSlugs?: readonly string[];
46
+ /**
47
+ * Session override (/router pin): route to this slug. Admitted into the
48
+ * tier as if pinned there, then chosen over ranking, hysteresis and the
49
+ * stay/switch comparison. Ignored when the catalog has no such model.
50
+ */
51
+ forceSlug?: string;
46
52
  }
47
53
 
48
54
  /**
@@ -102,6 +108,26 @@ function wideningOrder(tier: Tier, minTier: Tier, maxTier: Tier): Tier[] {
102
108
  return out;
103
109
  }
104
110
 
111
+ /** Evidence that an upgrade will stick: the conversation is failing or asked for deep reasoning. */
112
+ function hardSignal(f: Features): boolean {
113
+ const reasoning: string = f.requestedReasoning ?? "";
114
+ return f.lastToolFailed || f.circularToolCall || f.repeatedToolCall || reasoning === "high" || reasoning === "xhigh" || reasoning === "max";
115
+ }
116
+
117
+ /** Start of the UTC calendar month containing `nowMs`. */
118
+ export function monthStartMs(nowMs: number): number {
119
+ const d = new Date(nowMs);
120
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1);
121
+ }
122
+
123
+ /** The daily ceiling that spends what remains of a monthly target evenly over the days left (today included). */
124
+ export function monthPace(nowMs: number, perMonthUsd: number, spentUsd: number): { spentUsd: number; daysLeft: number; dailyCapUsd: number } {
125
+ const d = new Date(nowMs);
126
+ const daysInMonth = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate();
127
+ const daysLeft = Math.max(1, daysInMonth - d.getUTCDate() + 1);
128
+ return { spentUsd, daysLeft, dailyCapUsd: Math.max(0, (perMonthUsd - spentUsd) / daysLeft) };
129
+ }
130
+
105
131
  export function select(args: SelectArgs): Decision {
106
132
  const { req, features, classification, profile, state, snapshot, ledger, cfg, nowMs } = args;
107
133
  const reasons: string[] = [];
@@ -161,6 +187,42 @@ export function select(args: SelectArgs): Decision {
161
187
  // exploration (2c) and candidate building (3) so both agree on the term.
162
188
  const cacheWarm = state.cacheWarmSlug !== null && nowMs - state.cacheWarmAtMs <= cfg.hysteresis.cacheWarmTtlMs;
163
189
 
190
+ // 2a. Cache-aware upgrade confirmation. A low-confidence heuristic upgrade
191
+ // from a warm model waits one turn; the next turn's classification
192
+ // confirms or forgets it. Measured on 7 days of live traffic: 65 of 67
193
+ // moderate→hard upgrades bounced back within 3 turns, 50 of them below
194
+ // 0.6 confidence, and each paid a cold hard-tier read of a ~120k prompt
195
+ // ($17.90 in total against $0.23 for staying warm). Step 4's stay/switch
196
+ // comparison never sees these — the warm cheap model is below the new
197
+ // tier's floor, so it is not a candidate there. Escalations, explicit
198
+ // high reasoning and failing tool loops bypass the wait: those are the
199
+ // upgrades that stick.
200
+ let upgradeDeferred: Tier | null = null;
201
+ const confirmBelow = cfg.hysteresis.confirmUpgradesBelowConfidence;
202
+ if (
203
+ confirmBelow > 0 &&
204
+ cls.source === "heuristic" &&
205
+ classification.confidence < confirmBelow &&
206
+ state.currentTier !== null &&
207
+ state.currentSlug !== null &&
208
+ tierIdx(effective) > tierIdx(clampTier(state.currentTier)) &&
209
+ cacheWarm &&
210
+ state.cacheWarmSlug === state.currentSlug &&
211
+ (args.excludeSlugs === undefined || args.excludeSlugs.length === 0) &&
212
+ !hardSignal(features)
213
+ ) {
214
+ const held = clampTier(state.currentTier);
215
+ if (state.upgradeDeferredTier !== undefined && state.upgradeDeferredTier !== null) {
216
+ reasons.push(`upgrade ${held} → ${effective} confirmed: classified above ${held} on consecutive turns`);
217
+ } else {
218
+ reasons.push(
219
+ `upgrade ${held} → ${effective} deferred one turn: heuristic confidence ${classification.confidence.toFixed(2)} < ${confirmBelow} with ${state.currentSlug} warm`,
220
+ );
221
+ upgradeDeferred = effective;
222
+ effective = held;
223
+ }
224
+ }
225
+
164
226
  // 2b. Context compaction: shrink stale tool output before dispatch when the
165
227
  // prompt exceeds the token budget (or would overflow the profile window).
166
228
  // Deterministic and content-only (never removes a message), so downstream
@@ -287,6 +349,14 @@ export function select(args: SelectArgs): Decision {
287
349
  // The task type selects the quality axis and capability filters; the tier
288
350
  // still bounds cost (task selects, tier budgets).
289
351
  const warmSlug = cacheWarm ? state.cacheWarmSlug : null;
352
+ // Expected cache hit for a model: its observed rate once enough warm-expected
353
+ // samples exist (filters.cacheReliabilityMinSamples), else a reliable 1.
354
+ const cacheHitExpectation = (slug: string): { rate: number; measured: boolean; samples: number } => {
355
+ const min = cfg.filters.cacheReliabilityMinSamples;
356
+ const rel = min > 0 ? (ledger?.cacheReliability?.(slug) ?? null) : null;
357
+ if (rel === null || rel.samples < min) return { rate: 1, measured: false, samples: rel?.samples ?? 0 };
358
+ return { rate: rel.hitRate, measured: true, samples: rel.samples };
359
+ };
290
360
  // Pre-fetch trust/latency signals for all candidate slugs in one batch
291
361
  // query per signal kind, instead of per-model individual lookups.
292
362
  const candidateSignals =
@@ -300,6 +370,11 @@ export function select(args: SelectArgs): Decision {
300
370
  cfg.filters.escalationCostWeight > 0
301
371
  ? (ledger?.escalationCost?.(cfg.ledger.blendWindowDays)?.usdPerPromptToken ?? null)
302
372
  : null;
373
+ // A pinned slug is admitted the way a config pin is: into the tier's pin
374
+ // list for this call only, so the quality floor cannot keep it out.
375
+ const pinSlug = args.forceSlug !== undefined && snapshot.models.some((m) => m.slug === args.forceSlug) ? args.forceSlug : undefined;
376
+ const buildCfg = pinSlug === undefined ? cfg : { ...cfg, tiers: { ...cfg.tiers, [effective]: { ...cfg.tiers[effective], pin: [...cfg.tiers[effective].pin, pinSlug] } } };
377
+ if (args.forceSlug !== undefined && pinSlug === undefined) reasons.push(`pin ${args.forceSlug} ignored: not in the catalog`);
303
378
  const build = (t: Tier, relaxLevel = 0): { candidates: Candidate[]; rejected: Rejection[] } =>
304
379
  buildCandidates({
305
380
  req,
@@ -308,7 +383,7 @@ export function select(args: SelectArgs): Decision {
308
383
  task: classification.task,
309
384
  snapshot,
310
385
  ledger,
311
- cfg,
386
+ cfg: buildCfg,
312
387
  expectedCompletionTokens: EXPECTED_COMPLETION_TOKENS,
313
388
  warmSlug,
314
389
  relaxLevel,
@@ -322,7 +397,9 @@ export function select(args: SelectArgs): Decision {
322
397
  // search must rebuild at the same level, or it re-applies the strict config
323
398
  let rescuedRelax = 0;
324
399
  for (const t of wideningOrder(effective, profile.minTier, profile.maxTier)) {
325
- const b = build(t);
400
+ // A session pin is absolute: price ceiling, quality floor and trust all
401
+ // relax so the pinned model is admitted; hard exclusions still apply.
402
+ const b = build(t, pinSlug === undefined ? 0 : 3);
326
403
  if (b.candidates.length > 0) {
327
404
  built = b;
328
405
  chosenTier = t;
@@ -375,6 +452,11 @@ export function select(args: SelectArgs): Decision {
375
452
  const first = candidates[0];
376
453
  if (first === undefined) throw new Error(`no viable model: catalog exhausted across profile ${profile.id}`);
377
454
  let chosen = first;
455
+ const pinnedCandidate = pinSlug === undefined ? undefined : candidates.find((c) => c.model.slug === pinSlug);
456
+ if (pinnedCandidate !== undefined) {
457
+ chosen = pinnedCandidate;
458
+ reasons.push(`pinned to ${pinSlug} by session override (/router pin)`);
459
+ }
378
460
 
379
461
  // 4. Cache-aware switch decision. Staying prices the previous turn's prompt
380
462
  // at the warm model's cache-read rate; switching prices the full current
@@ -382,14 +464,19 @@ export function select(args: SelectArgs): Decision {
382
464
  // assume the whole prompt is written). Switch only when the saving
383
465
  // clears switchMargin.
384
466
  let sticky = false;
385
- if (warmSlug !== null && chosen.model.slug !== warmSlug) {
467
+ if (pinnedCandidate === undefined && warmSlug !== null && chosen.model.slug !== warmSlug) {
386
468
  const warm = candidates.find((c) => c.model.slug === warmSlug);
387
469
  if (warm !== undefined) {
388
470
  const warmPrice = priceAt(warm.model, Math.max(1, state.lastPromptTokens));
389
471
  const newPrice = priceAt(chosen.model, Math.max(1, effFeatures.promptTokens));
390
- const stayWarm = state.lastPromptTokens * (warmPrice.cacheRead ?? warmPrice.prompt);
472
+ // A warm read is only as cheap as the cache is reliable: price the
473
+ // expected mix of hits and provider-side misses, per model.
474
+ const warmHit = cacheHitExpectation(warm.model.slug);
475
+ const newHit = cacheHitExpectation(chosen.model.slug);
476
+ const stayWarm = state.lastPromptTokens * (warmHit.rate * (warmPrice.cacheRead ?? warmPrice.prompt) + (1 - warmHit.rate) * warmPrice.prompt);
391
477
  const switchCold = effFeatures.promptTokens * (newPrice.prompt + (newPrice.cacheWrite ?? 0));
392
- const newWarm = effFeatures.promptTokens * (newPrice.cacheRead ?? newPrice.prompt);
478
+ const newWarm = effFeatures.promptTokens * (newHit.rate * (newPrice.cacheRead ?? newPrice.prompt) + (1 - newHit.rate) * newPrice.prompt);
479
+ const hitNote = warmHit.measured ? `, warm hit ${(warmHit.rate * 100).toFixed(0)}% over ${warmHit.samples}` : "";
393
480
  // Amortise over the horizon: H turns of staying warm against one cold
394
481
  // switch plus H−1 turns warm on the new model. H = 1 is the one-turn
395
482
  // comparison, which kept a 25x-priced model warm for a 33-dispatch run
@@ -400,13 +487,13 @@ export function select(args: SelectArgs): Decision {
400
487
  const over = horizon > 1 ? ` over ${horizon} turns` : "";
401
488
  if (stayCost > switchCost * cfg.hysteresis.switchMargin) {
402
489
  reasons.push(
403
- `cache: switch ${warmSlug} → ${chosen.model.slug} (stay $${stayCost.toFixed(4)} > switch $${switchCost.toFixed(4)} × ${cfg.hysteresis.switchMargin}${over})`,
490
+ `cache: switch ${warmSlug} → ${chosen.model.slug} (stay $${stayCost.toFixed(4)} > switch $${switchCost.toFixed(4)} × ${cfg.hysteresis.switchMargin}${over}${hitNote})`,
404
491
  );
405
492
  } else {
406
493
  chosen = warm;
407
494
  sticky = true;
408
495
  reasons.push(
409
- `cache: keeping warm ${warmSlug} (stay $${stayCost.toFixed(4)} ≤ switch $${switchCost.toFixed(4)} × ${cfg.hysteresis.switchMargin}${over})`,
496
+ `cache: keeping warm ${warmSlug} (stay $${stayCost.toFixed(4)} ≤ switch $${switchCost.toFixed(4)} × ${cfg.hysteresis.switchMargin}${over}${hitNote})`,
410
497
  );
411
498
  }
412
499
  }
@@ -417,8 +504,19 @@ export function select(args: SelectArgs): Decision {
417
504
  perTurnUsd: profile.budget?.perTurnUsd ?? cfg.budget.perTurnUsd,
418
505
  perConversationUsd: profile.budget?.perConversationUsd ?? cfg.budget.perConversationUsd,
419
506
  perDayUsd: profile.budget?.perDayUsd ?? cfg.budget.perDayUsd,
507
+ perMonthUsd: profile.budget?.perMonthUsd ?? cfg.budget.perMonthUsd,
420
508
  onExceeded: profile.budget?.onExceeded ?? cfg.budget.onExceeded,
421
509
  };
510
+ // Month pacing: what is left of the month's target, spread over the days
511
+ // left, becomes a daily ceiling that tightens as the month runs ahead.
512
+ let paceNote = "";
513
+ if (budget.perMonthUsd !== undefined) {
514
+ const pace = monthPace(nowMs, budget.perMonthUsd, ledger?.spendSince(monthStartMs(nowMs), req.harnessId) ?? 0);
515
+ if (budget.perDayUsd === undefined || pace.dailyCapUsd < budget.perDayUsd) {
516
+ budget.perDayUsd = pace.dailyCapUsd;
517
+ paceNote = ` (month pacing: $${pace.spentUsd.toFixed(2)} of $${budget.perMonthUsd} spent, $${pace.dailyCapUsd.toFixed(2)}/day for ${pace.daysLeft} more days)`;
518
+ }
519
+ }
422
520
  const daySpend = budget.perDayUsd !== undefined ? (ledger?.spendSince(nowMs - DAY_MS, req.harnessId) ?? 0) : 0;
423
521
  const breach = (c: Candidate): string | null => {
424
522
  if (budget.perTurnUsd !== undefined && c.forecast.coldUsd > budget.perTurnUsd) {
@@ -432,7 +530,7 @@ export function select(args: SelectArgs): Decision {
432
530
  // identifies itself, so multiple harnesses sharing one router each get
433
531
  // their own daily budget instead of one exhausting it for the others.
434
532
  if (daySpend + c.forecast.coldUsd > budget.perDayUsd) {
435
- return `24h spend $${daySpend.toFixed(4)} + cold forecast > per-day budget $${budget.perDayUsd}`;
533
+ return `24h spend $${daySpend.toFixed(4)} + cold forecast > per-day budget $${budget.perDayUsd.toFixed(2)}${paceNote}`;
436
534
  }
437
535
  }
438
536
  return null;
@@ -531,5 +629,6 @@ export function select(args: SelectArgs): Decision {
531
629
  reasons,
532
630
  explored,
533
631
  budgetDowngraded,
632
+ upgradeDeferred,
534
633
  };
535
634
  }
@@ -32,6 +32,7 @@ interface Row {
32
32
  context_fetched_at_ms: number;
33
33
  compaction_plan: string | null;
34
34
  compaction_plan_tokens: number;
35
+ upgrade_deferred_tier: string | null;
35
36
  updated_at_ms: number;
36
37
  }
37
38
 
@@ -53,6 +54,7 @@ function toState(row: Row): ConversationState {
53
54
  contextFetchedAtMs: row.context_fetched_at_ms,
54
55
  compactionPlan: row.compaction_plan === null ? null : (JSON.parse(row.compaction_plan) as CompactionEdit[]),
55
56
  compactionPlanTokens: row.compaction_plan_tokens,
57
+ upgradeDeferredTier: row.upgrade_deferred_tier as Tier | null,
56
58
  updatedAtMs: row.updated_at_ms,
57
59
  };
58
60
  }
@@ -71,10 +73,10 @@ export function createConversationStore(db: Database): ConversationStore {
71
73
  INSERT INTO conversations (
72
74
  key, session_id, turn, current_slug, current_tier, sticky_until_turn,
73
75
  last_prompt_tokens, cache_warm_slug, cache_warm_at_ms,
74
- context_version, context_fetched_at_ms, compaction_plan, compaction_plan_tokens, updated_at_ms
76
+ context_version, context_fetched_at_ms, compaction_plan, compaction_plan_tokens, upgrade_deferred_tier, updated_at_ms
75
77
  ) VALUES ($key, $sessionId, $turn, $currentSlug, $currentTier, $stickyUntilTurn,
76
78
  $lastPromptTokens, $cacheWarmSlug, $cacheWarmAtMs,
77
- $contextVersion, $contextFetchedAtMs, $compactionPlan, $compactionPlanTokens, $updatedAtMs)
79
+ $contextVersion, $contextFetchedAtMs, $compactionPlan, $compactionPlanTokens, $upgradeDeferredTier, $updatedAtMs)
78
80
  ON CONFLICT(key) DO UPDATE SET
79
81
  session_id = excluded.session_id,
80
82
  turn = excluded.turn,
@@ -88,6 +90,7 @@ export function createConversationStore(db: Database): ConversationStore {
88
90
  context_fetched_at_ms = excluded.context_fetched_at_ms,
89
91
  compaction_plan = excluded.compaction_plan,
90
92
  compaction_plan_tokens = excluded.compaction_plan_tokens,
93
+ upgrade_deferred_tier = excluded.upgrade_deferred_tier,
91
94
  updated_at_ms = excluded.updated_at_ms
92
95
  `);
93
96
  // Read-modify-write in JS lost money: an aborted or failed dispatch is still
@@ -137,6 +140,7 @@ export function createConversationStore(db: Database): ConversationStore {
137
140
  $lastPromptTokens: state.lastPromptTokens,
138
141
  $compactionPlan: state.compactionPlan === null ? null : JSON.stringify(state.compactionPlan),
139
142
  $compactionPlanTokens: state.compactionPlanTokens ?? 0,
143
+ $upgradeDeferredTier: state.upgradeDeferredTier ?? null,
140
144
  $cacheWarmSlug: state.cacheWarmSlug,
141
145
  $cacheWarmAtMs: state.cacheWarmAtMs,
142
146
  $contextVersion: state.contextVersion,
@@ -90,6 +90,26 @@ export interface Features {
90
90
  questionCount: number;
91
91
  /** Newest user content is a single short imperative sentence. */
92
92
  isTerseInstruction: boolean;
93
+ /**
94
+ * Where the prompt's bytes are. Recorded with every turn so the question
95
+ * "what is the prompt made of, and how old is it?" can be answered from the
96
+ * ledger before deciding what compaction should shrink next. Absent on rows
97
+ * recorded before it existed.
98
+ */
99
+ anatomy?: PromptAnatomy;
100
+ }
101
+
102
+ /** Prompt bytes by message role and by age, plus the tool-schema bytes beside them. */
103
+ export interface PromptAnatomy {
104
+ messages: number;
105
+ systemBytes: number;
106
+ userBytes: number;
107
+ assistantBytes: number;
108
+ toolBytes: number;
109
+ /** Bytes of non-system messages in the OLDER half of the conversation (by message index). */
110
+ olderHalfBytes: number;
111
+ /** Bytes of tool results older than the newest 20 messages: what compaction can reach. */
112
+ staleToolBytes: number;
93
113
  }
94
114
 
95
115
  export type ClassificationSource = "heuristic" | "llm" | "sticky" | "forced" | "escalation";
@@ -105,6 +125,8 @@ export interface Classification {
105
125
  reasons: string[];
106
126
  /** Raw heuristic score before tier bucketing, 0-1. */
107
127
  score: number;
128
+ /** Learned P(escalate) when `classifier.learnedModelPath` is set (advisory; see router/learned.ts). */
129
+ learnedRisk?: number;
108
130
  }
109
131
 
110
132
  /** A model that survived capability filtering, with its economics attached. */
@@ -186,6 +208,13 @@ export interface ConversationState {
186
208
  compactionPlanTokens?: number;
187
209
  /** When that block was fetched, for the staleness TTL. */
188
210
  contextFetchedAtMs: number;
211
+ /**
212
+ * The tier a low-confidence upgrade was deferred to on the previous turn
213
+ * (`hysteresis.confirmUpgradesBelowConfidence`), or null. One-turn memory:
214
+ * every turn overwrites it, so a second consecutive upgrade classification
215
+ * confirms the switch and anything else forgets it.
216
+ */
217
+ upgradeDeferredTier?: Tier | null;
189
218
  updatedAtMs: number;
190
219
  }
191
220
 
@@ -270,6 +299,8 @@ export interface Decision {
270
299
  explored: Exploration | null;
271
300
  /** Budget guard forced a cheaper tier than the classifier asked for. */
272
301
  budgetDowngraded: boolean;
302
+ /** A low-confidence upgrade to this tier was deferred one turn to keep the warm model. */
303
+ upgradeDeferred: Tier | null;
273
304
  }
274
305
 
275
306
  /** Why a guarded probe rejected an attempt. */
@@ -294,6 +325,14 @@ export interface Router {
294
325
  */
295
326
  route(
296
327
  req: NormRequest,
297
- opts: { attempt: number; escalateFrom?: Tier; excludeSlugs?: readonly string[] },
328
+ opts: {
329
+ attempt: number;
330
+ escalateFrom?: Tier;
331
+ excludeSlugs?: readonly string[];
332
+ /** Session override (/router tier): classify as this tier. */
333
+ forceTier?: Tier;
334
+ /** Session override (/router pin): route to this slug when it exists in the catalog. */
335
+ forceSlug?: string;
336
+ },
298
337
  ): Promise<Decision>;
299
338
  }
@@ -3,8 +3,11 @@ import { dirname } from "node:path";
3
3
  import type { Server } from "bun";
4
4
  import { createProviders } from "./providers.ts";
5
5
  import { createBridgeFromConfig } from "../context/index.ts";
6
+ import { createFeedbackStore, type Verdict } from "../cost/feedback.ts";
6
7
  import { createLedger } from "../cost/ledger.ts";
7
- import { buildUsageReport } from "../cost/report.ts";
8
+ import { createSessionOverrides } from "./overrides.ts";
9
+ import { TIER_ORDER, type Tier } from "../router/types.ts";
10
+ import { baselinePrices, buildUsageReport } from "../cost/report.ts";
8
11
  import type { Ledger, ModelTrust } from "../cost/types.ts";
9
12
  import { createRouter } from "../router/index.ts";
10
13
  import { createConversationStore } from "../router/state.ts";
@@ -121,6 +124,18 @@ export function computeStats(ledger: Ledger, opts?: { windowDays?: number; nowMs
121
124
  };
122
125
  }
123
126
 
127
+ /** Days of included credits left at the last 7 days' burn (ledger, scaled by the calibration). */
128
+ export function ollamaRunway(
129
+ meter: { usedUsd: number; creditsUsd: number } | null,
130
+ ledgerUsd7d: number,
131
+ factor: number,
132
+ ): { dailyBurnUsd: number; creditsLeftUsd: number; days: number | null } | null {
133
+ if (meter === null) return null;
134
+ const dailyBurnUsd = (ledgerUsd7d / 7) * factor;
135
+ const creditsLeftUsd = Math.max(0, meter.creditsUsd - meter.usedUsd);
136
+ return { dailyBurnUsd, creditsLeftUsd, days: dailyBurnUsd > 0 ? creditsLeftUsd / dailyBurnUsd : null };
137
+ }
138
+
124
139
  function json(data: unknown, status = 200): Response {
125
140
  return new Response(JSON.stringify(data), {
126
141
  status,
@@ -173,11 +188,13 @@ export function startServer(cfg: RouterConfig): StartedServer {
173
188
  if (cfg.ledger.path !== ":memory:") mkdirSync(dirname(cfg.ledger.path), { recursive: true });
174
189
  const db = openDb(cfg.ledger.path);
175
190
  const ledger = createLedger(db, cfg);
176
- const { upstream, catalog, ollama, ollamaUsage } = createProviders(cfg, db, log);
191
+ const { upstream, catalog, ollama, ollamaUsage, ollamaCostScale } = createProviders(cfg, db, log);
177
192
  const conversations = createConversationStore(db);
178
193
  const router = createRouter({ config: cfg, catalog, ledger, conversations, upstream });
179
194
  const context = createBridgeFromConfig(cfg, db);
180
- const turnDeps = { config: cfg, router, upstream, ledger, conversations, catalog, context };
195
+ const overrides = createSessionOverrides();
196
+ const feedback = createFeedbackStore(db);
197
+ const turnDeps = { config: cfg, router, upstream, ledger, conversations, catalog, context, overrides, ollamaCostScale };
181
198
 
182
199
  // Hot reload: ranking knobs (tiers, filters, escalation, budgets, …) take
183
200
  // effect on the next turn without a restart, because every consumer reads
@@ -377,13 +394,70 @@ export function startServer(cfg: RouterConfig): StartedServer {
377
394
  const parsedDays = rawDays === null ? 7 : Number.parseInt(rawDays, 10);
378
395
  const windowDays = Number.isInteger(parsedDays) ? Math.min(Math.max(parsedDays, 1), 365) : 7;
379
396
  const harnessId = url.searchParams.get("harness") ?? "";
380
- return json(buildUsageReport(db, { windowDays, harnessId }));
397
+ return json(buildUsageReport(db, { windowDays, harnessId, baselines: baselinePrices(cfg.report.baselines, (s) => catalog.find(s)) }));
381
398
  }
382
399
  if (req.method === "GET" && url.pathname === "/v1/router/decisions") {
383
400
  const rawLimit = url.searchParams.get("limit");
384
401
  const parsed = rawLimit === null ? 50 : Number.parseInt(rawLimit, 10);
385
402
  const limit = Number.isInteger(parsed) ? Math.min(Math.max(parsed, 1), 1_000) : 50;
386
- return json({ entries: ledger.recentEntries(limit) });
403
+ // ?session=<omp session id> narrows to one session (/router why).
404
+ const session = url.searchParams.get("session") ?? "";
405
+ const entries = session === "" ? ledger.recentEntries(limit) : (ledger.entriesForSession?.(session, limit) ?? []);
406
+ return json({ entries: entries.map((e) => ({ ...e, feedback: feedback.forLedgerId(e.id) })) });
407
+ }
408
+ if (url.pathname === "/v1/router/override") {
409
+ // Per-session pin / tier overrides from omp. GET shows, POST sets or clears.
410
+ if (req.method === "GET") {
411
+ const session = url.searchParams.get("session") ?? "";
412
+ return json(session === "" ? { overrides: overrides.list() } : { override: overrides.get(session) });
413
+ }
414
+ if (req.method === "POST") {
415
+ const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
416
+ const session = typeof body?.ompSessionId === "string" ? body.ompSessionId : "";
417
+ if (session === "") return wireErrorResponse({ status: 400, code: "invalid_request_error", message: "ompSessionId required" });
418
+ if (body?.clear === true) {
419
+ overrides.clear(session);
420
+ return json({ override: null });
421
+ }
422
+ const tier = body?.tier;
423
+ if (tier !== undefined && tier !== null && !(TIER_ORDER as readonly string[]).includes(String(tier))) {
424
+ return wireErrorResponse({ status: 400, code: "invalid_request_error", message: `unknown tier ${String(tier)}` });
425
+ }
426
+ const slug = body?.slug;
427
+ if (typeof slug === "string" && slug !== "" && catalog.find(slug) === undefined) {
428
+ return wireErrorResponse({ status: 404, code: "not_found", message: `no model ${slug} in the catalog` });
429
+ }
430
+ const turns = typeof body?.turns === "number" ? body.turns : undefined;
431
+ const set = overrides.set(session, {
432
+ ...(tier === undefined ? {} : { tier: tier === null ? null : (String(tier) as Tier) }),
433
+ ...(slug === undefined ? {} : { slug: typeof slug === "string" && slug !== "" ? slug : null }),
434
+ ...(turns === undefined ? {} : { turns }),
435
+ });
436
+ return json({ override: set });
437
+ }
438
+ }
439
+ if (req.method === "POST" && url.pathname === "/v1/router/feedback") {
440
+ // A user verdict on the newest routed turn of an omp session.
441
+ const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
442
+ const session = typeof body?.ompSessionId === "string" ? body.ompSessionId : "";
443
+ const verdict = body?.verdict;
444
+ if (session === "" || (verdict !== "good" && verdict !== "bad")) {
445
+ return wireErrorResponse({ status: 400, code: "invalid_request_error", message: "ompSessionId and verdict (good|bad) required" });
446
+ }
447
+ const target =
448
+ typeof body?.ledgerId === "string"
449
+ ? (ledger.recentEntries(1_000).find((e) => e.id === body.ledgerId) ?? null)
450
+ : (ledger.latestForSession?.(session) ?? null);
451
+ if (target === null) return wireErrorResponse({ status: 404, code: "not_found", message: "no routed turn for that session yet" });
452
+ const id = feedback.record({
453
+ ledgerId: target.id,
454
+ ompSessionId: session,
455
+ slug: target.servedSlug ?? target.slug,
456
+ tier: target.tier,
457
+ verdict: verdict as Verdict,
458
+ note: typeof body?.note === "string" ? body.note : "",
459
+ });
460
+ return json({ id, ledgerId: target.id, slug: target.servedSlug ?? target.slug, tier: target.tier, verdict });
387
461
  }
388
462
  if (req.method === "GET" && url.pathname === "/health") {
389
463
  const snap = catalog.peek();
@@ -413,6 +487,9 @@ export function startServer(cfg: RouterConfig): StartedServer {
413
487
  usage: ollamaUsage.peek(),
414
488
  // The dashboard's dollar figure: plan share × included credits, when known.
415
489
  meter: ollamaMeter(ollamaUsage.peek(), cfg.ollama.planCreditsUsd),
490
+ // Ledger vs meter, and how long the credits last at the recent burn.
491
+ calibration: ollamaUsage.calibration(),
492
+ runway: ollamaRunway(ollamaMeter(ollamaUsage.peek(), cfg.ollama.planCreditsUsd), ledger.providerSpendSince?.("ollama/", Date.now() - 7 * 86_400_000) ?? 0, ollamaUsage.calibration()?.factor ?? 1),
416
493
  costBias: { configured: cfg.ollama.costBias, effective: catalog.ollamaBias?.() ?? cfg.ollama.costBias, biasUntilUsage: cfg.ollama.biasUntilUsage },
417
494
  },
418
495
  catalog: snap === null