auto-model-router 0.1.4 → 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.
Files changed (45) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +43 -55
  3. package/bun.lock +606 -0
  4. package/package.json +2 -1
  5. package/research/analyze-ledger.ts +173 -0
  6. package/research/apply-cost-tuning.ts +73 -0
  7. package/research/cost-analysis.ts +150 -0
  8. package/research/feed-check.ts +64 -0
  9. package/research/model-recommendations.ts +86 -0
  10. package/research/project-yield.ts +96 -0
  11. package/research/run-eval.ts +133 -0
  12. package/research/status.ts +55 -0
  13. package/research/tier-fill.ts +109 -0
  14. package/research/tier-map.ts +123 -0
  15. package/src/catalog/benchmark-feeds.ts +397 -0
  16. package/src/catalog/openrouter-catalog.ts +30 -0
  17. package/src/config/defaults.ts +30 -0
  18. package/src/config/load.ts +2 -0
  19. package/src/config/schema.ts +34 -0
  20. package/src/config/types.ts +106 -0
  21. package/src/cost/ledger.ts +23 -2
  22. package/src/cost/types.ts +24 -0
  23. package/src/eval/calibrate.ts +131 -0
  24. package/src/eval/grade.ts +115 -0
  25. package/src/eval/judge.ts +71 -0
  26. package/src/eval/run.ts +126 -0
  27. package/src/eval/tasks.ts +272 -0
  28. package/src/router/candidates.ts +13 -6
  29. package/src/router/explore.ts +59 -0
  30. package/src/router/select.ts +54 -4
  31. package/src/router/tier-plan.ts +57 -1
  32. package/src/router/types.ts +13 -0
  33. package/src/server/turn.ts +9 -2
  34. package/src/util/sqlite.ts +68 -1
  35. package/test/benchmark-feeds.test.ts +222 -0
  36. package/test/eval.test.ts +184 -0
  37. package/test/exploration.test.ts +251 -0
  38. package/test/failover.test.ts +4 -0
  39. package/test/hold-exploration.test.ts +124 -0
  40. package/test/tier-plan.test.ts +55 -1
  41. package/test/tokens.test.ts +7 -0
  42. package/test/trust-attribution.test.ts +96 -2
  43. package/test/turn.test.ts +45 -0
  44. package/tools/smoke.ts +2 -0
  45. package/tools/sync-marketplace-version.ts +60 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -11,6 +11,7 @@
11
11
  "typecheck": "tsc --noEmit",
12
12
  "test": "bun test",
13
13
  "smoke": "bun run tools/smoke.ts",
14
+ "version": "bun run tools/sync-marketplace-version.ts",
14
15
  "release": "npm version $1 && git push --follow-tags"
15
16
  },
16
17
  "dependencies": {
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Read-only analysis of a ledger snapshot.
3
+ *
4
+ * Answers the questions that decide whether a learned classifier is worth
5
+ * building at all: does the adjudicator ever actually run, how are tiers
6
+ * distributed, and how much label signal (escalations) exists.
7
+ *
8
+ * Usage: bun run research/analyze-ledger.ts [path-to-snapshot.db]
9
+ */
10
+
11
+ import { Database } from "bun:sqlite";
12
+
13
+ const path = process.argv[2] ?? "research-data/snapshot.db";
14
+ const db = new Database(path, { readonly: true });
15
+
16
+ function section(label: string, sql: string): void {
17
+ console.log(`\n=== ${label} ===`);
18
+ try {
19
+ const rows = db.query(sql).all() as Record<string, unknown>[];
20
+ if (rows.length === 0) {
21
+ console.log(" (no rows)");
22
+ return;
23
+ }
24
+ for (const r of rows) {
25
+ console.log(" " + Object.entries(r).map(([k, v]) => `${k}=${v}`).join(" "));
26
+ }
27
+ } catch (err) {
28
+ console.log(" ERROR: " + (err instanceof Error ? err.message : String(err)));
29
+ }
30
+ }
31
+
32
+ section(
33
+ "tier distribution",
34
+ `SELECT tier, COUNT(*) n, ROUND(100.0 * COUNT(*) / (SELECT COUNT(*) FROM ledger), 1) pct
35
+ FROM ledger GROUP BY tier ORDER BY n DESC`,
36
+ );
37
+
38
+ section(
39
+ "classification source",
40
+ `SELECT classification_source src, COUNT(*) n,
41
+ ROUND(100.0 * COUNT(*) / (SELECT COUNT(*) FROM ledger), 1) pct
42
+ FROM ledger GROUP BY src ORDER BY n DESC`,
43
+ );
44
+
45
+ section(
46
+ "adjudicator activity (from reasons text)",
47
+ `SELECT
48
+ SUM(CASE WHEN reasons LIKE '%adjudicator skipped%' THEN 1 ELSE 0 END) AS skipped_cost_guard,
49
+ SUM(CASE WHEN reasons LIKE '%adjudicator failed%' THEN 1 ELSE 0 END) AS failed,
50
+ SUM(CASE WHEN reasons LIKE '%not a tier word%' THEN 1 ELSE 0 END) AS bad_reply,
51
+ SUM(CASE WHEN reasons LIKE '%adjudicator:%' THEN 1 ELSE 0 END) AS verdict_used,
52
+ COUNT(*) AS total_turns
53
+ FROM ledger`,
54
+ );
55
+
56
+ section(
57
+ "escalation signals",
58
+ `SELECT COALESCE(escalation_signal, '(none)') signal, COUNT(*) n
59
+ FROM ledger GROUP BY signal ORDER BY n DESC`,
60
+ );
61
+
62
+ section(
63
+ "spend and waste",
64
+ `SELECT SUM(wasted) wasted_turns,
65
+ ROUND(SUM(COALESCE(reported_usd, predicted_usd)), 4) total_usd,
66
+ ROUND(SUM(CASE WHEN wasted = 1 THEN COALESCE(reported_usd, predicted_usd) ELSE 0 END), 4) wasted_usd
67
+ FROM ledger`,
68
+ );
69
+
70
+ section(
71
+ "spend by served model",
72
+ `SELECT COALESCE(served_slug, slug) model, COUNT(*) n,
73
+ ROUND(SUM(COALESCE(reported_usd, predicted_usd)), 4) usd
74
+ FROM ledger GROUP BY model ORDER BY usd DESC LIMIT 10`,
75
+ );
76
+
77
+ section(
78
+ "error kinds",
79
+ `SELECT COALESCE(error_kind, '(none)') kind, COUNT(*) n
80
+ FROM ledger GROUP BY kind ORDER BY n DESC`,
81
+ );
82
+
83
+
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Exploration (schema v7): the counterfactual natural traffic cannot supply.
87
+ //
88
+ // A turn we deliberately routed one tier cheaper either escalated (the cheap
89
+ // model genuinely could not do it) or committed (the classifier was over-
90
+ // routing, and the cheaper tier would have served the turn fine).
91
+ // ---------------------------------------------------------------------------
92
+
93
+ section(
94
+ "exploration coverage",
95
+ `SELECT
96
+ SUM(CASE WHEN explored_from IS NOT NULL THEN 1 ELSE 0 END) AS explored,
97
+ COUNT(*) AS total,
98
+ ROUND(100.0 * SUM(CASE WHEN explored_from IS NOT NULL THEN 1 ELSE 0 END) / COUNT(*), 2) AS pct
99
+ FROM ledger`,
100
+ );
101
+
102
+ section(
103
+ "exploration verdict by dropped-from tier",
104
+ `SELECT
105
+ explored_from AS dropped_from,
106
+ COUNT(*) AS n,
107
+ SUM(CASE WHEN escalation_signal IS NOT NULL THEN 1 ELSE 0 END) AS escalated,
108
+ ROUND(100.0 * SUM(CASE WHEN escalation_signal IS NULL THEN 1 ELSE 0 END) / COUNT(*), 1) AS pct_cheap_sufficed
109
+ FROM ledger
110
+ WHERE explored_from IS NOT NULL
111
+ GROUP BY explored_from
112
+ ORDER BY n DESC`,
113
+ );
114
+
115
+ section(
116
+ "what exploration cost vs. what it revealed",
117
+ `SELECT
118
+ ROUND(SUM(CASE WHEN explored_from IS NOT NULL AND wasted = 1
119
+ THEN COALESCE(reported_usd, predicted_usd) ELSE 0 END), 4) AS wasted_on_exploration_usd,
120
+ ROUND(SUM(CASE WHEN explored_from IS NOT NULL THEN COALESCE(reported_usd, predicted_usd) ELSE 0 END), 4) AS explored_spend_usd
121
+ FROM ledger`,
122
+ );
123
+
124
+ // Confidence distribution: this is what determines whether the LLM adjudicator
125
+ // is ever reached at all. Turns below the configured ambiguityThreshold should
126
+ // be adjudicated; if that bucket is populated but no row has source 'llm', the
127
+ // adjudicator is failing silently rather than never being needed.
128
+ section(
129
+ "confidence distribution vs. the adjudication band",
130
+ `SELECT
131
+ CASE
132
+ WHEN confidence IS NULL THEN '(uninstrumented)'
133
+ WHEN confidence < 0.6 THEN 'below 0.6 (should adjudicate)'
134
+ WHEN confidence < 0.8 THEN '0.6 - 0.8'
135
+ ELSE '0.8 - 1.0'
136
+ END AS band,
137
+ COUNT(*) AS n,
138
+ SUM(CASE WHEN classification_source = 'llm' THEN 1 ELSE 0 END) AS adjudicated
139
+ FROM ledger
140
+ GROUP BY band
141
+ ORDER BY n DESC`,
142
+ );
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Hold-length experiment (schema v8).
146
+ //
147
+ // ~95% of hard-tier spend arrives by hysteresis hold rather than by
148
+ // classification, and the hold length is a hand-picked constant. Each
149
+ // conversation is assigned one arm, so arms compare on cost PER
150
+ // CONVERSATION -- comparing per-turn would reward short holds automatically,
151
+ // since a shorter hold simply produces fewer expensive turns.
152
+ //
153
+ // Escalations are the quality guardrail: a shorter hold that costs less but
154
+ // escalates more has moved the cost, not removed it.
155
+ // ---------------------------------------------------------------------------
156
+
157
+ section(
158
+ "hold-length arms",
159
+ `SELECT hold_arm AS arm,
160
+ COUNT(DISTINCT conversation_key) AS conversations,
161
+ COUNT(*) AS turns,
162
+ ROUND(SUM(COALESCE(reported_usd, predicted_usd)), 4) AS usd,
163
+ ROUND(SUM(COALESCE(reported_usd, predicted_usd))
164
+ / NULLIF(COUNT(DISTINCT conversation_key), 0), 4) AS usd_per_conversation,
165
+ SUM(CASE WHEN escalation_signal IS NOT NULL THEN 1 ELSE 0 END) AS escalations,
166
+ ROUND(100.0 * SUM(CASE WHEN wasted = 1 THEN 1 ELSE 0 END) / COUNT(*), 1) AS pct_wasted
167
+ FROM ledger
168
+ WHERE hold_arm IS NOT NULL
169
+ GROUP BY hold_arm
170
+ ORDER BY hold_arm`,
171
+ );
172
+
173
+ db.close();
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Applies the cost-optimization config changes to the PRODUCTION router config
3
+ * (`~/.auto-model-router/config.yml`), merged + validated + backed up. Never
4
+ * prints the AA key.
5
+ *
6
+ * bun run research/apply-cost-tuning.ts # SAFE NOW: disable the dead
7
+ * # adjudicator only (routing-neutral).
8
+ * bun run research/apply-cost-tuning.ts --full # WINDOW-CLOSE: also raise
9
+ * # switchMargin and lower contextWindow
10
+ * # (these CHANGE live routing).
11
+ *
12
+ * See [[omp-router-cost-optimization]]. Restart all omp windows after applying.
13
+ */
14
+
15
+ import { chmodSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { join } from "node:path";
18
+
19
+ // The fork's .env points AUTO_MODEL_ROUTER_HOME at research-data/home; these
20
+ // changes must land in the PRODUCTION config the live routers read.
21
+ process.env.AUTO_MODEL_ROUTER_HOME = join(homedir(), ".auto-model-router");
22
+
23
+ import { routerConfigPath, writeRouterConfig } from "../src/cli/config-cmd.ts";
24
+ import { loadConfig } from "../src/config/load.ts";
25
+
26
+ const full = process.argv.includes("--full");
27
+ const cfg = loadConfig({});
28
+
29
+ // (3) Disable the broken adjudicator: classifier.model is absent from the catalog,
30
+ // so it never returns a verdict — always keeps the heuristic. Setting the
31
+ // threshold to 0 skips the doomed call. Routing-identical, just faster.
32
+ const patch: Record<string, unknown> = { classifier: { ambiguityThreshold: 0 } };
33
+
34
+ if (full) {
35
+ // (2) Stop forfeiting warm cache for tiny completion savings.
36
+ patch.hysteresis = { switchMargin: 4 };
37
+ // (1) Force earlier compaction by advertising a smaller context window.
38
+ // Preserve every other profile field; only contextWindow changes.
39
+ patch.profiles = cfg.profiles.map((p) => ({
40
+ id: p.id,
41
+ name: p.name,
42
+ minTier: p.minTier,
43
+ maxTier: p.maxTier,
44
+ contextWindow: 200_000,
45
+ maxTokens: p.maxTokens,
46
+ }));
47
+ // (4) Cap the input-dominated hard cost. Now SAFE: the key admits ~13 hard
48
+ // candidates, so a $3/Mtok ceiling still leaves gpt-5.6-sol (77.4), grok-4.6,
49
+ // gpt-5.6-terra, glm-5.3, qwen3.8, kimi-k3 with failover; it drops only the
50
+ // $5-10 models (opus-5, gpt-5.5, fable). Raise to ~$5 if you want opus-5
51
+ // available for the very hardest turns. Merges into hard, preserving its floor.
52
+ patch.tiers = { hard: { maxInputPerMtok: 3 } };
53
+ }
54
+
55
+ const target = routerConfigPath();
56
+ const backup = writeRouterConfig(target, patch);
57
+ try {
58
+ chmodSync(target, 0o600); // keep the AA-key-bearing file locked down (advisory on Windows)
59
+ } catch {
60
+ /* best-effort */
61
+ }
62
+
63
+ const after = loadConfig({});
64
+ console.log(`wrote ${target}${backup === null ? "" : `\nbackup ${backup}`}`);
65
+ console.log(`mode ${full ? "FULL (routing-changing)" : "adjudicator-only (safe)"}`);
66
+ console.log(`classifier.ambiguity ${after.classifier.ambiguityThreshold}`);
67
+ console.log(`hysteresis.switchMargin ${after.hysteresis.switchMargin}`);
68
+ console.log(`profiles contextWindow ${after.profiles.map((p) => `${p.id}:${p.contextWindow}`).join(" ")}`);
69
+ // Confirm nothing unrelated was disturbed.
70
+ const k = after.benchmarks.artificialAnalysisApiKey;
71
+ console.log(`AA key intact ${k.trim() === "" ? "MISSING" : `yes (…${k.slice(-4)})`}`);
72
+ console.log(`exploration intact enabled=${after.exploration.enabled} holdArms=[${after.exploration.holdTurns.values.join(",")}] sticky=${after.exploration.stickyPolicy}`);
73
+ console.log("\nrestart all omp windows for this to take effect.");
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Where the money actually goes, from the live ledger. Read-only.
3
+ *
4
+ * Reframes optimization around the finding that ~96% of spend is the PROMPT,
5
+ * not the model's output: decomposes spend by cost component, measures the
6
+ * realized cache hit rate, and LOCALIZES cache misses to their cause (model
7
+ * switching, warm-window expiry, first-turns, exploration). Also quantifies
8
+ * abandoned-attempt waste and whether the LLM adjudicator ever fires.
9
+ *
10
+ * bun run research/cost-analysis.ts
11
+ */
12
+
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+
16
+ // Force PRODUCTION home; the fork's .env points it at research-data/home.
17
+ process.env.AUTO_MODEL_ROUTER_HOME = join(homedir(), ".auto-model-router");
18
+
19
+ import { Database } from "bun:sqlite";
20
+ import { loadConfig } from "../src/config/load.ts";
21
+
22
+ const cfg = loadConfig({});
23
+ const db = new Database(cfg.ledger.path, { readonly: true });
24
+
25
+ function rows(sql: string): Record<string, unknown>[] {
26
+ return db.query(sql).all() as Record<string, unknown>[];
27
+ }
28
+ function table(title: string, data: Record<string, unknown>[]): void {
29
+ console.log(`\n=== ${title} ===`);
30
+ if (data.length === 0) {
31
+ console.log(" (no rows)");
32
+ return;
33
+ }
34
+ const cols = Object.keys(data[0]!);
35
+ const w = cols.map((c) => Math.max(c.length, ...data.map((r) => String(r[c] ?? "").length)));
36
+ console.log(" " + cols.map((c, i) => c.padEnd(w[i]!)).join(" "));
37
+ for (const r of data) console.log(" " + cols.map((c, i) => String(r[c] ?? "").padEnd(w[i]!)).join(" "));
38
+ }
39
+
40
+ const usd = "json_extract(cost_breakdown,'$.%s')";
41
+ const comp = (k: string) => usd.replace("%s", k);
42
+
43
+ // 1. Spend by cost component.
44
+ table("spend by component (USD)", rows(`
45
+ SELECT
46
+ round(sum(${comp("freshPrompt")}),2) AS fresh_in,
47
+ round(sum(${comp("cacheRead")}),2) AS cache_read,
48
+ round(sum(${comp("cacheWrite")}),2) AS cache_write,
49
+ round(sum(${comp("completion")}),2) AS completion,
50
+ round(sum(${comp("reasoning")}),2) AS reasoning,
51
+ round(sum(reported_usd),2) AS total
52
+ FROM ledger WHERE cost_breakdown IS NOT NULL`));
53
+
54
+ // 2. Token totals + input:output ratio + overall cache hit rate.
55
+ table("tokens", rows(`
56
+ SELECT
57
+ sum(json_extract(usage,'$.promptTokens')) AS prompt_toks,
58
+ sum(json_extract(usage,'$.completionTokens')) AS completion_toks,
59
+ round(sum(json_extract(usage,'$.promptTokens'))*1.0/max(1,sum(json_extract(usage,'$.completionTokens'))),0) AS in_out_ratio,
60
+ round(sum(json_extract(usage,'$.cachedTokens'))*1.0/max(1,sum(json_extract(usage,'$.promptTokens'))),3) AS cache_hit_rate
61
+ FROM ledger WHERE usage IS NOT NULL`));
62
+
63
+ // 3. Cache hit rate + spend by tier.
64
+ table("by tier", rows(`
65
+ SELECT tier,
66
+ count(*) AS n,
67
+ round(sum(reported_usd),2) AS usd,
68
+ round(avg(reported_usd),5) AS per_turn,
69
+ round(sum(json_extract(usage,'$.cachedTokens'))*1.0/max(1,sum(json_extract(usage,'$.promptTokens'))),3) AS cache_hit
70
+ FROM ledger WHERE usage IS NOT NULL GROUP BY tier ORDER BY usd DESC`));
71
+
72
+ // Per-turn view: first attempt only, with previous turn's model + timestamp.
73
+ const SEQ = `
74
+ WITH seq AS (
75
+ SELECT conversation_key, tier, served_slug, created_at_ms, reported_usd, usage, cost_breakdown, explored_from,
76
+ LAG(served_slug) OVER w AS prev_slug,
77
+ LAG(created_at_ms) OVER w AS prev_ms,
78
+ ROW_NUMBER() OVER w AS rn
79
+ FROM ledger
80
+ WHERE served_slug IS NOT NULL AND reported_usd IS NOT NULL AND attempt = 0
81
+ WINDOW w AS (PARTITION BY conversation_key ORDER BY turn, created_at_ms)
82
+ )`;
83
+
84
+ // 4. Cache misses localized to model switching (continuation turns only).
85
+ table("switch effect (continuation turns)", rows(`${SEQ}
86
+ SELECT
87
+ CASE WHEN served_slug = prev_slug THEN 'stayed' ELSE 'switched' END AS kind,
88
+ count(*) AS n,
89
+ round(sum(reported_usd),2) AS usd,
90
+ round(sum(json_extract(usage,'$.cachedTokens'))*1.0/max(1,sum(json_extract(usage,'$.promptTokens'))),3) AS cache_hit,
91
+ round(sum(${comp("cacheWrite")}),2) AS cache_write
92
+ FROM seq WHERE rn > 1 GROUP BY kind`));
93
+
94
+ // 5. Cache misses localized to warm-window expiry (continuation turns only).
95
+ const ttlMin = Math.round(cfg.hysteresis.cacheWarmTtlMs / 60000);
96
+ table(`gap effect (warm window = ${ttlMin} min)`, rows(`${SEQ}
97
+ SELECT
98
+ CASE WHEN (created_at_ms - prev_ms) < ${cfg.hysteresis.cacheWarmTtlMs} THEN 'within_window' ELSE 'expired' END AS gap,
99
+ count(*) AS n,
100
+ round(sum(json_extract(usage,'$.cachedTokens'))*1.0/max(1,sum(json_extract(usage,'$.promptTokens'))),3) AS cache_hit,
101
+ round(sum(${comp("cacheWrite")}),2) AS cache_write
102
+ FROM seq WHERE rn > 1 GROUP BY gap`));
103
+
104
+ // 6. First-turn vs continuation cache-write (first turns are unavoidably cold).
105
+ table("cache-write: first vs continuation", rows(`${SEQ}
106
+ SELECT
107
+ CASE WHEN rn = 1 THEN 'first_turn' ELSE 'continuation' END AS kind,
108
+ count(*) AS n,
109
+ round(sum(${comp("cacheWrite")}),2) AS cache_write,
110
+ round(sum(${comp("freshPrompt")}),2) AS fresh_in
111
+ FROM seq GROUP BY kind`));
112
+
113
+ // 7. Exploration's cache cost (stickyPolicy: always forfeits warm cache).
114
+ table("exploration effect", rows(`${SEQ}
115
+ SELECT
116
+ CASE WHEN explored_from IS NULL THEN 'normal' ELSE 'explored' END AS kind,
117
+ count(*) AS n,
118
+ round(sum(json_extract(usage,'$.cachedTokens'))*1.0/max(1,sum(json_extract(usage,'$.promptTokens'))),3) AS cache_hit,
119
+ round(sum(${comp("cacheWrite")}),2) AS cache_write
120
+ FROM seq WHERE rn > 1 GROUP BY kind`));
121
+
122
+ // 8. Abandoned-attempt waste.
123
+ table("waste (abandoned escalation attempts)", rows(`
124
+ SELECT
125
+ round(sum(CASE WHEN wasted = 1 THEN reported_usd ELSE 0 END),2) AS wasted_usd,
126
+ sum(CASE WHEN wasted = 1 THEN 1 ELSE 0 END) AS wasted_rows,
127
+ round(sum(reported_usd),2) AS total_usd
128
+ FROM ledger WHERE reported_usd IS NOT NULL`));
129
+
130
+ // 9. Does the LLM adjudicator ever fire? confidence vs the ambiguity band.
131
+ table(`adjudicator (ambiguityThreshold = ${cfg.classifier.ambiguityThreshold})`, rows(`
132
+ SELECT classification_source AS source, count(*) AS n,
133
+ round(avg(confidence),3) AS avg_conf,
134
+ sum(CASE WHEN confidence < ${cfg.classifier.ambiguityThreshold} THEN 1 ELSE 0 END) AS below_thresh
135
+ FROM ledger WHERE confidence IS NOT NULL GROUP BY classification_source`));
136
+
137
+ // 10. Spend by prompt-size bucket.
138
+ table("spend by prompt size", rows(`
139
+ SELECT
140
+ CASE
141
+ WHEN json_extract(usage,'$.promptTokens') < 50000 THEN 'a <50k'
142
+ WHEN json_extract(usage,'$.promptTokens') < 150000 THEN 'b 50-150k'
143
+ WHEN json_extract(usage,'$.promptTokens') < 300000 THEN 'c 150-300k'
144
+ ELSE 'd 300k+' END AS bucket,
145
+ count(*) AS n,
146
+ round(sum(reported_usd),2) AS usd,
147
+ round(avg(reported_usd),5) AS per_turn
148
+ FROM ledger WHERE usage IS NOT NULL GROUP BY bucket ORDER BY bucket`));
149
+
150
+ db.close();
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Live proof: fetch the external benchmark feeds for real, apply them to a COPY
3
+ * of the live catalog, and print which previously-unscored models gained scores.
4
+ * Read-only w.r.t. the production DB — never writes benchmark_cache.
5
+ *
6
+ * bun run research/feed-check.ts [db]
7
+ */
8
+
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ import { Database } from "bun:sqlite";
13
+
14
+ import { applyFeedScores, fetchAaScores, fetchBenchlmScores, type FeedScore } from "../src/catalog/benchmark-feeds.ts";
15
+ import { normalizeCatalogModel } from "../src/catalog/openrouter-catalog.ts";
16
+ import { loadConfig } from "../src/config/load.ts";
17
+
18
+ const dbPath = process.argv[2] ?? join(homedir(), ".auto-model-router", "router.db");
19
+ const cfg = loadConfig({});
20
+
21
+ const db = new Database(dbPath, { readonly: true });
22
+ const row = db.query("SELECT payload FROM catalog_cache WHERE id = 1").get() as { payload: string } | null;
23
+ db.close();
24
+ if (row === null) {
25
+ console.log("no cached catalog");
26
+ process.exit(1);
27
+ }
28
+
29
+ const parsed: unknown = JSON.parse(row.payload);
30
+ const rawModels: unknown[] = Array.isArray(parsed) ? parsed : [];
31
+
32
+ // Quality per slug BEFORE the fill.
33
+ function qualityBySlug(models: unknown[]): Map<string, object> {
34
+ const out = new Map<string, object>();
35
+ for (const raw of models) {
36
+ const m = normalizeCatalogModel(raw);
37
+ if (m !== null) out.set(m.slug, m.quality);
38
+ }
39
+ return out;
40
+ }
41
+
42
+ const before = qualityBySlug(structuredClone(rawModels));
43
+
44
+ const key = cfg.benchmarks.artificialAnalysisApiKey;
45
+ console.log(`AA key: ${key.trim() === "" ? "(none — AA feed skipped)" : "present"}`);
46
+ const [aa, bl] = await Promise.all([fetchAaScores(key, { timeoutMs: 30_000 }), fetchBenchlmScores({ timeoutMs: 30_000 })]);
47
+ const feeds: FeedScore[] = [...aa, ...bl];
48
+ console.log(`feeds fetched: artificial_analysis=${aa.length} benchlm=${bl.length}\n`);
49
+
50
+ if (feeds.length === 0) {
51
+ console.log("no feed data (endpoints unreachable or empty); nothing to apply.");
52
+ process.exit(0);
53
+ }
54
+
55
+ const result = applyFeedScores(rawModels, feeds);
56
+ const after = qualityBySlug(rawModels);
57
+
58
+ console.log(`filled ${result.modelsFilled} model(s): coding=${result.axes.coding} intelligence=${result.axes.intelligence} agentic=${result.axes.agentic} (aa=${result.sources.artificial_analysis} benchlm=${result.sources.benchlm})\n`);
59
+
60
+ for (const [slug, q] of after) {
61
+ const prev = JSON.stringify(before.get(slug) ?? {});
62
+ const now = JSON.stringify(q);
63
+ if (prev !== now) console.log(`${slug}\n before ${prev}\n after ${now}`);
64
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Which models to ENABLE on the OpenRouter key. The key-scoped catalog is narrow
3
+ * (guardrails/preferences), so tiers — `hard` especially — resolve to one
4
+ * candidate and can't take a price ceiling or same-tier failover. This fetches
5
+ * the FULL public catalog, runs it through the router's real tier filters, and
6
+ * reports the models that WOULD be eligible per tier but are not currently
7
+ * admitted by the key. Read-only. Needs network.
8
+ *
9
+ * bun run research/model-recommendations.ts
10
+ */
11
+
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ process.env.AUTO_MODEL_ROUTER_HOME = join(homedir(), ".auto-model-router");
16
+
17
+ import { normalizeCatalogModel } from "../src/catalog/openrouter-catalog.ts";
18
+ import type { CatalogModel, CatalogSnapshot } from "../src/catalog/types.ts";
19
+ import { loadConfig } from "../src/config/load.ts";
20
+ import { buildCandidates } from "../src/router/candidates.ts";
21
+ import { extractFeatures } from "../src/router/features.ts";
22
+ import { effectiveQualityFloor, tierPlanFor } from "../src/router/tier-plan.ts";
23
+ import type { Tier } from "../src/router/types.ts";
24
+ import { createOpenRouterClient } from "../src/upstream/openrouter.ts";
25
+ import { parseChatRequest } from "../src/wire/openai/request.ts";
26
+
27
+ const cfg = loadConfig({});
28
+
29
+ const client = createOpenRouterClient(cfg);
30
+
31
+ // Current key-scoped availability, fetched LIVE (/models/user) so it reflects
32
+ // account changes immediately — the cached snapshot lags until a router refresh.
33
+ const userRaw = await client.fetchModelsForUser(AbortSignal.timeout(30_000));
34
+ const keyScoped = new Set<string>(
35
+ userRaw.map(normalizeCatalogModel).filter((m): m is CatalogModel => m !== null).map((m) => m.slug),
36
+ );
37
+
38
+ // Full public catalog (carries AA benchmarks).
39
+ const raw = await client.fetchModels(AbortSignal.timeout(30_000));
40
+ const models = raw.map(normalizeCatalogModel).filter((m): m is CatalogModel => m !== null);
41
+ const snapshot: CatalogSnapshot = { models, fetchedAtMs: Date.now() };
42
+ console.log(`public catalog: ${models.length} models; key-scoped now (live): ${keyScoped.size}\n`);
43
+
44
+ // A representative hard coding turn, so task=coding (axis=coding).
45
+ const req = parseChatRequest(
46
+ { model: "auto", messages: [{ role: "user", content: "Refactor the scheduler to remove the global lock and prove it stays correct under concurrency." }] },
47
+ new Headers(),
48
+ );
49
+ const features = extractFeatures(req, 8000);
50
+
51
+ // Cover both axes a coding agent actually routes on: coding (task=coding) and
52
+ // intelligence (task=chat). Model choice for `hard` should be strong on both.
53
+ const combos: Array<[Tier, "coding" | "chat"]> = [
54
+ ["hard", "coding"],
55
+ ["hard", "chat"],
56
+ ["moderate", "coding"],
57
+ ];
58
+ for (const [tier, task] of combos) {
59
+ const axis = cfg.tasks[task].axis;
60
+ const tc = cfg.tiers[tier];
61
+ // Floor if the FULL catalog were available (not the collapsed key-scoped floor).
62
+ const floor = cfg.adaptiveTierFloors
63
+ ? effectiveQualityFloor(tc.minQuality, tier, axis, tierPlanFor(snapshot, cfg))
64
+ : tc.minQuality;
65
+ const { candidates } = buildCandidates({
66
+ req,
67
+ features,
68
+ tier,
69
+ task,
70
+ snapshot,
71
+ ledger: null,
72
+ cfg,
73
+ expectedCompletionTokens: 8000,
74
+ warmSlug: null,
75
+ });
76
+ const ranked = [...candidates].sort((a, b) => b.qualityScore - a.qualityScore);
77
+ const haveNow = ranked.filter((c) => keyScoped.has(c.model.slug)).length;
78
+ console.log(`\n===== ${tier} (axis ${axis}, floor ${floor.toFixed(1)}) — ${ranked.length} eligible in full catalog, ${haveNow} already on your key =====`);
79
+ console.log(" add? slug".padEnd(46) + "q in$/Mtok ctx");
80
+ for (const c of ranked.slice(0, 14)) {
81
+ const tag = keyScoped.has(c.model.slug) ? "have " : "ADD ";
82
+ console.log(
83
+ ` ${tag} ${c.model.slug.padEnd(38)} ${String(c.qualityScore).padEnd(5)} ${(c.model.price.prompt * 1e6).toFixed(2).padStart(7)} ${c.model.contextLength}`,
84
+ );
85
+ }
86
+ }
@@ -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();