auto-model-router 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +127 -46
- package/bun.lock +606 -0
- package/omp-extension/router-embed.ts +14 -6
- package/omp-extension/router-toast.ts +6 -1
- package/omp-extension/toast-logic.ts +7 -0
- package/package.json +2 -1
- package/research/analyze-ledger.ts +173 -0
- package/research/apply-cost-tuning.ts +73 -0
- package/research/cost-analysis.ts +150 -0
- package/research/feed-check.ts +64 -0
- package/research/model-recommendations.ts +86 -0
- package/research/project-yield.ts +96 -0
- package/research/run-eval.ts +133 -0
- package/research/status.ts +55 -0
- package/research/tier-fill.ts +109 -0
- package/research/tier-map.ts +123 -0
- package/src/catalog/benchmark-feeds.ts +397 -0
- package/src/catalog/openrouter-catalog.ts +30 -0
- package/src/config/defaults.ts +30 -0
- package/src/config/load.ts +2 -0
- package/src/config/schema.ts +34 -0
- package/src/config/types.ts +106 -0
- package/src/cost/ledger.ts +27 -3
- package/src/cost/types.ts +30 -0
- package/src/eval/calibrate.ts +131 -0
- package/src/eval/grade.ts +115 -0
- package/src/eval/judge.ts +71 -0
- package/src/eval/run.ts +126 -0
- package/src/eval/tasks.ts +272 -0
- package/src/index.ts +0 -1
- package/src/router/candidates.ts +13 -6
- package/src/router/explore.ts +59 -0
- package/src/router/select.ts +54 -4
- package/src/router/tier-plan.ts +57 -1
- package/src/router/types.ts +13 -0
- package/src/server/turn.ts +10 -2
- package/src/util/sqlite.ts +79 -1
- package/src/wire/openai/request.ts +5 -0
- package/src/wire/types.ts +7 -0
- package/test/benchmark-feeds.test.ts +222 -0
- package/test/escalate.test.ts +1 -0
- package/test/eval.test.ts +184 -0
- package/test/exploration.test.ts +251 -0
- package/test/failover.test.ts +5 -0
- package/test/hold-exploration.test.ts +124 -0
- package/test/tier-plan.test.ts +55 -1
- package/test/toast-logic.test.ts +32 -0
- package/test/tokens.test.ts +8 -0
- package/test/trust-attribution.test.ts +110 -2
- package/test/turn.test.ts +46 -0
- package/test/wire-request.test.ts +11 -0
- package/tools/smoke.ts +2 -0
- package/tools/sync-marketplace-version.ts +60 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projects how many exploration samples a given set of rates would actually
|
|
3
|
+
* yield, from a ledger snapshot of real traffic.
|
|
4
|
+
*
|
|
5
|
+
* Choosing exploration rates blind is how you end up spending a month
|
|
6
|
+
* sampling the cheapest boundary in the system. This answers the only
|
|
7
|
+
* question that matters up front: at these rates, how many turns of each
|
|
8
|
+
* tier do I get, and how long until there are enough to fit on?
|
|
9
|
+
*
|
|
10
|
+
* Cache-coldness is approximated by the gap to the previous turn in the same
|
|
11
|
+
* conversation: if more than `cacheWarmTtlMs` elapsed, the prompt cache would
|
|
12
|
+
* have expired, which is what makes a hysteresis-held turn explorable.
|
|
13
|
+
*
|
|
14
|
+
* Usage: bun run research/project-yield.ts [snapshot.db]
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Database } from "bun:sqlite";
|
|
18
|
+
|
|
19
|
+
import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
|
|
20
|
+
import type { Tier } from "../src/router/types.ts";
|
|
21
|
+
|
|
22
|
+
const path = process.argv[2] ?? "research-data/snapshot.db";
|
|
23
|
+
const db = new Database(path, { readonly: true });
|
|
24
|
+
|
|
25
|
+
const TTL = DEFAULT_CONFIG.hysteresis.cacheWarmTtlMs;
|
|
26
|
+
const RATES = DEFAULT_CONFIG.exploration.rates;
|
|
27
|
+
|
|
28
|
+
interface Row {
|
|
29
|
+
tier: Tier;
|
|
30
|
+
total: number;
|
|
31
|
+
old_eligible: number;
|
|
32
|
+
new_eligible: number;
|
|
33
|
+
spend: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const rows = db
|
|
37
|
+
.query(
|
|
38
|
+
`WITH t AS (
|
|
39
|
+
SELECT tier, classification_source AS src,
|
|
40
|
+
COALESCE(reported_usd, predicted_usd) AS usd,
|
|
41
|
+
created_at_ms - LAG(created_at_ms) OVER (
|
|
42
|
+
PARTITION BY conversation_key ORDER BY created_at_ms
|
|
43
|
+
) AS gap
|
|
44
|
+
FROM ledger
|
|
45
|
+
)
|
|
46
|
+
SELECT tier,
|
|
47
|
+
COUNT(*) AS total,
|
|
48
|
+
ROUND(SUM(usd), 2) AS spend,
|
|
49
|
+
SUM(CASE WHEN src = 'heuristic' THEN 1 ELSE 0 END) AS old_eligible,
|
|
50
|
+
SUM(CASE WHEN src = 'heuristic'
|
|
51
|
+
OR (src = 'sticky' AND (gap IS NULL OR gap > ${TTL}))
|
|
52
|
+
THEN 1 ELSE 0 END) AS new_eligible
|
|
53
|
+
FROM t
|
|
54
|
+
WHERE tier IN ('simple', 'moderate', 'hard')
|
|
55
|
+
GROUP BY tier`,
|
|
56
|
+
)
|
|
57
|
+
.all() as unknown as Row[];
|
|
58
|
+
|
|
59
|
+
const { days } = db.query("SELECT (MAX(created_at_ms) - MIN(created_at_ms)) / 86400000.0 AS days FROM ledger").get() as {
|
|
60
|
+
days: number;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const order: Tier[] = ["hard", "moderate", "simple"];
|
|
64
|
+
const byTier = new Map(rows.map((r) => [r.tier, r]));
|
|
65
|
+
|
|
66
|
+
console.log(`window: ${days.toFixed(2)} days\n`);
|
|
67
|
+
console.log("tier spend turns elig(sticky-excl) elig(cold-ok) rate /day 2wk 4wk");
|
|
68
|
+
|
|
69
|
+
let oldPerDay = 0;
|
|
70
|
+
let newPerDay = 0;
|
|
71
|
+
for (const tier of order) {
|
|
72
|
+
const r = byTier.get(tier);
|
|
73
|
+
if (r === undefined) continue;
|
|
74
|
+
const rate = RATES[tier] ?? 0;
|
|
75
|
+
const perDay = (r.new_eligible / days) * rate;
|
|
76
|
+
oldPerDay += (r.old_eligible / days) * rate;
|
|
77
|
+
newPerDay += perDay;
|
|
78
|
+
console.log(
|
|
79
|
+
" " +
|
|
80
|
+
tier.padEnd(10) +
|
|
81
|
+
("$" + r.spend).padStart(6) +
|
|
82
|
+
String(r.total).padStart(8) +
|
|
83
|
+
String(r.old_eligible).padStart(19) +
|
|
84
|
+
String(r.new_eligible).padStart(15) +
|
|
85
|
+
String(rate).padStart(7) +
|
|
86
|
+
perDay.toFixed(1).padStart(7) +
|
|
87
|
+
(perDay * 14).toFixed(0).padStart(7) +
|
|
88
|
+
(perDay * 28).toFixed(0).padStart(7),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log("");
|
|
93
|
+
console.log(`total explored/day sticky-excluded ${oldPerDay.toFixed(1)} -> cold-cache allowed ${newPerDay.toFixed(1)}`);
|
|
94
|
+
console.log(`over 4 weeks sticky-excluded ${(oldPerDay * 28).toFixed(0)} -> cold-cache allowed ${(newPerDay * 28).toFixed(0)}`);
|
|
95
|
+
|
|
96
|
+
db.close();
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run our own on-distribution benchmark and calibrate it to the AA scale.
|
|
3
|
+
*
|
|
4
|
+
* Reads the live cached catalog, splits it into ANCHORS (models AA already
|
|
5
|
+
* scored) and TARGETS (unscored, routable), runs the curated suite against every
|
|
6
|
+
* one via real OpenRouter completions, fits raw->AA per axis from the anchors,
|
|
7
|
+
* and writes calibrated `local` scores for the targets into `local_scores`.
|
|
8
|
+
*
|
|
9
|
+
* Those scores are INERT until `benchmarks.useLocalScores` is turned on — this
|
|
10
|
+
* script never flips it, so a run cannot perturb live routing on its own.
|
|
11
|
+
*
|
|
12
|
+
* bun run research/run-eval.ts
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
18
|
+
// Force the PRODUCTION home; the fork's .env points it at research-data/home.
|
|
19
|
+
process.env.AUTO_MODEL_ROUTER_HOME = join(homedir(), ".auto-model-router");
|
|
20
|
+
|
|
21
|
+
import type { QualityAxis } from "../src/config/types.ts";
|
|
22
|
+
import type { CatalogModel } from "../src/catalog/types.ts";
|
|
23
|
+
import { normalizeCatalogModel } from "../src/catalog/openrouter-catalog.ts";
|
|
24
|
+
import { saveLocalScores, normalizeModelKey } from "../src/catalog/benchmark-feeds.ts";
|
|
25
|
+
import { loadConfig } from "../src/config/load.ts";
|
|
26
|
+
import { fitCalibration, toLocalFeedScores, MIN_ANCHORS } from "../src/eval/calibrate.ts";
|
|
27
|
+
import { runEval, type ChatMessage, type EvalResult } from "../src/eval/run.ts";
|
|
28
|
+
import { makeJudge } from "../src/eval/judge.ts";
|
|
29
|
+
import { EVAL_TASKS, JUDGED_TASKS } from "../src/eval/tasks.ts";
|
|
30
|
+
import { createOpenRouterClient } from "../src/upstream/openrouter.ts";
|
|
31
|
+
import { openDb } from "../src/util/sqlite.ts";
|
|
32
|
+
|
|
33
|
+
const AXES: readonly QualityAxis[] = ["coding", "intelligence", "agentic"];
|
|
34
|
+
const cfg = loadConfig({});
|
|
35
|
+
if (cfg.openrouter.apiKey.trim() === "") {
|
|
36
|
+
console.log("no OpenRouter key resolved; the eval needs one to dispatch completions.");
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const db = openDb(cfg.ledger.path);
|
|
41
|
+
const row = db.query("SELECT payload FROM catalog_cache WHERE id = 1").get() as { payload: string } | null;
|
|
42
|
+
if (row === null) {
|
|
43
|
+
console.log("no cached catalog");
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
const models: CatalogModel[] = (JSON.parse(row.payload) as unknown[])
|
|
47
|
+
.map(normalizeCatalogModel)
|
|
48
|
+
.filter((m): m is CatalogModel => m !== null);
|
|
49
|
+
|
|
50
|
+
const routable = (m: CatalogModel): boolean => !m.slug.startsWith("~") && !m.isFree && m.author !== "openrouter";
|
|
51
|
+
const scored = (m: CatalogModel): boolean => m.quality.coding !== undefined || m.quality.intelligence !== undefined || m.quality.agentic !== undefined;
|
|
52
|
+
|
|
53
|
+
const anchors = models.filter((m) => routable(m) && scored(m));
|
|
54
|
+
const targets = models.filter((m) => routable(m) && !scored(m));
|
|
55
|
+
console.log(`catalog ${models.length}: ${anchors.length} anchors (AA-scored), ${targets.length} targets (unscored)\n`);
|
|
56
|
+
if (anchors.length < MIN_ANCHORS) {
|
|
57
|
+
console.log(`only ${anchors.length} anchors (< ${MIN_ANCHORS}); cannot calibrate. Aborting.`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const client = createOpenRouterClient(cfg);
|
|
62
|
+
// Open-ended (judged) answers are long and slow reasoning models need room, or
|
|
63
|
+
// they time out / truncate and get spuriously excluded — which would make raw
|
|
64
|
+
// scores incomparable across models. Generous ceiling; objective tasks ignore it.
|
|
65
|
+
const complete = async (slug: string, messages: ChatMessage[]): Promise<string> => {
|
|
66
|
+
const body = { model: slug, messages, temperature: 0, max_tokens: 2048 };
|
|
67
|
+
const { text } = await client.complete(body, AbortSignal.timeout(120_000));
|
|
68
|
+
return text;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// The judge is the highest-intelligence anchor: the strongest grader available.
|
|
72
|
+
// It also gets scored here (minor self-preference bias, called out in the report).
|
|
73
|
+
const judgeModel = anchors.reduce((best, m) => ((m.quality.intelligence ?? 0) > (best.quality.intelligence ?? 0) ? m : best));
|
|
74
|
+
const judge = makeJudge(complete, judgeModel.slug);
|
|
75
|
+
const allSlugs = [...anchors, ...targets].map((m) => m.slug);
|
|
76
|
+
console.log(`judge: ${judgeModel.slug} (intelligence ${judgeModel.quality.intelligence ?? "-"})`);
|
|
77
|
+
console.log(`running ${EVAL_TASKS.length} objective + ${JUDGED_TASKS.length} judged tasks against ${allSlugs.length} models...`);
|
|
78
|
+
const results = await runEval({
|
|
79
|
+
slugs: allSlugs,
|
|
80
|
+
complete,
|
|
81
|
+
judge,
|
|
82
|
+
concurrency: 4,
|
|
83
|
+
onProgress: (r, done, total) => console.log(` [${done}/${total}] ${r.slug}${r.errors > 0 ? ` (errors: ${r.errors})` : ""}`),
|
|
84
|
+
});
|
|
85
|
+
const bySlug = new Map<string, EvalResult>(results.map((r) => [r.slug, r]));
|
|
86
|
+
|
|
87
|
+
const qualityOf = new Map<string, CatalogModel["quality"]>(models.map((m) => [m.slug, m.quality]));
|
|
88
|
+
const anchorAa = (slug: string, axis: QualityAxis): number | undefined => qualityOf.get(slug)?.[axis];
|
|
89
|
+
|
|
90
|
+
const mean = (r: EvalResult | undefined, axis: QualityAxis): number | null =>
|
|
91
|
+
r === undefined || r.axes[axis].n === 0 ? null : r.axes[axis].sum / r.axes[axis].n;
|
|
92
|
+
|
|
93
|
+
const anchorResults = anchors.map((m) => bySlug.get(m.slug)).filter((r): r is EvalResult => r !== undefined);
|
|
94
|
+
const targetResults = targets.map((m) => bySlug.get(m.slug)).filter((r): r is EvalResult => r !== undefined);
|
|
95
|
+
|
|
96
|
+
const cal = fitCalibration(anchorResults, anchorAa);
|
|
97
|
+
|
|
98
|
+
console.log("\n=== anchors: raw suite mean vs known AA ===");
|
|
99
|
+
for (const m of anchors) {
|
|
100
|
+
const r = bySlug.get(m.slug);
|
|
101
|
+
const cells = AXES.map((a) => {
|
|
102
|
+
const raw = mean(r, a);
|
|
103
|
+
const aa = m.quality[a];
|
|
104
|
+
return `${a[0]}: raw ${raw === null ? "-" : raw.toFixed(2)} / aa ${aa ?? "-"}`;
|
|
105
|
+
}).join(" ");
|
|
106
|
+
console.log(` ${m.slug.padEnd(34)} ${cells}${r !== undefined && r.errors > 0 ? ` (errors: ${r.errors})` : ""}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
console.log("\n=== calibration fit (raw 0-1 -> AA 0-100) ===");
|
|
110
|
+
for (const a of AXES) {
|
|
111
|
+
const f = cal[a];
|
|
112
|
+
console.log(` ${a.padEnd(13)} ${f === undefined ? "(no fit — too few anchors, no spread, or weak/negative correlation)" : `aa = ${f.slope.toFixed(1)}*raw + ${f.intercept.toFixed(1)} (r=${f.r.toFixed(2)}, n=${f.n})`}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const localScores = toLocalFeedScores(targetResults, cal, (slug) => {
|
|
116
|
+
const bare = slug.startsWith("~") ? slug.slice(1) : slug;
|
|
117
|
+
const slash = bare.indexOf("/");
|
|
118
|
+
return slash === -1 ? "" : bare.slice(0, slash);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
console.log("\n=== targets: calibrated local scores ===");
|
|
122
|
+
for (const m of targets) {
|
|
123
|
+
const r = bySlug.get(m.slug);
|
|
124
|
+
const fs = localScores.find((s) => s.key === normalizeModelKey(m.slug));
|
|
125
|
+
const raws = AXES.map((a) => `${a[0]}:${mean(r, a)?.toFixed(2) ?? "-"}`).join(" ");
|
|
126
|
+
const cal2 = fs === undefined ? "(none)" : AXES.map((a) => `${a[0]}:${fs[a]?.toFixed(1) ?? "-"}`).join(" ");
|
|
127
|
+
console.log(` ${m.slug.padEnd(34)} raw[${raws}] -> local[${cal2}]`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
saveLocalScores(db, localScores);
|
|
131
|
+
db.close();
|
|
132
|
+
console.log(`\nwrote ${localScores.length} local score row(s) to local_scores.`);
|
|
133
|
+
console.log(`benchmarks.useLocalScores = ${cfg.benchmarks.useLocalScores} — scores are INERT until this is true.`);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quick "is the experiment actually running?" check.
|
|
3
|
+
*
|
|
4
|
+
* Distinct from analyze-ledger.ts, which reads results. This answers the
|
|
5
|
+
* narrower question you ask right after a restart: is the fork serving turns,
|
|
6
|
+
* and are both experiments recording?
|
|
7
|
+
*
|
|
8
|
+
* Usage: bun run research/status.ts [path-to-router.db]
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
import { Database } from "bun:sqlite";
|
|
15
|
+
|
|
16
|
+
const path = process.argv[2] ?? join(homedir(), ".auto-model-router", "router.db");
|
|
17
|
+
const db = new Database(path, { readonly: true });
|
|
18
|
+
|
|
19
|
+
const one = <T>(sql: string): T => db.query(sql).get() as T;
|
|
20
|
+
|
|
21
|
+
const version = one<{ user_version: number }>("PRAGMA user_version").user_version;
|
|
22
|
+
const total = one<{ n: number }>("SELECT COUNT(*) AS n FROM ledger").n;
|
|
23
|
+
|
|
24
|
+
// Instrumented rows are the only ones the fork can have written, so they are
|
|
25
|
+
// the dividing line between "old build" and "this build" traffic.
|
|
26
|
+
const instrumented = one<{ n: number }>("SELECT COUNT(*) AS n FROM ledger WHERE features IS NOT NULL").n;
|
|
27
|
+
const withArm = one<{ n: number }>("SELECT COUNT(*) AS n FROM ledger WHERE hold_arm IS NOT NULL").n;
|
|
28
|
+
const explored = one<{ n: number }>("SELECT COUNT(*) AS n FROM ledger WHERE explored_from IS NOT NULL").n;
|
|
29
|
+
const newest = one<{ m: number | null }>("SELECT MAX(created_at_ms) AS m FROM ledger").m;
|
|
30
|
+
const newestInstrumented = one<{ m: number | null }>(
|
|
31
|
+
"SELECT MAX(created_at_ms) AS m FROM ledger WHERE features IS NOT NULL",
|
|
32
|
+
).m;
|
|
33
|
+
|
|
34
|
+
const stamp = (ms: number | null): string => (ms === null ? "never" : new Date(ms).toISOString());
|
|
35
|
+
const ok = (b: boolean): string => (b ? "OK " : "WAIT");
|
|
36
|
+
|
|
37
|
+
console.log(`db ${path}`);
|
|
38
|
+
console.log(`schema v${version}${version === 8 ? "" : " <-- expected v8"}`);
|
|
39
|
+
console.log(`rows ${total}`);
|
|
40
|
+
console.log("");
|
|
41
|
+
console.log(`${ok(instrumented > 0)} instrumented ${instrumented} (feature vectors recorded)`);
|
|
42
|
+
console.log(`${ok(withArm > 0)} hold arms ${withArm} (should equal instrumented once the hold experiment is live)`);
|
|
43
|
+
console.log(`${ok(explored > 0)} explored ${explored} (sampled, so stays 0 for a while at low rates)`);
|
|
44
|
+
console.log("");
|
|
45
|
+
console.log(`newest row ${stamp(newest)}`);
|
|
46
|
+
console.log(`newest instrumented ${stamp(newestInstrumented)}`);
|
|
47
|
+
|
|
48
|
+
if (instrumented > 0 && withArm < instrumented) {
|
|
49
|
+
console.log("");
|
|
50
|
+
console.log(`note: ${instrumented - withArm} instrumented row(s) have no hold arm.`);
|
|
51
|
+
console.log(" Rows written before the hold experiment shipped are expected to be null.");
|
|
52
|
+
console.log(" If the count keeps growing, the running router predates it -- restart omp.");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
db.close();
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reports how many candidates each tier actually has, from the live cached
|
|
3
|
+
* catalog.
|
|
4
|
+
*
|
|
5
|
+
* The exploration plan assumes `hard` turns exist and have somewhere cheaper
|
|
6
|
+
* to land. If a narrow OpenRouter key-scoping leaves upper tiers empty, or
|
|
7
|
+
* only a handful of models carry benchmark scores, exploration cannot yield
|
|
8
|
+
* what the projection promised. This checks that rather than assuming it.
|
|
9
|
+
*
|
|
10
|
+
* Usage: bun run research/tier-fill.ts [path-to-router.db]
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
import { Database } from "bun:sqlite";
|
|
17
|
+
|
|
18
|
+
import { normalizeCatalogModel } from "../src/catalog/openrouter-catalog.ts";
|
|
19
|
+
import type { CatalogModel, CatalogSnapshot } from "../src/catalog/types.ts";
|
|
20
|
+
import { loadConfig } from "../src/config/load.ts";
|
|
21
|
+
import { scoreHeuristic } from "../src/router/classify.ts";
|
|
22
|
+
import { extractFeatures } from "../src/router/features.ts";
|
|
23
|
+
import { select } from "../src/router/select.ts";
|
|
24
|
+
import type { ConversationState, Tier } from "../src/router/types.ts";
|
|
25
|
+
import { parseChatRequest } from "../src/wire/openai/request.ts";
|
|
26
|
+
|
|
27
|
+
const dbPath = process.argv[2] ?? join(homedir(), ".auto-model-router", "router.db");
|
|
28
|
+
const db = new Database(dbPath, { readonly: true });
|
|
29
|
+
const row = db.query("SELECT payload FROM catalog_cache WHERE id = 1").get() as { payload: string } | null;
|
|
30
|
+
db.close();
|
|
31
|
+
|
|
32
|
+
if (row === null) {
|
|
33
|
+
console.log("no cached catalog in " + dbPath);
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const models: CatalogModel[] = (JSON.parse(row.payload) as unknown[])
|
|
38
|
+
.map(normalizeCatalogModel)
|
|
39
|
+
.filter((m): m is CatalogModel => m !== null);
|
|
40
|
+
|
|
41
|
+
const snapshot: CatalogSnapshot = { models, fetchedAtMs: Date.now() };
|
|
42
|
+
const cfg = loadConfig({});
|
|
43
|
+
|
|
44
|
+
console.log(`catalog: ${models.length} models`);
|
|
45
|
+
console.log(`adaptiveTierFloors: ${cfg.adaptiveTierFloors}`);
|
|
46
|
+
console.log("");
|
|
47
|
+
|
|
48
|
+
const req = parseChatRequest(
|
|
49
|
+
{
|
|
50
|
+
model: "auto",
|
|
51
|
+
messages: [
|
|
52
|
+
{ role: "system", content: "You are a coding agent." },
|
|
53
|
+
{ role: "user", content: "refactor the retry helper and explain the race condition" },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
new Headers(),
|
|
57
|
+
);
|
|
58
|
+
const features = extractFeatures(req, 4000);
|
|
59
|
+
const heuristic = scoreHeuristic(features, cfg);
|
|
60
|
+
|
|
61
|
+
const state: ConversationState = {
|
|
62
|
+
key: "tier-fill-probe",
|
|
63
|
+
sessionId: "probe",
|
|
64
|
+
turn: 1,
|
|
65
|
+
currentSlug: null,
|
|
66
|
+
currentTier: null,
|
|
67
|
+
stickyUntilTurn: 0,
|
|
68
|
+
escalations: 0,
|
|
69
|
+
spentUsd: 0,
|
|
70
|
+
lastPromptTokens: 0,
|
|
71
|
+
cacheWarmSlug: null,
|
|
72
|
+
cacheWarmAtMs: 0,
|
|
73
|
+
updatedAtMs: Date.now(),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const profile = cfg.profiles.find((p) => p.id === "auto") ?? cfg.profiles[0];
|
|
77
|
+
if (profile === undefined) {
|
|
78
|
+
console.log("no profiles configured");
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log("tier candidates chosen note");
|
|
83
|
+
for (const tier of ["trivial", "simple", "moderate", "hard"] as Tier[]) {
|
|
84
|
+
try {
|
|
85
|
+
const d = select({
|
|
86
|
+
req,
|
|
87
|
+
features,
|
|
88
|
+
classification: { ...heuristic, tier },
|
|
89
|
+
profile,
|
|
90
|
+
state,
|
|
91
|
+
snapshot,
|
|
92
|
+
ledger: null,
|
|
93
|
+
cfg,
|
|
94
|
+
nowMs: Date.now(),
|
|
95
|
+
});
|
|
96
|
+
// A widened decision means the requested tier had nothing of its own.
|
|
97
|
+
const widened = d.reasons.find((r) => r.startsWith("widened"));
|
|
98
|
+
console.log(
|
|
99
|
+
" " +
|
|
100
|
+
tier.padEnd(10) +
|
|
101
|
+
String(d.considered.length).padStart(10) +
|
|
102
|
+
" " +
|
|
103
|
+
d.slug.padEnd(32) +
|
|
104
|
+
(widened ?? ""),
|
|
105
|
+
);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.log(" " + tier.padEnd(10) + " ERROR: " + (err instanceof Error ? err.message : String(err)));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full placement matrix: for every catalog model, which tiers it is eligible
|
|
3
|
+
* for, on each routed axis (coding task -> coding axis, chat task -> intelligence
|
|
4
|
+
* axis). Faithful to src/router/candidates.ts (adaptive floors, price ceilings,
|
|
5
|
+
* capability filters). Reads the on-disk catalog cache; no network.
|
|
6
|
+
*
|
|
7
|
+
* bun run research/tier-map.ts [db]
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { Database } from "bun:sqlite";
|
|
14
|
+
|
|
15
|
+
import { normalizeCatalogModel } from "../src/catalog/openrouter-catalog.ts";
|
|
16
|
+
import type { CatalogModel, CatalogSnapshot } from "../src/catalog/types.ts";
|
|
17
|
+
import { loadConfig } from "../src/config/load.ts";
|
|
18
|
+
import { buildCandidates } from "../src/router/candidates.ts";
|
|
19
|
+
import { extractFeatures } from "../src/router/features.ts";
|
|
20
|
+
import { effectiveQualityFloor, tierPlanFor } from "../src/router/tier-plan.ts";
|
|
21
|
+
import { TIER_ORDER, type Tier, type TaskType } from "../src/router/types.ts";
|
|
22
|
+
import { parseChatRequest } from "../src/wire/openai/request.ts";
|
|
23
|
+
|
|
24
|
+
const dbPath = process.argv[2] ?? join(homedir(), ".auto-model-router", "router.db");
|
|
25
|
+
const db = new Database(dbPath, { readonly: true });
|
|
26
|
+
const row = db.query("SELECT payload FROM catalog_cache WHERE id = 1").get() as { payload: string } | null;
|
|
27
|
+
db.close();
|
|
28
|
+
if (row === null) {
|
|
29
|
+
console.log("no cached catalog");
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const models: CatalogModel[] = (JSON.parse(row.payload) as unknown[])
|
|
34
|
+
.map(normalizeCatalogModel)
|
|
35
|
+
.filter((m): m is CatalogModel => m !== null)
|
|
36
|
+
.sort((a, b) => a.price.prompt - b.price.prompt);
|
|
37
|
+
|
|
38
|
+
const snapshot: CatalogSnapshot = { models, fetchedAtMs: Date.now() };
|
|
39
|
+
const cfg = loadConfig({});
|
|
40
|
+
|
|
41
|
+
const req = parseChatRequest(
|
|
42
|
+
{ model: "auto", messages: [{ role: "user", content: "Implement and verify the change." }] },
|
|
43
|
+
new Headers(),
|
|
44
|
+
);
|
|
45
|
+
const features = extractFeatures(req, 4000);
|
|
46
|
+
|
|
47
|
+
// reason -> single-char code for the matrix cell
|
|
48
|
+
function code(reason: string | undefined): string {
|
|
49
|
+
if (reason === undefined) return "OK ";
|
|
50
|
+
if (reason === "below_quality_floor") return "qual";
|
|
51
|
+
if (reason === "over_price_ceiling") return "pric";
|
|
52
|
+
if (reason === "free_tier_excluded") return "free";
|
|
53
|
+
return reason.slice(0, 4);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const axes: Array<{ label: string; task: TaskType }> = [
|
|
57
|
+
{ label: "coding axis (task=coding)", task: "coding" as TaskType },
|
|
58
|
+
{ label: "intelligence axis (task=chat)", task: "chat" as TaskType },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
for (const { label, task } of axes) {
|
|
62
|
+
const axis = cfg.tasks[task].axis;
|
|
63
|
+
console.log(`\n===== ${label} =====`);
|
|
64
|
+
|
|
65
|
+
// Effective (adaptive) floor per tier on this axis.
|
|
66
|
+
const floors: Record<string, number> = {};
|
|
67
|
+
for (const tier of TIER_ORDER) {
|
|
68
|
+
const tc = cfg.tiers[tier];
|
|
69
|
+
const adaptive = cfg.adaptiveTierFloors
|
|
70
|
+
? effectiveQualityFloor(tc.minQuality, tier, axis, tierPlanFor(snapshot, cfg))
|
|
71
|
+
: tc.minQuality;
|
|
72
|
+
floors[tier] = Math.max(adaptive, cfg.tasks[task].minQuality ?? 0);
|
|
73
|
+
}
|
|
74
|
+
console.log(
|
|
75
|
+
`floors ` +
|
|
76
|
+
TIER_ORDER.map((t) => `${t}=${floors[t]?.toFixed(1)}(cfg ${cfg.tiers[t].minQuality})`).join(" ") +
|
|
77
|
+
`\nceil$ ` +
|
|
78
|
+
TIER_ORDER.map((t) => `${t}=${cfg.tiers[t].maxInputPerMtok ?? "inf"}`).join(" ") +
|
|
79
|
+
`\n`,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// Per tier: eligible set + rejection reasons.
|
|
83
|
+
const perTier = TIER_ORDER.map((tier) => {
|
|
84
|
+
const { candidates, rejected } = buildCandidates({
|
|
85
|
+
req,
|
|
86
|
+
features,
|
|
87
|
+
tier,
|
|
88
|
+
task,
|
|
89
|
+
snapshot,
|
|
90
|
+
ledger: null,
|
|
91
|
+
cfg,
|
|
92
|
+
expectedCompletionTokens: 4000,
|
|
93
|
+
warmSlug: null,
|
|
94
|
+
});
|
|
95
|
+
const elig = new Set(candidates.map((c) => c.model.slug));
|
|
96
|
+
const rej = new Map(rejected.map((r) => [r.slug, r.reason]));
|
|
97
|
+
return { tier, elig, rej };
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const qOf = (m: CatalogModel): number | undefined =>
|
|
101
|
+
axis === "coding" ? m.quality.coding : axis === "agentic" ? m.quality.agentic : m.quality.intelligence;
|
|
102
|
+
|
|
103
|
+
const nameW = Math.max(...models.map((m) => m.slug.length));
|
|
104
|
+
const header =
|
|
105
|
+
"model".padEnd(nameW) + " in$ " + "q".padEnd(6) + TIER_ORDER.map((t) => t.slice(0, 4).padEnd(6)).join("");
|
|
106
|
+
console.log(header);
|
|
107
|
+
console.log("-".repeat(header.length));
|
|
108
|
+
for (const m of models) {
|
|
109
|
+
const q = qOf(m);
|
|
110
|
+
const cells = perTier
|
|
111
|
+
.map((pt) => (pt.elig.has(m.slug) ? "OK " : code(pt.rej.get(m.slug))).padEnd(6))
|
|
112
|
+
.join("");
|
|
113
|
+
console.log(
|
|
114
|
+
m.slug.padEnd(nameW) +
|
|
115
|
+
" " +
|
|
116
|
+
(m.price.prompt * 1e6).toFixed(2).padStart(5) +
|
|
117
|
+
" " +
|
|
118
|
+
(q === undefined ? "-" : q.toFixed(1)).padEnd(6) +
|
|
119
|
+
cells,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
console.log("\ncells: OK=eligible qual=below_quality_floor pric=over_price_ceiling free=free_tier_excluded");
|