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.
@@ -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.25.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.25.0",
17
+ "version": "0.27.0",
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.25.0",
3
+ "version": "0.27.0",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -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" },
@@ -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,
@@ -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(),
@@ -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
  *
@@ -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;
@@ -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
- const effectiveAxis = taskCfg.axis;
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, effectiveAxis, plan)
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
- const quality = resolveQuality(model, effectiveAxis);
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
- // "cheapest above the floor"; the floor does the quality work.
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
- quality === null
415
+ rankQuality === null
384
416
  ? "unscored on every quality axis"
385
- : `quality ${quality.score} on ${quality.axis}${quality.axis === effectiveAxis ? "" : ` (fallback from ${effectiveAxis})`}`,
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`,
@@ -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[] = [
@@ -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: "",
@@ -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
- expect(d.reasons.length).toBeGreaterThan(0);
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
  });
@@ -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: "",