auto-model-router 0.26.0 → 0.27.1

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.26.0",
10
+ "version": "0.27.1",
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.26.0",
17
+ "version": "0.27.1",
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.26.0",
3
+ "version": "0.27.1",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -20,6 +20,25 @@ export const MIN_ANCHORS = 3;
20
20
  /** Minimum Pearson correlation between raw suite scores and AA before a fit is trusted. */
21
21
  export const MIN_R = 0.5;
22
22
 
23
+ /**
24
+ * Anchor models for a run, chosen from the catalog and spread across its score range.
25
+ *
26
+ * A fit from three models that all score ~70 describes that cluster, not the scale: the
27
+ * slope rests on a span of noise. Sampling the extremes and the quartiles gives the least
28
+ * squares something to work with. Tool-capable only, since the suite calls tools, and never
29
+ * the target itself. Fewer than `MIN_ANCHORS` scored models available ⇒ empty, and the
30
+ * caller refuses rather than fitting a line through two points.
31
+ */
32
+ export function pickAnchors(models: readonly { slug: string; quality: { coding?: number }; supportsTools: boolean }[], target: string): string[] {
33
+ const scored = models
34
+ .filter((m) => m.slug !== target && typeof m.quality.coding === "number" && m.supportsTools)
35
+ .sort((a, b) => (a.quality.coding ?? 0) - (b.quality.coding ?? 0));
36
+ if (scored.length < MIN_ANCHORS) return [];
37
+ const last = scored.length - 1;
38
+ const picks = [0, Math.floor(last / 4), Math.floor(last / 2), Math.floor((3 * last) / 4), last];
39
+ return [...new Set(picks.map((i) => scored[i]!.slug))];
40
+ }
41
+
23
42
  export interface LineFit {
24
43
  slope: number;
25
44
  intercept: number;
@@ -14,6 +14,11 @@ import { invalidateFeedCache } from "../catalog/benchmark-feeds.ts";
14
14
  import { applyRequestPolicy, resolveProfile } from "../router/index.ts";
15
15
  import { parsePolicyHeader } from "../wire/openai/request.ts";
16
16
  import { createDigester } from "./digest.ts";
17
+ import { runEval, type Completer } from "../eval/run.ts";
18
+ import type { QualityAxis } from "../config/types.ts";
19
+ import { fitCalibration, pickAnchors, toLocalFeedScores, MIN_ANCHORS } from "../eval/calibrate.ts";
20
+ import { makeJudge } from "../eval/judge.ts";
21
+ import { loadLocalScores, saveLocalScores } from "../catalog/benchmark-feeds.ts";
17
22
  import { advise } from "./advise.ts";
18
23
  import { TIER_ORDER, type Tier } from "../router/types.ts";
19
24
  import { baselinePrices, buildUsageReport, renderUsageReport } from "../cost/report.ts";
@@ -696,6 +701,73 @@ export function startServer(cfg: RouterConfig): StartedServer {
696
701
  if (result.deleted > 0) log.info("pruned ledger rows past retention", { deleted: result.deleted, retentionDays: cfg.ledger.retentionDays });
697
702
  return json({ ...result, retentionDays: cfg.ledger.retentionDays });
698
703
  }
704
+ if (req.method === "POST" && url.pathname === "/v1/router/benchmark") {
705
+ // Score one model with our OWN eval suite, for the models no feed covers: a
706
+ // third of a live catalog carries no published score on any axis, and a model
707
+ // with no score cannot clear any floor, so routing may never pick it.
708
+ //
709
+ // A raw mean is not comparable with a published index, so the run also evals
710
+ // ANCHOR models that do have published scores and fits raw → published per
711
+ // axis. Anchors are chosen from the catalog across its score range unless the
712
+ // caller names them, so one slug is all this needs.
713
+ //
714
+ // The result is written to `local_scores`, which only reaches routing when
715
+ // `benchmarks.useLocalScores` is on — measuring a model and trusting it are
716
+ // deliberately two decisions.
717
+ const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
718
+ const slug = typeof body?.slug === "string" ? body.slug.trim() : "";
719
+ if (slug === "") return wireErrorResponse({ status: 400, code: "invalid_request_error", message: "slug required" });
720
+ const snap = catalog.peekAll?.() ?? catalog.peek();
721
+ const models = snap?.models ?? [];
722
+ if (models.find((m) => m.slug === slug) === undefined) return wireErrorResponse({ status: 404, code: "invalid_request_error", message: `${slug} is not in the catalog` });
723
+ const named = Array.isArray(body?.anchors) ? (body.anchors as unknown[]).filter((a): a is string => typeof a === "string") : [];
724
+ const anchors = named.length > 0 ? [...new Set(named)] : pickAnchors(models, slug);
725
+ if (anchors.length < MIN_ANCHORS) return wireErrorResponse({ status: 422, code: "invalid_request_error", message: `need at least ${MIN_ANCHORS} scored, tool-capable anchor models to calibrate against` });
726
+ // A rate-limited dispatch is not a failed task. Ollama Cloud answers "too many
727
+ // concurrent requests" well below four models in flight, and the runner folds a
728
+ // throwing completion in as grade 0 — which would score a provider's throttle as
729
+ // the model being wrong. Retry with backoff, and keep the default concurrency
730
+ // low enough that the throttle is rarely reached in the first place.
731
+ const complete: Completer = async (target, messages) => {
732
+ let last: unknown = null;
733
+ for (let attempt = 0; attempt < 4; attempt++) {
734
+ if (attempt > 0) {
735
+ const { promise, resolve } = Promise.withResolvers<void>();
736
+ setTimeout(resolve, attempt * 4000);
737
+ await promise;
738
+ }
739
+ try {
740
+ const out = await upstream.complete({ model: target, stream: false, temperature: 0, max_tokens: 1024, messages }, AbortSignal.timeout(120_000));
741
+ return out.text;
742
+ } catch (err) {
743
+ last = err;
744
+ }
745
+ }
746
+ throw last instanceof Error ? last : new Error(String(last));
747
+ };
748
+ const judgeSlug = typeof body?.judge === "string" && body.judge !== "" ? body.judge : "";
749
+ const asked = typeof body?.concurrency === "number" ? Math.floor(body.concurrency) : 2;
750
+ const results = await runEval({
751
+ slugs: [slug, ...anchors],
752
+ complete,
753
+ concurrency: Math.min(Math.max(1, asked), 8),
754
+ ...(judgeSlug === "" ? {} : { judge: makeJudge(complete, judgeSlug) }),
755
+ });
756
+ const target = results[0]!;
757
+ const anchorResults = results.slice(1);
758
+ const published = (s: string, axis: QualityAxis): number | undefined => models.find((m) => m.slug === s)?.quality[axis];
759
+ const cal = fitCalibration(anchorResults, published);
760
+ const authorOf = (s: string): string => models.find((m) => m.slug === s)?.author ?? "";
761
+ const fresh = toLocalFeedScores([target], cal, authorOf);
762
+ if (fresh.length === 0) {
763
+ return json({ slug, anchors, calibrated: null, raw: target.axes, errors: target.errors, applied: false, reason: "no axis produced a usable fit; try more or better-spread anchors" });
764
+ }
765
+ // Merge, never replace: other models' measurements are not this run's to discard.
766
+ const kept = loadLocalScores(db).filter((s) => s.key !== fresh[0]!.key);
767
+ saveLocalScores(db, [...kept, ...fresh]);
768
+ log.info("benchmarked a model with the local eval suite", { slug, anchors: anchors.length, errors: target.errors, useLocalScores: cfg.benchmarks.useLocalScores });
769
+ return json({ slug, anchors, raw: target.axes, calibrated: fresh[0], errors: target.errors, applied: cfg.benchmarks.useLocalScores });
770
+ }
699
771
  if (req.method === "POST" && url.pathname === "/v1/router/feedback") {
700
772
  // A user verdict on the newest routed turn of an omp session.
701
773
  const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
package/test/eval.test.ts CHANGED
@@ -3,7 +3,7 @@ import { describe, expect, test } from "bun:test";
3
3
  import { normalizeCatalogModel } from "../src/catalog/openrouter-catalog.ts";
4
4
  import { applyFeedScores, loadLocalScores, saveLocalScores, type FeedScore } from "../src/catalog/benchmark-feeds.ts";
5
5
  import { answerScore, extractJson, isRefusalOrEmpty, jsonField, tokenCoverage } from "../src/eval/grade.ts";
6
- import { applyFit, fitAxis, fitCalibration, toLocalFeedScores, MIN_ANCHORS } from "../src/eval/calibrate.ts";
6
+ import { applyFit, fitAxis, fitCalibration, pickAnchors, toLocalFeedScores, MIN_ANCHORS } from "../src/eval/calibrate.ts";
7
7
  import { runEval, type EvalResult } from "../src/eval/run.ts";
8
8
  import { makeJudge, parseScore } from "../src/eval/judge.ts";
9
9
  import type { EvalTask, JudgedTask } from "../src/eval/tasks.ts";
@@ -54,6 +54,24 @@ describe("calibration", () => {
54
54
  expect(fitAxis([{ raw: 0.8, aa: 40 }, { raw: 0.5, aa: 60 }, { raw: 0.2, aa: 80 }])).toBeNull();
55
55
  });
56
56
 
57
+ test("pickAnchors spreads over the score range, skips the target and the unscored", () => {
58
+ const m = (slug: string, coding: number | undefined, supportsTools = true) => ({ slug, quality: coding === undefined ? {} : { coding }, supportsTools });
59
+ const catalog = [m("a/10", 10), m("a/30", 30), m("a/50", 50), m("a/70", 70), m("a/90", 90), m("a/target", undefined), m("a/notools", 60, false)];
60
+ const picked = pickAnchors(catalog, "a/target");
61
+ // Both extremes, so the fitted line spans the scale rather than a cluster.
62
+ expect(picked).toContain("a/10");
63
+ expect(picked).toContain("a/90");
64
+ expect(picked.length).toBeGreaterThanOrEqual(MIN_ANCHORS);
65
+ // An unscored model cannot anchor anything, and one that cannot call tools would fail
66
+ // the suite's tool tasks for a reason unrelated to its quality.
67
+ expect(picked).not.toContain("a/target");
68
+ expect(picked).not.toContain("a/notools");
69
+ // Refuses rather than fitting a line through too few points.
70
+ expect(pickAnchors([m("a/10", 10), m("a/90", 90)], "a/target")).toEqual([]);
71
+ // The target is excluded even when it is itself scored (a re-measurement).
72
+ expect(pickAnchors(catalog, "a/50")).not.toContain("a/50");
73
+ });
74
+
57
75
  test("fitCalibration + toLocalFeedScores place a target on the AA scale", () => {
58
76
  expect(MIN_ANCHORS).toBe(3);
59
77
  const anchors: EvalResult[] = [