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 +2 -0
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +47 -1
- package/bunfig.toml +2 -0
- package/omp-extension/pi-coding-agent.d.ts +6 -0
- package/omp-extension/router-switch.ts +100 -0
- package/omp-extension/switch-logic.ts +81 -0
- package/package.json +1 -1
- package/src/catalog/composite.ts +4 -1
- package/src/cli/config-wizard.ts +9 -0
- package/src/config/defaults.ts +7 -0
- package/src/config/hot-reload.ts +58 -9
- package/src/config/schema.ts +8 -0
- package/src/config/types.ts +27 -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/advise.ts +82 -0
- package/src/server/compaction-digest.ts +2 -0
- package/src/server/digest.ts +55 -1
- package/src/server/http.ts +41 -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/config-wizard.test.ts +1 -0
- package/test/digest.test.ts +22 -0
- package/test/failover.test.ts +2 -1
- package/test/harness-switch.test.ts +59 -0
- 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 +2 -1
- package/tools/gen-migration-fixtures.ts +69 -0
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
|
@@ -77,9 +77,10 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
77
77
|
compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true, replanGrowthRatio: 1, digestToolResults: false, digestMaxPerTurn: 2 },
|
|
78
78
|
budget: { onExceeded: "downgrade" },
|
|
79
79
|
report: { baselines: [], dailySummary: false },
|
|
80
|
+
harnessSwitch: { enabled: false, models: {}, minConfidence: 0.6 },
|
|
80
81
|
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
82
|
profiles: [],
|
|
82
|
-
ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 },
|
|
83
|
+
ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 , retentionDays: 0,},
|
|
83
84
|
adaptiveTierFloors: true,
|
|
84
85
|
adaptivePriceCeilings: false,
|
|
85
86
|
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 });
|