auto-model-router 0.3.3 → 0.4.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 (43) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +25 -2
  3. package/omp-extension/report-logic.ts +93 -0
  4. package/omp-extension/router-configure.ts +128 -2
  5. package/package.json +1 -1
  6. package/src/catalog/ollama-catalog.ts +30 -2
  7. package/src/cli/config-wizard.ts +9 -0
  8. package/src/cli/report.ts +7 -2
  9. package/src/config/defaults.ts +12 -0
  10. package/src/config/schema.ts +6 -0
  11. package/src/config/types.ts +54 -0
  12. package/src/cost/feedback.ts +81 -0
  13. package/src/cost/ledger.ts +73 -0
  14. package/src/cost/report.ts +106 -2
  15. package/src/cost/types.ts +28 -0
  16. package/src/router/candidates.ts +13 -4
  17. package/src/router/classify.ts +10 -0
  18. package/src/router/features.ts +20 -1
  19. package/src/router/index.ts +12 -1
  20. package/src/router/learned.ts +202 -0
  21. package/src/router/select.ts +107 -8
  22. package/src/router/state.ts +6 -2
  23. package/src/router/types.ts +40 -1
  24. package/src/server/http.ts +82 -5
  25. package/src/server/overrides.ts +83 -0
  26. package/src/server/providers.ts +10 -2
  27. package/src/server/turn.ts +18 -1
  28. package/src/upstream/ollama-usage.ts +79 -2
  29. package/src/util/sqlite.ts +38 -1
  30. package/test/config-wizard.test.ts +2 -1
  31. package/test/controls.test.ts +223 -0
  32. package/test/failover.test.ts +5 -3
  33. package/test/features.test.ts +31 -0
  34. package/test/learned.test.ts +61 -0
  35. package/test/ollama.test.ts +74 -2
  36. package/test/report-hub.test.ts +4 -2
  37. package/test/report-logic.test.ts +3 -0
  38. package/test/report.test.ts +43 -0
  39. package/test/select.test.ts +180 -1
  40. package/test/trust-attribution.test.ts +58 -2
  41. package/test/turn.test.ts +35 -3
  42. package/tools/replay.ts +266 -156
  43. package/tools/train-classifier.ts +111 -0
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Fit the learned escalation-risk model on the ledger and write it out.
4
+ *
5
+ * bun tools/train-classifier.ts # evaluate + write $AUTO_MODEL_ROUTER_HOME/classifier-learned.json
6
+ * bun tools/train-classifier.ts --dry-run # evaluate only
7
+ * bun tools/train-classifier.ts --days 30 # bound the training window
8
+ * bun tools/train-classifier.ts --out path.json
9
+ *
10
+ * Label: the served turn escalated — a cheaper attempt was probe-rejected
11
+ * first (attempt > 0). Split: the oldest 80% train, the newest 20% test, so
12
+ * the score is what the model would have done on turns it had not seen.
13
+ * Prints holdout AUC for the learned model and for the heuristic's own score,
14
+ * plus precision/recall at a few thresholds. Read-only on the ledger.
15
+ *
16
+ * Then set `classifier.learnedModelPath` to the written file: the router
17
+ * records `learned: p(escalate)=…` on every decision, advisory only.
18
+ */
19
+
20
+ import { Database } from "bun:sqlite";
21
+ import { dirname, join } from "node:path";
22
+ import { loadConfig } from "../src/config/load.ts";
23
+ import { auc, FEATURE_NAMES, LEARNED_MODEL_VERSION, learnedVector, type LearnedModel, trainLogistic } from "../src/router/learned.ts";
24
+ import type { Features } from "../src/router/types.ts";
25
+
26
+ const argv = process.argv.slice(2);
27
+ const flag = (name: string): string | undefined => {
28
+ const i = argv.indexOf(name);
29
+ return i >= 0 ? argv[i + 1] : undefined;
30
+ };
31
+ const dryRun = argv.includes("--dry-run");
32
+ const days = Number.parseInt(flag("--days") ?? "0", 10);
33
+ const cfg = loadConfig({});
34
+ const out = flag("--out") ?? join(dirname(cfg.ledger.path), "classifier-learned.json");
35
+
36
+ const db = new Database(cfg.ledger.path, { readonly: true });
37
+ const since = days > 0 ? Date.now() - days * 86_400_000 : 0;
38
+ // For the heuristic baseline on an escalated turn, the score that MATTERS is
39
+ // the one the rejected first attempt was routed on; the served row carries the
40
+ // escalation classification (score 1 by construction), which would leak the label.
41
+ const rows = db
42
+ .query(
43
+ `SELECT l.features, l.attempt,
44
+ COALESCE((SELECT w.score FROM ledger w WHERE w.conversation_key = l.conversation_key AND w.turn = l.turn AND w.attempt = 0 AND w.wasted = 1 LIMIT 1), l.score) AS score
45
+ FROM ledger l
46
+ WHERE l.features IS NOT NULL AND l.wasted = 0 AND l.error IS NULL AND l.created_at_ms >= ?
47
+ ORDER BY l.created_at_ms ASC`,
48
+ )
49
+ .all(since) as { features: string; attempt: number; score: number | null }[];
50
+ db.close();
51
+ if (rows.length < 200) {
52
+ console.error(`only ${rows.length} rows with features; need a few hundred to fit anything`);
53
+ process.exit(2);
54
+ }
55
+
56
+ const xs = rows.map((r) => learnedVector(JSON.parse(r.features) as Partial<Features>));
57
+ const ys: number[] = rows.map((r) => (r.attempt > 0 ? 1 : 0));
58
+ const heuristic = rows.map((r) => r.score ?? 0);
59
+ const split = Math.floor(rows.length * 0.8);
60
+ const fit = trainLogistic(xs.slice(0, split), ys.slice(0, split));
61
+ const model: LearnedModel = {
62
+ version: LEARNED_MODEL_VERSION,
63
+ trainedAtMs: Date.now(),
64
+ rows: rows.length,
65
+ positives: ys.reduce((s, y) => s + y, 0),
66
+ names: [...FEATURE_NAMES],
67
+ means: fit.means,
68
+ stds: fit.stds,
69
+ weights: fit.weights,
70
+ bias: fit.bias,
71
+ auc: 0,
72
+ };
73
+ const sigmoid = (z: number): number => 1 / (1 + Math.exp(-z));
74
+ const score = (x: number[]): number => {
75
+ let z = model.bias;
76
+ for (let j = 0; j < x.length; j++) z += model.weights[j]! * ((x[j]! - model.means[j]!) / model.stds[j]!);
77
+ return sigmoid(z);
78
+ };
79
+ const testX = xs.slice(split);
80
+ const testY = ys.slice(split);
81
+ const testP = testX.map(score);
82
+ model.auc = auc(testP, testY);
83
+ const heuristicAuc = auc(heuristic.slice(split), testY);
84
+
85
+ console.log(`rows ${rows.length} (train ${split}, test ${rows.length - split}); positives ${model.positives} (${((100 * model.positives) / rows.length).toFixed(2)}%)`);
86
+ console.log(`holdout AUC: learned ${model.auc.toFixed(3)} heuristic score ${heuristicAuc.toFixed(3)}`);
87
+ console.log("\nprecision / recall on the holdout at p(escalate) thresholds:");
88
+ for (const t of [0.5, 0.7, 0.8, 0.9]) {
89
+ let tp = 0;
90
+ let fp = 0;
91
+ let fn = 0;
92
+ testP.forEach((p, i) => {
93
+ const y = testY[i]!;
94
+ if (p >= t && y === 1) tp++;
95
+ else if (p >= t) fp++;
96
+ else if (y === 1) fn++;
97
+ });
98
+ const prec = tp + fp > 0 ? tp / (tp + fp) : 0;
99
+ const rec = tp + fn > 0 ? tp / (tp + fn) : 0;
100
+ console.log(` p ≥ ${t.toFixed(1)} flagged ${String(tp + fp).padStart(5)} precision ${(100 * prec).toFixed(1).padStart(5)}% recall ${(100 * rec).toFixed(1).padStart(5)}%`);
101
+ }
102
+ console.log("\nweights (standardised; positive ⇒ more likely to escalate):");
103
+ const ranked = model.names.map((n, i) => [n, model.weights[i]!] as const).sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]));
104
+ for (const [n, w] of ranked.slice(0, 12)) console.log(` ${n.padEnd(30)} ${w >= 0 ? "+" : ""}${w.toFixed(3)}`);
105
+
106
+ if (dryRun) {
107
+ console.log("\ndry run: nothing written");
108
+ } else {
109
+ await Bun.write(out, JSON.stringify(model, null, 1));
110
+ console.log(`\nwrote ${out}\nset classifier.learnedModelPath to it to record learned risk on every decision (advisory).`);
111
+ }