auto-model-router 0.30.0 → 0.30.2

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.
@@ -7,14 +7,14 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
10
- "version": "0.30.0",
10
+ "version": "0.30.2",
11
11
  "pluginRoot": "."
12
12
  },
13
13
  "plugins": [
14
14
  {
15
15
  "name": "auto-model-router",
16
16
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
17
- "version": "0.30.0",
17
+ "version": "0.30.2",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.30.0",
3
+ "version": "0.30.2",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
package/src/eval/tasks.ts CHANGED
@@ -307,6 +307,46 @@ export const EVAL_TASKS: readonly EvalTask[] = [
307
307
  return words.every((w) => known.includes(w)) ? 1 : 0.5;
308
308
  },
309
309
  },
310
+
311
+ // ---- harder still. The first hard band was aced 6/6 by deepseek-v4.1-flash, so it was
312
+ // not measuring a ceiling either. These need multi-step state tracking with no shortcut:
313
+ // the answer cannot be recalled, only computed, and one slip anywhere changes it.
314
+ {
315
+ id: "coding/stack-machine",
316
+ axis: "coding",
317
+ complexity: "hard",
318
+ system: JSON_ONLY,
319
+ user: "A stack machine starts with an empty stack. Execute: PUSH 4, PUSH 7, ADD, PUSH 3, SWAP, SUB, PUSH 2, MUL, DUP, ADD.\nSUB pops a then b and pushes b-a. SWAP exchanges the top two. DUP duplicates the top. Reply with the final stack, bottom to top, comma separated.",
320
+ // 4 | 4,7 | 11 | 11,3 | 3,11 | 3-11=-8 | -8,2 | -16 | -16,-16 | -32
321
+ grade: (o) => answerScore(o, "-32"),
322
+ },
323
+ {
324
+ id: "intel/ledger-balance",
325
+ axis: "intelligence",
326
+ complexity: "hard",
327
+ system: JSON_ONLY,
328
+ user: "An account starts at 0. Apply in order: +120, -45, then double the balance, -30, then halve the balance (round DOWN to a whole number), +7, then subtract a tenth of the balance (round DOWN), finally -1. Reply with the final balance alone.",
329
+ // 0→120→75→150→120→60→67; a tenth of 67 floors to 6 ⇒ 61; −1 ⇒ 60
330
+ grade: (o) => answerScore(o, "60"),
331
+ },
332
+ {
333
+ id: "intel/constraint-conflict",
334
+ axis: "intelligence",
335
+ complexity: "hard",
336
+ system: "Follow every constraint. If two constraints cannot both hold, say IMPOSSIBLE and nothing else.",
337
+ user: "Give a single whole number that is greater than 10, less than 20, divisible by 4, and odd. Reply with the number, or IMPOSSIBLE.",
338
+ // No odd multiple of 4 exists: recognising the contradiction is the capability.
339
+ grade: (o) => (/\bimpossible\b/i.test(o) ? 1 : 0),
340
+ },
341
+ {
342
+ id: "coding/regex-backtrack",
343
+ axis: "coding",
344
+ complexity: "hard",
345
+ system: JSON_ONLY,
346
+ user: "In JavaScript, give the result of each, one per line, in order:\n(1) 'aaa'.replace(/a*/g, 'X')\n(2) 'a1b2'.match(/[a-z](?=\\d)/g).join('')\n(3) /^(a+)+$/.test('aaab')\n(4) 'x.y.z'.split('.', 2).join('|')\n(5) 'abc'.replace(/(b)/, '[$1$$]')",
347
+ // Empty match at end ⇒ "XX"; lookahead ⇒ "ab"; false; "x|y"; "$$" is a literal $ ⇒ "a[b$]c"
348
+ grade: (o) => multiAnswerCoverage(o, ["XX", "ab", "false", "x|y", "a[b$]c"]),
349
+ },
310
350
  ];
311
351
 
312
352
  /**
@@ -248,6 +248,11 @@ export function startServer(cfg: RouterConfig): StartedServer {
248
248
  const overrides = createSessionOverrides();
249
249
  const feedback = createFeedbackStore(db);
250
250
  const kv = createKv(db);
251
+ /**
252
+ * Background local-benchmark runs. In memory and not persisted: a run is an explicit,
253
+ * attended admin action, and its lasting output is the `local_scores` row it writes.
254
+ */
255
+ const benchmarkJobs = new Map<string, { state: "running" | "done" | "error"; slug: string; startedAtMs: number; result?: Record<string, unknown>; error?: string }>();
251
256
  const digester = createDigester({ cfg, catalog, ledger, upstream, log });
252
257
  const turnDeps = { config: cfg, router, upstream, ledger, conversations, catalog, context, overrides, ollamaCostScale, digester };
253
258
 
@@ -768,28 +773,52 @@ export function startServer(cfg: RouterConfig): StartedServer {
768
773
  };
769
774
  const judgeSlug = typeof body?.judge === "string" && body.judge !== "" ? body.judge : "";
770
775
  const asked = typeof body?.concurrency === "number" ? Math.floor(body.concurrency) : 2;
771
- const results = await runEval({
772
- slugs: [slug, ...anchors],
773
- complete,
774
- toolComplete,
775
- repeats: Math.min(Math.max(1, typeof body?.repeats === "number" ? Math.floor(body.repeats) : 1), 20),
776
- concurrency: Math.min(Math.max(1, asked), 8),
777
- ...(judgeSlug === "" ? {} : { judge: makeJudge(complete, judgeSlug) }),
778
- });
779
- const target = results[0]!;
780
- const anchorResults = results.slice(1);
781
- const published = (s: string, axis: QualityAxis): number | undefined => models.find((m) => m.slug === s)?.quality[axis];
782
- const cal = fitCalibration(anchorResults, published);
783
- const authorOf = (s: string): string => models.find((m) => m.slug === s)?.author ?? "";
784
- const fresh = toLocalFeedScores([target], cal, authorOf);
785
- if (fresh.length === 0) {
786
- return json({ slug, anchors, calibrated: null, raw: target.axes, byComplexity: target.byComplexity, repeats: target.repeats, spread: target.spread, errors: target.errors, applied: false, reason: "no axis produced a usable fit; try more or better-spread anchors" });
787
- }
788
- // Merge, never replace: other models' measurements are not this run's to discard.
789
- const kept = loadLocalScores(db).filter((s) => s.key !== fresh[0]!.key);
790
- saveLocalScores(db, [...kept, ...fresh]);
791
- log.info("benchmarked a model with the local eval suite", { slug, anchors: anchors.length, errors: target.errors, useLocalScores: cfg.benchmarks.useLocalScores });
792
- return json({ slug, anchors, raw: target.axes, byComplexity: target.byComplexity, repeats: target.repeats, spread: target.spread, calibrated: fresh[0], errors: target.errors, applied: cfg.benchmarks.useLocalScores });
776
+ // The run is a BACKGROUND job, not a long request. Bun caps `idleTimeout` at 255
777
+ // seconds, so a suite of any size over ten repeats outlives the socket: measured,
778
+ // a single pass returned in 193s and two passes died at ~350s with the response
779
+ // lost. The caller polls instead.
780
+ const jobId = `bench_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
781
+ const started = Date.now();
782
+ benchmarkJobs.set(jobId, { state: "running", slug, startedAtMs: started });
783
+ void (async () => {
784
+ try {
785
+ const results = await runEval({
786
+ slugs: [slug, ...anchors],
787
+ complete,
788
+ toolComplete,
789
+ repeats: Math.min(Math.max(1, typeof body?.repeats === "number" ? Math.floor(body.repeats) : 1), 20),
790
+ concurrency: Math.min(Math.max(1, asked), 8),
791
+ ...(judgeSlug === "" ? {} : { judge: makeJudge(complete, judgeSlug) }),
792
+ });
793
+ const target = results[0]!;
794
+ const published = (s: string, axis: QualityAxis): number | undefined => models.find((m) => m.slug === s)?.quality[axis];
795
+ const cal = fitCalibration(results.slice(1), published);
796
+ const authorOf = (s: string): string => models.find((m) => m.slug === s)?.author ?? "";
797
+ const fresh = toLocalFeedScores([target], cal, authorOf);
798
+ const shared = { slug, anchors, raw: target.axes, byComplexity: target.byComplexity, repeats: target.repeats, spread: target.spread, errors: target.errors, tookMs: Date.now() - started };
799
+ if (fresh.length === 0) {
800
+ benchmarkJobs.set(jobId, { state: "done", slug, startedAtMs: started, result: { ...shared, calibrated: null, applied: false, reason: "no axis produced a usable fit; try more or better-spread anchors" } });
801
+ return;
802
+ }
803
+ // Merge, never replace: other models' measurements are not this run's to discard.
804
+ const kept = loadLocalScores(db).filter((s) => s.key !== fresh[0]!.key);
805
+ saveLocalScores(db, [...kept, ...fresh]);
806
+ log.info("benchmarked a model with the local eval suite", { slug, anchors: anchors.length, repeats: target.repeats, errors: target.errors, useLocalScores: cfg.benchmarks.useLocalScores });
807
+ benchmarkJobs.set(jobId, { state: "done", slug, startedAtMs: started, result: { ...shared, calibrated: fresh[0], applied: cfg.benchmarks.useLocalScores } });
808
+ } catch (err) {
809
+ const message = err instanceof Error ? err.message : String(err);
810
+ log.warn("local benchmark run failed", { slug, error: message });
811
+ benchmarkJobs.set(jobId, { state: "error", slug, startedAtMs: started, error: message });
812
+ }
813
+ })();
814
+ return json({ jobId, state: "running", slug, anchors });
815
+ }
816
+ if (req.method === "GET" && url.pathname === "/v1/router/benchmark") {
817
+ const jobId = url.searchParams.get("job");
818
+ if (jobId === null) return json({ jobs: [...benchmarkJobs.entries()].map(([id, j]) => ({ id, slug: j.slug, state: j.state, startedAtMs: j.startedAtMs })) });
819
+ const job = benchmarkJobs.get(jobId);
820
+ if (job === undefined) return wireErrorResponse({ status: 404, code: "invalid_request_error", message: `no benchmark job ${jobId}` });
821
+ return json({ id: jobId, slug: job.slug, state: job.state, startedAtMs: job.startedAtMs, ...(job.result === undefined ? {} : { result: job.result }), ...(job.error === undefined ? {} : { error: job.error }) });
793
822
  }
794
823
  if (req.method === "POST" && url.pathname === "/v1/router/feedback") {
795
824
  // A user verdict on the newest routed turn of an omp session.
package/test/eval.test.ts CHANGED
@@ -117,6 +117,14 @@ describe("calibration", () => {
117
117
  expect(hard.find((t) => t.id === "coding/sort-lexicographic")!.grade("[9, 10, 80]")).toBeLessThan(1);
118
118
  expect(hard.find((t) => t.id === "intel/collatz-steps")!.grade("1")).toBeLessThan(1);
119
119
  expect(hard.find((t) => t.id === "intel/strict-format")!.grade("Red, blue, and yellow.")).toBeLessThan(1);
120
+ // The second hard band: computable only, no recall shortcut.
121
+ expect(hard.find((t) => t.id === "coding/stack-machine")!.grade("-32")).toBe(1);
122
+ expect(hard.find((t) => t.id === "coding/stack-machine")!.grade("-16")).toBeLessThan(1);
123
+ expect(hard.find((t) => t.id === "intel/ledger-balance")!.grade("60")).toBe(1);
124
+ expect(hard.find((t) => t.id === "intel/ledger-balance")!.grade("61")).toBeLessThan(1);
125
+ expect(hard.find((t) => t.id === "intel/constraint-conflict")!.grade("IMPOSSIBLE")).toBe(1);
126
+ expect(hard.find((t) => t.id === "intel/constraint-conflict")!.grade("12")).toBe(0);
127
+ expect(hard.find((t) => t.id === "coding/regex-backtrack")!.grade("XX\nab\nfalse\nx|y\na[b$]c")).toBe(1);
120
128
  });
121
129
 
122
130
  test("fitCalibration + toLocalFeedScores place a target on the AA scale", () => {