auto-model-router 0.3.4 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +29 -4
  3. package/omp-extension/report-logic.ts +93 -0
  4. package/omp-extension/router-configure.ts +128 -2
  5. package/omp-extension/router-embed.ts +9 -6
  6. package/package.json +1 -1
  7. package/src/catalog/ollama-catalog.ts +30 -2
  8. package/src/cli/config-wizard.ts +11 -0
  9. package/src/cli/report.ts +7 -2
  10. package/src/config/defaults.ts +17 -0
  11. package/src/config/schema.ts +8 -0
  12. package/src/config/types.ts +65 -0
  13. package/src/cost/feedback.ts +81 -0
  14. package/src/cost/ledger.ts +110 -5
  15. package/src/cost/report.ts +118 -2
  16. package/src/cost/types.ts +32 -1
  17. package/src/router/candidates.ts +13 -4
  18. package/src/router/classify.ts +13 -0
  19. package/src/router/features.ts +59 -1
  20. package/src/router/index.ts +23 -5
  21. package/src/router/learned.ts +202 -0
  22. package/src/router/select.ts +64 -8
  23. package/src/router/types.ts +39 -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 +16 -1
  28. package/src/upstream/ollama-usage.ts +79 -2
  29. package/src/util/sqlite.ts +30 -0
  30. package/src/wire/openai/request.ts +4 -0
  31. package/src/wire/types.ts +2 -0
  32. package/test/classify.test.ts +13 -0
  33. package/test/config-wizard.test.ts +8 -6
  34. package/test/controls.test.ts +238 -0
  35. package/test/escalate.test.ts +1 -0
  36. package/test/failover.test.ts +6 -4
  37. package/test/features.test.ts +63 -0
  38. package/test/http-resilience.test.ts +1 -1
  39. package/test/learned.test.ts +61 -0
  40. package/test/ollama.test.ts +74 -2
  41. package/test/report-hub.test.ts +6 -2
  42. package/test/report-logic.test.ts +3 -0
  43. package/test/report.test.ts +57 -0
  44. package/test/select.test.ts +126 -1
  45. package/test/trust-attribution.test.ts +95 -0
  46. package/test/turn.test.ts +36 -4
  47. package/tools/replay.ts +267 -156
  48. package/tools/train-classifier.ts +111 -0
@@ -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
  }
@@ -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,
@@ -292,6 +292,9 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
292
292
  // ⇒ the server falls back to its configured default scope.
293
293
  const agentdoxScope = (headers.get("x-agentdox-scope") ?? "").trim();
294
294
 
295
+ // Subagent marker from the embed extension (sessions without a UI).
296
+ const isSubagent = (headers.get("x-omp-subagent") ?? "").trim() === "1";
297
+
295
298
  if (typeof b.model !== "string" || b.model.length === 0) {
296
299
  throw invalidRequest("model must be a non-empty string");
297
300
  }
@@ -352,6 +355,7 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
352
355
  harnessId,
353
356
  ompSessionId,
354
357
  agentdoxScope,
358
+ isSubagent,
355
359
  requestedModel,
356
360
  messages,
357
361
  tools,
package/src/wire/types.ts CHANGED
@@ -82,6 +82,8 @@ export interface NormRequest {
82
82
  * and if that is empty too the bridge stays inert for this request.
83
83
  */
84
84
  agentdoxScope: string;
85
+ /** `X-Omp-Subagent: 1`: the caller is an omp subagent (a session without a UI). */
86
+ isSubagent: boolean;
85
87
  /** Virtual model the client selected, e.g. `auto`, `auto-cheap`, `auto-max`. */
86
88
  requestedModel: string;
87
89
  messages: NormMessage[];
@@ -458,3 +458,16 @@ describe("classifyTask", () => {
458
458
  expect(classifyTask(featuresFor([SYSTEM, { role: "user", content: "explain the architecture of the system" }], []))).toBe("documentation");
459
459
  });
460
460
  });
461
+
462
+ describe("classifier.readOnlyToolWeight", () => {
463
+ test("subtracts only when enabled and the tail is a read-only loop", () => {
464
+ const base = { ...featuresFor([{ role: "user", content: "look" }]), isToolResultContinuation: true, readOnlyToolTail: true };
465
+ const off = scoreHeuristic(base, DEFAULT_CONFIG);
466
+ const cfg = structuredClone(DEFAULT_CONFIG);
467
+ cfg.classifier.readOnlyToolWeight = 0.1;
468
+ const on = scoreHeuristic(base, cfg);
469
+ expect(on.score).toBeCloseTo(Math.max(0, off.score - 0.1), 6);
470
+ expect(on.reasons.some((r) => r.includes("read-only tool loop"))).toBe(true);
471
+ expect(scoreHeuristic({ ...base, readOnlyToolTail: false }, cfg).score).toBeCloseTo(off.score, 6);
472
+ });
473
+ });
@@ -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
  ]),
@@ -280,6 +280,7 @@ describe("runWizard", () => {
280
280
  "", // keep apiKey
281
281
  "", // keep harnessId
282
282
  "", // keep maxConcurrentTurns
283
+ "", // keep subagentProfile
283
284
  "s",
284
285
  ]);
285
286
  expect(partial).toEqual({ server: { port: 9000 } });
@@ -303,6 +304,7 @@ describe("runWizard", () => {
303
304
  CLEAR_TOKEN, // clear perTurnUsd
304
305
  "",
305
306
  "",
307
+ "", // perMonthUsd
306
308
  "",
307
309
  "s",
308
310
  ]);
@@ -357,7 +359,7 @@ describe("runWizard: profiles", () => {
357
359
  const profiles = (partial ?? {})["profiles"];
358
360
  expect(Array.isArray(profiles)).toBe(true);
359
361
  if (!Array.isArray(profiles)) return;
360
- expect(profiles).toHaveLength(3);
362
+ expect(profiles).toHaveLength(4);
361
363
  expect(profiles[0]).toMatchObject({ id: "auto", contextWindow: 500000 });
362
364
  expect(profiles[1]).toMatchObject({ id: "auto-cheap", contextWindow: 400000 });
363
365
  });
@@ -378,8 +380,8 @@ describe("runWizard: profiles", () => {
378
380
  const profiles = (partial ?? {})["profiles"];
379
381
  expect(Array.isArray(profiles)).toBe(true);
380
382
  if (!Array.isArray(profiles)) return;
381
- expect(profiles).toHaveLength(4);
382
- expect(profiles[3]).toEqual({
383
+ expect(profiles).toHaveLength(5);
384
+ expect(profiles[4]).toEqual({
383
385
  id: "auto-fast",
384
386
  name: "Auto Fast",
385
387
  minTier: "trivial",
@@ -399,11 +401,11 @@ describe("runWizard: profiles", () => {
399
401
  const profiles = (partial ?? {})["profiles"];
400
402
  expect(Array.isArray(profiles)).toBe(true);
401
403
  if (!Array.isArray(profiles)) return;
402
- expect(profiles.map((p) => (p as Record<string, unknown>)["id"])).toEqual(["auto", "auto-max"]);
404
+ expect(profiles.map((p) => (p as Record<string, unknown>)["id"])).toEqual(["auto", "auto-max", "auto-sub"]);
403
405
  });
404
406
 
405
407
  test("refuses to delete the last remaining profile", async () => {
406
- const { out } = await drive(["p", "x3", "x2", "x1", "b", "q"]);
408
+ const { out } = await drive(["p", "x4", "x3", "x2", "x1", "b", "q"]);
407
409
  expect(out).toContain("cannot delete the last profile");
408
410
  });
409
411
 
@@ -0,0 +1,238 @@
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 { resolveProfile } from "../src/router/index.ts";
10
+ import { ollamaRunway } from "../src/server/http.ts";
11
+ import { createSessionOverrides, OVERRIDE_TTL_MS } from "../src/server/overrides.ts";
12
+ import { openDb } from "../src/util/sqlite.ts";
13
+ import { describeOverride, parseOverrideArgs, renderWhy, type WhyEntry } from "../omp-extension/report-logic.ts";
14
+
15
+ /**
16
+ * Session controls from omp: pin/tier overrides (process-local, per omp
17
+ * session, counted down per committed dispatch), user feedback tied to the
18
+ * ledger row judged, and the /router why rendering.
19
+ */
20
+
21
+ describe("session overrides", () => {
22
+ test("set, read, count down, clear, and expire", () => {
23
+ const o = createSessionOverrides();
24
+ const now = Date.now();
25
+ expect(o.get("s1")).toBeNull();
26
+ o.set("s1", { tier: "hard", turns: 2 }, now);
27
+ expect(o.get("s1")).toEqual({ slug: null, tier: "hard", turnsLeft: 2, setAtMs: now });
28
+ // A pin added later keeps the tier and its countdown.
29
+ o.set("s1", { slug: "ollama/glm-5.3-flash" }, now + 1);
30
+ expect(o.get("s1")?.slug).toBe("ollama/glm-5.3-flash");
31
+ expect(o.get("s1")?.tier).toBe("hard");
32
+ o.consume("s1");
33
+ expect(o.get("s1")?.turnsLeft).toBe(1);
34
+ o.consume("s1");
35
+ expect(o.get("s1")).toBeNull(); // used up
36
+ o.set("s2", { slug: "x/y", turns: 0 });
37
+ o.consume("s2");
38
+ o.consume("s2");
39
+ expect(o.get("s2")?.turnsLeft).toBe(0); // unlimited never counts down
40
+ o.clear("s2");
41
+ expect(o.get("s2")).toBeNull();
42
+ o.set("s3", { tier: "simple" }, Date.now() - OVERRIDE_TTL_MS - 1);
43
+ expect(o.get("s3")).toBeNull(); // expired
44
+ expect(o.get("")).toBeNull();
45
+ });
46
+
47
+ test("clearing both fields reads as no override", () => {
48
+ const o = createSessionOverrides();
49
+ o.set("s", { tier: "hard" });
50
+ o.set("s", { tier: null });
51
+ expect(o.get("s")).toBeNull();
52
+ expect(o.list()).toHaveLength(1); // the entry exists but carries nothing
53
+ });
54
+ });
55
+
56
+ describe("feedback store", () => {
57
+ function entry(over: Partial<LedgerEntry>): LedgerEntry {
58
+ return {
59
+ id: crypto.randomUUID(),
60
+ createdAtMs: Date.now(),
61
+ conversationKey: "k",
62
+ sessionId: "s",
63
+ turn: 1,
64
+ requestedModel: "auto",
65
+ harnessId: "",
66
+ ompSessionId: "omp-1",
67
+ slug: "v/m",
68
+ servedSlug: "v/m",
69
+ tier: "simple",
70
+ classificationSource: "heuristic",
71
+ reasons: [],
72
+ features: null,
73
+ score: null,
74
+ confidence: null,
75
+ task: null,
76
+ classifierReasons: null,
77
+ exploredFrom: null,
78
+ holdArm: null,
79
+ predictedUsd: 0.001,
80
+ reportedUsd: 0.001,
81
+ usage: { ...EMPTY_USAGE, promptTokens: 10, completionTokens: 5 },
82
+ attempt: 0,
83
+ escalationSignal: null,
84
+ latencyMs: 100,
85
+ ttftMs: 50,
86
+ finishReason: "stop",
87
+ wasted: false,
88
+ upstreamGenerationId: null,
89
+ error: null,
90
+ promptTokensSaved: null,
91
+ ...over,
92
+ } as LedgerEntry;
93
+ }
94
+
95
+ test("records verdicts against the session's newest kept turn and counts them by model", () => {
96
+ const db = openDb(":memory:");
97
+ const cfg = structuredClone(DEFAULT_CONFIG);
98
+ cfg.ledger.path = ":memory:";
99
+ const ledger = createLedger(db, cfg);
100
+ const fb = createFeedbackStore(db);
101
+ ledger.record(entry({ createdAtMs: 1_000, slug: "a/m", servedSlug: "a/m" }));
102
+ ledger.record(entry({ createdAtMs: 2_000, slug: "b/m", servedSlug: "b/m", wasted: true })); // a wasted probe is never "the turn"
103
+ ledger.record(entry({ createdAtMs: 3_000, slug: "c/m", servedSlug: "c/m" }));
104
+ ledger.record(entry({ createdAtMs: 4_000, ompSessionId: "omp-2", slug: "d/m", servedSlug: "d/m" }));
105
+ const latest = ledger.latestForSession!("omp-1")!;
106
+ expect(latest.servedSlug).toBe("c/m");
107
+ expect(ledger.entriesForSession!("omp-1", 10).map((e) => e.servedSlug)).toEqual(["c/m", "a/m"]);
108
+ expect(ledger.latestForSession!("")).toBeNull();
109
+
110
+ fb.record({ ledgerId: latest.id, ompSessionId: "omp-1", slug: "c/m", tier: "simple", verdict: "bad", note: "wrong file" }, 5_000);
111
+ fb.record({ ledgerId: latest.id, ompSessionId: "omp-1", slug: "c/m", tier: "simple", verdict: "good", note: "" }, 6_000);
112
+ expect(fb.forLedgerId(latest.id).map((f) => f.verdict)).toEqual(["good", "bad"]);
113
+ expect([...fb.countsBySlug(0).entries()]).toEqual([["c/m", { good: 1, bad: 1 }]]);
114
+ expect(fb.countsBySlug(5_500).get("c/m")).toEqual({ good: 1, bad: 0 });
115
+ db.close();
116
+ });
117
+ });
118
+
119
+ describe("override and feedback endpoints", () => {
120
+ let handle: StartedServer;
121
+ let baseUrl = "";
122
+ beforeAll(() => {
123
+ const cfg: RouterConfig = {
124
+ ...structuredClone(DEFAULT_CONFIG),
125
+ server: { host: "127.0.0.1", port: 0, maxConcurrentTurns: 24, subagentProfile: "auto-sub" },
126
+ ledger: { ...DEFAULT_CONFIG.ledger, path: ":memory:" },
127
+ logLevel: "silent",
128
+ };
129
+ handle = startServer(cfg);
130
+ baseUrl = `http://127.0.0.1:${handle.server.port}`;
131
+ });
132
+ afterAll(async () => {
133
+ await handle.stop();
134
+ });
135
+ const post = (path: string, body: unknown) =>
136
+ fetch(`${baseUrl}${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
137
+
138
+ test("override: validates, sets a tier with a countdown, shows it, clears it", async () => {
139
+ expect((await post("/v1/router/override", { tier: "hard" })).status).toBe(400); // no session
140
+ expect((await post("/v1/router/override", { ompSessionId: "s", tier: "epic" })).status).toBe(400);
141
+ expect((await post("/v1/router/override", { ompSessionId: "s", slug: "nope/model" })).status).toBe(404);
142
+ const set = (await (await post("/v1/router/override", { ompSessionId: "s", tier: "hard", turns: 3 })).json()) as { override: { tier: string; turnsLeft: number } };
143
+ expect(set.override.tier).toBe("hard");
144
+ expect(set.override.turnsLeft).toBe(3);
145
+ const shown = (await (await fetch(`${baseUrl}/v1/router/override?session=s`)).json()) as { override: { tier: string } | null };
146
+ expect(shown.override?.tier).toBe("hard");
147
+ const all = (await (await fetch(`${baseUrl}/v1/router/override`)).json()) as { overrides: unknown[] };
148
+ expect(all.overrides).toHaveLength(1);
149
+ const cleared = (await (await post("/v1/router/override", { ompSessionId: "s", clear: true })).json()) as { override: null };
150
+ expect(cleared.override).toBeNull();
151
+ });
152
+
153
+ test("feedback: rejects a bad verdict and a session with no turns", async () => {
154
+ expect((await post("/v1/router/feedback", { ompSessionId: "s", verdict: "meh" })).status).toBe(400);
155
+ expect((await post("/v1/router/feedback", { ompSessionId: "never", verdict: "good" })).status).toBe(404);
156
+ });
157
+
158
+ test("decisions can be narrowed to a session", async () => {
159
+ const body = (await (await fetch(`${baseUrl}/v1/router/decisions?session=none`)).json()) as { entries: unknown[] };
160
+ expect(body.entries).toEqual([]);
161
+ });
162
+ });
163
+
164
+ describe("/router why and override parsing", () => {
165
+ const entry: WhyEntry = {
166
+ id: "abc",
167
+ createdAtMs: 1_000_000,
168
+ turn: 12,
169
+ slug: "z-ai/glm-5.3-flash",
170
+ servedSlug: "ollama/glm-5.3-flash",
171
+ tier: "moderate",
172
+ classificationSource: "heuristic",
173
+ confidence: 0.42,
174
+ task: "coding",
175
+ reasons: ["classified moderate", "cache: keeping warm ollama/glm-5.3-flash (stay $0.0010 ≤ switch $0.0300 × 1.3)"],
176
+ classifierReasons: ["+0.10 complexity keyword: refactor"],
177
+ reportedUsd: 0.0123,
178
+ predictedUsd: 0.02,
179
+ usage: { promptTokens: 120_000, cachedTokens: 100_000, completionTokens: 300, cachedEstimated: true },
180
+ latencyMs: 4_200,
181
+ ttftMs: 900,
182
+ attempt: 0,
183
+ escalationSignal: null,
184
+ feedback: [{ verdict: "bad", note: "edited the wrong file", createdAtMs: 1_000_500 }],
185
+ };
186
+
187
+ test("renderWhy names the model, tier, confidence, cost, cache, trail and feedback", () => {
188
+ const text = renderWhy(entry, 1_060_000);
189
+ expect(text).toContain("turn 12 · 1m ago · ollama · ollama/glm-5.3-flash (asked z-ai/glm-5.3-flash) [moderate]");
190
+ expect(text).toContain("classified moderate by heuristic at 42% confidence · task coding");
191
+ expect(text).toContain("cost $0.0123 · prompt 120,000 tok (cache 83% est.) · completion 300 tok · ttft 0.9s · total 4.2s");
192
+ expect(text).toContain(" - cache: keeping warm");
193
+ expect(text).toContain(" - +0.10 complexity keyword: refactor");
194
+ expect(text).toContain(" - bad: edited the wrong file");
195
+ });
196
+
197
+ test("parseOverrideArgs handles pin, tier with turns, off, and errors", () => {
198
+ expect(parseOverrideArgs("pin", "ollama/glm-5.3-flash")).toEqual({ kind: "pin", slug: "ollama/glm-5.3-flash" });
199
+ expect(parseOverrideArgs("pin", "off")).toEqual({ kind: "pin", slug: null });
200
+ expect(parseOverrideArgs("pin", "")).toEqual({ kind: "show" });
201
+ expect(parseOverrideArgs("tier", "hard")).toEqual({ kind: "tier", tier: "hard", turns: 10 });
202
+ expect(parseOverrideArgs("tier", "Simple 3")).toEqual({ kind: "tier", tier: "simple", turns: 3 });
203
+ expect(parseOverrideArgs("tier", "off")).toEqual({ kind: "tier", tier: null, turns: 0 });
204
+ expect(parseOverrideArgs("tier", "epic").kind).toBe("error");
205
+ expect(parseOverrideArgs("tier", "hard x").kind).toBe("error");
206
+ });
207
+
208
+ test("describeOverride summarises what is in force", () => {
209
+ expect(describeOverride(null)).toBe("no override on this session");
210
+ expect(describeOverride({ slug: "a/b", tier: "hard", turnsLeft: 1 })).toBe("pinned to a/b, tier forced to hard · 1 turn left");
211
+ expect(describeOverride({ slug: null, tier: "simple", turnsLeft: 0 })).toBe("tier forced to simple · until cleared");
212
+ });
213
+ });
214
+
215
+ describe("ollamaRunway", () => {
216
+ test("days of credits left at the calibrated weekly burn", () => {
217
+ const r = ollamaRunway({ usedUsd: 6.3, creditsUsd: 60 }, 7, 1.25)!;
218
+ expect(r.dailyBurnUsd).toBeCloseTo(1.25, 6);
219
+ expect(r.creditsLeftUsd).toBeCloseTo(53.7, 6);
220
+ expect(r.days).toBeCloseTo(53.7 / 1.25, 6);
221
+ expect(ollamaRunway({ usedUsd: 6.3, creditsUsd: 60 }, 0, 1)!.days).toBeNull();
222
+ expect(ollamaRunway(null, 7, 1)).toBeNull();
223
+ });
224
+ });
225
+
226
+ describe("subagent profile", () => {
227
+ test("a subagent asking for the default profile is routed under server.subagentProfile; explicit profiles are honoured", () => {
228
+ const cfg = structuredClone(DEFAULT_CONFIG);
229
+ expect(resolveProfile(cfg, "auto", true).id).toBe("auto-sub");
230
+ expect(resolveProfile(cfg, "auto", false).id).toBe("auto");
231
+ expect(resolveProfile(cfg, "auto-max", true).id).toBe("auto-max");
232
+ expect(resolveProfile(cfg, "unknown", true).id).toBe("auto-sub"); // unknown ids fall back to the default, which a subagent remaps
233
+ cfg.server.subagentProfile = "";
234
+ expect(resolveProfile(cfg, "auto", true).id).toBe("auto");
235
+ cfg.server.subagentProfile = "nope";
236
+ expect(resolveProfile(cfg, "auto", true).id).toBe("auto");
237
+ });
238
+ });
@@ -24,6 +24,7 @@ function req(messages: NormMessage[] = [], over: Partial<NormRequest> = {}): Nor
24
24
  harnessId: "",
25
25
  ompSessionId: "",
26
26
  agentdoxScope: "",
27
+ isSubagent: false,
27
28
  requestedModel: "auto",
28
29
  messages,
29
30
  tools: [],