auto-model-router 0.4.3 → 0.4.4
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 +2 -0
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +10 -1
- package/bunfig.toml +2 -0
- package/package.json +1 -1
- package/src/catalog/composite.ts +4 -1
- package/src/cli/config-wizard.ts +1 -0
- package/src/config/defaults.ts +1 -0
- package/src/config/hot-reload.ts +58 -9
- package/src/config/schema.ts +1 -0
- package/src/config/types.ts +7 -0
- package/src/cost/ledger.ts +9 -0
- package/src/cost/report.ts +26 -1
- package/src/cost/types.ts +4 -0
- package/src/router/compaction.ts +1 -1
- package/src/router/select.ts +22 -2
- package/src/server/compaction-digest.ts +2 -0
- package/src/server/digest.ts +55 -1
- package/src/server/http.ts +21 -6
- package/src/server/providers.ts +1 -0
- package/src/server/turn.ts +11 -0
- package/test/cache-control.test.ts +1 -1
- package/test/digest.test.ts +22 -0
- package/test/failover.test.ts +1 -1
- package/test/hot-reload.test.ts +37 -1
- package/test/migrations.test.ts +84 -0
- package/test/report-hub.test.ts +1 -1
- package/test/report.test.ts +29 -1
- package/test/select.test.ts +24 -0
- package/test/support/preload.ts +19 -0
- package/test/tokens.test.ts +24 -0
- package/test/turn.test.ts +1 -1
- package/tools/gen-migration-fixtures.ts +69 -0
package/.gitattributes
ADDED
|
@@ -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.
|
|
10
|
+
"version": "0.4.4",
|
|
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.
|
|
17
|
+
"version": "0.4.4",
|
|
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
|
|
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 |
|
|
@@ -838,6 +846,7 @@ is a ledger row (`requestedModel` `digest`) and the report totals them.
|
|
|
838
846
|
| `blendMinSamples` | `25` | Turns before the measured blend replaces the fallback. |
|
|
839
847
|
| `fallbackBlend` | input `1.5`, output `7.5` | Pre-measurement blend (USD/Mtok) for omp's cost display. |
|
|
840
848
|
| `conversationTtlMs` | `604800000` (7 d) | Drop conversation state untouched this long. |
|
|
849
|
+
| `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
850
|
|
|
842
851
|
### Top-level
|
|
843
852
|
|
package/bunfig.toml
ADDED
package/package.json
CHANGED
package/src/catalog/composite.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/src/cli/config-wizard.ts
CHANGED
|
@@ -344,6 +344,7 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
|
|
|
344
344
|
{ path: "ledger.fallbackBlend.inputPerMtok", label: "Fallback blend input $/Mtok", kind: "number", min: 0 },
|
|
345
345
|
{ path: "ledger.fallbackBlend.outputPerMtok", label: "Fallback blend output $/Mtok", kind: "number", min: 0 },
|
|
346
346
|
{ path: "ledger.conversationTtlMs", label: "Conversation TTL", kind: "number", min: 1, hint: "ms" },
|
|
347
|
+
{ path: "ledger.retentionDays", label: "Ledger retention", kind: "number", min: 0, hint: "days; 0 keeps everything" },
|
|
347
348
|
],
|
|
348
349
|
},
|
|
349
350
|
{
|
package/src/config/defaults.ts
CHANGED
|
@@ -337,6 +337,7 @@ export const DEFAULT_CONFIG: RouterConfig = {
|
|
|
337
337
|
// so early cost reporting never underreports.
|
|
338
338
|
fallbackBlend: { inputPerMtok: 1.5, outputPerMtok: 7.5 },
|
|
339
339
|
conversationTtlMs: 7 * 24 * 60 * 60 * 1000,
|
|
340
|
+
retentionDays: 365,
|
|
340
341
|
},
|
|
341
342
|
// On by default: an absolute floor that no available model meets is how the
|
|
342
343
|
// router ends up serving every turn from the cheapest tier.
|
package/src/config/hot-reload.ts
CHANGED
|
@@ -90,15 +90,47 @@ export interface WatchConfigOptions {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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);
|
package/src/config/schema.ts
CHANGED
|
@@ -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
|
package/src/config/types.ts
CHANGED
|
@@ -657,6 +657,13 @@ export interface LedgerConfig {
|
|
|
657
657
|
fallbackBlend: { inputPerMtok: number; outputPerMtok: number };
|
|
658
658
|
/** Drop conversation state untouched for longer than this, ms. */
|
|
659
659
|
conversationTtlMs: number;
|
|
660
|
+
/**
|
|
661
|
+
* Delete ledger rows older than this many days (checked hourly). 0 keeps
|
|
662
|
+
* everything. The ledger grows ~2.5 MB a day under steady use; trust,
|
|
663
|
+
* reports and replay only read windows well inside a year. Freed pages
|
|
664
|
+
* are reused, so the file stops growing rather than shrinking.
|
|
665
|
+
*/
|
|
666
|
+
retentionDays: number;
|
|
660
667
|
}
|
|
661
668
|
|
|
662
669
|
/**
|
package/src/cost/ledger.ts
CHANGED
|
@@ -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;
|
package/src/cost/report.ts
CHANGED
|
@@ -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(
|
|
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
|
}
|
package/src/router/compaction.ts
CHANGED
|
@@ -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") {
|
package/src/router/select.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { CatalogSnapshot } from "../catalog/types.ts";
|
|
10
10
|
import type { ProfileConfig, RouterConfig } from "../config/types.ts";
|
|
11
|
-
import { priceAt } from "../cost/forecast.ts";
|
|
11
|
+
import { forecast, priceAt } from "../cost/forecast.ts";
|
|
12
12
|
import type { Ledger } from "../cost/types.ts";
|
|
13
13
|
import { explorationDraw } from "./explore.ts";
|
|
14
14
|
import type { CompactionEdit, NormRequest, ReasoningLevel } from "../wire/types.ts";
|
|
@@ -610,13 +610,33 @@ export function select(args: SelectArgs): Decision {
|
|
|
610
610
|
}
|
|
611
611
|
const stripAssistantReasoning = !(chosen.model.supportsReasoning && REASONING_REPLAY_AUTHORS[chosen.model.author] === true);
|
|
612
612
|
|
|
613
|
+
// The recorded forecast is the EXPECTED price of this dispatch, not the
|
|
614
|
+
// cold worst case candidates are ranked on. When the chosen model's cache
|
|
615
|
+
// is warm, the previous prompt's tokens are priced as cache reads at the
|
|
616
|
+
// model's measured hit rate; coldUsd stays the cold figure the budget
|
|
617
|
+
// guards used. Before this every recorded forecast was cold while nine
|
|
618
|
+
// turns in ten were warm: 89% over-predicted, median error 220%.
|
|
619
|
+
let expectedForecast = chosen.forecast;
|
|
620
|
+
if (warmSlug !== null && chosen.model.slug === warmSlug && effFeatures.promptTokens > 0 && state.lastPromptTokens > 0) {
|
|
621
|
+
const cachedShare = Math.min(1, state.lastPromptTokens / effFeatures.promptTokens);
|
|
622
|
+
let images = 0;
|
|
623
|
+
if (req.hasImages) for (const m of req.messages) images += m.images;
|
|
624
|
+
const warm = forecast(chosen.model, {
|
|
625
|
+
promptTokens: effFeatures.promptTokens,
|
|
626
|
+
completionTokens: EXPECTED_COMPLETION_TOKENS,
|
|
627
|
+
cacheHitRate: cacheHitExpectation(chosen.model.slug).rate * cachedShare,
|
|
628
|
+
images,
|
|
629
|
+
});
|
|
630
|
+
expectedForecast = { ...warm, coldUsd: chosen.forecast.coldUsd };
|
|
631
|
+
}
|
|
632
|
+
|
|
613
633
|
return {
|
|
614
634
|
slug: chosen.model.slug,
|
|
615
635
|
fallbacks,
|
|
616
636
|
tier: chosenTier,
|
|
617
637
|
classification: cls,
|
|
618
638
|
features,
|
|
619
|
-
forecast:
|
|
639
|
+
forecast: expectedForecast,
|
|
620
640
|
sessionId: state.sessionId,
|
|
621
641
|
sticky,
|
|
622
642
|
cacheBreakpointMessageIndices,
|
|
@@ -23,6 +23,8 @@ import type { DigestRequest, DigestResult } from "./digest.ts";
|
|
|
23
23
|
|
|
24
24
|
export interface CompactionDigester {
|
|
25
25
|
digest(req: DigestRequest): Promise<DigestResult>;
|
|
26
|
+
/** See Digester.noteToolCalls; optional so a fake need not implement it. */
|
|
27
|
+
noteToolCalls?(ompSessionId: string, calls: readonly { name: string; argsJson: string }[], nowMs?: number): number;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export interface DigestCompactionArgs {
|
package/src/server/digest.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type { DigestConfig, RouterConfig } from "../config/types.ts";
|
|
|
23
23
|
import { computeCost, forecast } from "../cost/forecast.ts";
|
|
24
24
|
import type { Ledger, LedgerEntry } from "../cost/types.ts";
|
|
25
25
|
import { buildCandidates } from "../router/candidates.ts";
|
|
26
|
+
import { primaryArg } from "../router/compaction.ts";
|
|
26
27
|
import { extractFeatures } from "../router/features.ts";
|
|
27
28
|
import { TIER_ORDER, type Tier } from "../router/types.ts";
|
|
28
29
|
import { estimateTokens } from "../tokens/estimate.ts";
|
|
@@ -107,8 +108,31 @@ function syntheticRequest(req: DigestRequest, promptText: string): NormRequest {
|
|
|
107
108
|
};
|
|
108
109
|
}
|
|
109
110
|
|
|
110
|
-
|
|
111
|
+
/** A digest the agent may still go back on: same tool, same primary argument, within RERUN_WINDOW_MS. */
|
|
112
|
+
interface RecentDigest {
|
|
113
|
+
tool: string;
|
|
114
|
+
arg: string | null;
|
|
115
|
+
atMs: number;
|
|
116
|
+
ledgerId: string;
|
|
117
|
+
rerun: boolean;
|
|
118
|
+
}
|
|
119
|
+
const RERUN_WINDOW_MS = 2 * 3_600_000;
|
|
120
|
+
const RECENT_PER_SESSION = 50;
|
|
121
|
+
|
|
122
|
+
export interface Digester {
|
|
123
|
+
digest(req: DigestRequest): Promise<DigestResult>;
|
|
124
|
+
/**
|
|
125
|
+
* Quality signal: the tool calls a session just made. One that repeats a
|
|
126
|
+
* recent digest (same tool, same primary argument) means the agent went
|
|
127
|
+
* back for the full output; that digest's ledger row is marked wasted and
|
|
128
|
+
* the report shows the re-run rate. Returns how many were marked.
|
|
129
|
+
*/
|
|
130
|
+
noteToolCalls(ompSessionId: string, calls: readonly { name: string; argsJson: string }[], nowMs?: number): number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function createDigester(deps: DigesterDeps): Digester {
|
|
111
134
|
const { cfg, catalog, ledger, upstream, log } = deps;
|
|
135
|
+
const recent = new Map<string, RecentDigest[]>();
|
|
112
136
|
|
|
113
137
|
/** Cheapest simple-tier model that fits the prompt, or the configured one. */
|
|
114
138
|
async function pickModel(req: NormRequest, promptTokens: number): Promise<CatalogModel | null> {
|
|
@@ -229,6 +253,11 @@ export function createDigester(deps: DigesterDeps): { digest(req: DigestRequest)
|
|
|
229
253
|
}
|
|
230
254
|
if (error !== null) return { digested: false, reason: `digest model failed: ${error}` };
|
|
231
255
|
if (text === "" || text.length >= inputBytes * 0.9) return { digested: false, reason: "digest did not shrink the output" };
|
|
256
|
+
if (req.ompSessionId !== "") {
|
|
257
|
+
const list = recent.get(req.ompSessionId) ?? [];
|
|
258
|
+
list.push({ tool: req.toolName.toLowerCase(), arg: primaryArg(JSON.stringify(req.input)), atMs: startedAt, ledgerId: entry.id, rerun: false });
|
|
259
|
+
recent.set(req.ompSessionId, list.slice(-RECENT_PER_SESSION));
|
|
260
|
+
}
|
|
232
261
|
return {
|
|
233
262
|
digested: true,
|
|
234
263
|
text: `${digestMarker(req.toolName, req.input, model.slug, inputBytes, text.length)}\n${text}`,
|
|
@@ -239,5 +268,30 @@ export function createDigester(deps: DigesterDeps): { digest(req: DigestRequest)
|
|
|
239
268
|
ms,
|
|
240
269
|
};
|
|
241
270
|
},
|
|
271
|
+
noteToolCalls(ompSessionId, calls, nowMs = Date.now()) {
|
|
272
|
+
const list = recent.get(ompSessionId);
|
|
273
|
+
if (list === undefined || list.length === 0) return 0;
|
|
274
|
+
let marked = 0;
|
|
275
|
+
for (const c of calls) {
|
|
276
|
+
const tool = c.name.toLowerCase();
|
|
277
|
+
const arg = primaryArg(c.argsJson);
|
|
278
|
+
if (arg === null) continue;
|
|
279
|
+
for (const d of list) {
|
|
280
|
+
if (d.rerun || d.tool !== tool || d.arg !== arg || nowMs - d.atMs > RERUN_WINDOW_MS) continue;
|
|
281
|
+
d.rerun = true;
|
|
282
|
+
marked++;
|
|
283
|
+
try {
|
|
284
|
+
ledger.markWasted?.(d.ledgerId);
|
|
285
|
+
} catch (err) {
|
|
286
|
+
log.debug("digest re-run mark failed", { error: err instanceof Error ? err.message : String(err) });
|
|
287
|
+
}
|
|
288
|
+
log.info("digest re-run: the agent fetched the full output after all", { tool, arg: arg.slice(0, 80) });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const kept = list.filter((d) => nowMs - d.atMs <= RERUN_WINDOW_MS);
|
|
292
|
+
if (kept.length === 0) recent.delete(ompSessionId);
|
|
293
|
+
else recent.set(ompSessionId, kept);
|
|
294
|
+
return marked;
|
|
295
|
+
},
|
|
242
296
|
};
|
|
243
297
|
}
|
package/src/server/http.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { UpstreamError } from "../upstream/types.ts";
|
|
|
17
17
|
import { apiKeySource, ollamaKeySource } from "../config/load.ts";
|
|
18
18
|
import { ollamaMeter } from "../upstream/ollama-usage.ts";
|
|
19
19
|
import { routerConfigPath } from "../cli/config-cmd.ts";
|
|
20
|
-
import { watchConfig } from "../config/hot-reload.ts";
|
|
20
|
+
import { PINNED_CONFIG_PATHS, watchConfig } from "../config/hot-reload.ts";
|
|
21
21
|
import type { RouterConfig } from "../config/types.ts";
|
|
22
22
|
import { createLogger } from "../util/log.ts";
|
|
23
23
|
import { openDb } from "../util/sqlite.ts";
|
|
@@ -202,15 +202,16 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
202
202
|
|
|
203
203
|
// Hot reload: ranking knobs (tiers, filters, escalation, budgets, …) take
|
|
204
204
|
// effect on the next turn without a restart, because every consumer reads
|
|
205
|
-
// the shared config object at call time. Construction-captured
|
|
206
|
-
// (server socket,
|
|
207
|
-
//
|
|
208
|
-
|
|
205
|
+
// the shared config object at call time. Construction-captured settings
|
|
206
|
+
// (server socket, upstream clients, agentdox bridge, ledger file) are
|
|
207
|
+
// pinned by path (PINNED_CONFIG_PATHS); editing those still requires a
|
|
208
|
+
// restart. The blocks are deep-copied so a reload cannot mutate the pin.
|
|
209
|
+
const pinned = structuredClone(cfg);
|
|
209
210
|
const configWatcher = watchConfig(
|
|
210
211
|
routerConfigPath(),
|
|
211
212
|
cfg,
|
|
212
213
|
pinned,
|
|
213
|
-
|
|
214
|
+
PINNED_CONFIG_PATHS,
|
|
214
215
|
{
|
|
215
216
|
onReload: ({ changed }) => {
|
|
216
217
|
log.info("config reloaded", { changed: changed.join(", ") });
|
|
@@ -269,6 +270,20 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
269
270
|
}, 60_000);
|
|
270
271
|
pruneTimer.unref();
|
|
271
272
|
|
|
273
|
+
// Ledger retention: hourly, and once at boot so a lowered setting takes
|
|
274
|
+
// effect without waiting. Reads the live config, so it hot-reloads.
|
|
275
|
+
const retain = (): void => {
|
|
276
|
+
try {
|
|
277
|
+
const dropped = ledger.prune?.(cfg.ledger.retentionDays) ?? 0;
|
|
278
|
+
if (dropped > 0) log.info("pruned ledger rows past retention", { dropped, retentionDays: cfg.ledger.retentionDays });
|
|
279
|
+
} catch (err) {
|
|
280
|
+
log.warn("ledger retention prune failed", { error: err instanceof Error ? err.message : String(err) });
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
const retentionTimer = setInterval(retain, 3_600_000);
|
|
284
|
+
retentionTimer.unref();
|
|
285
|
+
setTimeout(retain, 5_000).unref();
|
|
286
|
+
|
|
272
287
|
// Periodically refetch the (key-scoped) catalog in the background so
|
|
273
288
|
// guardrail/preference changes are picked up without needing traffic and a
|
|
274
289
|
// TTL expiry. catalogRefreshMs === 0 disables this.
|
package/src/server/providers.ts
CHANGED
|
@@ -54,6 +54,7 @@ export function createProviders(cfg: RouterConfig, db: Database, log: Logger = c
|
|
|
54
54
|
costBias: cfg.ollama.costBias,
|
|
55
55
|
biasUntilUsage: cfg.ollama.biasUntilUsage,
|
|
56
56
|
usage: ollamaUsage,
|
|
57
|
+
live: () => ({ costBias: cfg.ollama.costBias, biasUntilUsage: cfg.ollama.biasUntilUsage }),
|
|
57
58
|
}),
|
|
58
59
|
ollama,
|
|
59
60
|
ollamaUsage,
|
package/src/server/turn.ts
CHANGED
|
@@ -114,6 +114,17 @@ export async function runTurn(
|
|
|
114
114
|
const log = createLogger(config.logLevel);
|
|
115
115
|
const state = conversations.load(req.conversationKey);
|
|
116
116
|
const turnNumber = state.turn + 1;
|
|
117
|
+
// Digest quality signal: the calls the agent just made, matched against
|
|
118
|
+
// recent digests of this session (a re-run of a digested read means the
|
|
119
|
+
// digest was not enough). The last assistant message holds this turn's calls.
|
|
120
|
+
if (deps.digester?.noteToolCalls !== undefined && req.ompSessionId !== "") {
|
|
121
|
+
for (let i = req.messages.length - 1; i >= 0; i--) {
|
|
122
|
+
const m = req.messages[i];
|
|
123
|
+
if (m === undefined || m.role !== "assistant") continue;
|
|
124
|
+
if (m.toolCalls.length > 0) deps.digester.noteToolCalls(req.ompSessionId, m.toolCalls.map((c) => ({ name: c.name, argsJson: c.argsJson })));
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
117
128
|
// Request header wins; the configured default covers harnesses that send none.
|
|
118
129
|
const doxScope = req.agentdoxScope !== "" ? req.agentdoxScope : config.context.defaultScope;
|
|
119
130
|
const doxActive = bridge.enabled && doxScope !== "";
|
|
@@ -93,7 +93,7 @@ describe("planCacheBreakpoints", () => {
|
|
|
93
93
|
test("milestones follow post-compaction sizes", () => {
|
|
94
94
|
const req = loop(30);
|
|
95
95
|
const tail = req.messages.length - 1;
|
|
96
|
-
const plan = planCompaction(req.messages, BASE.compaction, req.promptBytes * 0.3, req.promptBytes);
|
|
96
|
+
const plan = planCompaction(req.messages, { ...BASE.compaction, enabled: true }, req.promptBytes * 0.3, req.promptBytes);
|
|
97
97
|
expect(plan.edits.length).toBeGreaterThan(0);
|
|
98
98
|
const options = cfg({ maxBreakpoints: 64, milestoneTokens: 4_000 });
|
|
99
99
|
const raw = planCacheBreakpoints(req, MODEL, options).filter((i) => i !== 0 && i !== tail);
|
package/test/digest.test.ts
CHANGED
|
@@ -184,6 +184,28 @@ describe("createDigester", () => {
|
|
|
184
184
|
db.close();
|
|
185
185
|
});
|
|
186
186
|
|
|
187
|
+
test("a later call of the same tool with the same primary argument marks the digest wasted", async () => {
|
|
188
|
+
const cfg = cfgWith();
|
|
189
|
+
const db = openDb(":memory:");
|
|
190
|
+
const ledger = createLedger(db, cfg);
|
|
191
|
+
seedSession(ledger, "hard");
|
|
192
|
+
const dg = createDigester({ cfg, catalog, ledger, upstream: fakeUpstream(() => "Condensed.").upstream, log });
|
|
193
|
+
const r = await dg.digest({ ompSessionId: "omp-1", harnessId: "", toolName: "read", input: { path: "src/a.ts", offset: 1 }, content: BIG, query: "" });
|
|
194
|
+
expect(r.digested).toBe(true);
|
|
195
|
+
const row = () => ledger.recentEntries(10).find((e) => e.requestedModel === "digest")!;
|
|
196
|
+
expect(row().wasted).toBe(false);
|
|
197
|
+
// A different file, a different tool, another session: no match.
|
|
198
|
+
expect(dg.noteToolCalls("omp-1", [{ name: "read", argsJson: '{"path":"src/b.ts"}' }, { name: "grep", argsJson: '{"pattern":"src/a.ts"}' }])).toBe(0);
|
|
199
|
+
expect(dg.noteToolCalls("omp-2", [{ name: "read", argsJson: '{"path":"src/a.ts"}' }])).toBe(0);
|
|
200
|
+
expect(row().wasted).toBe(false);
|
|
201
|
+
// The same read again (case-insensitive tool name, any other args): the agent wanted the full output.
|
|
202
|
+
expect(dg.noteToolCalls("omp-1", [{ name: "Read", argsJson: '{"path":"src/a.ts","limit":50}' }])).toBe(1);
|
|
203
|
+
expect(row().wasted).toBe(true);
|
|
204
|
+
// Marked once; a third read does not count again.
|
|
205
|
+
expect(dg.noteToolCalls("omp-1", [{ name: "read", argsJson: '{"path":"src/a.ts"}' }])).toBe(0);
|
|
206
|
+
db.close();
|
|
207
|
+
});
|
|
208
|
+
|
|
187
209
|
test("a pinned digest model is used as-is", async () => {
|
|
188
210
|
const pinned = MODELS.find((m) => m.price.prompt > 0)!.slug;
|
|
189
211
|
const cfg = cfgWith({ model: pinned });
|
package/test/failover.test.ts
CHANGED
|
@@ -79,7 +79,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
79
79
|
report: { baselines: [], dailySummary: false },
|
|
80
80
|
digest: { enabled: false, minBytes: 12_000, maxBytes: 400_000, tools: ["read"], fromTier: "moderate", tier: "simple", model: "", maxOutputTokens: 700, maxCostUsd: 0.02, timeoutMs: 25_000 },
|
|
81
81
|
profiles: [],
|
|
82
|
-
ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 },
|
|
82
|
+
ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 , retentionDays: 0,},
|
|
83
83
|
adaptiveTierFloors: true,
|
|
84
84
|
adaptivePriceCeilings: false,
|
|
85
85
|
logLevel: "silent",
|
package/test/hot-reload.test.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
|
|
5
5
|
import type { RouterConfig } from "../src/config/types.ts";
|
|
6
|
-
import { readValidatedConfig, watchConfig, type ConfigWatcher } from "../src/config/hot-reload.ts";
|
|
6
|
+
import { PINNED_CONFIG_PATHS, readValidatedConfig, watchConfig, type ConfigWatcher } from "../src/config/hot-reload.ts";
|
|
7
7
|
|
|
8
8
|
const DIR = join(import.meta.dir, ".tmp-hot-reload");
|
|
9
9
|
const CFG = join(DIR, "config.yml");
|
|
@@ -129,3 +129,39 @@ describe("watchConfig", () => {
|
|
|
129
129
|
expect(live.filters.latencyWeight).not.toBe(9.9);
|
|
130
130
|
});
|
|
131
131
|
});
|
|
132
|
+
|
|
133
|
+
describe("watchConfig pins by path", () => {
|
|
134
|
+
const CFG2 = join(DIR, "config-paths.yml");
|
|
135
|
+
const live: RouterConfig = structuredClone(DEFAULT_CONFIG);
|
|
136
|
+
let watcher: ConfigWatcher | null = null;
|
|
137
|
+
|
|
138
|
+
beforeAll(() => {
|
|
139
|
+
writeFileSync(CFG2, "");
|
|
140
|
+
const pinned = structuredClone(DEFAULT_CONFIG);
|
|
141
|
+
pinned.ollama.apiKey = "pinned-key";
|
|
142
|
+
watcher = watchConfig(CFG2, live, pinned, PINNED_CONFIG_PATHS);
|
|
143
|
+
});
|
|
144
|
+
afterAll(() => watcher?.close());
|
|
145
|
+
|
|
146
|
+
test("a pinned key inside a block keeps its construction value while its siblings hot-reload", async () => {
|
|
147
|
+
writeFileSync(CFG2, yamlOf({ ollama: { apiKey: "from-file", costBias: 0.25, biasUntilUsage: 0.5 }, server: { port: 1, subagentProfile: "auto" }, ledger: { retentionDays: 30 } }));
|
|
148
|
+
await settle();
|
|
149
|
+
expect(live.ollama.costBias).toBe(0.25);
|
|
150
|
+
expect(live.ollama.biasUntilUsage).toBe(0.5);
|
|
151
|
+
expect(live.ollama.apiKey).toBe("pinned-key");
|
|
152
|
+
expect(live.server.port).toBe(DEFAULT_CONFIG.server.port);
|
|
153
|
+
expect(live.server.subagentProfile).toBe("auto");
|
|
154
|
+
expect(live.ledger.retentionDays).toBe(30);
|
|
155
|
+
expect(live.ledger.path).toBe(DEFAULT_CONFIG.ledger.path);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("the pinned path list names only real config keys", () => {
|
|
159
|
+
const root = DEFAULT_CONFIG as unknown as Record<string, Record<string, unknown>>;
|
|
160
|
+
for (const p of PINNED_CONFIG_PATHS) {
|
|
161
|
+
const [block = "", key] = p.split(".");
|
|
162
|
+
expect(block in root).toBe(true);
|
|
163
|
+
// Optional keys (server.apiKey, server.harnessId) are absent from the defaults but real.
|
|
164
|
+
if (key !== undefined && !["apiKey", "harnessId"].includes(key)) expect(key in root[block]!).toBe(true);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { copyFileSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
|
|
7
|
+
import { createFeedbackStore } from "../src/cost/feedback.ts";
|
|
8
|
+
import { createLedger } from "../src/cost/ledger.ts";
|
|
9
|
+
import { buildUsageReport } from "../src/cost/report.ts";
|
|
10
|
+
import { buildDailySummary, createKv } from "../src/cost/summary.ts";
|
|
11
|
+
import { createConversationStore } from "../src/router/state.ts";
|
|
12
|
+
import { openDb } from "../src/util/sqlite.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Every ledger a past release wrote must open under the current bootstrap:
|
|
16
|
+
* the version lands on the current number, every prepared statement the
|
|
17
|
+
* router uses compiles against the migrated schema, the carried rows survive
|
|
18
|
+
* with their backfills, and the aggregates run. The fixtures come from
|
|
19
|
+
* tools/gen-migration-fixtures.ts (each tag's own bootstrap plus one row per
|
|
20
|
+
* table), so a column added without a guard, or a statement that assumes a
|
|
21
|
+
* column older files lack, fails here rather than on a user's install.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const FIXTURES = join(import.meta.dir, "fixtures", "migrations");
|
|
25
|
+
const files = readdirSync(FIXTURES).filter((f) => /^router-v\d+\.db$/.test(f)).sort((a, b) => Number(/\d+/.exec(a)![0]) - Number(/\d+/.exec(b)![0]));
|
|
26
|
+
const CURRENT_VERSION = 17;
|
|
27
|
+
|
|
28
|
+
describe("schema migrations from every shipped version", () => {
|
|
29
|
+
test("fixtures exist for the versions that shipped", () => {
|
|
30
|
+
expect(files.map((f) => Number(/\d+/.exec(f)![0]))).toEqual([4, 5, 10, 12, 13, 14, 16]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
for (const file of files) {
|
|
34
|
+
const from = Number(/\d+/.exec(file)![0]);
|
|
35
|
+
test(`v${from} → v${CURRENT_VERSION}: opens, migrates, keeps its rows, and every consumer runs`, () => {
|
|
36
|
+
const dir = mkdtempSync(join(tmpdir(), "amr-migrate-"));
|
|
37
|
+
const path = join(dir, "router.db");
|
|
38
|
+
copyFileSync(join(FIXTURES, file), path);
|
|
39
|
+
const cfg = structuredClone(DEFAULT_CONFIG);
|
|
40
|
+
cfg.ledger.path = path;
|
|
41
|
+
const db = openDb(path);
|
|
42
|
+
try {
|
|
43
|
+
expect((db.query("PRAGMA user_version").get() as { user_version: number }).user_version).toBe(CURRENT_VERSION);
|
|
44
|
+
// Every column the current code writes exists after migration.
|
|
45
|
+
const ledgerCols = new Set((db.query("PRAGMA table_info(ledger)").all() as { name: string }[]).map((c) => c.name));
|
|
46
|
+
for (const c of ["harness_id", "error_kind", "omp_session_id", "features", "explored_from", "hold_arm", "prompt_tokens_saved"]) expect(ledgerCols.has(c)).toBe(true);
|
|
47
|
+
const convCols = new Set((db.query("PRAGMA table_info(conversations)").all() as { name: string }[]).map((c) => c.name));
|
|
48
|
+
for (const c of ["context_version", "compaction_plan", "compaction_plan_tokens", "upgrade_deferred_tier"]) expect(convCols.has(c)).toBe(true);
|
|
49
|
+
// The fixture's ledger row survived the ALTERs with its values.
|
|
50
|
+
const row = db.query("SELECT id, error, slug FROM ledger").get() as { id: string; error: string | null; slug: string } | null;
|
|
51
|
+
expect(row).toEqual({ id: "fixture-id", error: "upstream_error: 502", slug: "fixture-slug" });
|
|
52
|
+
// Every prepared statement compiles and every consumer runs on the migrated file.
|
|
53
|
+
const ledger = createLedger(db, cfg);
|
|
54
|
+
const conversations = createConversationStore(db);
|
|
55
|
+
createFeedbackStore(db);
|
|
56
|
+
createKv(db);
|
|
57
|
+
expect(ledger.recentEntries(5)).toHaveLength(1);
|
|
58
|
+
expect(ledger.trust("fixture-slug")).not.toBeNull();
|
|
59
|
+
expect(ledger.softFailureSpikes?.()).toEqual([]);
|
|
60
|
+
expect(ledger.latestForSession?.("nope")).toBeNull();
|
|
61
|
+
expect(conversations.load("fixture-key").key).toBe("fixture-key");
|
|
62
|
+
expect(buildUsageReport(db, { windowDays: 3650 }).totals.dispatches).toBe(1);
|
|
63
|
+
expect(buildDailySummary(db, {}).current.dispatches).toBe(0);
|
|
64
|
+
expect(ledger.prune?.(0)).toBe(0);
|
|
65
|
+
} finally {
|
|
66
|
+
db.close();
|
|
67
|
+
try {
|
|
68
|
+
rmSync(dir, { recursive: true, force: true });
|
|
69
|
+
} catch {
|
|
70
|
+
// Windows keeps the file locked until the statements are collected; the temp dir is disposable.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
test("a fresh database lands on the same version as a migrated one", () => {
|
|
77
|
+
const db = openDb(":memory:");
|
|
78
|
+
try {
|
|
79
|
+
expect((db.query("PRAGMA user_version").get() as { user_version: number }).user_version).toBe(CURRENT_VERSION);
|
|
80
|
+
} finally {
|
|
81
|
+
db.close();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
package/test/report-hub.test.ts
CHANGED
|
@@ -80,7 +80,7 @@ function report(over: Partial<UsageReport> = {}): UsageReport {
|
|
|
80
80
|
subagentSpendUsd: 0,
|
|
81
81
|
digests: 0,
|
|
82
82
|
digestSpendUsd: 0,
|
|
83
|
-
digestInputTokens: 0,
|
|
83
|
+
digestInputTokens: 0, digestReruns: 0, forecastSamples: 0, forecastMeanError: 0, forecastOverShare: 0,
|
|
84
84
|
},
|
|
85
85
|
providers: [row("openrouter", 2), row("ollama", 1)],
|
|
86
86
|
models: [
|
package/test/report.test.ts
CHANGED
|
@@ -229,7 +229,7 @@ describe("buildUsageReport", () => {
|
|
|
229
229
|
subagentSpendUsd: 0,
|
|
230
230
|
digests: 0,
|
|
231
231
|
digestSpendUsd: 0,
|
|
232
|
-
digestInputTokens: 0,
|
|
232
|
+
digestInputTokens: 0, digestReruns: 0, forecastSamples: 0, forecastMeanError: 0, forecastOverShare: 0,
|
|
233
233
|
});
|
|
234
234
|
expect(r.providers).toEqual([]);
|
|
235
235
|
expect(r.models).toEqual([]);
|
|
@@ -291,3 +291,31 @@ describe("renderUsageReport", () => {
|
|
|
291
291
|
db.close();
|
|
292
292
|
});
|
|
293
293
|
});
|
|
294
|
+
|
|
295
|
+
describe("digest re-runs and forecast accuracy", () => {
|
|
296
|
+
test("wasted digest rows count as re-runs; forecast error is judged on clean kept rows only", () => {
|
|
297
|
+
const { db, ledger } = seeded();
|
|
298
|
+
try {
|
|
299
|
+
ledger.record(entry({ requestedModel: "digest", conversationKey: "d1", reportedUsd: 0.001, predictedUsd: 0.001 }));
|
|
300
|
+
ledger.record(entry({ requestedModel: "digest", conversationKey: "d2", reportedUsd: 0.001, predictedUsd: 0.001, wasted: true }));
|
|
301
|
+
// Two clean turns: one predicted double, one predicted half.
|
|
302
|
+
ledger.record(entry({ predictedUsd: 0.02, reportedUsd: 0.01 }));
|
|
303
|
+
ledger.record(entry({ predictedUsd: 0.005, reportedUsd: 0.01 }));
|
|
304
|
+
// Excluded from the forecast judgement: wasted, errored, no reported cost.
|
|
305
|
+
ledger.record(entry({ predictedUsd: 1, reportedUsd: 0.01, wasted: true }));
|
|
306
|
+
ledger.record(entry({ predictedUsd: 1, reportedUsd: 0.01, error: "upstream_error: 500" }));
|
|
307
|
+
ledger.record(entry({ predictedUsd: 1, reportedUsd: null }));
|
|
308
|
+
const t = buildUsageReport(db, { windowDays: 1, nowMs: NOW }).totals;
|
|
309
|
+
expect(t.digests).toBe(2);
|
|
310
|
+
expect(t.digestReruns).toBe(1);
|
|
311
|
+
expect(t.forecastSamples).toBe(2);
|
|
312
|
+
expect(t.forecastMeanError).toBeCloseTo((1 + 0.5) / 2, 6);
|
|
313
|
+
expect(t.forecastOverShare).toBeCloseTo(0.5, 6);
|
|
314
|
+
const text = renderUsageReport(buildUsageReport(db, { windowDays: 1, nowMs: NOW }));
|
|
315
|
+
expect(text).toContain("re-run rate 50% (1 fetched again in full)");
|
|
316
|
+
expect(text).toContain("forecast: mean error 75% of reported cost over 2 turns · 50% over-predicted");
|
|
317
|
+
} finally {
|
|
318
|
+
db.close();
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
});
|
package/test/select.test.ts
CHANGED
|
@@ -1052,6 +1052,30 @@ describe("hysteresis.confirmUpgradesBelowConfidence", () => {
|
|
|
1052
1052
|
});
|
|
1053
1053
|
});
|
|
1054
1054
|
|
|
1055
|
+
describe("recorded forecast is the expected price, not the cold worst case", () => {
|
|
1056
|
+
const warmSlug = "x-ai/grok-4.6";
|
|
1057
|
+
test("a warm stay prices the previous prompt as cache reads; coldUsd keeps the cold figure", () => {
|
|
1058
|
+
const cfg: RouterConfig = { ...BASE, hysteresis: { ...BASE.hysteresis, switchMargin: 1e6 } };
|
|
1059
|
+
const d = run({
|
|
1060
|
+
tier: "hard",
|
|
1061
|
+
promptTokens: 80_000,
|
|
1062
|
+
cfg,
|
|
1063
|
+
st: state({ currentSlug: warmSlug, currentTier: "hard", cacheWarmSlug: warmSlug, cacheWarmAtMs: Date.now(), lastPromptTokens: 60_000 }),
|
|
1064
|
+
});
|
|
1065
|
+
expect(d.slug).toBe(warmSlug);
|
|
1066
|
+
// 60k of the 80k prompt is the cached prefix; no reliability sample ⇒ assumed reliable.
|
|
1067
|
+
expect(d.forecast.assumedCacheHitRate).toBeCloseTo(0.75, 6);
|
|
1068
|
+
expect(d.forecast.expectedUsd).toBeLessThan(d.forecast.coldUsd);
|
|
1069
|
+
expect(d.forecast.breakdown.cacheRead).toBeGreaterThan(0);
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
test("a cold turn records the cold price", () => {
|
|
1073
|
+
const d = run({ tier: "hard", promptTokens: 80_000 });
|
|
1074
|
+
expect(d.forecast.assumedCacheHitRate).toBe(0);
|
|
1075
|
+
expect(d.forecast.expectedUsd).toBeLessThanOrEqual(d.forecast.coldUsd);
|
|
1076
|
+
});
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1055
1079
|
describe("cache reliability in the stay/switch comparison", () => {
|
|
1056
1080
|
const warmSlug = "x-ai/grok-4.6";
|
|
1057
1081
|
function ledgerWithReliability(rate: number | null, samples = 50): Ledger {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test preload (bunfig.toml `[test] preload`): isolate every test from the
|
|
3
|
+
* developer's live router home BEFORE any module loads.
|
|
4
|
+
*
|
|
5
|
+
* Five test files build their config with `loadConfig({})`, which layers
|
|
6
|
+
* `$AUTO_MODEL_ROUTER_HOME/config.yml` over the defaults. Run alone they read
|
|
7
|
+
* the real config and fail on whatever the developer has tuned; run in the
|
|
8
|
+
* full suite they happened to pass because an earlier file had already
|
|
9
|
+
* pointed the home at a temp dir. Doing it here makes both cases the same.
|
|
10
|
+
* Tests that want a specific home (config.test.ts, embed-lifecycle) still
|
|
11
|
+
* set their own; this only supplies the default.
|
|
12
|
+
*/
|
|
13
|
+
import { mkdtempSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
|
|
17
|
+
if (process.env.AUTO_MODEL_ROUTER_HOME === undefined) {
|
|
18
|
+
process.env.AUTO_MODEL_ROUTER_HOME = mkdtempSync(join(tmpdir(), "amr-test-home-"));
|
|
19
|
+
}
|
package/test/tokens.test.ts
CHANGED
|
@@ -281,3 +281,27 @@ describe("ledger.softFailureSpikes", () => {
|
|
|
281
281
|
}
|
|
282
282
|
});
|
|
283
283
|
});
|
|
284
|
+
|
|
285
|
+
describe("ledger.prune and markWasted", () => {
|
|
286
|
+
test("prune deletes rows past retention and 0 keeps everything; markWasted flips one row", () => {
|
|
287
|
+
const db = openDb(":memory:");
|
|
288
|
+
try {
|
|
289
|
+
const ledger = createLedger(db, cfg);
|
|
290
|
+
const now = 1_800_000_000_000;
|
|
291
|
+
const DAY = 86_400_000;
|
|
292
|
+
for (let i = 0; i < 5; i++) ledger.record(entry({ createdAtMs: now - i * 100 * DAY }));
|
|
293
|
+
expect(ledger.prune?.(0, now)).toBe(0);
|
|
294
|
+
expect(ledger.recentEntries(10)).toHaveLength(5);
|
|
295
|
+
expect(ledger.prune?.(365, now)).toBe(1); // only the 400-day-old row
|
|
296
|
+
expect(ledger.recentEntries(10)).toHaveLength(4);
|
|
297
|
+
expect(ledger.prune?.(150, now)).toBe(2); // 200 and 300 days old
|
|
298
|
+
const left = ledger.recentEntries(10);
|
|
299
|
+
expect(left).toHaveLength(2);
|
|
300
|
+
expect(left.every((e) => e.wasted === false)).toBe(true);
|
|
301
|
+
ledger.markWasted?.(left[0]!.id);
|
|
302
|
+
expect(ledger.recentEntries(10).find((e) => e.id === left[0]!.id)?.wasted).toBe(true);
|
|
303
|
+
} finally {
|
|
304
|
+
db.close();
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
});
|
package/test/turn.test.ts
CHANGED
|
@@ -79,7 +79,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
79
79
|
report: { baselines: [], dailySummary: false },
|
|
80
80
|
digest: { enabled: false, minBytes: 12_000, maxBytes: 400_000, tools: ["read"], fromTier: "moderate", tier: "simple", model: "", maxOutputTokens: 700, maxCostUsd: 0.02, timeoutMs: 25_000 },
|
|
81
81
|
profiles: [],
|
|
82
|
-
ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 },
|
|
82
|
+
ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 , retentionDays: 0,},
|
|
83
83
|
adaptiveTierFloors: true,
|
|
84
84
|
adaptivePriceCeilings: false,
|
|
85
85
|
logLevel: "silent",
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Regenerates the old-schema ledger fixtures that test/migrations.test.ts
|
|
4
|
+
* opens with the CURRENT bootstrap.
|
|
5
|
+
*
|
|
6
|
+
* bun tools/gen-migration-fixtures.ts
|
|
7
|
+
*
|
|
8
|
+
* For each release tag that changed the schema, the bootstrap of THAT tag is
|
|
9
|
+
* taken from git, run against a fresh file, and a dummy row is inserted into
|
|
10
|
+
* every table (filling each NOT NULL column without a default by its declared
|
|
11
|
+
* type), so the migrations have data to carry, not just DDL. The WAL is folded
|
|
12
|
+
* back into the main file and the result lands in test/fixtures/migrations/
|
|
13
|
+
* as router-v<user_version>.db. Small (a few dozen KB each); commit them.
|
|
14
|
+
*
|
|
15
|
+
* Re-run only when adding a NEW historical version: rewriting existing
|
|
16
|
+
* fixtures would erase the very thing the test guards.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { Database } from "bun:sqlite";
|
|
20
|
+
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { $ } from "bun";
|
|
24
|
+
|
|
25
|
+
/** One tag per schema version that shipped. */
|
|
26
|
+
const TAGS = ["v0.1.0", "v0.1.4", "v0.2.0", "v0.2.10", "v0.2.22", "v0.2.28", "v0.3.0"];
|
|
27
|
+
const OUT_DIR = join(import.meta.dir, "..", "test", "fixtures", "migrations");
|
|
28
|
+
mkdirSync(OUT_DIR, { recursive: true });
|
|
29
|
+
const work = mkdtempSync(join(tmpdir(), "amr-migrations-"));
|
|
30
|
+
|
|
31
|
+
function dummy(type: string, name: string): string | number {
|
|
32
|
+
const t = type.toUpperCase();
|
|
33
|
+
if (name === "id" || name === "key") return `fixture-${name}`;
|
|
34
|
+
if (name === "created_at_ms" || name === "updated_at_ms" || name === "fetched_at_ms") return 1_756_000_000_000;
|
|
35
|
+
if (t.includes("INT") || t.includes("REAL")) return 1;
|
|
36
|
+
if (name === "usage") return JSON.stringify({ promptTokens: 100, cachedTokens: 0, cacheWriteTokens: 0, completionTokens: 10, reasoningTokens: 0, images: 0 });
|
|
37
|
+
if (name === "reasons") return JSON.stringify(["fixture"]);
|
|
38
|
+
if (name === "payload") return JSON.stringify({ data: [] });
|
|
39
|
+
return `fixture-${name}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const tag of TAGS) {
|
|
43
|
+
const src = await $`git show ${tag}:src/util/sqlite.ts`.text();
|
|
44
|
+
const modPath = join(work, `sqlite-${tag}.ts`);
|
|
45
|
+
await Bun.write(modPath, src);
|
|
46
|
+
const dbPath = join(work, `${tag}.db`);
|
|
47
|
+
const mod = (await import(modPath)) as { openDb(path: string): Database };
|
|
48
|
+
const db = mod.openDb(dbPath);
|
|
49
|
+
const version = (db.query("PRAGMA user_version").get() as { user_version: number }).user_version;
|
|
50
|
+
const tables = (db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all() as { name: string }[]).map((r) => r.name);
|
|
51
|
+
for (const table of tables) {
|
|
52
|
+
const cols = db.query(`PRAGMA table_info(${table})`).all() as { name: string; type: string; notnull: number; dflt_value: string | null; pk: number }[];
|
|
53
|
+
// NOT NULL columns without a default, plus text primary keys (SQLite lets
|
|
54
|
+
// a TEXT PRIMARY KEY be NULL, but the router never writes one that way).
|
|
55
|
+
const fill = cols.filter((c) => (c.notnull === 1 && c.dflt_value === null && !(c.pk === 1 && c.type.toUpperCase().includes("INT"))) || (c.pk === 1 && !c.type.toUpperCase().includes("INT")));
|
|
56
|
+
// A ledger row with an error string exercises the v4 error_kind backfill.
|
|
57
|
+
const values = fill.map((c) => (table === "ledger" && c.name === "error" ? "upstream_error: 502" : dummy(c.type, c.name)));
|
|
58
|
+
if (fill.length === 0) continue;
|
|
59
|
+
db.run(`INSERT INTO ${table} (${fill.map((c) => c.name).join(", ")}) VALUES (${fill.map(() => "?").join(", ")})`, values);
|
|
60
|
+
if (table === "ledger" && cols.some((c) => c.name === "error")) db.run(`UPDATE ledger SET error = 'upstream_error: 502'`);
|
|
61
|
+
}
|
|
62
|
+
db.run("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
63
|
+
db.run("PRAGMA journal_mode = DELETE");
|
|
64
|
+
db.close();
|
|
65
|
+
const out = join(OUT_DIR, `router-v${version}.db`);
|
|
66
|
+
await Bun.write(out, Bun.file(dbPath));
|
|
67
|
+
console.log(`${tag} → ${out} (user_version ${version}, tables: ${tables.join(", ")})`);
|
|
68
|
+
}
|
|
69
|
+
rmSync(work, { recursive: true, force: true });
|