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
@@ -27,6 +27,7 @@ import {
27
27
  type Tier,
28
28
  } from "../router/types.ts";
29
29
  import { UpstreamError, type Dispatch, type UpstreamClient } from "../upstream/types.ts";
30
+ import type { SessionOverrides } from "./overrides.ts";
30
31
  import { createLogger } from "../util/log.ts";
31
32
  import type { NormRequest, ResponseSink, TurnSummary, UpstreamChunk } from "../wire/types.ts";
32
33
 
@@ -66,6 +67,10 @@ export interface TurnDeps {
66
67
  catalog: CatalogSource;
67
68
  /** agentdox bridge. The disabled bridge makes every call here a no-op. */
68
69
  context: ContextBridge;
70
+ /** Per-session pin/tier overrides from omp. Absent ⇒ none. */
71
+ overrides?: SessionOverrides;
72
+ /** Ledger-vs-meter calibration for Ollama's estimated costs; absent ⇒ 1. */
73
+ ollamaCostScale?: () => number;
69
74
  }
70
75
 
71
76
  /** A dead client connection surfaces as the sink throwing mid-stream. */
@@ -117,6 +122,9 @@ export async function runTurn(
117
122
  else triggers.delete("length_stop");
118
123
 
119
124
  const maxAttempts = Math.max(1, config.escalation.maxAttempts);
125
+ // A session override applies to the first attempt only: an escalation or
126
+ // failover after it is the router's business, not the pin's.
127
+ const override = deps.overrides?.get(req.ompSessionId) ?? null;
120
128
  let escalateFrom: Tier | undefined;
121
129
  let escalations = 0;
122
130
  // Slugs that returned a retryable upstream error on THIS turn, fed back
@@ -138,9 +146,13 @@ export async function runTurn(
138
146
  pendingDecision = null;
139
147
  } else {
140
148
  try {
141
- const opts: { attempt: number; escalateFrom?: Tier; excludeSlugs?: readonly string[] } = { attempt };
149
+ const opts: { attempt: number; escalateFrom?: Tier; excludeSlugs?: readonly string[]; forceTier?: Tier; forceSlug?: string } = { attempt };
142
150
  if (escalateFrom !== undefined) opts.escalateFrom = escalateFrom;
143
151
  if (failedSlugs.length > 0) opts.excludeSlugs = failedSlugs;
152
+ if (override !== null && attempt === 0) {
153
+ if (override.tier !== null) opts.forceTier = override.tier;
154
+ if (override.slug !== null) opts.forceSlug = override.slug;
155
+ }
144
156
  decision = await router.route(req, opts);
145
157
  } catch (err) {
146
158
  await sink.error({ status: 500, code: "router_error", message: err instanceof Error ? err.message : String(err) });
@@ -480,6 +492,8 @@ export async function runTurn(
480
492
  });
481
493
  }
482
494
  reportedUsd = computeCost(served, usage).total;
495
+ // Scale the estimate to what the plan meter has been billing for it.
496
+ if (served.provider === "ollama") reportedUsd *= deps.ollamaCostScale?.() ?? 1;
483
497
  }
484
498
  }
485
499
 
@@ -538,6 +552,7 @@ export async function runTurn(
538
552
 
539
553
  state.turn = turnNumber;
540
554
  state.currentSlug = servedSlug ?? decision.slug;
555
+ if (override !== null) deps.overrides?.consume(req.ompSessionId);
541
556
  // Capture the previously-served tier BEFORE overwriting it, so the
542
557
  // hysteresis re-arm below can tell whether this turn changed tier.
543
558
  const prevTier = state.currentTier;
@@ -24,6 +24,7 @@
24
24
  * percents) rather than 100%, which errs toward keeping the bias on.
25
25
  */
26
26
 
27
+ import type { Database } from "bun:sqlite";
27
28
  import type { Logger } from "../util/log.ts";
28
29
 
29
30
  export interface OllamaUsage {
@@ -96,20 +97,94 @@ export function parseOllamaUsage(json: unknown, nowMs = Date.now()): OllamaUsage
96
97
 
97
98
  export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
98
99
 
100
+ /** One reading of the plan meter beside the ledger's own Ollama total. */
101
+ export interface MeterSample {
102
+ atMs: number;
103
+ meterUsd: number;
104
+ ledgerUsd: number;
105
+ }
106
+
107
+ /** How the ledger's Ollama estimate compares with what ollama.com metered over the same span. */
108
+ export interface OllamaCalibration {
109
+ /** metered ÷ ledger over the span, clamped to [0.5, 2]. Multiply estimates by it. */
110
+ factor: number;
111
+ meterDeltaUsd: number;
112
+ ledgerDeltaUsd: number;
113
+ spanHours: number;
114
+ samples: number;
115
+ }
116
+
117
+ /** Bounds on the span a calibration may rest on: the meter moves in $0.06 steps on Pro, so a tiny span is noise. */
118
+ const CALIBRATION_MIN_LEDGER_USD = 0.5;
119
+ const CALIBRATION_MIN_METER_USD = 0.06;
120
+ export const CALIBRATION_MIN_FACTOR = 0.5;
121
+ export const CALIBRATION_MAX_FACTOR = 2;
122
+
123
+ /**
124
+ * The calibration from samples ordered oldest→newest. Walks back from the
125
+ * newest reading while the meter is non-decreasing (a drop is a billing-cycle
126
+ * reset), then compares the two ends. Null until the span carries enough
127
+ * spend to mean anything.
128
+ */
129
+ export function calibrationFrom(samples: readonly MeterSample[]): OllamaCalibration | null {
130
+ if (samples.length < 2) return null;
131
+ const newest = samples[samples.length - 1]!;
132
+ let oldest = newest;
133
+ let n = 1;
134
+ for (let i = samples.length - 2; i >= 0; i--) {
135
+ const s = samples[i]!;
136
+ if (s.meterUsd > oldest.meterUsd || s.ledgerUsd > oldest.ledgerUsd) break; // reset (meter) or a ledger purge
137
+ oldest = s;
138
+ n++;
139
+ }
140
+ const meterDeltaUsd = newest.meterUsd - oldest.meterUsd;
141
+ const ledgerDeltaUsd = newest.ledgerUsd - oldest.ledgerUsd;
142
+ if (ledgerDeltaUsd < CALIBRATION_MIN_LEDGER_USD || meterDeltaUsd < CALIBRATION_MIN_METER_USD) return null;
143
+ const factor = Math.min(CALIBRATION_MAX_FACTOR, Math.max(CALIBRATION_MIN_FACTOR, meterDeltaUsd / ledgerDeltaUsd));
144
+ return { factor, meterDeltaUsd, ledgerDeltaUsd, spanHours: (newest.atMs - oldest.atMs) / 3_600_000, samples: n };
145
+ }
146
+
99
147
  export interface OllamaUsageSource {
100
148
  /** Latest usage, refreshed when older than the poll interval; last good value on failure. */
101
149
  get(): Promise<OllamaUsage | null>;
102
150
  /** Last fetched value without touching the network. */
103
151
  peek(): OllamaUsage | null;
152
+ /** Ledger-vs-meter calibration, when enough metered spend has accrued. */
153
+ calibration(): OllamaCalibration | null;
104
154
  }
105
155
 
106
156
  /** Inert source for setups with no key (the daemon path without `/login ollama-cloud`). */
107
- export const NO_USAGE: OllamaUsageSource = { get: async () => null, peek: () => null };
157
+ export const NO_USAGE: OllamaUsageSource = { get: async () => null, peek: () => null, calibration: () => null };
158
+
159
+ /** Where calibration samples come from and go: the ledger's Ollama total and the plan's credits. */
160
+ export interface CalibrationDeps {
161
+ db: Database;
162
+ /** The ledger's all-time Ollama spend, read at sampling time. */
163
+ ledgerUsd(): number;
164
+ /** Configured credit override (0 = detect from the plan). */
165
+ planCreditsOverrideUsd: number;
166
+ }
108
167
 
109
168
  export function createOllamaUsageSource(
110
- opts: { apiKey: string; pollMs: number; timeoutMs: number; log: Logger; fetchImpl?: FetchLike; root?: string },
169
+ opts: { apiKey: string; pollMs: number; timeoutMs: number; log: Logger; fetchImpl?: FetchLike; root?: string; calibration?: CalibrationDeps },
111
170
  ): OllamaUsageSource {
112
171
  if (opts.apiKey === "" || opts.pollMs <= 0) return NO_USAGE;
172
+ const cal = opts.calibration;
173
+ const insertSample = cal === undefined ? null : cal.db.query("INSERT OR REPLACE INTO ollama_meter_samples (at_ms, meter_usd, ledger_usd) VALUES (?, ?, ?)");
174
+ const readSamples = cal === undefined ? null : cal.db.query("SELECT at_ms, meter_usd, ledger_usd FROM ollama_meter_samples WHERE at_ms >= ? ORDER BY at_ms ASC");
175
+ let calibrationMemo: OllamaCalibration | null = null;
176
+ function sample(usage: OllamaUsage): void {
177
+ if (cal === null || cal === undefined || insertSample === null || readSamples === null) return;
178
+ const credits = ollamaPlanCredits(usage, cal.planCreditsOverrideUsd);
179
+ if (credits === null || usage.monthlyUsedFraction === null) return;
180
+ try {
181
+ insertSample.run(usage.fetchedAtMs, usage.monthlyUsedFraction * credits, cal.ledgerUsd());
182
+ const rows = readSamples.all(usage.fetchedAtMs - 30 * 86_400_000) as { at_ms: number; meter_usd: number; ledger_usd: number }[];
183
+ calibrationMemo = calibrationFrom(rows.map((r) => ({ atMs: r.at_ms, meterUsd: r.meter_usd, ledgerUsd: r.ledger_usd })));
184
+ } catch (err) {
185
+ opts.log.debug("ollama calibration sample failed", { error: err instanceof Error ? err.message : String(err) });
186
+ }
187
+ }
113
188
  const fetchImpl = opts.fetchImpl ?? fetch;
114
189
  const root = (opts.root ?? "https://ollama.com").replace(/\/+$/, "");
115
190
  let current: OllamaUsage | null = null;
@@ -155,6 +230,7 @@ export function createOllamaUsageSource(
155
230
  if (parsed !== null) {
156
231
  current = { ...parsed, plan };
157
232
  warned = false;
233
+ sample(current);
158
234
  } else if (!warned) {
159
235
  warned = true;
160
236
  opts.log.warn("ollama usage payload had no recognisable fields; credit-aware bias stays on its last reading");
@@ -184,6 +260,7 @@ export function createOllamaUsageSource(
184
260
  return inflight;
185
261
  },
186
262
  peek: () => current,
263
+ calibration: () => calibrationMemo,
187
264
  };
188
265
  }
189
266
 
@@ -29,6 +29,14 @@ CREATE TABLE IF NOT EXISTS catalog_cache (
29
29
  key_scoped INTEGER NOT NULL DEFAULT 0
30
30
  );
31
31
 
32
+ -- Last built Ollama Cloud model set, so a restart routes from disk and
33
+ -- offline tools (tools/replay.ts) see the same composite catalog the router did.
34
+ CREATE TABLE IF NOT EXISTS ollama_catalog_cache (
35
+ id INTEGER PRIMARY KEY CHECK (id = 1),
36
+ payload TEXT NOT NULL,
37
+ fetched_at_ms INTEGER NOT NULL
38
+ );
39
+
32
40
  CREATE TABLE IF NOT EXISTS benchmark_cache (
33
41
  id INTEGER PRIMARY KEY CHECK (id = 1),
34
42
  payload TEXT NOT NULL,
@@ -111,6 +119,28 @@ CREATE TABLE IF NOT EXISTS context_blocks (
111
119
  fetched_at_ms INTEGER NOT NULL
112
120
  );
113
121
 
122
+ -- ollama.com plan-meter readings beside the ledger's own Ollama total at the
123
+ -- same instant, so the ledger's estimate can be calibrated against the bill.
124
+ CREATE TABLE IF NOT EXISTS ollama_meter_samples (
125
+ at_ms INTEGER PRIMARY KEY,
126
+ meter_usd REAL NOT NULL,
127
+ ledger_usd REAL NOT NULL
128
+ );
129
+
130
+ -- User verdicts on routed turns (/router feedback), tied to the ledger row judged.
131
+ CREATE TABLE IF NOT EXISTS feedback (
132
+ id TEXT PRIMARY KEY,
133
+ ledger_id TEXT NOT NULL,
134
+ omp_session_id TEXT NOT NULL DEFAULT '',
135
+ slug TEXT NOT NULL,
136
+ tier TEXT NOT NULL,
137
+ verdict TEXT NOT NULL,
138
+ note TEXT NOT NULL DEFAULT '',
139
+ created_at_ms INTEGER NOT NULL
140
+ );
141
+ CREATE INDEX IF NOT EXISTS idx_feedback_created ON feedback (created_at_ms);
142
+ CREATE INDEX IF NOT EXISTS idx_feedback_ledger ON feedback (ledger_id);
143
+
114
144
  CREATE TABLE IF NOT EXISTS agentdox_sessions (
115
145
  conversation_key TEXT PRIMARY KEY,
116
146
  scope TEXT NOT NULL,
@@ -166,7 +166,7 @@ describe("WIZARD_SECTIONS coverage", () => {
166
166
  // they are listed here so a typo in a new field path fails loudly.
167
167
  const KNOWN_OPTIONAL = new Set([
168
168
  "server.apiKey", "server.harnessId", "openrouter.referer",
169
- "budget.perTurnUsd", "budget.perConversationUsd", "budget.perDayUsd", "filters.maxExpectedWaitMs",
169
+ "budget.perTurnUsd", "budget.perConversationUsd", "budget.perDayUsd", "budget.perMonthUsd", "filters.maxExpectedWaitMs", "filters.latencyWeightContinuation",
170
170
  ...["trivial", "simple", "moderate", "hard"].flatMap((t) => [
171
171
  `tiers.${t}.maxInputPerMtok`, `tiers.${t}.maxOutputPerMtok`, `tiers.${t}.qualityNormalization`, `tiers.${t}.capabilityFloorUsd`,
172
172
  ]),
@@ -303,6 +303,7 @@ describe("runWizard", () => {
303
303
  CLEAR_TOKEN, // clear perTurnUsd
304
304
  "",
305
305
  "",
306
+ "", // perMonthUsd
306
307
  "",
307
308
  "s",
308
309
  ]);
@@ -0,0 +1,223 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+
3
+ import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
4
+ import type { RouterConfig } from "../src/config/types.ts";
5
+ import { createFeedbackStore } from "../src/cost/feedback.ts";
6
+ import { createLedger } from "../src/cost/ledger.ts";
7
+ import { EMPTY_USAGE, type LedgerEntry } from "../src/cost/types.ts";
8
+ import { startServer, type StartedServer } from "../src/server/http.ts";
9
+ import { ollamaRunway } from "../src/server/http.ts";
10
+ import { createSessionOverrides, OVERRIDE_TTL_MS } from "../src/server/overrides.ts";
11
+ import { openDb } from "../src/util/sqlite.ts";
12
+ import { describeOverride, parseOverrideArgs, renderWhy, type WhyEntry } from "../omp-extension/report-logic.ts";
13
+
14
+ /**
15
+ * Session controls from omp: pin/tier overrides (process-local, per omp
16
+ * session, counted down per committed dispatch), user feedback tied to the
17
+ * ledger row judged, and the /router why rendering.
18
+ */
19
+
20
+ describe("session overrides", () => {
21
+ test("set, read, count down, clear, and expire", () => {
22
+ const o = createSessionOverrides();
23
+ const now = Date.now();
24
+ expect(o.get("s1")).toBeNull();
25
+ o.set("s1", { tier: "hard", turns: 2 }, now);
26
+ expect(o.get("s1")).toEqual({ slug: null, tier: "hard", turnsLeft: 2, setAtMs: now });
27
+ // A pin added later keeps the tier and its countdown.
28
+ o.set("s1", { slug: "ollama/glm-5.3-flash" }, now + 1);
29
+ expect(o.get("s1")?.slug).toBe("ollama/glm-5.3-flash");
30
+ expect(o.get("s1")?.tier).toBe("hard");
31
+ o.consume("s1");
32
+ expect(o.get("s1")?.turnsLeft).toBe(1);
33
+ o.consume("s1");
34
+ expect(o.get("s1")).toBeNull(); // used up
35
+ o.set("s2", { slug: "x/y", turns: 0 });
36
+ o.consume("s2");
37
+ o.consume("s2");
38
+ expect(o.get("s2")?.turnsLeft).toBe(0); // unlimited never counts down
39
+ o.clear("s2");
40
+ expect(o.get("s2")).toBeNull();
41
+ o.set("s3", { tier: "simple" }, Date.now() - OVERRIDE_TTL_MS - 1);
42
+ expect(o.get("s3")).toBeNull(); // expired
43
+ expect(o.get("")).toBeNull();
44
+ });
45
+
46
+ test("clearing both fields reads as no override", () => {
47
+ const o = createSessionOverrides();
48
+ o.set("s", { tier: "hard" });
49
+ o.set("s", { tier: null });
50
+ expect(o.get("s")).toBeNull();
51
+ expect(o.list()).toHaveLength(1); // the entry exists but carries nothing
52
+ });
53
+ });
54
+
55
+ describe("feedback store", () => {
56
+ function entry(over: Partial<LedgerEntry>): LedgerEntry {
57
+ return {
58
+ id: crypto.randomUUID(),
59
+ createdAtMs: Date.now(),
60
+ conversationKey: "k",
61
+ sessionId: "s",
62
+ turn: 1,
63
+ requestedModel: "auto",
64
+ harnessId: "",
65
+ ompSessionId: "omp-1",
66
+ slug: "v/m",
67
+ servedSlug: "v/m",
68
+ tier: "simple",
69
+ classificationSource: "heuristic",
70
+ reasons: [],
71
+ features: null,
72
+ score: null,
73
+ confidence: null,
74
+ task: null,
75
+ classifierReasons: null,
76
+ exploredFrom: null,
77
+ holdArm: null,
78
+ predictedUsd: 0.001,
79
+ reportedUsd: 0.001,
80
+ usage: { ...EMPTY_USAGE, promptTokens: 10, completionTokens: 5 },
81
+ attempt: 0,
82
+ escalationSignal: null,
83
+ latencyMs: 100,
84
+ ttftMs: 50,
85
+ finishReason: "stop",
86
+ wasted: false,
87
+ upstreamGenerationId: null,
88
+ error: null,
89
+ promptTokensSaved: null,
90
+ ...over,
91
+ } as LedgerEntry;
92
+ }
93
+
94
+ test("records verdicts against the session's newest kept turn and counts them by model", () => {
95
+ const db = openDb(":memory:");
96
+ const cfg = structuredClone(DEFAULT_CONFIG);
97
+ cfg.ledger.path = ":memory:";
98
+ const ledger = createLedger(db, cfg);
99
+ const fb = createFeedbackStore(db);
100
+ ledger.record(entry({ createdAtMs: 1_000, slug: "a/m", servedSlug: "a/m" }));
101
+ ledger.record(entry({ createdAtMs: 2_000, slug: "b/m", servedSlug: "b/m", wasted: true })); // a wasted probe is never "the turn"
102
+ ledger.record(entry({ createdAtMs: 3_000, slug: "c/m", servedSlug: "c/m" }));
103
+ ledger.record(entry({ createdAtMs: 4_000, ompSessionId: "omp-2", slug: "d/m", servedSlug: "d/m" }));
104
+ const latest = ledger.latestForSession!("omp-1")!;
105
+ expect(latest.servedSlug).toBe("c/m");
106
+ expect(ledger.entriesForSession!("omp-1", 10).map((e) => e.servedSlug)).toEqual(["c/m", "a/m"]);
107
+ expect(ledger.latestForSession!("")).toBeNull();
108
+
109
+ fb.record({ ledgerId: latest.id, ompSessionId: "omp-1", slug: "c/m", tier: "simple", verdict: "bad", note: "wrong file" }, 5_000);
110
+ fb.record({ ledgerId: latest.id, ompSessionId: "omp-1", slug: "c/m", tier: "simple", verdict: "good", note: "" }, 6_000);
111
+ expect(fb.forLedgerId(latest.id).map((f) => f.verdict)).toEqual(["good", "bad"]);
112
+ expect([...fb.countsBySlug(0).entries()]).toEqual([["c/m", { good: 1, bad: 1 }]]);
113
+ expect(fb.countsBySlug(5_500).get("c/m")).toEqual({ good: 1, bad: 0 });
114
+ db.close();
115
+ });
116
+ });
117
+
118
+ describe("override and feedback endpoints", () => {
119
+ let handle: StartedServer;
120
+ let baseUrl = "";
121
+ beforeAll(() => {
122
+ const cfg: RouterConfig = {
123
+ ...structuredClone(DEFAULT_CONFIG),
124
+ server: { host: "127.0.0.1", port: 0, maxConcurrentTurns: 24 },
125
+ ledger: { ...DEFAULT_CONFIG.ledger, path: ":memory:" },
126
+ logLevel: "silent",
127
+ };
128
+ handle = startServer(cfg);
129
+ baseUrl = `http://127.0.0.1:${handle.server.port}`;
130
+ });
131
+ afterAll(async () => {
132
+ await handle.stop();
133
+ });
134
+ const post = (path: string, body: unknown) =>
135
+ fetch(`${baseUrl}${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
136
+
137
+ test("override: validates, sets a tier with a countdown, shows it, clears it", async () => {
138
+ expect((await post("/v1/router/override", { tier: "hard" })).status).toBe(400); // no session
139
+ expect((await post("/v1/router/override", { ompSessionId: "s", tier: "epic" })).status).toBe(400);
140
+ expect((await post("/v1/router/override", { ompSessionId: "s", slug: "nope/model" })).status).toBe(404);
141
+ const set = (await (await post("/v1/router/override", { ompSessionId: "s", tier: "hard", turns: 3 })).json()) as { override: { tier: string; turnsLeft: number } };
142
+ expect(set.override.tier).toBe("hard");
143
+ expect(set.override.turnsLeft).toBe(3);
144
+ const shown = (await (await fetch(`${baseUrl}/v1/router/override?session=s`)).json()) as { override: { tier: string } | null };
145
+ expect(shown.override?.tier).toBe("hard");
146
+ const all = (await (await fetch(`${baseUrl}/v1/router/override`)).json()) as { overrides: unknown[] };
147
+ expect(all.overrides).toHaveLength(1);
148
+ const cleared = (await (await post("/v1/router/override", { ompSessionId: "s", clear: true })).json()) as { override: null };
149
+ expect(cleared.override).toBeNull();
150
+ });
151
+
152
+ test("feedback: rejects a bad verdict and a session with no turns", async () => {
153
+ expect((await post("/v1/router/feedback", { ompSessionId: "s", verdict: "meh" })).status).toBe(400);
154
+ expect((await post("/v1/router/feedback", { ompSessionId: "never", verdict: "good" })).status).toBe(404);
155
+ });
156
+
157
+ test("decisions can be narrowed to a session", async () => {
158
+ const body = (await (await fetch(`${baseUrl}/v1/router/decisions?session=none`)).json()) as { entries: unknown[] };
159
+ expect(body.entries).toEqual([]);
160
+ });
161
+ });
162
+
163
+ describe("/router why and override parsing", () => {
164
+ const entry: WhyEntry = {
165
+ id: "abc",
166
+ createdAtMs: 1_000_000,
167
+ turn: 12,
168
+ slug: "z-ai/glm-5.3-flash",
169
+ servedSlug: "ollama/glm-5.3-flash",
170
+ tier: "moderate",
171
+ classificationSource: "heuristic",
172
+ confidence: 0.42,
173
+ task: "coding",
174
+ reasons: ["classified moderate", "cache: keeping warm ollama/glm-5.3-flash (stay $0.0010 ≤ switch $0.0300 × 1.3)"],
175
+ classifierReasons: ["+0.10 complexity keyword: refactor"],
176
+ reportedUsd: 0.0123,
177
+ predictedUsd: 0.02,
178
+ usage: { promptTokens: 120_000, cachedTokens: 100_000, completionTokens: 300, cachedEstimated: true },
179
+ latencyMs: 4_200,
180
+ ttftMs: 900,
181
+ attempt: 0,
182
+ escalationSignal: null,
183
+ feedback: [{ verdict: "bad", note: "edited the wrong file", createdAtMs: 1_000_500 }],
184
+ };
185
+
186
+ test("renderWhy names the model, tier, confidence, cost, cache, trail and feedback", () => {
187
+ const text = renderWhy(entry, 1_060_000);
188
+ expect(text).toContain("turn 12 · 1m ago · ollama · ollama/glm-5.3-flash (asked z-ai/glm-5.3-flash) [moderate]");
189
+ expect(text).toContain("classified moderate by heuristic at 42% confidence · task coding");
190
+ expect(text).toContain("cost $0.0123 · prompt 120,000 tok (cache 83% est.) · completion 300 tok · ttft 0.9s · total 4.2s");
191
+ expect(text).toContain(" - cache: keeping warm");
192
+ expect(text).toContain(" - +0.10 complexity keyword: refactor");
193
+ expect(text).toContain(" - bad: edited the wrong file");
194
+ });
195
+
196
+ test("parseOverrideArgs handles pin, tier with turns, off, and errors", () => {
197
+ expect(parseOverrideArgs("pin", "ollama/glm-5.3-flash")).toEqual({ kind: "pin", slug: "ollama/glm-5.3-flash" });
198
+ expect(parseOverrideArgs("pin", "off")).toEqual({ kind: "pin", slug: null });
199
+ expect(parseOverrideArgs("pin", "")).toEqual({ kind: "show" });
200
+ expect(parseOverrideArgs("tier", "hard")).toEqual({ kind: "tier", tier: "hard", turns: 10 });
201
+ expect(parseOverrideArgs("tier", "Simple 3")).toEqual({ kind: "tier", tier: "simple", turns: 3 });
202
+ expect(parseOverrideArgs("tier", "off")).toEqual({ kind: "tier", tier: null, turns: 0 });
203
+ expect(parseOverrideArgs("tier", "epic").kind).toBe("error");
204
+ expect(parseOverrideArgs("tier", "hard x").kind).toBe("error");
205
+ });
206
+
207
+ test("describeOverride summarises what is in force", () => {
208
+ expect(describeOverride(null)).toBe("no override on this session");
209
+ expect(describeOverride({ slug: "a/b", tier: "hard", turnsLeft: 1 })).toBe("pinned to a/b, tier forced to hard · 1 turn left");
210
+ expect(describeOverride({ slug: null, tier: "simple", turnsLeft: 0 })).toBe("tier forced to simple · until cleared");
211
+ });
212
+ });
213
+
214
+ describe("ollamaRunway", () => {
215
+ test("days of credits left at the calibrated weekly burn", () => {
216
+ const r = ollamaRunway({ usedUsd: 6.3, creditsUsd: 60 }, 7, 1.25)!;
217
+ expect(r.dailyBurnUsd).toBeCloseTo(1.25, 6);
218
+ expect(r.creditsLeftUsd).toBeCloseTo(53.7, 6);
219
+ expect(r.days).toBeCloseTo(53.7 / 1.25, 6);
220
+ expect(ollamaRunway({ usedUsd: 6.3, creditsUsd: 60 }, 0, 1)!.days).toBeNull();
221
+ expect(ollamaRunway(null, 7, 1)).toBeNull();
222
+ });
223
+ });
@@ -46,10 +46,10 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
46
46
  data: { axis: "intelligence", minQuality: 0 },
47
47
  chat: { axis: "intelligence", minQuality: 0 },
48
48
  },
49
- filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, latencyMinSamples: 20, escalationCostWeight: 0 },
49
+ filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
50
50
  classifier: {
51
51
  ambiguityThreshold: 0,
52
- model: "test/adjudicator",
52
+ model: "test/adjudicator", learnedModelPath: "",
53
53
  maxCostFraction: 0.1,
54
54
  maxCostUsd: 0.01,
55
55
  timeoutMs: 5000,
@@ -76,6 +76,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
76
76
  context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, maxQueue: 64 },
77
77
  compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true, replanGrowthRatio: 1 },
78
78
  budget: { onExceeded: "downgrade" },
79
+ report: { baselines: [] },
79
80
  profiles: [],
80
81
  ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 },
81
82
  adaptiveTierFloors: true,
@@ -306,3 +306,34 @@ describe("user-visible tool failure (review 2026-09-05)", () => {
306
306
  expect(f.lastToolFailed).toBe(false);
307
307
  });
308
308
  });
309
+
310
+ describe("prompt anatomy", () => {
311
+ test("splits prompt bytes by role and marks the older half and stale tool results", () => {
312
+ // 24 non-system messages: 12 tool-call/result pairs. The newest 20
313
+ // non-system messages are "fresh"; the 4 before them hold 2 stale tool results.
314
+ const messages: unknown[] = [SYSTEM];
315
+ for (let i = 0; i < 12; i++) {
316
+ messages.push(toolCall(`c${i}`, "bash", `{"command":"ls ${i}"}`));
317
+ messages.push({ role: "tool", tool_call_id: `c${i}`, content: "x".repeat(100) });
318
+ }
319
+ messages.push({ role: "user", content: "y".repeat(50) });
320
+ const f = extractFeatures(req(messages), 5000);
321
+ const a = f.anatomy!;
322
+ expect(a.messages).toBe(26);
323
+ expect(a.systemBytes).toBe("You are a coding agent.".length);
324
+ expect(a.userBytes).toBe(50);
325
+ expect(a.toolBytes).toBe(1200);
326
+ // 25 non-system messages; the older half is the first 12 (6 pairs ⇒ 6 tool results).
327
+ expect(a.olderHalfBytes).toBeGreaterThanOrEqual(600);
328
+ expect(a.olderHalfBytes).toBeLessThan(1200);
329
+ // Stale: tool results among the first 25-20 = 5 non-system messages ⇒ results at index 1 and 3.
330
+ expect(a.staleToolBytes).toBe(200);
331
+ });
332
+
333
+ test("a bare chat request has no stale tool bytes", () => {
334
+ const a = extractFeatures(req([SYSTEM, { role: "user", content: "hi" }]), 20).anatomy!;
335
+ expect(a.toolBytes).toBe(0);
336
+ expect(a.staleToolBytes).toBe(0);
337
+ expect(a.olderHalfBytes).toBe(0);
338
+ });
339
+ });
@@ -0,0 +1,61 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { auc, FEATURE_NAMES, learnedVector, predictRisk, trainLogistic, type LearnedModel } from "../src/router/learned.ts";
4
+
5
+ /**
6
+ * The learned escalation-risk model: a deterministic logistic regression that
7
+ * must separate a synthetic dataset, score by rank correctly, and tolerate
8
+ * old ledger rows missing fields.
9
+ */
10
+
11
+ describe("learnedVector", () => {
12
+ test("matches FEATURE_NAMES in length and tolerates missing fields", () => {
13
+ const v = learnedVector({});
14
+ expect(v).toHaveLength(FEATURE_NAMES.length);
15
+ expect(v.every((x) => x === 0)).toBe(true);
16
+ const w = learnedVector({ promptTokens: 1000, lastToolFailed: true, requestedReasoning: "high", complexityKeywords: ["refactor", "migrate"] });
17
+ expect(w[FEATURE_NAMES.indexOf("log_prompt_tokens")]).toBeCloseTo(Math.log1p(1000), 6);
18
+ expect(w[FEATURE_NAMES.indexOf("last_tool_failed")]).toBe(1);
19
+ expect(w[FEATURE_NAMES.indexOf("requested_reasoning")]).toBe(3);
20
+ expect(w[FEATURE_NAMES.indexOf("complexity_keywords")]).toBe(2);
21
+ });
22
+ });
23
+
24
+ describe("auc", () => {
25
+ test("perfect ranking is 1, inverted is 0, ties count half", () => {
26
+ expect(auc([0.9, 0.8, 0.1, 0.2], [1, 1, 0, 0])).toBe(1);
27
+ expect(auc([0.1, 0.2, 0.9, 0.8], [1, 1, 0, 0])).toBe(0);
28
+ expect(auc([0.5, 0.5], [1, 0])).toBe(0.5);
29
+ expect(auc([0.3], [1])).toBe(0.5);
30
+ });
31
+ });
32
+
33
+ describe("trainLogistic", () => {
34
+ test("separates a dataset where escalation follows failed tools and long prompts", () => {
35
+ const xs: number[][] = [];
36
+ const ys: number[] = [];
37
+ let seed = 7;
38
+ const rnd = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648);
39
+ for (let i = 0; i < 2000; i++) {
40
+ const failed = rnd() < 0.1;
41
+ const prompt = Math.floor(rnd() * 150_000);
42
+ const y = failed && prompt > 60_000 ? (rnd() < 0.9 ? 1 : 0) : rnd() < 0.02 ? 1 : 0;
43
+ xs.push(learnedVector({ promptTokens: prompt, lastToolFailed: failed, turnDepth: Math.floor(rnd() * 100), toolCount: 12 }));
44
+ ys.push(y);
45
+ }
46
+ const fit = trainLogistic(xs.slice(0, 1600), ys.slice(0, 1600), { epochs: 300 });
47
+ const model: LearnedModel = { version: 1, trainedAtMs: 0, rows: 1600, positives: 0, names: [...FEATURE_NAMES], means: fit.means, stds: fit.stds, weights: fit.weights, bias: fit.bias, auc: 0 };
48
+ const scores = xs.slice(1600).map((x) => {
49
+ // predictRisk takes features; rebuild the same vector through it for parity.
50
+ const f = { promptTokens: Math.expm1(x[0]!), lastToolFailed: x[8] === 1, turnDepth: x[2]!, toolCount: x[3]! };
51
+ return predictRisk(model, f);
52
+ });
53
+ expect(auc(scores, ys.slice(1600))).toBeGreaterThan(0.85);
54
+ expect(fit.weights[FEATURE_NAMES.indexOf("last_tool_failed")]!).toBeGreaterThan(0);
55
+ expect(fit.weights[FEATURE_NAMES.indexOf("log_prompt_tokens")]!).toBeGreaterThan(0);
56
+ });
57
+
58
+ test("refuses an empty dataset", () => {
59
+ expect(() => trainLogistic([], [])).toThrow();
60
+ });
61
+ });