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.
- package/.omp-plugin/marketplace.json +2 -2
- package/package.json +1 -1
- package/src/eval/calibrate.ts +19 -0
- package/src/server/http.ts +72 -0
- package/test/eval.test.ts +19 -1
|
@@ -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.
|
|
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.
|
|
17
|
+
"version": "0.27.1",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/package.json
CHANGED
package/src/eval/calibrate.ts
CHANGED
|
@@ -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;
|
package/src/server/http.ts
CHANGED
|
@@ -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[] = [
|