auto-model-router 0.3.4 → 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 (42) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +24 -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 +8 -0
  8. package/src/cli/report.ts +7 -2
  9. package/src/config/defaults.ts +9 -0
  10. package/src/config/schema.ts +5 -0
  11. package/src/config/types.ts +41 -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 +64 -8
  22. package/src/router/types.ts +31 -1
  23. package/src/server/http.ts +82 -5
  24. package/src/server/overrides.ts +83 -0
  25. package/src/server/providers.ts +10 -2
  26. package/src/server/turn.ts +16 -1
  27. package/src/upstream/ollama-usage.ts +79 -2
  28. package/src/util/sqlite.ts +30 -0
  29. package/test/config-wizard.test.ts +2 -1
  30. package/test/controls.test.ts +223 -0
  31. package/test/failover.test.ts +3 -2
  32. package/test/features.test.ts +31 -0
  33. package/test/learned.test.ts +61 -0
  34. package/test/ollama.test.ts +74 -2
  35. package/test/report-hub.test.ts +4 -2
  36. package/test/report-logic.test.ts +3 -0
  37. package/test/report.test.ts +43 -0
  38. package/test/select.test.ts +126 -1
  39. package/test/trust-attribution.test.ts +42 -0
  40. package/test/turn.test.ts +33 -2
  41. package/tools/replay.ts +266 -156
  42. 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
  /**
@@ -108,6 +114,20 @@ function hardSignal(f: Features): boolean {
108
114
  return f.lastToolFailed || f.circularToolCall || f.repeatedToolCall || reasoning === "high" || reasoning === "xhigh" || reasoning === "max";
109
115
  }
110
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
+
111
131
  export function select(args: SelectArgs): Decision {
112
132
  const { req, features, classification, profile, state, snapshot, ledger, cfg, nowMs } = args;
113
133
  const reasons: string[] = [];
@@ -329,6 +349,14 @@ export function select(args: SelectArgs): Decision {
329
349
  // The task type selects the quality axis and capability filters; the tier
330
350
  // still bounds cost (task selects, tier budgets).
331
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
+ };
332
360
  // Pre-fetch trust/latency signals for all candidate slugs in one batch
333
361
  // query per signal kind, instead of per-model individual lookups.
334
362
  const candidateSignals =
@@ -342,6 +370,11 @@ export function select(args: SelectArgs): Decision {
342
370
  cfg.filters.escalationCostWeight > 0
343
371
  ? (ledger?.escalationCost?.(cfg.ledger.blendWindowDays)?.usdPerPromptToken ?? null)
344
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`);
345
378
  const build = (t: Tier, relaxLevel = 0): { candidates: Candidate[]; rejected: Rejection[] } =>
346
379
  buildCandidates({
347
380
  req,
@@ -350,7 +383,7 @@ export function select(args: SelectArgs): Decision {
350
383
  task: classification.task,
351
384
  snapshot,
352
385
  ledger,
353
- cfg,
386
+ cfg: buildCfg,
354
387
  expectedCompletionTokens: EXPECTED_COMPLETION_TOKENS,
355
388
  warmSlug,
356
389
  relaxLevel,
@@ -364,7 +397,9 @@ export function select(args: SelectArgs): Decision {
364
397
  // search must rebuild at the same level, or it re-applies the strict config
365
398
  let rescuedRelax = 0;
366
399
  for (const t of wideningOrder(effective, profile.minTier, profile.maxTier)) {
367
- 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);
368
403
  if (b.candidates.length > 0) {
369
404
  built = b;
370
405
  chosenTier = t;
@@ -417,6 +452,11 @@ export function select(args: SelectArgs): Decision {
417
452
  const first = candidates[0];
418
453
  if (first === undefined) throw new Error(`no viable model: catalog exhausted across profile ${profile.id}`);
419
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
+ }
420
460
 
421
461
  // 4. Cache-aware switch decision. Staying prices the previous turn's prompt
422
462
  // at the warm model's cache-read rate; switching prices the full current
@@ -424,14 +464,19 @@ export function select(args: SelectArgs): Decision {
424
464
  // assume the whole prompt is written). Switch only when the saving
425
465
  // clears switchMargin.
426
466
  let sticky = false;
427
- if (warmSlug !== null && chosen.model.slug !== warmSlug) {
467
+ if (pinnedCandidate === undefined && warmSlug !== null && chosen.model.slug !== warmSlug) {
428
468
  const warm = candidates.find((c) => c.model.slug === warmSlug);
429
469
  if (warm !== undefined) {
430
470
  const warmPrice = priceAt(warm.model, Math.max(1, state.lastPromptTokens));
431
471
  const newPrice = priceAt(chosen.model, Math.max(1, effFeatures.promptTokens));
432
- 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);
433
477
  const switchCold = effFeatures.promptTokens * (newPrice.prompt + (newPrice.cacheWrite ?? 0));
434
- 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}` : "";
435
480
  // Amortise over the horizon: H turns of staying warm against one cold
436
481
  // switch plus H−1 turns warm on the new model. H = 1 is the one-turn
437
482
  // comparison, which kept a 25x-priced model warm for a 33-dispatch run
@@ -442,13 +487,13 @@ export function select(args: SelectArgs): Decision {
442
487
  const over = horizon > 1 ? ` over ${horizon} turns` : "";
443
488
  if (stayCost > switchCost * cfg.hysteresis.switchMargin) {
444
489
  reasons.push(
445
- `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})`,
446
491
  );
447
492
  } else {
448
493
  chosen = warm;
449
494
  sticky = true;
450
495
  reasons.push(
451
- `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})`,
452
497
  );
453
498
  }
454
499
  }
@@ -459,8 +504,19 @@ export function select(args: SelectArgs): Decision {
459
504
  perTurnUsd: profile.budget?.perTurnUsd ?? cfg.budget.perTurnUsd,
460
505
  perConversationUsd: profile.budget?.perConversationUsd ?? cfg.budget.perConversationUsd,
461
506
  perDayUsd: profile.budget?.perDayUsd ?? cfg.budget.perDayUsd,
507
+ perMonthUsd: profile.budget?.perMonthUsd ?? cfg.budget.perMonthUsd,
462
508
  onExceeded: profile.budget?.onExceeded ?? cfg.budget.onExceeded,
463
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
+ }
464
520
  const daySpend = budget.perDayUsd !== undefined ? (ledger?.spendSince(nowMs - DAY_MS, req.harnessId) ?? 0) : 0;
465
521
  const breach = (c: Candidate): string | null => {
466
522
  if (budget.perTurnUsd !== undefined && c.forecast.coldUsd > budget.perTurnUsd) {
@@ -474,7 +530,7 @@ export function select(args: SelectArgs): Decision {
474
530
  // identifies itself, so multiple harnesses sharing one router each get
475
531
  // their own daily budget instead of one exhausting it for the others.
476
532
  if (daySpend + c.forecast.coldUsd > budget.perDayUsd) {
477
- 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}`;
478
534
  }
479
535
  }
480
536
  return null;
@@ -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. */
@@ -303,6 +325,14 @@ export interface Router {
303
325
  */
304
326
  route(
305
327
  req: NormRequest,
306
- 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
+ },
307
337
  ): Promise<Decision>;
308
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
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Per-session routing overrides set from omp (`/router pin`, `/router tier`).
3
+ *
4
+ * Keyed by the omp session id the embed extension sends as `X-Omp-Session`,
5
+ * so an override never leaks into another session sharing the router.
6
+ * Process-local on purpose: an override is a user's momentary intent, not
7
+ * state to survive a restart. `turns` counts down per committed dispatch;
8
+ * 0 means until cleared or the entry expires.
9
+ */
10
+
11
+ import type { Tier } from "../router/types.ts";
12
+
13
+ export interface SessionOverride {
14
+ /** Model to route every turn to, when set. */
15
+ slug: string | null;
16
+ /** Tier to classify every turn as, when set. */
17
+ tier: Tier | null;
18
+ /** Dispatches left; 0 ⇒ unlimited. */
19
+ turnsLeft: number;
20
+ setAtMs: number;
21
+ }
22
+
23
+ export interface SessionOverrides {
24
+ get(ompSessionId: string): SessionOverride | null;
25
+ set(ompSessionId: string, override: { slug?: string | null; tier?: Tier | null; turns?: number }, nowMs?: number): SessionOverride;
26
+ clear(ompSessionId: string): void;
27
+ /** A committed dispatch used one turn of the override. */
28
+ consume(ompSessionId: string): void;
29
+ /** Every live override, for status. */
30
+ list(): Array<{ ompSessionId: string } & SessionOverride>;
31
+ }
32
+
33
+ /** Overrides older than this are forgotten: a session that idled a day is a new session. */
34
+ export const OVERRIDE_TTL_MS = 12 * 60 * 60 * 1000;
35
+
36
+ export function createSessionOverrides(): SessionOverrides {
37
+ const map = new Map<string, SessionOverride>();
38
+ const live = (id: string, nowMs: number): SessionOverride | null => {
39
+ const o = map.get(id);
40
+ if (o === undefined) return null;
41
+ if (nowMs - o.setAtMs > OVERRIDE_TTL_MS) {
42
+ map.delete(id);
43
+ return null;
44
+ }
45
+ return o;
46
+ };
47
+ return {
48
+ get(id) {
49
+ if (id === "") return null;
50
+ const o = live(id, Date.now());
51
+ return o !== null && (o.slug !== null || o.tier !== null) ? o : null;
52
+ },
53
+ set(id, over, nowMs = Date.now()) {
54
+ const prev = live(id, nowMs) ?? { slug: null, tier: null, turnsLeft: 0, setAtMs: nowMs };
55
+ const next: SessionOverride = {
56
+ slug: over.slug === undefined ? prev.slug : over.slug,
57
+ tier: over.tier === undefined ? prev.tier : over.tier,
58
+ turnsLeft: over.turns === undefined ? prev.turnsLeft : Math.max(0, Math.floor(over.turns)),
59
+ setAtMs: nowMs,
60
+ };
61
+ map.set(id, next);
62
+ return next;
63
+ },
64
+ clear(id) {
65
+ map.delete(id);
66
+ },
67
+ consume(id) {
68
+ const o = map.get(id);
69
+ if (o === undefined || o.turnsLeft === 0) return;
70
+ o.turnsLeft -= 1;
71
+ if (o.turnsLeft === 0) map.delete(id);
72
+ },
73
+ list() {
74
+ const now = Date.now();
75
+ const out: Array<{ ompSessionId: string } & SessionOverride> = [];
76
+ for (const [id] of map) {
77
+ const o = live(id, now);
78
+ if (o !== null) out.push({ ompSessionId: id, ...o });
79
+ }
80
+ return out;
81
+ },
82
+ };
83
+ }
@@ -12,6 +12,7 @@ import type { CatalogSource } from "../catalog/types.ts";
12
12
  import type { RouterConfig } from "../config/types.ts";
13
13
  import { createMultiUpstream } from "../upstream/multi.ts";
14
14
  import { createOllamaClient, type OllamaClient } from "../upstream/ollama.ts";
15
+ import { createLedger } from "../cost/ledger.ts";
15
16
  import { createOllamaUsageSource, NO_USAGE, type OllamaUsageSource } from "../upstream/ollama-usage.ts";
16
17
  import { createOpenRouterClient } from "../upstream/openrouter.ts";
17
18
  import type { UpstreamClient } from "../upstream/types.ts";
@@ -24,31 +25,38 @@ export interface Providers {
24
25
  ollama: OllamaClient | null;
25
26
  /** Plan usage reader; inert without a key. */
26
27
  ollamaUsage: OllamaUsageSource;
28
+ /** Multiplier that brings the ledger's Ollama estimate in line with the plan meter; 1 until calibrated. */
29
+ ollamaCostScale: () => number;
27
30
  }
28
31
 
29
32
  export function createProviders(cfg: RouterConfig, db: Database, log: Logger = createLogger(cfg.logLevel)): Providers {
30
33
  const openrouter = createOpenRouterClient(cfg);
31
34
  const openrouterCatalog = createCatalog(cfg, openrouter, db);
32
- if (!cfg.ollama.enabled) return { upstream: openrouter, catalog: openrouterCatalog, ollama: null, ollamaUsage: NO_USAGE };
35
+ if (!cfg.ollama.enabled) return { upstream: openrouter, catalog: openrouterCatalog, ollama: null, ollamaUsage: NO_USAGE, ollamaCostScale: () => 1 };
33
36
  // Ollama Cloud is a second upstream ranked in the same catalog: `ollama/…`
34
37
  // slugs dispatch to it, everything else to OpenRouter.
35
38
  const ollama = createOllamaClient(cfg);
36
39
  // Plan usage lives on ollama.com whichever base URL dispatches; it needs the
37
40
  // key, so the daemon path without `/login ollama-cloud` keeps a static bias.
41
+ const ledgerForCalibration = createLedger(db, cfg);
38
42
  const ollamaUsage = createOllamaUsageSource({
39
43
  apiKey: cfg.ollama.apiKey,
40
44
  pollMs: cfg.ollama.usagePollMs,
41
45
  timeoutMs: Math.min(cfg.ollama.timeoutMs, 15_000),
42
46
  log,
47
+ // Each poll records the meter beside the ledger's Ollama total, so the
48
+ // estimate can be scaled to what ollama.com actually bills.
49
+ calibration: { db, ledgerUsd: () => ledgerForCalibration.providerSpendSince?.("ollama/", 0) ?? 0, planCreditsOverrideUsd: cfg.ollama.planCreditsUsd },
43
50
  });
44
51
  return {
45
52
  upstream: createMultiUpstream(openrouter, ollama),
46
- catalog: createCompositeCatalog(openrouterCatalog, createOllamaCatalog(cfg.ollama, log), ollama, {
53
+ catalog: createCompositeCatalog(openrouterCatalog, createOllamaCatalog(cfg.ollama, log, fetch, db), ollama, {
47
54
  costBias: cfg.ollama.costBias,
48
55
  biasUntilUsage: cfg.ollama.biasUntilUsage,
49
56
  usage: ollamaUsage,
50
57
  }),
51
58
  ollama,
52
59
  ollamaUsage,
60
+ ollamaCostScale: () => ollamaUsage.calibration()?.factor ?? 1,
53
61
  };
54
62
  }