auto-model-router 0.25.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 +40 -8
- 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.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,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/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: "",
|