auto-model-router 0.24.0 → 0.26.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/router/candidates.ts +60 -20
- package/test/failover.test.ts +1 -1
- package/test/select.test.ts +4 -1
- package/test/tier-plan.test.ts +49 -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.26.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.26.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/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,35 +261,59 @@ 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.
|
|
256
289
|
// Catalog prices are per-token; ceilings are per million tokens.
|
|
290
|
+
//
|
|
291
|
+
// The ceiling is compared against the BIASED price, because that is what the turn
|
|
292
|
+
// actually costs this deployment: capacity already paid for — an Ollama plan's
|
|
293
|
+
// included credits, a Claude Pro/Max subscription — carries the list price of the
|
|
294
|
+
// twin it is priced from, and a subscription model at $2-5/Mtok list would be
|
|
295
|
+
// thrown out here on every cheap tier before `costBias` was ever consulted. That
|
|
296
|
+
// made the bias silently inert: setting it to 0.00001 changed no decision at all.
|
|
257
297
|
const price = priceAt(model, Math.max(1, features.promptTokens));
|
|
258
|
-
|
|
298
|
+
const providerBias =
|
|
299
|
+
snapshot.providerBias?.[model.provider] ??
|
|
300
|
+
(model.provider === "ollama" ? cfg.ollama.costBias : (cfg.upstreams.find((u) => u.id === model.provider)?.costBias ?? 1));
|
|
301
|
+
const biasedPrompt = price.prompt * providerBias;
|
|
302
|
+
const biasedCompletion = price.completion * providerBias;
|
|
303
|
+
const biasNote = providerBias === 1 ? "" : ` (×${providerBias} bias on $${(price.prompt * 1e6).toFixed(2)} list)`;
|
|
304
|
+
if (!relaxPrice && priceCeiling !== undefined && biasedPrompt * 1e6 > priceCeiling) {
|
|
259
305
|
rejected.push({
|
|
260
306
|
slug,
|
|
261
307
|
reason: "over_price_ceiling",
|
|
262
|
-
detail: `input $${(
|
|
308
|
+
detail: `input $${(biasedPrompt * 1e6).toFixed(2)}/Mtok > ceiling $${priceCeiling.toFixed(2)}${biasNote}`,
|
|
263
309
|
});
|
|
264
310
|
continue;
|
|
265
311
|
}
|
|
266
|
-
if (!relaxPrice && tierCfg.maxOutputPerMtok !== undefined &&
|
|
312
|
+
if (!relaxPrice && tierCfg.maxOutputPerMtok !== undefined && biasedCompletion * 1e6 > tierCfg.maxOutputPerMtok) {
|
|
267
313
|
rejected.push({
|
|
268
314
|
slug,
|
|
269
315
|
reason: "over_price_ceiling",
|
|
270
|
-
detail: `output $${(
|
|
316
|
+
detail: `output $${(biasedCompletion * 1e6).toFixed(2)}/Mtok > ceiling $${tierCfg.maxOutputPerMtok}${biasNote}`,
|
|
271
317
|
});
|
|
272
318
|
continue;
|
|
273
319
|
}
|
|
@@ -339,11 +385,10 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
339
385
|
images,
|
|
340
386
|
});
|
|
341
387
|
const trustScore = trust !== null && trust.attempts > 0 ? trust.successRate : UNMEASURED_TRUST;
|
|
342
|
-
const qualityScore = quality?.score ?? 0;
|
|
343
388
|
// Shared scoring: trust converts flakiness into money — a model failing
|
|
344
389
|
// 20% of the time really costs ~25% more in retries. Latency does the same
|
|
345
390
|
// for slowness (TTFT over the reference). qualityExponent 0 makes this
|
|
346
|
-
|
|
391
|
+
const qualityScore = rankQuality?.score ?? 0;
|
|
347
392
|
const latencyMult = latencyMultiplier(latency, filters, expectedCompletionTokens, latencyWeightFor(filters, features.isToolResultContinuation));
|
|
348
393
|
// Escalation-cost term: the trust divisor prices a failure as a retry of
|
|
349
394
|
// THIS model, but a probe escalation re-dispatches the whole prompt on
|
|
@@ -357,14 +402,9 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
357
402
|
filters.escalationCostWeight > 0 && args.escalationUsdPerPromptToken !== undefined
|
|
358
403
|
? filters.escalationCostWeight * escalationRate * args.escalationUsdPerPromptToken * features.promptTokens
|
|
359
404
|
: 0;
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
// The snapshot carries the LIVE bias (credit-aware, and each named upstream's own);
|
|
364
|
-
// the static config value is the fallback for snapshots built without one.
|
|
365
|
-
const providerBias =
|
|
366
|
-
snapshot.providerBias?.[model.provider] ??
|
|
367
|
-
(model.provider === "ollama" ? cfg.ollama.costBias : (cfg.upstreams.find((u) => u.id === model.provider)?.costBias ?? 1));
|
|
405
|
+
// `providerBias` is computed with the price ceilings above — capacity already paid for
|
|
406
|
+
// is money already spent, so it is valued below list in ranking AND against the
|
|
407
|
+
// ceilings. The ledger still records list price either way.
|
|
368
408
|
const effectiveUsd = (fc.expectedUsd / Math.max(trustScore, 0.5) + escalationUsd) * latencyMult * providerBias;
|
|
369
409
|
// Score is assigned in a SECOND PASS below: both qualityNormalization and
|
|
370
410
|
// capabilityFloorUsd are properties of the candidate SET, not of one
|
|
@@ -372,9 +412,9 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
372
412
|
const score = 0;
|
|
373
413
|
|
|
374
414
|
const reasons: string[] = [
|
|
375
|
-
|
|
415
|
+
rankQuality === null
|
|
376
416
|
? "unscored on every quality axis"
|
|
377
|
-
: `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`}`,
|
|
378
418
|
trust === null || trust.attempts === 0
|
|
379
419
|
? `trust unmeasured: neutral prior ${UNMEASURED_TRUST}`
|
|
380
420
|
: `trust ${trustScore.toFixed(2)} over ${trust.attempts} attempts`,
|
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
|
@@ -358,6 +358,55 @@ describe("adaptive price ceilings", () => {
|
|
|
358
358
|
expect(on.candidates.map((c) => c.model.slug)).not.toContain("a/4");
|
|
359
359
|
expect(on.rejected.some((r) => r.slug === "a/4" && r.reason === "over_price_ceiling")).toBe(true);
|
|
360
360
|
});
|
|
361
|
+
|
|
362
|
+
test("a price ceiling judges the BIASED price, so prepaid capacity is not thrown out on list", () => {
|
|
363
|
+
// A subscription upstream inherits its OpenRouter twin's list price ($4/Mtok here) and is
|
|
364
|
+
// discounted by `costBias` because the capacity is already paid for. Judging the ceiling on
|
|
365
|
+
// list threw it out before the bias was ever read, which made the bias entirely inert.
|
|
366
|
+
const snap = snapshot(priced);
|
|
367
|
+
const biased = { ...snap, providerBias: { [snap.models[0]!.provider]: 0.1 } };
|
|
368
|
+
const run = (s: typeof snap) =>
|
|
369
|
+
buildCandidates({ req, features, tier: "moderate", task: "coding", snapshot: s, ledger: null, cfg: { ...BASE, adaptivePriceCeilings: true }, expectedCompletionTokens: 512, warmSlug: null });
|
|
370
|
+
// Unbiased: the band tightens moderate to $3 and a/4 is over it.
|
|
371
|
+
expect(run(snap).rejected.some((r) => r.slug === "a/4" && r.reason === "over_price_ceiling")).toBe(true);
|
|
372
|
+
// Biased ×0.1: $4 list is $0.40 to this deployment, so it clears the same ceiling.
|
|
373
|
+
const on = run(biased);
|
|
374
|
+
expect(on.candidates.map((c) => c.model.slug)).toContain("a/4");
|
|
375
|
+
expect(on.rejected.some((r) => r.slug === "a/4")).toBe(false);
|
|
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
|
+
});
|
|
361
410
|
});
|
|
362
411
|
|
|
363
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: "",
|