auto-model-router 0.4.3 → 0.4.5

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.
package/.gitattributes ADDED
@@ -0,0 +1,2 @@
1
+ # SQLite fixtures must never get line-ending conversion (core.autocrlf is on for this checkout).
2
+ *.db binary
@@ -7,14 +7,14 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
10
- "version": "0.4.3",
10
+ "version": "0.4.5",
11
11
  "pluginRoot": "."
12
12
  },
13
13
  "plugins": [
14
14
  {
15
15
  "name": "auto-model-router",
16
16
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
17
- "version": "0.4.3",
17
+ "version": "0.4.5",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -562,7 +562,9 @@ as the placeholder — empty input keeps it, `-` clears an optional field,
562
562
  credentials show as `set`/`unset` and are never echoed. `Save and exit` writes the merged config
563
563
  (schema-checked and backed up first). Tier, task, filter, classifier,
564
564
  hysteresis, exploration, compaction, cache and budget changes hot-reload;
565
- restart omp for `server`, `openrouter`, `ollama`, `context` and `ledger`.
565
+ restart omp for `server` (except `subagentProfile`), `openrouter`, `context`,
566
+ `ledger.path` and the Ollama connection keys; `ollama.costBias`,
567
+ `ollama.biasUntilUsage` and `ledger.retentionDays` hot-reload too.
566
568
 
567
569
  ### Via `auto-model-router config` (text wizard / CLI)
568
570
 
@@ -822,6 +824,12 @@ is a ledger row (`requestedModel` `digest`) and the report totals them.
822
824
  | `maxCostUsd` | `0.02` | Skip when the digest itself would cost more. |
823
825
  | `timeoutMs` | `25000` | The raw result stands if the cheap model is slower. |
824
826
 
827
+ Quality signal: when the agent later calls the same tool with the same
828
+ primary argument (re-reads a digested file, re-runs a digested grep), the
829
+ router marks that digest's ledger row wasted. The report's `digests` line
830
+ shows the re-run rate; a high rate means the digest is dropping what the
831
+ task needed, and `digest.maxOutputTokens` or `digest.model` is the lever.
832
+
825
833
  ### `report` — usage-report options
826
834
 
827
835
  | Key | Default | Meaning |
@@ -829,6 +837,14 @@ is a ledger row (`requestedModel` `digest`) and the report totals them.
829
837
  | `baselines` | `anthropic/claude-opus-5`, `anthropic/claude-sonnet-5` | Models the report prices the window's traffic on as a single-model counterfactual. Unknown slugs are skipped. |
830
838
  | `dailySummary` | `true` | Post the daily summary (below) into the transcript at the first interactive omp session start of each day. Hot-reloads. |
831
839
 
840
+ ### `harnessSwitch` — harness-side model switch (experimental)
841
+
842
+ | Key | Default | Meaning |
843
+ | --- | --- | --- |
844
+ | `enabled` | `false` | Let the `router-switch` extension move omp's active model for mapped tiers. |
845
+ | `models` | `{}` | Tier → harness model as `provider/id` in omp's own registry, e.g. `hard: anthropic/claude-opus-4-8`. A tier serves itself and every tier above it up to the next mapped one; unmapped tiers stay on the router. |
846
+ | `minConfidence` | `0.6` | Advice below this heuristic confidence leaves the model where it is. |
847
+
832
848
  ### `ledger` — cost measurement
833
849
 
834
850
  | Key | Default | Meaning |
@@ -838,6 +854,7 @@ is a ledger row (`requestedModel` `digest`) and the report totals them.
838
854
  | `blendMinSamples` | `25` | Turns before the measured blend replaces the fallback. |
839
855
  | `fallbackBlend` | input `1.5`, output `7.5` | Pre-measurement blend (USD/Mtok) for omp's cost display. |
840
856
  | `conversationTtlMs` | `604800000` (7 d) | Drop conversation state untouched this long. |
857
+ | `retentionDays` | `365` | Delete ledger rows older than this, checked hourly; `0` keeps everything. The ledger grows about 2.5 MB a day under steady use. Freed pages are reused, so the file stops growing rather than shrinking. |
841
858
 
842
859
  ### Top-level
843
860
 
@@ -920,6 +937,35 @@ router handles for you: no `models[]` fallback cascade, no `tool_choice`,
920
937
  `reasoning_effort` instead of the `reasoning` object, and no `cache_control`
921
938
  markers (they are stripped before dispatch).
922
939
 
940
+ ## Harness-side model switch (experimental)
941
+
942
+ Most engineers reach Claude through a subscription, not an API key, and a
943
+ subscription model cannot be proxied: the router would have to translate to
944
+ Anthropic's wire format and carry omp's OAuth token through a third-party
945
+ process. The `router-switch` extension takes the other route. Before omp
946
+ starts a turn on a user prompt it asks the router which tier the prompt is
947
+ (`POST /v1/router/advise`, the heuristic classifier over the prompt text,
948
+ nothing dispatched or recorded). When that tier is mapped in
949
+ `harnessSwitch.models`, the extension moves omp's active model to the mapped
950
+ harness model; when a later prompt is advised below every mapped tier, it
951
+ moves back to the router model it left. A model the user picked by hand is
952
+ never touched. Native turns bill the subscription and never reach the
953
+ ledger; the router serves and accounts for the rest.
954
+
955
+ ```yaml
956
+ # ~/.auto-model-router/config.yml
957
+ harnessSwitch:
958
+ enabled: true
959
+ models:
960
+ hard: anthropic/claude-opus-4-8
961
+ ```
962
+
963
+ Install `omp-extension/router-switch.ts` beside the embed extension and
964
+ restart omp. Known limits of the prototype: the advice sees only the prompt
965
+ text, not the conversation, so a hard task that only becomes hard three tool
966
+ calls in stays on the router (the router's own escalation still applies
967
+ there); and the switch happens at prompt boundaries, never mid-turn.
968
+
923
969
  ## Multiple coding harnesses, one router
924
970
 
925
971
  A single embedded router can serve several omp sessions without them stepping
package/bunfig.toml ADDED
@@ -0,0 +1,2 @@
1
+ [test]
2
+ preload = ["./test/support/preload.ts"]
@@ -109,6 +109,12 @@ declare module "@oh-my-pi/pi-coding-agent" {
109
109
  /** Interval whose errors omp isolates, and whose handle `clearTimer` cancels. */
110
110
  setInterval(handler: () => void | Promise<void>, ms: number): unknown;
111
111
  clearTimer(timer: unknown): void;
112
+ /** The active model, when one is set. (Real type: `Model`.) */
113
+ model: { provider: string; id: string } | undefined;
114
+ /** Looks a model up in omp's registry by provider and id. */
115
+ modelRegistry: { find(provider: string, modelId: string): unknown };
116
+ /** Sets the session's active model; false when omp has no key for it. */
117
+ setModel(model: unknown): Promise<boolean>;
112
118
  }
113
119
 
114
120
  export interface CommandDefinition {
@@ -0,0 +1,100 @@
1
+ /**
2
+ * omp extension: harness-side model switch (experimental).
3
+ *
4
+ * Before omp starts a turn on a user prompt, ask the router which tier the
5
+ * prompt is, and when that tier is mapped to a harness-native model in the
6
+ * router's `harnessSwitch.models` (a Claude subscription model, typically),
7
+ * move omp's active model there; when a later prompt is advised below the
8
+ * mapped tiers, move back to the router model we left. A model the user
9
+ * picked by hand is never touched.
10
+ *
11
+ * Nothing is proxied and no token leaves omp: the native turns bill the
12
+ * subscription, the router serves the rest and keeps the ledger for those.
13
+ * Off unless `harnessSwitch.enabled` is set in the router config.
14
+ *
15
+ * # ~/.omp/agent/config.yml
16
+ * extensions:
17
+ * - /path/to/auto-model-router/omp-extension/router-embed.ts
18
+ * - /path/to/auto-model-router/omp-extension/router-switch.ts
19
+ */
20
+
21
+ import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
22
+
23
+ import { EMBED_PROVIDER_ID } from "./embed-logic.ts";
24
+ import { routerAuthHeaders, routerBaseUrl } from "./router-url.ts";
25
+ import { DISABLED_SWITCH, decideSwitch, parseSwitchPolicy, type SwitchPolicy, type TierName } from "./switch-logic.ts";
26
+
27
+ const HARNESS_ID = process.env.OMP_HARNESS_ID ?? "";
28
+ const POLICY_TTL_MS = 60_000;
29
+
30
+ export default function (pi: ExtensionAPI): void {
31
+ pi.setLabel("auto-model-router switch");
32
+
33
+ let policy: SwitchPolicy = DISABLED_SWITCH;
34
+ let policyAtMs = 0;
35
+ let switchedTo: string | null = null;
36
+ let returnTo: string | null = null;
37
+
38
+ async function refreshPolicy(): Promise<void> {
39
+ if (Date.now() - policyAtMs < POLICY_TTL_MS) return;
40
+ policyAtMs = Date.now();
41
+ try {
42
+ const res = await fetch(`${routerBaseUrl()}/v1/router/advise/policy`, { headers: routerAuthHeaders(), signal: AbortSignal.timeout(2_000) });
43
+ policy = res.ok ? parseSwitchPolicy(await res.json()) : DISABLED_SWITCH;
44
+ } catch {
45
+ policy = DISABLED_SWITCH;
46
+ }
47
+ }
48
+
49
+ pi.on("session_start", async () => {
50
+ policyAtMs = 0;
51
+ switchedTo = null;
52
+ returnTo = null;
53
+ await refreshPolicy();
54
+ });
55
+
56
+ pi.on("before_agent_start", async (event, ctx) => {
57
+ const e = event as { prompt?: string };
58
+ if (typeof e.prompt !== "string" || e.prompt.trim() === "") return undefined;
59
+ await refreshPolicy();
60
+ if (!policy.enabled) return undefined;
61
+ const activeModel = ctx.model;
62
+ const active = activeModel === undefined ? null : `${activeModel.provider}/${activeModel.id}`;
63
+ const activeIsRouter = activeModel?.provider === EMBED_PROVIDER_ID;
64
+ let advised: { tier: TierName; confidence: number };
65
+ try {
66
+ const res = await fetch(`${routerBaseUrl()}/v1/router/advise`, {
67
+ method: "POST",
68
+ headers: { ...routerAuthHeaders(), "content-type": "application/json" },
69
+ body: JSON.stringify({ ompSessionId: ctx.sessionManager.getSessionId(), harnessId: HARNESS_ID, text: e.prompt.slice(0, 8_000) }),
70
+ signal: AbortSignal.timeout(2_000),
71
+ });
72
+ if (!res.ok) return undefined;
73
+ advised = (await res.json()) as { tier: TierName; confidence: number };
74
+ } catch {
75
+ return undefined;
76
+ }
77
+ const decision = decideSwitch({ policy, advised, active, activeIsRouter, switchedTo, returnTo });
78
+ if (decision.action === "none") return undefined;
79
+ const [provider = "", ...rest] = decision.model.split("/");
80
+ const target = ctx.modelRegistry.find(provider, rest.join("/"));
81
+ if (target === undefined) {
82
+ if (ctx.hasUI) ctx.ui.notify(`router switch: ${decision.model} is not in omp's model registry`, "warn");
83
+ return undefined;
84
+ }
85
+ const ok = await ctx.setModel(target);
86
+ if (!ok) {
87
+ if (ctx.hasUI) ctx.ui.notify(`router switch: omp has no key for ${decision.model}`, "warn");
88
+ return undefined;
89
+ }
90
+ if (decision.action === "up") {
91
+ if (activeIsRouter) returnTo = active;
92
+ switchedTo = decision.model;
93
+ } else {
94
+ switchedTo = null;
95
+ returnTo = null;
96
+ }
97
+ if (ctx.hasUI) ctx.ui.notify(`router switch: ${decision.reason}`, "info");
98
+ return undefined;
99
+ });
100
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Pure decision logic behind the harness-side model switch (router-switch.ts):
3
+ * given the router's advice for a prompt and what model omp is on, say whether
4
+ * to move up to a harness-native model, back to the router, or stay put.
5
+ * Free of omp types so it is unit-testable.
6
+ */
7
+
8
+ export const TIERS = ["trivial", "simple", "moderate", "hard"] as const;
9
+ export type TierName = (typeof TIERS)[number];
10
+
11
+ export interface SwitchPolicy {
12
+ enabled: boolean;
13
+ /** Tier → harness model (`provider/id`) that serves that tier and above, up to the next configured tier. */
14
+ models: Partial<Record<TierName, string>>;
15
+ /** Advice below this confidence never moves the model. */
16
+ minConfidence: number;
17
+ }
18
+
19
+ export const DISABLED_SWITCH: SwitchPolicy = { enabled: false, models: {}, minConfidence: 1 };
20
+
21
+ export function parseSwitchPolicy(raw: unknown): SwitchPolicy {
22
+ if (raw === null || typeof raw !== "object") return DISABLED_SWITCH;
23
+ const r = raw as Record<string, unknown>;
24
+ const models: Partial<Record<TierName, string>> = {};
25
+ if (r.models !== null && typeof r.models === "object") {
26
+ for (const t of TIERS) {
27
+ const v = (r.models as Record<string, unknown>)[t];
28
+ if (typeof v === "string" && v.includes("/")) models[t] = v;
29
+ }
30
+ }
31
+ return {
32
+ enabled: r.enabled === true,
33
+ models,
34
+ minConfidence: typeof r.minConfidence === "number" ? r.minConfidence : 0.6,
35
+ };
36
+ }
37
+
38
+ /** The harness model configured for the highest tier at or below `tier`, if any. */
39
+ export function nativeModelFor(policy: SwitchPolicy, tier: TierName): string | undefined {
40
+ for (let i = TIERS.indexOf(tier); i >= 0; i--) {
41
+ const m = policy.models[TIERS[i]!];
42
+ if (m !== undefined) return m;
43
+ }
44
+ return undefined;
45
+ }
46
+
47
+ export interface SwitchInput {
48
+ policy: SwitchPolicy;
49
+ advised: { tier: TierName; confidence: number };
50
+ /** omp's active model as `provider/id`, or null when none. */
51
+ active: string | null;
52
+ /** True when the active model belongs to the router's provider. */
53
+ activeIsRouter: boolean;
54
+ /** The harness model this extension last switched TO, if omp is still on it. */
55
+ switchedTo: string | null;
56
+ /** The router model omp was on before the switch, to return to. */
57
+ returnTo: string | null;
58
+ }
59
+
60
+ export type SwitchDecision =
61
+ | { action: "up"; model: string; reason: string }
62
+ | { action: "back"; model: string; reason: string }
63
+ | { action: "none"; reason: string };
64
+
65
+ export function decideSwitch(input: SwitchInput): SwitchDecision {
66
+ const { policy, advised, active, activeIsRouter, switchedTo, returnTo } = input;
67
+ if (!policy.enabled) return { action: "none", reason: "harnessSwitch.enabled is off" };
68
+ const onOurSwitch = switchedTo !== null && active === switchedTo;
69
+ // The user picked something else by hand: never fight a manual choice.
70
+ if (!activeIsRouter && !onOurSwitch) return { action: "none", reason: `active model ${active ?? "(none)"} was chosen by the user` };
71
+ const native = nativeModelFor(policy, advised.tier);
72
+ if (native === undefined) {
73
+ if (onOurSwitch && returnTo !== null) return { action: "back", model: returnTo, reason: `${advised.tier} work: back to the router` };
74
+ return { action: "none", reason: `${advised.tier} work stays on the router` };
75
+ }
76
+ if (advised.confidence < policy.minConfidence) {
77
+ return { action: "none", reason: `${advised.tier} at confidence ${advised.confidence.toFixed(2)} < ${policy.minConfidence}` };
78
+ }
79
+ if (active === native) return { action: "none", reason: `already on ${native}` };
80
+ return { action: "up", model: native, reason: `${advised.tier} work (confidence ${advised.confidence.toFixed(2)}) → ${native}` };
81
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -23,6 +23,8 @@ export interface CompositeBias {
23
23
  /** Plan usage fraction at which the bias switches off (list price). */
24
24
  biasUntilUsage: number;
25
25
  usage: OllamaUsageSource;
26
+ /** When given, read on every use instead of the static pair, so a config hot reload applies. */
27
+ live?: () => { costBias: number; biasUntilUsage: number };
26
28
  }
27
29
 
28
30
  export function createCompositeCatalog(
@@ -39,7 +41,8 @@ export function createCompositeCatalog(
39
41
 
40
42
  /** The multiplier in force from the latest usage reading (no network). */
41
43
  function currentBias(): number {
42
- return effectiveOllamaBias(bias.costBias, bias.biasUntilUsage, bias.usage.peek());
44
+ const b = bias.live?.() ?? bias;
45
+ return effectiveOllamaBias(b.costBias, b.biasUntilUsage, bias.usage.peek());
43
46
  }
44
47
 
45
48
  function combine(base: CatalogSnapshot, models: readonly CatalogModel[]): CatalogSnapshot {
@@ -335,6 +335,14 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
335
335
  { path: "report.dailySummary", label: "Daily summary at session start", kind: "boolean" },
336
336
  ],
337
337
  },
338
+ {
339
+ title: "Harness switch",
340
+ fields: [
341
+ { path: "harnessSwitch.enabled", label: "Switch omp's model for mapped tiers (experimental)", kind: "boolean" },
342
+ { path: "harnessSwitch.minConfidence", label: "Minimum advice confidence to switch", kind: "number", min: 0, max: 1 },
343
+ ...TIER_NAMES.map((t): FieldSpec => ({ path: `harnessSwitch.models.${t}`, label: `${t}: harness model (provider/id)`, kind: "string", optional: true, hint: "e.g. anthropic/claude-opus-4-8" })),
344
+ ],
345
+ },
338
346
  {
339
347
  title: "Ledger",
340
348
  fields: [
@@ -344,6 +352,7 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
344
352
  { path: "ledger.fallbackBlend.inputPerMtok", label: "Fallback blend input $/Mtok", kind: "number", min: 0 },
345
353
  { path: "ledger.fallbackBlend.outputPerMtok", label: "Fallback blend output $/Mtok", kind: "number", min: 0 },
346
354
  { path: "ledger.conversationTtlMs", label: "Conversation TTL", kind: "number", min: 1, hint: "ms" },
355
+ { path: "ledger.retentionDays", label: "Ledger retention", kind: "number", min: 0, hint: "days; 0 keeps everything" },
347
356
  ],
348
357
  },
349
358
  {
@@ -316,6 +316,12 @@ export const DEFAULT_CONFIG: RouterConfig = {
316
316
  // One transcript message per day, at the first interactive session start.
317
317
  dailySummary: true,
318
318
  },
319
+ harnessSwitch: {
320
+ // Off: moving the harness's own model is a visible change the operator opts into.
321
+ enabled: false,
322
+ models: {},
323
+ minConfidence: 0.6,
324
+ },
319
325
  budget: {
320
326
  // No caps by default; at a configured ceiling, downgrade rather than fail.
321
327
  onExceeded: "downgrade",
@@ -337,6 +343,7 @@ export const DEFAULT_CONFIG: RouterConfig = {
337
343
  // so early cost reporting never underreports.
338
344
  fallbackBlend: { inputPerMtok: 1.5, outputPerMtok: 7.5 },
339
345
  conversationTtlMs: 7 * 24 * 60 * 60 * 1000,
346
+ retentionDays: 365,
340
347
  },
341
348
  // On by default: an absolute floor that no available model meets is how the
342
349
  // router ends up serving every turn from the cheapest tier.
@@ -90,15 +90,47 @@ export interface WatchConfigOptions {
90
90
  }
91
91
 
92
92
  /**
93
- * Watches `path` and applies valid changes to `live` in place. `frozen` blocks
94
- * (top-level names) are re-copied from `pinned` after every reload so file
95
- * edits to construction-captured blocks cannot silently diverge.
93
+ * Config paths captured at construction, so a file edit cannot reach the
94
+ * running process: the socket, the upstream clients, the agentdox bridge, the
95
+ * ledger file. Everything else, including `ollama.costBias`,
96
+ * `ollama.biasUntilUsage`, `server.subagentProfile` and `ledger.retentionDays`,
97
+ * is read at call time and hot-reloads. A bare block name pins the whole
98
+ * block; `block.key` pins one key and lets its siblings through.
99
+ */
100
+ export const PINNED_CONFIG_PATHS: readonly string[] = [
101
+ "server.host",
102
+ "server.port",
103
+ "server.apiKey",
104
+ "server.harnessId",
105
+ "server.maxConcurrentTurns",
106
+ "openrouter",
107
+ "ollama.enabled",
108
+ "ollama.baseUrl",
109
+ "ollama.apiKey",
110
+ "ollama.timeoutMs",
111
+ "ollama.catalogTtlMs",
112
+ "ollama.includeLocal",
113
+ "ollama.prices",
114
+ "ollama.twins",
115
+ "ollama.usagePollMs",
116
+ "ollama.quotaCooldownMs",
117
+ "ollama.rateLimitCooldownMs",
118
+ "ollama.planCreditsUsd",
119
+ "context",
120
+ "ledger.path",
121
+ ];
122
+
123
+ /**
124
+ * Watches `path` and applies valid changes to `live` in place. `frozen`
125
+ * entries are re-copied from `pinned` after every reload so file edits to
126
+ * construction-captured settings cannot silently diverge: a top-level name
127
+ * pins the whole block, `block.key` pins one key of it.
96
128
  */
97
129
  export function watchConfig(
98
130
  path: string,
99
131
  live: RouterConfig,
100
132
  pinned: RouterConfig,
101
- frozen: readonly (keyof RouterConfig)[],
133
+ frozen: readonly string[],
102
134
  opts: WatchConfigOptions = {},
103
135
  ): ConfigWatcher {
104
136
  let closed = false;
@@ -119,14 +151,31 @@ export function watchConfig(
119
151
  }
120
152
  lastError = "";
121
153
 
122
- const frozenSet = new Set(frozen);
154
+ const frozenBlocks = new Set(frozen.filter((f) => !f.includes(".")));
155
+ const frozenKeys = new Map<string, string[]>();
156
+ for (const f of frozen) {
157
+ const dot = f.indexOf(".");
158
+ if (dot < 0) continue;
159
+ const block = f.slice(0, dot);
160
+ frozenKeys.set(block, [...(frozenKeys.get(block) ?? []), f.slice(dot + 1)]);
161
+ }
123
162
  const changed: string[] = [];
124
163
  const next = result.cfg as unknown as Record<string, unknown>;
164
+ const pinnedRec = pinned as unknown as Record<string, unknown>;
125
165
  for (const key of Object.keys(next)) {
126
- // Frozen blocks belong to construction: keep the pinned values.
127
- const value = frozenSet.has(key as keyof RouterConfig)
128
- ? (pinned as unknown as Record<string, unknown>)[key]
129
- : next[key];
166
+ // Frozen blocks belong to construction: keep the pinned values. A
167
+ // partially frozen block takes the file's siblings and the pinned keys.
168
+ let value = frozenBlocks.has(key) ? pinnedRec[key] : next[key];
169
+ const keys = frozenKeys.get(key);
170
+ if (keys !== undefined && !frozenBlocks.has(key) && value !== null && typeof value === "object") {
171
+ const merged: Record<string, unknown> = { ...(value as Record<string, unknown>) };
172
+ const pinnedBlock = (pinnedRec[key] ?? {}) as Record<string, unknown>;
173
+ for (const k of keys) {
174
+ if (pinnedBlock[k] === undefined) delete merged[k];
175
+ else merged[k] = pinnedBlock[k];
176
+ }
177
+ value = merged;
178
+ }
130
179
  const before = JSON.stringify((live as unknown as Record<string, unknown>)[key]);
131
180
  const after = JSON.stringify(value);
132
181
  if (before !== after) changed.push(key);
@@ -229,6 +229,7 @@ const ledger = z.strictObject({
229
229
  blendMinSamples: z.number().int().nonnegative().optional(),
230
230
  fallbackBlend: fallbackBlend.optional(),
231
231
  conversationTtlMs: z.number().positive().optional(),
232
+ retentionDays: z.number().int().nonnegative().optional(),
232
233
  });
233
234
 
234
235
  // Complete entries: arrays replace wholesale, so a partial profile would
@@ -276,6 +277,13 @@ export const configInputSchema = z.strictObject({
276
277
  budget: budget.optional(),
277
278
  profiles: z.array(profile).optional(),
278
279
  report: z.strictObject({ baselines: z.array(z.string()).optional(), dailySummary: z.boolean().optional() }).optional(),
280
+ harnessSwitch: z
281
+ .strictObject({
282
+ enabled: z.boolean().optional(),
283
+ models: z.record(tier, z.string().regex(/^[^/]+\/.+$/, "provider/id")).optional(),
284
+ minConfidence: z.number().min(0).max(1).optional(),
285
+ })
286
+ .optional(),
279
287
  digest: z
280
288
  .strictObject({
281
289
  enabled: z.boolean().optional(),
@@ -607,6 +607,25 @@ export interface ReportConfig {
607
607
  dailySummary: boolean;
608
608
  }
609
609
 
610
+ /**
611
+ * Harness-side model switch (experimental): the router advises a tier for
612
+ * each user prompt and the harness moves its own active model to a
613
+ * harness-native one for the mapped tiers. See omp-extension/router-switch.ts.
614
+ */
615
+ export interface HarnessSwitchConfig {
616
+ enabled: boolean;
617
+ /**
618
+ * Tier → harness model as `provider/id` in the harness's own registry
619
+ * (e.g. `hard: anthropic/claude-opus-4-8`). A tier serves itself and every
620
+ * tier above it up to the next mapped one; unmapped low tiers stay on the
621
+ * router. Turns on a native model bill the harness's own provider (a
622
+ * subscription, typically) and never reach the ledger.
623
+ */
624
+ models: Partial<Record<"trivial" | "simple" | "moderate" | "hard", string>>;
625
+ /** Advice below this heuristic confidence leaves the model where it is. */
626
+ minConfidence: number;
627
+ }
628
+
610
629
  export interface BudgetConfig {
611
630
  /** Reject or downgrade when a turn's cold forecast exceeds this, USD. */
612
631
  perTurnUsd?: number;
@@ -657,6 +676,13 @@ export interface LedgerConfig {
657
676
  fallbackBlend: { inputPerMtok: number; outputPerMtok: number };
658
677
  /** Drop conversation state untouched for longer than this, ms. */
659
678
  conversationTtlMs: number;
679
+ /**
680
+ * Delete ledger rows older than this many days (checked hourly). 0 keeps
681
+ * everything. The ledger grows ~2.5 MB a day under steady use; trust,
682
+ * reports and replay only read windows well inside a year. Freed pages
683
+ * are reused, so the file stops growing rather than shrinking.
684
+ */
685
+ retentionDays: number;
660
686
  }
661
687
 
662
688
  /**
@@ -795,6 +821,7 @@ export interface RouterConfig {
795
821
  budget: BudgetConfig;
796
822
  report: ReportConfig;
797
823
  digest: DigestConfig;
824
+ harnessSwitch: HarnessSwitchConfig;
798
825
  profiles: ProfileConfig[];
799
826
  ledger: LedgerConfig;
800
827
  /**
@@ -375,6 +375,8 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
375
375
  );
376
376
  const ratioStmt = db.query("SELECT est_bytes, actual_tokens, samples FROM token_calibration WHERE tokenizer = ?");
377
377
  const recentStmt = db.query("SELECT * FROM ledger ORDER BY created_at_ms DESC LIMIT ?");
378
+ const pruneStmt = db.query("DELETE FROM ledger WHERE created_at_ms < ?");
379
+ const wasteStmt = db.query("UPDATE ledger SET wasted = 1 WHERE id = ?");
378
380
  const providerSpendStmt = db.query(
379
381
  "SELECT COALESCE(SUM(COALESCE(reported_usd, predicted_usd)), 0) AS total FROM ledger WHERE created_at_ms >= ? AND COALESCE(served_slug, slug) LIKE ?",
380
382
  );
@@ -623,6 +625,13 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
623
625
  const row = providerSpendStmt.get(sinceMs, `${slugPrefix}%`) as { total: number } | null;
624
626
  return row?.total ?? 0;
625
627
  },
628
+ prune(retentionDays: number, nowMs = Date.now()): number {
629
+ if (retentionDays <= 0) return 0;
630
+ return pruneStmt.run(nowMs - retentionDays * DAY_MS).changes;
631
+ },
632
+ markWasted(id: string): void {
633
+ wasteStmt.run(id);
634
+ },
626
635
  latestForSession(ompSessionId: string): LedgerEntry | null {
627
636
  if (ompSessionId === "") return null;
628
637
  const row = sessionStmt.get(ompSessionId, 1) as LedgerRow | null;
@@ -34,6 +34,12 @@ export interface ReportTotals {
34
34
  digests: number;
35
35
  digestSpendUsd: number;
36
36
  digestInputTokens: number;
37
+ /** Digests the agent went back on: the same tool re-run with the same primary argument afterwards (row marked wasted). */
38
+ digestReruns: number;
39
+ /** Forecast accuracy over clean kept rows with a reported cost: mean |predicted − reported| ÷ reported, and the share over-predicted. */
40
+ forecastSamples: number;
41
+ forecastMeanError: number;
42
+ forecastOverShare: number;
37
43
  }
38
44
 
39
45
  export interface ReportRow {
@@ -134,6 +140,8 @@ const COMP = "json_extract(usage, '$.completionTokens')";
134
140
  const PROVIDER = "CASE WHEN slug LIKE 'ollama/%' THEN 'ollama' ELSE 'openrouter' END";
135
141
  const STREAMED = "ttft_ms IS NOT NULL AND ttft_ms > 0 AND error IS NULL";
136
142
  const EST = "json_extract(usage, '$.cachedEstimated') = 1";
143
+ /** Rows a forecast can be judged on: a reported cost, a prediction, clean and kept, not a side call. */
144
+ const FORECASTABLE = "reported_usd > 0 AND predicted_usd IS NOT NULL AND wasted = 0 AND error IS NULL AND requested_model <> 'digest'";
137
145
 
138
146
  const ROW_SELECT = `
139
147
  COUNT(*) AS dispatches,
@@ -213,6 +221,10 @@ export function buildUsageReport(
213
221
  SUM(CASE WHEN requested_model = 'digest' THEN 1 ELSE 0 END) AS digests,
214
222
  COALESCE(SUM(CASE WHEN requested_model = 'digest' THEN ${USD} ELSE 0 END), 0) AS digest_spend,
215
223
  COALESCE(SUM(CASE WHEN requested_model = 'digest' THEN ${PT} ELSE 0 END), 0) AS digest_input,
224
+ SUM(CASE WHEN requested_model = 'digest' AND wasted = 1 THEN 1 ELSE 0 END) AS digest_reruns,
225
+ SUM(CASE WHEN ${FORECASTABLE} THEN 1 ELSE 0 END) AS fc_n,
226
+ COALESCE(SUM(CASE WHEN ${FORECASTABLE} THEN ABS(predicted_usd - reported_usd) / reported_usd END), 0) AS fc_err,
227
+ SUM(CASE WHEN ${FORECASTABLE} AND predicted_usd > reported_usd THEN 1 ELSE 0 END) AS fc_over,
216
228
  SUM(CASE WHEN escalation_signal IS NOT NULL THEN 1 ELSE 0 END) AS escalations,
217
229
  SUM(CASE WHEN instr(reasons, 'failover:') > 0 THEN 1 ELSE 0 END) AS failovers,
218
230
  SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) AS errors,
@@ -232,6 +244,10 @@ export function buildUsageReport(
232
244
  digests: number | null;
233
245
  digest_spend: number;
234
246
  digest_input: number;
247
+ digest_reruns: number | null;
248
+ fc_n: number | null;
249
+ fc_err: number;
250
+ fc_over: number | null;
235
251
  escalations: number | null;
236
252
  failovers: number | null;
237
253
  errors: number | null;
@@ -354,6 +370,10 @@ export function buildUsageReport(
354
370
  digests: t.digests ?? 0,
355
371
  digestSpendUsd: t.digest_spend,
356
372
  digestInputTokens: t.digest_input,
373
+ digestReruns: t.digest_reruns ?? 0,
374
+ forecastSamples: t.fc_n ?? 0,
375
+ forecastMeanError: (t.fc_n ?? 0) > 0 ? t.fc_err / (t.fc_n ?? 1) : 0,
376
+ forecastOverShare: (t.fc_n ?? 0) > 0 ? (t.fc_over ?? 0) / (t.fc_n ?? 1) : 0,
357
377
  },
358
378
  providers,
359
379
  models,
@@ -419,7 +439,12 @@ export function reportView(r: UsageReport, opts: { maxModels?: number } = {}): R
419
439
  );
420
440
  }
421
441
  if (t.digests > 0) {
422
- summary.push(`digests: ${num(t.digests)} tool results condensed (${num(t.digestInputTokens)} tok read by a cheap model) for ${usd(t.digestSpendUsd)}`);
442
+ summary.push(
443
+ `digests: ${num(t.digests)} tool results condensed (${num(t.digestInputTokens)} tok read by a cheap model) for ${usd(t.digestSpendUsd)} · re-run rate ${pct(t.digestReruns / t.digests)} (${num(t.digestReruns)} fetched again in full)`,
444
+ );
445
+ }
446
+ if (t.forecastSamples > 0) {
447
+ summary.push(`forecast: mean error ${pct(t.forecastMeanError)} of reported cost over ${num(t.forecastSamples)} turns · ${pct(t.forecastOverShare)} over-predicted`);
423
448
  }
424
449
  if (t.subagentDispatches > 0) {
425
450
  summary.push(`subagents: ${num(t.subagentDispatches)} dispatches, ${usd(t.subagentSpendUsd)} (${pct(t.spendUsd > 0 ? t.subagentSpendUsd / t.spendUsd : 0)} of spend)`);
package/src/cost/types.ts CHANGED
@@ -303,4 +303,8 @@ export interface Ledger {
303
303
  latestForSession?(ompSessionId: string): LedgerEntry | null;
304
304
  /** Newest entries for an omp session, newest first. Optional. */
305
305
  entriesForSession?(ompSessionId: string, limit: number): LedgerEntry[];
306
+ /** Deletes rows older than `retentionDays` (0 ⇒ none); returns how many. Optional. */
307
+ prune?(retentionDays: number, nowMs?: number): number;
308
+ /** Marks one row wasted after the fact (a digest the agent went back on). Optional. */
309
+ markWasted?(id: string): void;
306
310
  }
@@ -64,7 +64,7 @@ export function validatePlan(
64
64
  * for the resource a call operates on (a `path`, `id`, `query`, ...). Used to
65
65
  * detect when a later call supersedes an earlier read of the same resource.
66
66
  */
67
- function primaryArg(argsJson: string): string | null {
67
+ export function primaryArg(argsJson: string): string | null {
68
68
  try {
69
69
  const parsed: unknown = JSON.parse(argsJson);
70
70
  if (parsed !== null && typeof parsed === "object") {