auto-model-router 0.25.0 → 0.27.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.
- package/.omp-plugin/marketplace.json +2 -2
- package/package.json +1 -1
- package/src/cli/config-wizard.ts +2 -0
- package/src/config/defaults.ts +2 -0
- package/src/config/schema.ts +2 -0
- package/src/config/types.ts +20 -0
- package/src/eval/calibrate.ts +19 -0
- package/src/router/candidates.ts +40 -8
- package/src/server/http.ts +52 -0
- package/test/eval.test.ts +19 -1
- package/test/failover.test.ts +1 -1
- package/test/select.test.ts +4 -1
- package/test/tier-plan.test.ts +33 -0
- package/test/turn.test.ts +1 -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.0",
|
|
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.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/package.json
CHANGED
package/src/cli/config-wizard.ts
CHANGED
|
@@ -182,6 +182,8 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
|
|
|
182
182
|
{ path: "filters.deny", label: "Deny globs", kind: "stringArray", hint: "comma-separated" },
|
|
183
183
|
{ path: "filters.includeFree", label: "Include free models", kind: "boolean" },
|
|
184
184
|
{ path: "filters.requireToolSupport", label: "Require tool support", kind: "boolean" },
|
|
185
|
+
{ path: "filters.agenticAxisForToolTurns", label: "Rank tool turns on the agentic axis", kind: "boolean", hint: "a tool loop is won on tool-driving ability, not the task's own axis" },
|
|
186
|
+
{ path: "filters.minAgenticForToolTurns", label: "Min agentic score for tool turns", kind: "number", min: 0, max: 100, hint: "its own scale, not a tier floor; 0 disables. Models with no agentic score are not filtered" },
|
|
185
187
|
{ path: "filters.minTrust", label: "Min trust", kind: "number", min: 0, max: 1 },
|
|
186
188
|
{ path: "filters.feedbackWeight", label: "Feedback weight in trust", kind: "number", min: 0, hint: "0=record only; a bad verdict = this many failures" },
|
|
187
189
|
{ path: "filters.feedbackByTask", label: "Scope verdicts to the task type", kind: "boolean" },
|
package/src/config/defaults.ts
CHANGED
|
@@ -103,6 +103,8 @@ export const DEFAULT_CONFIG: RouterConfig = {
|
|
|
103
103
|
// Free models are rate-limited hard enough that retries cost more than they save.
|
|
104
104
|
includeFree: false,
|
|
105
105
|
requireToolSupport: true,
|
|
106
|
+
agenticAxisForToolTurns: true,
|
|
107
|
+
minAgenticForToolTurns: 25,
|
|
106
108
|
minTrust: 0.7,
|
|
107
109
|
// Verdicts are recorded and reported first; weigh them once there are some.
|
|
108
110
|
feedbackWeight: 0,
|
package/src/config/schema.ts
CHANGED
|
@@ -128,6 +128,8 @@ const filters = z.strictObject({
|
|
|
128
128
|
deny: z.array(z.string()).optional(),
|
|
129
129
|
includeFree: z.boolean().optional(),
|
|
130
130
|
requireToolSupport: z.boolean().optional(),
|
|
131
|
+
agenticAxisForToolTurns: z.boolean().optional(),
|
|
132
|
+
minAgenticForToolTurns: z.number().min(0).max(100).optional(),
|
|
131
133
|
minTrust: z.number().min(0).max(1).optional(),
|
|
132
134
|
feedbackWeight: z.number().nonnegative().optional(),
|
|
133
135
|
feedbackByTask: z.boolean().optional(),
|
package/src/config/types.ts
CHANGED
|
@@ -221,6 +221,26 @@ export interface FilterConfig {
|
|
|
221
221
|
includeFree: boolean;
|
|
222
222
|
/** Require `supported_parameters` to include `tools` whenever the request offers tools. */
|
|
223
223
|
requireToolSupport: boolean;
|
|
224
|
+
/**
|
|
225
|
+
* Score a turn that carries tools on the `agentic` axis instead of its task's axis.
|
|
226
|
+
* On by default: a tool loop is won or lost on tool-driving ability, and `chat` and
|
|
227
|
+
* `documentation` score on `intelligence`, which does not measure it. Off restores
|
|
228
|
+
* the task's own axis for every turn.
|
|
229
|
+
*/
|
|
230
|
+
agenticAxisForToolTurns: boolean;
|
|
231
|
+
/**
|
|
232
|
+
* Minimum `agentic` score a model needs to be offered a turn that carries tools, 0-100.
|
|
233
|
+
* The cheap tiers rank with `qualityExponent: 0` — cheapest above the floor — so on those
|
|
234
|
+
* tiers the ranking axis is inert and only a floor keeps a tool-incapable model out.
|
|
235
|
+
*
|
|
236
|
+
* Judged on its own scale, NOT against a tier's `minQuality`: agentic scores run far lower
|
|
237
|
+
* than coding and intelligence, so reusing a tier floor here empties the catalog. Measured:
|
|
238
|
+
* 25 drops gpt-oss-20b (1.4) and gemma-3-12b (0.1) while leaving 14 models under $0.30/Mtok.
|
|
239
|
+
*
|
|
240
|
+
* A model that publishes NO agentic score is not filtered — only 103 of 223 tool-capable
|
|
241
|
+
* models carry one, so rejecting the unscored would discard half the catalog. 0 disables.
|
|
242
|
+
*/
|
|
243
|
+
minAgenticForToolTurns: number;
|
|
224
244
|
/**
|
|
225
245
|
* Smallest completion budget a REASONING model is dispatched with, tokens.
|
|
226
246
|
*
|
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/router/candidates.ts
CHANGED
|
@@ -169,7 +169,15 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
169
169
|
const minContext = Math.ceil(features.promptTokens * filters.contextHeadroom) + expectedCompletionTokens;
|
|
170
170
|
// Task selects the quality axis and capability filters; the tier still
|
|
171
171
|
// bounds cost.
|
|
172
|
-
|
|
172
|
+
//
|
|
173
|
+
// A turn that carries tools is scored on `agentic`, whatever the task's own axis says.
|
|
174
|
+
// `chat` and `documentation` score on `intelligence`, which says nothing about whether a
|
|
175
|
+
// model can drive a tool loop: measured here, ollama/gpt-oss:20b (intelligence 9, agentic
|
|
176
|
+
// 1.4) won a tool-bearing trivial turn on price because every candidate sat under the
|
|
177
|
+
// tier floor and the adaptive band then relaxed it. The `agentic` score is already in the
|
|
178
|
+
// catalog for every model and was the one axis nothing routed on.
|
|
179
|
+
const toolTurn = req.tools.length > 0;
|
|
180
|
+
const effectiveAxis: QualityAxis = toolTurn && filters.agenticAxisForToolTurns ? "agentic" : taskCfg.axis;
|
|
173
181
|
// Two floors with different meanings, and only one of them may be relaxed:
|
|
174
182
|
// - the TIER floor is an economic envelope tuned against the full catalog,
|
|
175
183
|
// so when a guardrail narrows availability below it, relaxing to the
|
|
@@ -180,7 +188,7 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
180
188
|
const plan = cfg.adaptiveTierFloors || cfg.adaptivePriceCeilings ? tierPlanFor(snapshot, cfg) : null;
|
|
181
189
|
const adaptiveTierFloor =
|
|
182
190
|
cfg.adaptiveTierFloors && plan !== null
|
|
183
|
-
? effectiveQualityFloor(tierCfg.minQuality, tier,
|
|
191
|
+
? effectiveQualityFloor(tierCfg.minQuality, tier, taskCfg.axis, plan)
|
|
184
192
|
: tierCfg.minQuality;
|
|
185
193
|
const qualityFloor = Math.max(taskFloor, adaptiveTierFloor);
|
|
186
194
|
// Input-price ceiling: catalog-derived band when adaptive, else the fixed config.
|
|
@@ -229,6 +237,20 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
229
237
|
rejected.push({ slug, reason: "no_tool_support" });
|
|
230
238
|
continue;
|
|
231
239
|
}
|
|
240
|
+
// A tool loop needs a model that can drive one. The cheap tiers rank with
|
|
241
|
+
// `qualityExponent: 0` — cheapest above the floor — so there the ranking axis decides
|
|
242
|
+
// nothing and only this keeps a tool-incapable model out: ollama/gpt-oss:20b (agentic
|
|
243
|
+
// 1.4) was winning tool-bearing trivial turns purely on price. Judged on the agentic
|
|
244
|
+
// scale rather than a tier floor, and never applied to a model that publishes no
|
|
245
|
+
// agentic score, since most of the catalog does not. Relaxed with the rest under
|
|
246
|
+
// tier rescue, so a narrowed catalog still gets a turn.
|
|
247
|
+
if (toolTurn && !relaxQuality && filters.minAgenticForToolTurns > 0) {
|
|
248
|
+
const agentic = model.quality.agentic;
|
|
249
|
+
if (agentic !== undefined && agentic < filters.minAgenticForToolTurns) {
|
|
250
|
+
rejected.push({ slug, reason: "below_quality_floor", detail: `agentic ${agentic} < ${filters.minAgenticForToolTurns} for a tool turn` });
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
232
254
|
if ((req.hasImages || taskCfg.requireImage === true) && !model.inputModalities.includes("image")) {
|
|
233
255
|
rejected.push({ slug, reason: "no_image_support" });
|
|
234
256
|
continue;
|
|
@@ -239,17 +261,28 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
239
261
|
}
|
|
240
262
|
|
|
241
263
|
const pinned = tierCfg.pin.includes(slug) || taskPins.includes(slug);
|
|
242
|
-
|
|
264
|
+
// Floors are judged on the TASK's axis, always. The `agentic` scores sit on a lower
|
|
265
|
+
// scale than coding and intelligence (glm-5.3-flash: coding 71.5, agentic 51.2), so
|
|
266
|
+
// reusing one numeric floor across axes empties the set — measured here, a floor of
|
|
267
|
+
// 60 on `agentic` admitted nothing, tier rescue then dropped the floor entirely and
|
|
268
|
+
// the WEAKEST model won. The axis switch below therefore reorders candidates without
|
|
269
|
+
// touching who is eligible.
|
|
270
|
+
const quality = resolveQuality(model, taskCfg.axis);
|
|
243
271
|
if (!pinned && !relaxQuality && qualityFloor > 0) {
|
|
244
272
|
if (quality === null) {
|
|
245
273
|
rejected.push({ slug, reason: "below_quality_floor", detail: "no published quality score" });
|
|
246
274
|
continue;
|
|
247
275
|
}
|
|
248
276
|
if (quality.score < qualityFloor) {
|
|
249
|
-
rejected.push({ slug, reason: "below_quality_floor", detail: `${quality.score} < floor ${qualityFloor}` });
|
|
277
|
+
rejected.push({ slug, reason: "below_quality_floor", detail: `${quality.score} < floor ${qualityFloor} on ${quality.axis}` });
|
|
250
278
|
continue;
|
|
251
279
|
}
|
|
252
280
|
}
|
|
281
|
+
// What the candidate is RANKED on: a tool-bearing turn is won or lost on tool-driving
|
|
282
|
+
// ability, and `chat`/`documentation` score on `intelligence`, which does not measure
|
|
283
|
+
// it. Ranking is relative within the admitted set, so a lower-scaled axis is safe here
|
|
284
|
+
// in a way a floor is not.
|
|
285
|
+
const rankQuality = effectiveAxis === taskCfg.axis ? quality : resolveQuality(model, effectiveAxis);
|
|
253
286
|
|
|
254
287
|
// Price ceilings at the ACTUAL prompt size: long-context overrides can
|
|
255
288
|
// push a model over the ceiling exactly when conversations get long.
|
|
@@ -352,11 +385,10 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
352
385
|
images,
|
|
353
386
|
});
|
|
354
387
|
const trustScore = trust !== null && trust.attempts > 0 ? trust.successRate : UNMEASURED_TRUST;
|
|
355
|
-
const qualityScore = quality?.score ?? 0;
|
|
356
388
|
// Shared scoring: trust converts flakiness into money — a model failing
|
|
357
389
|
// 20% of the time really costs ~25% more in retries. Latency does the same
|
|
358
390
|
// for slowness (TTFT over the reference). qualityExponent 0 makes this
|
|
359
|
-
|
|
391
|
+
const qualityScore = rankQuality?.score ?? 0;
|
|
360
392
|
const latencyMult = latencyMultiplier(latency, filters, expectedCompletionTokens, latencyWeightFor(filters, features.isToolResultContinuation));
|
|
361
393
|
// Escalation-cost term: the trust divisor prices a failure as a retry of
|
|
362
394
|
// THIS model, but a probe escalation re-dispatches the whole prompt on
|
|
@@ -380,9 +412,9 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
380
412
|
const score = 0;
|
|
381
413
|
|
|
382
414
|
const reasons: string[] = [
|
|
383
|
-
|
|
415
|
+
rankQuality === null
|
|
384
416
|
? "unscored on every quality axis"
|
|
385
|
-
: `quality ${
|
|
417
|
+
: `quality ${rankQuality.score} on ${rankQuality.axis}${rankQuality.axis === effectiveAxis ? "" : ` (fallback from ${effectiveAxis})`}${effectiveAxis === taskCfg.axis ? "" : ` — ranked on ${effectiveAxis} because the turn carries tools`}`,
|
|
386
418
|
trust === null || trust.attempts === 0
|
|
387
419
|
? `trust unmeasured: neutral prior ${UNMEASURED_TRUST}`
|
|
388
420
|
: `trust ${trustScore.toFixed(2)} over ${trust.attempts} attempts`,
|
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,53 @@ 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
|
+
const complete: Completer = async (target, messages) => {
|
|
727
|
+
const out = await upstream.complete({ model: target, stream: false, temperature: 0, max_tokens: 1024, messages }, AbortSignal.timeout(120_000));
|
|
728
|
+
return out.text;
|
|
729
|
+
};
|
|
730
|
+
const judgeSlug = typeof body?.judge === "string" && body.judge !== "" ? body.judge : "";
|
|
731
|
+
const results = await runEval({
|
|
732
|
+
slugs: [slug, ...anchors],
|
|
733
|
+
complete,
|
|
734
|
+
...(judgeSlug === "" ? {} : { judge: makeJudge(complete, judgeSlug) }),
|
|
735
|
+
});
|
|
736
|
+
const target = results[0]!;
|
|
737
|
+
const anchorResults = results.slice(1);
|
|
738
|
+
const published = (s: string, axis: QualityAxis): number | undefined => models.find((m) => m.slug === s)?.quality[axis];
|
|
739
|
+
const cal = fitCalibration(anchorResults, published);
|
|
740
|
+
const authorOf = (s: string): string => models.find((m) => m.slug === s)?.author ?? "";
|
|
741
|
+
const fresh = toLocalFeedScores([target], cal, authorOf);
|
|
742
|
+
if (fresh.length === 0) {
|
|
743
|
+
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" });
|
|
744
|
+
}
|
|
745
|
+
// Merge, never replace: other models' measurements are not this run's to discard.
|
|
746
|
+
const kept = loadLocalScores(db).filter((s) => s.key !== fresh[0]!.key);
|
|
747
|
+
saveLocalScores(db, [...kept, ...fresh]);
|
|
748
|
+
log.info("benchmarked a model with the local eval suite", { slug, anchors: anchors.length, errors: target.errors, useLocalScores: cfg.benchmarks.useLocalScores });
|
|
749
|
+
return json({ slug, anchors, raw: target.axes, calibrated: fresh[0], errors: target.errors, applied: cfg.benchmarks.useLocalScores });
|
|
750
|
+
}
|
|
699
751
|
if (req.method === "POST" && url.pathname === "/v1/router/feedback") {
|
|
700
752
|
// A user verdict on the newest routed turn of an omp session.
|
|
701
753
|
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[] = [
|
package/test/failover.test.ts
CHANGED
|
@@ -47,7 +47,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
47
47
|
data: { axis: "intelligence", minQuality: 0 },
|
|
48
48
|
chat: { axis: "intelligence", minQuality: 0 },
|
|
49
49
|
},
|
|
50
|
-
filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, feedbackWeight: 0, feedbackByTask: false, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, reasoningCompletionFloor: 0, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
|
|
50
|
+
filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, agenticAxisForToolTurns: true, minAgenticForToolTurns: 0, minTrust: 0.6, feedbackWeight: 0, feedbackByTask: false, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, reasoningCompletionFloor: 0, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
|
|
51
51
|
classifier: {
|
|
52
52
|
ambiguityThreshold: 0,
|
|
53
53
|
model: "test/adjudicator", learnedModelPath: "",
|
package/test/select.test.ts
CHANGED
|
@@ -389,7 +389,10 @@ describe("decision shape", () => {
|
|
|
389
389
|
test("carries the session id, features, and a reasoning trail", () => {
|
|
390
390
|
const d = run({ tier: "simple" });
|
|
391
391
|
expect(d.sessionId.startsWith("omp-")).toBe(true);
|
|
392
|
-
|
|
392
|
+
// `d.reasons` holds decision-level notes — a widening, a hysteresis hold — and is
|
|
393
|
+
// legitimately empty when a tier serves the turn without incident. The trail that is
|
|
394
|
+
// always present is the per-candidate one, so that is what a caller can rely on.
|
|
395
|
+
expect(d.considered[0]!.reasons.length).toBeGreaterThan(0);
|
|
393
396
|
expect(d.features.toolCount).toBe(1);
|
|
394
397
|
expect(d.considered.length).toBeGreaterThan(0);
|
|
395
398
|
});
|
package/test/tier-plan.test.ts
CHANGED
|
@@ -374,6 +374,39 @@ describe("adaptive price ceilings", () => {
|
|
|
374
374
|
expect(on.candidates.map((c) => c.model.slug)).toContain("a/4");
|
|
375
375
|
expect(on.rejected.some((r) => r.slug === "a/4")).toBe(false);
|
|
376
376
|
});
|
|
377
|
+
|
|
378
|
+
test("a tool turn excludes tool-incapable models on the cheap tiers and ranks on agentic above them", () => {
|
|
379
|
+
// Two Ollama-priced models: the cheap one cannot drive a tool loop (agentic 1.4, as
|
|
380
|
+
// ollama/gpt-oss:20b really scores), the dearer one can (agentic 51.2, glm-5.3-flash).
|
|
381
|
+
const mk = (slug: string, price: number, intelligence: number, agentic: number) => ({
|
|
382
|
+
slug, canonicalSlug: slug, name: slug, provider: "ollama", vendor: "ollama",
|
|
383
|
+
contextLength: 131072, maxCompletionTokens: 32000, supportsTools: true, supportsReasoning: false,
|
|
384
|
+
reasoningMandatory: false, supportsToolChoice: true, inputModalities: ["text" as const],
|
|
385
|
+
price: { prompt: price / 1e6, completion: price / 1e6 }, priceTiers: [],
|
|
386
|
+
quality: { intelligence, coding: intelligence, agentic }, tokenizer: "Other",
|
|
387
|
+
isFree: false, createdAtMs: 0, author: "ollama",
|
|
388
|
+
});
|
|
389
|
+
const weak = mk("ollama/weak", 0.07, 9, 1.4);
|
|
390
|
+
const capable = mk("ollama/capable", 0.15, 41.9, 51.2);
|
|
391
|
+
const snap = { models: [weak, capable], fetchedAtMs: Date.now(), keyScoped: false };
|
|
392
|
+
const run = (tier: "trivial" | "moderate", min: number) =>
|
|
393
|
+
buildCandidates({
|
|
394
|
+
req, features, tier, task: "chat", snapshot: snap, ledger: null,
|
|
395
|
+
cfg: { ...BASE, filters: { ...BASE.filters, minAgenticForToolTurns: min } },
|
|
396
|
+
expectedCompletionTokens: 512, warmSlug: null,
|
|
397
|
+
});
|
|
398
|
+
// `trivial` ranks with qualityExponent 0 — cheapest above the floor — so the axis
|
|
399
|
+
// decides nothing there and only the agentic floor keeps the weak model out.
|
|
400
|
+
expect(run("trivial", 0).candidates[0]!.model.slug).toBe("ollama/weak");
|
|
401
|
+
const floored = run("trivial", 25);
|
|
402
|
+
expect(floored.candidates.map((c) => c.model.slug)).toEqual(["ollama/capable"]);
|
|
403
|
+
expect(floored.rejected.some((x) => x.slug === "ollama/weak" && (x.detail ?? "").includes("agentic 1.4 < 25"))).toBe(true);
|
|
404
|
+
// Above the cheap tiers quality does carry weight, and there the turn is ranked on
|
|
405
|
+
// agentic: the capable model leads even though it costs more than twice as much.
|
|
406
|
+
const ranked = run("moderate", 0);
|
|
407
|
+
expect(ranked.candidates[0]!.model.slug).toBe("ollama/capable");
|
|
408
|
+
expect(ranked.candidates[0]!.reasons.join(" ")).toContain("ranked on agentic");
|
|
409
|
+
});
|
|
377
410
|
});
|
|
378
411
|
|
|
379
412
|
describe("quality normalization and capability floor (benchmark findings 4/6)", () => {
|
package/test/turn.test.ts
CHANGED
|
@@ -49,7 +49,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
49
49
|
data: { axis: "intelligence", minQuality: 0 },
|
|
50
50
|
chat: { axis: "intelligence", minQuality: 0 },
|
|
51
51
|
},
|
|
52
|
-
filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, feedbackWeight: 0, feedbackByTask: false, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, reasoningCompletionFloor: 0, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
|
|
52
|
+
filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, agenticAxisForToolTurns: true, minAgenticForToolTurns: 0, minTrust: 0.6, feedbackWeight: 0, feedbackByTask: false, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, reasoningCompletionFloor: 0, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
|
|
53
53
|
classifier: {
|
|
54
54
|
ambiguityThreshold: 0,
|
|
55
55
|
model: "test/adjudicator", learnedModelPath: "",
|