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.
Files changed (54) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +127 -46
  3. package/bun.lock +606 -0
  4. package/omp-extension/router-embed.ts +14 -6
  5. package/omp-extension/router-toast.ts +6 -1
  6. package/omp-extension/toast-logic.ts +7 -0
  7. package/package.json +2 -1
  8. package/research/analyze-ledger.ts +173 -0
  9. package/research/apply-cost-tuning.ts +73 -0
  10. package/research/cost-analysis.ts +150 -0
  11. package/research/feed-check.ts +64 -0
  12. package/research/model-recommendations.ts +86 -0
  13. package/research/project-yield.ts +96 -0
  14. package/research/run-eval.ts +133 -0
  15. package/research/status.ts +55 -0
  16. package/research/tier-fill.ts +109 -0
  17. package/research/tier-map.ts +123 -0
  18. package/src/catalog/benchmark-feeds.ts +397 -0
  19. package/src/catalog/openrouter-catalog.ts +30 -0
  20. package/src/config/defaults.ts +30 -0
  21. package/src/config/load.ts +2 -0
  22. package/src/config/schema.ts +34 -0
  23. package/src/config/types.ts +106 -0
  24. package/src/cost/ledger.ts +27 -3
  25. package/src/cost/types.ts +30 -0
  26. package/src/eval/calibrate.ts +131 -0
  27. package/src/eval/grade.ts +115 -0
  28. package/src/eval/judge.ts +71 -0
  29. package/src/eval/run.ts +126 -0
  30. package/src/eval/tasks.ts +272 -0
  31. package/src/index.ts +0 -1
  32. package/src/router/candidates.ts +13 -6
  33. package/src/router/explore.ts +59 -0
  34. package/src/router/select.ts +54 -4
  35. package/src/router/tier-plan.ts +57 -1
  36. package/src/router/types.ts +13 -0
  37. package/src/server/turn.ts +10 -2
  38. package/src/util/sqlite.ts +79 -1
  39. package/src/wire/openai/request.ts +5 -0
  40. package/src/wire/types.ts +7 -0
  41. package/test/benchmark-feeds.test.ts +222 -0
  42. package/test/escalate.test.ts +1 -0
  43. package/test/eval.test.ts +184 -0
  44. package/test/exploration.test.ts +251 -0
  45. package/test/failover.test.ts +5 -0
  46. package/test/hold-exploration.test.ts +124 -0
  47. package/test/tier-plan.test.ts +55 -1
  48. package/test/toast-logic.test.ts +32 -0
  49. package/test/tokens.test.ts +8 -0
  50. package/test/trust-attribution.test.ts +110 -2
  51. package/test/turn.test.ts +46 -0
  52. package/test/wire-request.test.ts +11 -0
  53. package/tools/smoke.ts +2 -0
  54. package/tools/sync-marketplace-version.ts +60 -0
@@ -141,6 +141,38 @@ describe("selectToasts", () => {
141
141
  ];
142
142
  expect(selectToasts(entries, "", "")).toHaveLength(2);
143
143
  });
144
+
145
+ test("filters to the requesting omp session when one is set", () => {
146
+ // Two interactive omp sessions sharing one router's ledger: session-a's
147
+ // toast must not surface session-b's decisions.
148
+ const entries = [
149
+ dec({ id: "d3", slug: "mine", ompSessionId: "sess-a" }),
150
+ dec({ id: "d2", slug: "other", ompSessionId: "sess-b" }),
151
+ dec({ id: "d1", slug: "prior", ompSessionId: "sess-a" }),
152
+ ];
153
+ const toasts = selectToasts(entries, "d1", "", "sess-a");
154
+ expect(toasts).toHaveLength(1);
155
+ expect(toasts[0]?.model).toBe("mine");
156
+ });
157
+
158
+ test("empty omp session id toasts every session", () => {
159
+ const entries = [
160
+ dec({ id: "d2", slug: "a", ompSessionId: "sess-a" }),
161
+ dec({ id: "d1", slug: "b", ompSessionId: "sess-b" }),
162
+ ];
163
+ expect(selectToasts(entries, "", "", "")).toHaveLength(2);
164
+ });
165
+
166
+ test("harness and session filters compose", () => {
167
+ const entries = [
168
+ dec({ id: "d3", slug: "keep", harnessId: "h", ompSessionId: "sess-a" }),
169
+ dec({ id: "d2", slug: "wrong-session", harnessId: "h", ompSessionId: "sess-b" }),
170
+ dec({ id: "d1", slug: "wrong-harness", harnessId: "other", ompSessionId: "sess-a" }),
171
+ ];
172
+ const toasts = selectToasts(entries, "", "h", "sess-a");
173
+ expect(toasts).toHaveLength(1);
174
+ expect(toasts[0]?.model).toBe("keep");
175
+ });
144
176
  });
145
177
 
146
178
  describe("toToastText", () => {
@@ -18,11 +18,19 @@ function entry(over: Partial<LedgerEntry>): LedgerEntry {
18
18
  turn: 1,
19
19
  requestedModel: "auto",
20
20
  harnessId: "",
21
+ ompSessionId: "",
21
22
  slug: "openai/gpt-5-mini",
22
23
  servedSlug: "openai/gpt-5-mini",
23
24
  tier: "simple",
24
25
  classificationSource: "heuristic",
25
26
  reasons: [],
27
+ features: null,
28
+ score: null,
29
+ confidence: null,
30
+ task: null,
31
+ classifierReasons: null,
32
+ exploredFrom: null,
33
+ holdArm: null,
26
34
  predictedUsd: 0.001,
27
35
  reportedUsd: 0.001,
28
36
  usage: EMPTY_USAGE,
@@ -16,11 +16,19 @@ function entry(over: Partial<LedgerEntry>): LedgerEntry {
16
16
  turn: 1,
17
17
  requestedModel: "auto",
18
18
  harnessId: "",
19
+ ompSessionId: "",
19
20
  slug: "vendor/model",
20
21
  servedSlug: "vendor/model",
21
22
  tier: "simple",
22
23
  classificationSource: "heuristic",
23
24
  reasons: [],
25
+ features: null,
26
+ score: null,
27
+ confidence: null,
28
+ task: null,
29
+ classifierReasons: null,
30
+ exploredFrom: null,
31
+ holdArm: null,
24
32
  predictedUsd: 0.001,
25
33
  reportedUsd: 0.001,
26
34
  usage: EMPTY_USAGE,
@@ -163,11 +171,111 @@ describe("v4 migration", () => {
163
171
  }
164
172
  });
165
173
 
166
- test("schema is at user_version 4", () => {
174
+ test("schema is at user_version 10", () => {
167
175
  const db = openDb(":memory:");
168
176
  try {
169
177
  const row = db.query("PRAGMA user_version").get() as { user_version: number };
170
- expect(row.user_version).toBe(4);
178
+ expect(row.user_version).toBe(10);
179
+ } finally {
180
+ db.close();
181
+ }
182
+ });
183
+
184
+ test("persists omp_session_id and returns it via recentEntries", () => {
185
+ const db = openDb(":memory:");
186
+ try {
187
+ const ledger = createLedger(db, cfg);
188
+ ledger.record(entry({ ompSessionId: "sess-a" }));
189
+ ledger.record(entry({ ompSessionId: "" }));
190
+ const got = ledger.recentEntries(10).map((e) => e.ompSessionId).sort();
191
+ expect(got).toEqual(["", "sess-a"]);
192
+ } finally {
193
+ db.close();
194
+ }
195
+ });
196
+ });
197
+
198
+ describe("v6 classifier instrumentation", () => {
199
+ const FEATURES = {
200
+ promptTokens: 1234,
201
+ isToolResultContinuation: true,
202
+ toolLoopDepth: 3,
203
+ complexityKeywords: ["race", "debug"],
204
+ };
205
+
206
+ test("round-trips the feature vector and classifier outputs", () => {
207
+ const db = openDb(":memory:");
208
+ try {
209
+ const ledger = createLedger(db, cfg);
210
+ ledger.record(
211
+ entry({
212
+ features: FEATURES,
213
+ score: 0.42,
214
+ confidence: 0.75,
215
+ task: "coding",
216
+ classifierReasons: ["-0.28 tool-result continuation"],
217
+ }),
218
+ );
219
+
220
+ const got = ledger.recentEntries(1)[0];
221
+ expect(got?.features).toEqual(FEATURES);
222
+ expect(got?.score).toBe(0.42);
223
+ expect(got?.confidence).toBe(0.75);
224
+ expect(got?.task).toBe("coding");
225
+ expect(got?.classifierReasons).toEqual(["-0.28 tool-result continuation"]);
226
+ } finally {
227
+ db.close();
228
+ }
229
+ });
230
+
231
+ test("an uninstrumented row reads back as null, not as invented data", () => {
232
+ const db = openDb(":memory:");
233
+ try {
234
+ const ledger = createLedger(db, cfg);
235
+ ledger.record(entry({}));
236
+ const got = ledger.recentEntries(1)[0];
237
+ expect(got?.features).toBeNull();
238
+ expect(got?.score).toBeNull();
239
+ expect(got?.confidence).toBeNull();
240
+ expect(got?.task).toBeNull();
241
+ expect(got?.classifierReasons).toBeNull();
242
+ } finally {
243
+ db.close();
244
+ }
245
+ });
246
+
247
+ test("records which tier exploration dropped from, and NULL otherwise", () => {
248
+ const db = openDb(":memory:");
249
+ try {
250
+ const ledger = createLedger(db, cfg);
251
+ ledger.record(entry({ tier: "simple", exploredFrom: "moderate" }));
252
+ ledger.record(entry({ tier: "moderate" }));
253
+
254
+ const got = ledger.recentEntries(10);
255
+ expect(got.map((e) => e.exploredFrom).sort()).toEqual(["moderate", null] as unknown as string[]);
256
+
257
+ // The counterfactual query this whole column exists to make possible:
258
+ // of the turns we deliberately under-routed, how many had to escalate?
259
+ const counted = db
260
+ .query("SELECT COUNT(*) n FROM ledger WHERE explored_from IS NOT NULL")
261
+ .get() as { n: number };
262
+ expect(counted.n).toBe(1);
263
+ } finally {
264
+ db.close();
265
+ }
266
+ });
267
+ test("features land in the column as queryable JSON", () => {
268
+ const db = openDb(":memory:");
269
+ try {
270
+ const ledger = createLedger(db, cfg);
271
+ ledger.record(entry({ features: FEATURES, score: 0.9, confidence: 0.1, task: "vision" }));
272
+ // SQLite json_extract proves the blob is real JSON, not a stringified object.
273
+ const row = db
274
+ .query("SELECT json_extract(features, '$.toolLoopDepth') AS depth, score, task FROM ledger")
275
+ .get() as { depth: number; score: number; task: string };
276
+ expect(row.depth).toBe(3);
277
+ expect(row.score).toBe(0.9);
278
+ expect(row.task).toBe("vision");
171
279
  } finally {
172
280
  db.close();
173
281
  }
package/test/turn.test.ts CHANGED
@@ -29,6 +29,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
29
29
  return {
30
30
  server: { host: "127.0.0.1", port: 8787 },
31
31
  openrouter: { baseUrl: "https://openrouter.ai/api/v1", apiKey: "", title: "test", timeoutMs: 30_000, catalogTtlMs: 3_600_000, catalogRefreshMs: 0 },
32
+ benchmarks: { enabled: false, artificialAnalysisApiKey: "", benchlm: true, refreshMs: 86_400_000, timeoutMs: 30_000, useLocalScores: false },
32
33
  tiers: {
33
34
  trivial: { minQuality: 0, maxInputPerMtok: 0.3, qualityExponent: 0, pin: [] },
34
35
  simple: { minQuality: 40, maxInputPerMtok: 1.5, qualityExponent: 0, pin: [] },
@@ -65,11 +66,13 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
65
66
  ...escalation,
66
67
  },
67
68
  hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1 },
69
+ exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
68
70
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024 },
69
71
  budget: { onExceeded: "downgrade" },
70
72
  profiles: [],
71
73
  ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 },
72
74
  adaptiveTierFloors: true,
75
+ adaptivePriceCeilings: false,
73
76
  logLevel: "silent",
74
77
  };
75
78
  }
@@ -79,6 +82,7 @@ function mkReq(): NormRequest {
79
82
  protocol: "openai-chat",
80
83
  conversationKey: "conv-test",
81
84
  harnessId: "",
85
+ ompSessionId: "",
82
86
  requestedModel: "auto",
83
87
  messages: [{ role: "user", text: "hi", images: 0, textBytes: 2, toolCalls: [] }],
84
88
  tools: [],
@@ -140,6 +144,7 @@ function mkDecision(tier: Tier, slug: string, probe: Partial<ProbePlan> = {}): D
140
144
  considered: [],
141
145
  rejected: [],
142
146
  reasons: ["test decision"],
147
+ explored: null,
143
148
  budgetDowngraded: false,
144
149
  };
145
150
  }
@@ -496,3 +501,44 @@ describe("runTurn", () => {
496
501
  expect(errors).toHaveLength(0);
497
502
  });
498
503
  });
504
+
505
+ describe("exploration reaches the ledger", () => {
506
+ test("an explored turn records the tier it was dropped from", async () => {
507
+ const explored = { ...mkDecision("simple", "cheap/model"), explored: { from: "moderate" as Tier, to: "simple" as Tier } };
508
+ const { router } = mkRouter([explored]);
509
+ const { upstream } = mkUpstream([
510
+ {
511
+ kind: "chunks",
512
+ chunks: [startChunk("cheap/model"), textChunk("ok"), finishChunk("stop"), usageChunk({ promptTokens: 10, completionTokens: 2 }, 0.0001)],
513
+ },
514
+ ]);
515
+ const { ledger, entries } = mkLedger();
516
+ const { store } = mkConversations();
517
+ const { sink } = mkSink();
518
+
519
+ await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog }, new AbortController().signal);
520
+
521
+ expect(entries).toHaveLength(1);
522
+ // The counterfactual pair: what the classifier wanted, and what actually ran.
523
+ expect(entries[0]?.exploredFrom).toBe("moderate");
524
+ expect(entries[0]?.tier).toBe("simple");
525
+ });
526
+
527
+ test("a normally routed turn leaves it null", async () => {
528
+ const { router } = mkRouter([mkDecision("simple", "cheap/model")]);
529
+ const { upstream } = mkUpstream([
530
+ {
531
+ kind: "chunks",
532
+ chunks: [startChunk("cheap/model"), textChunk("ok"), finishChunk("stop"), usageChunk({ promptTokens: 10, completionTokens: 2 }, 0.0001)],
533
+ },
534
+ ]);
535
+ const { ledger, entries } = mkLedger();
536
+ const { store } = mkConversations();
537
+ const { sink } = mkSink();
538
+
539
+ await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog }, new AbortController().signal);
540
+
541
+ expect(entries).toHaveLength(1);
542
+ expect(entries[0]?.exploredFrom).toBeNull();
543
+ });
544
+ });
@@ -58,6 +58,17 @@ describe("parseChatRequest normalization", () => {
58
58
  expect(prefixed.requestedModel).toBe("auto");
59
59
  });
60
60
 
61
+ test("reads harness and omp session ids from headers, trimmed", () => {
62
+ const headers = new Headers({ "x-omp-harness": " prod-a ", "x-omp-session": " sess-1 " });
63
+ const req = parseChatRequest(userBody("hi"), headers);
64
+ expect(req.harnessId).toBe("prod-a");
65
+ expect(req.ompSessionId).toBe("sess-1");
66
+ });
67
+
68
+ test("omp session id defaults to empty when the header is absent", () => {
69
+ expect(parseChatRequest(userBody("hi"), HEADERS).ompSessionId).toBe("");
70
+ });
71
+
61
72
  test("tool schemas, names, and descriptions contribute to promptBytes", () => {
62
73
  const parameters = { type: "object", properties: { path: { type: "string" } } };
63
74
  const withTools = parseChatRequest(
package/tools/smoke.ts CHANGED
@@ -160,6 +160,8 @@ cfg.ledger.path = join(home, "router.db");
160
160
  cfg.logLevel = "warn";
161
161
  // The adjudicator would call the mock and obscure which tier the heuristic chose.
162
162
  cfg.classifier.ambiguityThreshold = 0;
163
+ // External benchmark feeds hit the real internet; keep the smoke test hermetic.
164
+ cfg.benchmarks.enabled = false;
163
165
 
164
166
  const app = startServer(cfg);
165
167
  const base = `http://127.0.0.1:${app.server.port}`;
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Syncs the Git-marketplace catalog version with package.json.
4
+ *
5
+ * The marketplace (`.omp-plugin/marketplace.json`) serves the repo tip, and omp
6
+ * uses the catalog `version` for upgrade comparisons. If it lags the published
7
+ * package version, marketplace users are never offered new releases.
8
+ *
9
+ * This runs automatically as the npm `version` lifecycle script (`npm version`
10
+ * → this → commit), so every release keeps the catalog in step. It rewrites
11
+ * `metadata.version` and every `plugins[].version` to package.json's version and
12
+ * stages the file so it lands in the same version commit.
13
+ *
14
+ * Idempotent: a no-op when already in sync (nothing to stage). Also runnable by
15
+ * hand: `bun tools/sync-marketplace-version.ts`.
16
+ */
17
+
18
+ import { spawnSync } from "node:child_process";
19
+ import { readFileSync, writeFileSync } from "node:fs";
20
+ import { join, resolve } from "node:path";
21
+
22
+ const ROOT = resolve(import.meta.dir, "..");
23
+ const PKG_PATH = join(ROOT, "package.json");
24
+ const CATALOG_PATH = join(ROOT, ".omp-plugin", "marketplace.json");
25
+
26
+ function main(): void {
27
+ const pkg = JSON.parse(readFileSync(PKG_PATH, "utf8")) as { version?: unknown };
28
+ const version = pkg.version;
29
+ if (typeof version !== "string" || version === "") {
30
+ throw new Error(`package.json has no usable version: ${JSON.stringify(version)}`);
31
+ }
32
+
33
+ const before = readFileSync(CATALOG_PATH, "utf8");
34
+ // Replace only the value of every `"version": "..."` field, preserving all
35
+ // other formatting (single-line arrays, spacing, key order) so the tool is a
36
+ // true no-op once in sync. marketplace.json carries exactly two: the
37
+ // top-level metadata.version and the single plugin's version.
38
+ if (!/"version"\s*:\s*"/.test(before)) {
39
+ throw new Error(`no "version" field found in ${CATALOG_PATH}`);
40
+ }
41
+ const after = before.replace(
42
+ /("version"\s*:\s*")[^"]*(")/g,
43
+ (_m, head: string, tail: string) => `${head}${version}${tail}`,
44
+ );
45
+ if (after === before) {
46
+ console.log(`marketplace.json already at ${version}`);
47
+ return;
48
+ }
49
+
50
+ writeFileSync(CATALOG_PATH, after, "utf8");
51
+ console.log(`marketplace.json → ${version}`);
52
+
53
+ // Stage it so `npm version`'s commit includes the sync.
54
+ const add = spawnSync("git", ["add", CATALOG_PATH], { cwd: ROOT, stdio: "inherit" });
55
+ if (add.status !== 0) {
56
+ throw new Error(`git add failed for ${CATALOG_PATH} (exit ${add.status ?? "signal"})`);
57
+ }
58
+ }
59
+
60
+ main();