auto-model-router 0.2.31 → 0.2.33
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/README.md +3 -0
- package/package.json +1 -1
- package/src/config/defaults.ts +5 -2
- package/src/config/schema.ts +2 -0
- package/src/config/types.ts +20 -0
- package/src/router/candidates.ts +39 -8
- package/src/router/classify.ts +15 -1
- package/src/router/types.ts +1 -0
- package/test/classify.test.ts +23 -2
- package/test/failover.test.ts +1 -0
- package/test/select.test.ts +25 -0
- package/test/turn.test.ts +1 -0
- package/tools/build-site.ts +1 -0
|
@@ -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.2.
|
|
10
|
+
"version": "0.2.32",
|
|
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.2.
|
|
17
|
+
"version": "0.2.32",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/README.md
CHANGED
|
@@ -577,6 +577,9 @@ Each task (`coding`, `vision`, `documentation`, `data`, `chat`) is a
|
|
|
577
577
|
| `minTrustSamples` | `12` | Attempts before trust is enforced. |
|
|
578
578
|
| `trustScopedByHarness` | `false` | `true` = each harness reads only its own trust rows. |
|
|
579
579
|
| `contextHeadroom` | `1.25` | Fraction of context kept free (a model must fit prompt × this). |
|
|
580
|
+
| `latencyWeight` | `0` | How hard to penalise slow models in scoring (soft multiplier on effective cost). `0` disables it. |
|
|
581
|
+
| `latencyMinSamples` | `20` | Streamed samples before latency is judged against a model. |
|
|
582
|
+
| `maxExpectedWaitMs` | unset | Absolute expected-wait ceiling (ms): a hard drop for models *proven* slower (≥ `latencyMinSamples`), regardless of price. The soft penalty is multiplicative and capped, so it cannot demote a slow-but-cheap model — this can. New models keep their cold-start turns; relaxed with trust in tier rescue. Undefined ⇒ off. |
|
|
580
583
|
|
|
581
584
|
### `classifier` — complexity adjudication
|
|
582
585
|
|
package/package.json
CHANGED
package/src/config/defaults.ts
CHANGED
|
@@ -101,8 +101,11 @@ export const DEFAULT_CONFIG: RouterConfig = {
|
|
|
101
101
|
toolAxis: "coding",
|
|
102
102
|
chatAxis: "intelligence",
|
|
103
103
|
agenticLoopDepth: 3,
|
|
104
|
-
//
|
|
105
|
-
//
|
|
104
|
+
// A mechanical retry (failed tool call + tool-result continuation) keeps
|
|
105
|
+
// only a fifth of the +0.26; a user-visible failure keeps the full weight.
|
|
106
|
+
mechanicalRetryFactor: 0.2,
|
|
107
|
+
// Shipped reasoning values, unchanged. See ClassifierConfig.reasoningWeights:
|
|
108
|
+
// a harness that pins the level for a whole session turns these into a
|
|
106
109
|
// constant tier offset, in which case `medium` belongs near 0.
|
|
107
110
|
reasoningWeights: { medium: 0.14, high: 0.24, xhigh: 0.3, max: 0.34 },
|
|
108
111
|
},
|
package/src/config/schema.ts
CHANGED
|
@@ -73,6 +73,7 @@ const filters = z.strictObject({
|
|
|
73
73
|
latencyReferenceMs: z.number().positive().optional(),
|
|
74
74
|
latencyReferenceTokensPerSec: z.number().positive().optional(),
|
|
75
75
|
latencyMinSamples: z.number().int().nonnegative().optional(),
|
|
76
|
+
maxExpectedWaitMs: z.number().positive().optional(),
|
|
76
77
|
});
|
|
77
78
|
|
|
78
79
|
const classifier = z.strictObject({
|
|
@@ -85,6 +86,7 @@ const classifier = z.strictObject({
|
|
|
85
86
|
toolAxis: qualityAxis.optional(),
|
|
86
87
|
chatAxis: qualityAxis.optional(),
|
|
87
88
|
agenticLoopDepth: z.number().int().nonnegative().optional(),
|
|
89
|
+
mechanicalRetryFactor: z.number().min(0).max(1).optional(),
|
|
88
90
|
reasoningWeights: z
|
|
89
91
|
.strictObject({
|
|
90
92
|
medium: z.number().nonnegative().optional(),
|
package/src/config/types.ts
CHANGED
|
@@ -206,6 +206,19 @@ export interface FilterConfig {
|
|
|
206
206
|
latencyReferenceTokensPerSec: number;
|
|
207
207
|
/** Streamed samples required before latency is scored against a model. */
|
|
208
208
|
latencyMinSamples: number;
|
|
209
|
+
/**
|
|
210
|
+
* Absolute expected-wait ceiling (ms). A hard drop, mirroring the price
|
|
211
|
+
* ceiling: any model whose expected total wait (TTFT + streaming the expected
|
|
212
|
+
* completion at its measured throughput) exceeds this is rejected outright,
|
|
213
|
+
* regardless of tier or price. This is the gate the latency *penalty* cannot
|
|
214
|
+
* be — the penalty is multiplicative on cost and capped, so on an ultra-cheap
|
|
215
|
+
* model even the capped multiple leaves it cheapest; a slow-but-cheap model is
|
|
216
|
+
* never demoted by scoring alone. Only models with at least `latencyMinSamples`
|
|
217
|
+
* observations are dropped, so a new model still gets its cold-start turns.
|
|
218
|
+
* Relaxed alongside trust in tier rescue so a narrowed catalog never 500s.
|
|
219
|
+
* Undefined ⇒ off (the default).
|
|
220
|
+
*/
|
|
221
|
+
maxExpectedWaitMs?: number;
|
|
209
222
|
}
|
|
210
223
|
|
|
211
224
|
export interface ClassifierConfig {
|
|
@@ -229,6 +242,13 @@ export interface ClassifierConfig {
|
|
|
229
242
|
chatAxis: QualityAxis;
|
|
230
243
|
/** Tool-loop depth above which the agentic axis takes over. */
|
|
231
244
|
agenticLoopDepth: number;
|
|
245
|
+
/**
|
|
246
|
+
* Fraction of the failed-tool weight that survives when the turn is a
|
|
247
|
+
* mechanical tool-result continuation. A retry after a failed tool call is
|
|
248
|
+
* the most mechanical turn there is; the flat weight let automated retry
|
|
249
|
+
* loops buy the hard tier. 1 preserves the shipped behaviour.
|
|
250
|
+
*/
|
|
251
|
+
mechanicalRetryFactor: number;
|
|
232
252
|
/**
|
|
233
253
|
* Score added when the CLIENT asks for a reasoning effort, per level. The
|
|
234
254
|
* premise is that asking for reasoning states expected difficulty directly.
|
package/src/router/candidates.ts
CHANGED
|
@@ -71,6 +71,12 @@ const UNMEASURED_TRUST = 0.9;
|
|
|
71
71
|
/** Excess-ratio cap so one very slow model cannot be penalised into oblivion. */
|
|
72
72
|
const LATENCY_EXCESS_CAP = 3;
|
|
73
73
|
|
|
74
|
+
/** Expected total wait: time to first token plus streaming the expected completion at measured throughput. */
|
|
75
|
+
function expectedWaitMs(latency: ModelLatency, expectedCompletionTokens: number): number {
|
|
76
|
+
const streamMs = latency.tokensPerSec > 0 ? (expectedCompletionTokens / latency.tokensPerSec) * 1000 : 0;
|
|
77
|
+
return latency.ttftMs + streamMs;
|
|
78
|
+
}
|
|
79
|
+
|
|
74
80
|
/**
|
|
75
81
|
* Latency penalty as a multiplier on effective cost (>= 1; 1 = no penalty).
|
|
76
82
|
*
|
|
@@ -81,13 +87,15 @@ const LATENCY_EXCESS_CAP = 3;
|
|
|
81
87
|
* model of equal quality and price outranks a sluggish one. Capturing throughput,
|
|
82
88
|
* not just TTFT, is what catches a model that starts fast but streams slowly
|
|
83
89
|
* (deepseek-v4-flash: ~2s TTFT yet ~20 tok/s → ~38s total). Inert when the weight
|
|
84
|
-
* is 0 or the model has too few streamed samples to judge
|
|
85
|
-
*
|
|
90
|
+
* is 0 or the model has too few streamed samples to judge.
|
|
91
|
+
*
|
|
92
|
+
* The penalty CANNOT discipline a slow-but-cheap model: it is multiplicative on a
|
|
93
|
+
* tiny cost and capped at LATENCY_EXCESS_CAP, so the model stays cheapest. That is
|
|
94
|
+
* `filters.maxExpectedWaitMs`'s job — a hard drop, applied in buildCandidates.
|
|
86
95
|
*/
|
|
87
96
|
function latencyMultiplier(latency: ModelLatency | null, filters: FilterConfig, expectedCompletionTokens: number): number {
|
|
88
97
|
if (latency === null || filters.latencyWeight <= 0 || latency.samples < filters.latencyMinSamples) return 1;
|
|
89
|
-
const
|
|
90
|
-
const waitMs = latency.ttftMs + streamMs;
|
|
98
|
+
const waitMs = expectedWaitMs(latency, expectedCompletionTokens);
|
|
91
99
|
const refWaitMs = filters.latencyReferenceMs + (expectedCompletionTokens / filters.latencyReferenceTokensPerSec) * 1000;
|
|
92
100
|
const excess = refWaitMs > 0 ? Math.max(0, (waitMs - refWaitMs) / refWaitMs) : 0;
|
|
93
101
|
return 1 + filters.latencyWeight * Math.min(excess, LATENCY_EXCESS_CAP);
|
|
@@ -233,6 +241,33 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
233
241
|
continue;
|
|
234
242
|
}
|
|
235
243
|
|
|
244
|
+
// Fetch latency ONCE for both the ceiling gate here and the scoring
|
|
245
|
+
// multiplier below. Absolute latency ceiling: a hard drop, mirroring the
|
|
246
|
+
// price ceiling, for models PROVEN slow (>= latencyMinSamples). The penalty
|
|
247
|
+
// alone cannot demote a slow-but-cheap model (see latencyMultiplier); this
|
|
248
|
+
// gate can. Only measured models are dropped, so a new model still gets its
|
|
249
|
+
// cold-start turns to accumulate samples. Relaxed with trust in rescue.
|
|
250
|
+
const needLatency = filters.latencyWeight > 0 || filters.maxExpectedWaitMs !== undefined;
|
|
251
|
+
const latency = needLatency
|
|
252
|
+
? (ledger?.latency(slug, filters.trustScopedByHarness ? req.harnessId : undefined) ?? null)
|
|
253
|
+
: null;
|
|
254
|
+
if (
|
|
255
|
+
!relaxTrust &&
|
|
256
|
+
filters.maxExpectedWaitMs !== undefined &&
|
|
257
|
+
latency !== null &&
|
|
258
|
+
latency.samples >= filters.latencyMinSamples
|
|
259
|
+
) {
|
|
260
|
+
const waitMs = expectedWaitMs(latency, expectedCompletionTokens);
|
|
261
|
+
if (waitMs > filters.maxExpectedWaitMs) {
|
|
262
|
+
rejected.push({
|
|
263
|
+
slug,
|
|
264
|
+
reason: "over_latency_ceiling",
|
|
265
|
+
detail: `expected wait ${Math.round(waitMs)}ms > ceiling ${filters.maxExpectedWaitMs}ms (ttft ${Math.round(latency.ttftMs)}ms, ${latency.tokensPerSec.toFixed(0)} tok/s over ${latency.samples} samples)`,
|
|
266
|
+
});
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
236
271
|
// Every candidate is priced COLD, deliberately, and this has been measured
|
|
237
272
|
// rather than assumed. Two reasons:
|
|
238
273
|
// 1. `coldUsd` feeds the budget guard in select.ts, and a budget must
|
|
@@ -259,10 +294,6 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
259
294
|
// 20% of the time really costs ~25% more in retries. Latency does the same
|
|
260
295
|
// for slowness (TTFT over the reference). qualityExponent 0 makes this
|
|
261
296
|
// "cheapest above the floor"; the floor does the quality work.
|
|
262
|
-
const latency =
|
|
263
|
-
filters.latencyWeight > 0
|
|
264
|
-
? (ledger?.latency(slug, filters.trustScopedByHarness ? req.harnessId : undefined) ?? null)
|
|
265
|
-
: null;
|
|
266
297
|
const latencyMult = latencyMultiplier(latency, filters, expectedCompletionTokens);
|
|
267
298
|
const effectiveUsd = (fc.expectedUsd / Math.max(trustScore, 0.5)) * latencyMult;
|
|
268
299
|
// Score is assigned in a SECOND PASS below: both qualityNormalization and
|
package/src/router/classify.ts
CHANGED
|
@@ -118,7 +118,21 @@ export function scoreHeuristic(f: Features, cfg: RouterConfig): Classification {
|
|
|
118
118
|
Math.max(f.trivialityKeywords.length * W_TRIVIALITY_KEYWORD, CAP_TRIVIALITY),
|
|
119
119
|
`triviality keywords [${f.trivialityKeywords.join(", ")}]`,
|
|
120
120
|
);
|
|
121
|
-
if (f.lastToolFailed)
|
|
121
|
+
if (f.lastToolFailed) {
|
|
122
|
+
// A retry after a failed tool call is the MOST mechanical turn there is:
|
|
123
|
+
// no new user intent, same prompt prefix, the harness just re-asks. The
|
|
124
|
+
// flat +0.26 let an automated retry loop buy the hard tier ($7.02 of one
|
|
125
|
+
// measured day vs $0.19 for the same rows as moderate picks). A
|
|
126
|
+
// continuation keeps only a small nudge; a genuine user-visible failure
|
|
127
|
+
// (NOT a tool-result continuation) keeps the full weight.
|
|
128
|
+
const failed = f.isToolResultContinuation ? W_TOOL_FAILED * cfg.classifier.mechanicalRetryFactor : W_TOOL_FAILED;
|
|
129
|
+
add(
|
|
130
|
+
failed,
|
|
131
|
+
f.isToolResultContinuation
|
|
132
|
+
? `last tool result failed (mechanical retry, damped x${cfg.classifier.mechanicalRetryFactor})`
|
|
133
|
+
: "last tool result failed",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
122
136
|
if (f.circularToolCall) add(W_CIRCULAR_LOOP, "circular tool call (re-issued a prior call; stuck)");
|
|
123
137
|
const rw = reasoningWeight(f.requestedReasoning, cfg);
|
|
124
138
|
if (rw > 0) add(rw, `client requested reasoning=${f.requestedReasoning ?? ""}`);
|
package/src/router/types.ts
CHANGED
package/test/classify.test.ts
CHANGED
|
@@ -269,9 +269,30 @@ describe("scoreHeuristic", () => {
|
|
|
269
269
|
expect(deepCircular.tier).toBe("hard");
|
|
270
270
|
});
|
|
271
271
|
|
|
272
|
-
test("a failing tool result on a deep loop is at least
|
|
272
|
+
test("a failing tool result on a deep loop is at least simple", () => {
|
|
273
|
+
// Was 'at least moderate' before the mechanical-retry damp: the flat
|
|
274
|
+
// +0.26 pushed deep mechanical retry loops into hard. A damped retry
|
|
275
|
+
// still clears trivial.
|
|
273
276
|
const deepAndFailing = scoreHeuristic(contFeatures(20, { lastToolFailed: true }), BASE);
|
|
274
|
-
expect(tierIdx(deepAndFailing.tier)).toBeGreaterThanOrEqual(tierIdx("
|
|
277
|
+
expect(tierIdx(deepAndFailing.tier)).toBeGreaterThanOrEqual(tierIdx("simple"));
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("a failed-tool retry on a mechanical continuation is damped, not hard", () => {
|
|
281
|
+
// A retry after a failed tool call is the most mechanical turn there is;
|
|
282
|
+
// the flat +0.26 let automated retry loops buy the hard tier ($7.02 of one
|
|
283
|
+
// measured day vs $0.19 for the same rows as moderate picks). The
|
|
284
|
+
// continuation keeps only mechanicalRetryFactor of the weight.
|
|
285
|
+
const retry = scoreHeuristic(contFeatures(20, { lastToolFailed: true }), BASE);
|
|
286
|
+
const quiet = scoreHeuristic(contFeatures(20), BASE);
|
|
287
|
+
expect(tierIdx(retry.tier)).toBeLessThan(tierIdx("hard"));
|
|
288
|
+
expect(retry.score - quiet.score).toBeCloseTo(
|
|
289
|
+
BASE.classifier.mechanicalRetryFactor * 0.26,
|
|
290
|
+
5,
|
|
291
|
+
);
|
|
292
|
+
// A failure the USER sees (not a tool-result continuation) keeps the full
|
|
293
|
+
// weight: that genuinely changes what the turn needs.
|
|
294
|
+
const userSeen = scoreHeuristic(contFeatures(2, { isToolResultContinuation: false, lastToolFailed: true }), BASE);
|
|
295
|
+
expect(userSeen.score - scoreHeuristic(contFeatures(2, { isToolResultContinuation: false }), BASE).score).toBeCloseTo(0.26, 5);
|
|
275
296
|
});
|
|
276
297
|
});
|
|
277
298
|
|
package/test/failover.test.ts
CHANGED
|
@@ -55,6 +55,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
55
55
|
toolAxis: "coding",
|
|
56
56
|
chatAxis: "intelligence",
|
|
57
57
|
agenticLoopDepth: 3,
|
|
58
|
+
mechanicalRetryFactor: 0.2,
|
|
58
59
|
reasoningWeights: { medium: 0.14, high: 0.24, xhigh: 0.3, max: 0.34 },
|
|
59
60
|
},
|
|
60
61
|
escalation: {
|
package/test/select.test.ts
CHANGED
|
@@ -594,6 +594,31 @@ describe("latency scoring", () => {
|
|
|
594
594
|
const d = run({ tier: "simple", cfg: withWeight(2), ledger });
|
|
595
595
|
expect(d.slug).not.toBe(slow);
|
|
596
596
|
});
|
|
597
|
+
|
|
598
|
+
const withCeiling = (maxExpectedWaitMs: number, latencyWeight = 0): RouterConfig => ({
|
|
599
|
+
...BASE,
|
|
600
|
+
filters: { ...BASE.filters, latencyWeight, latencyReferenceMs: 5000, latencyMinSamples: 20, maxExpectedWaitMs },
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test("ceiling hard-drops a proven-slow model the penalty cannot, even at weight 0", () => {
|
|
604
|
+
const slow = run({ tier: "simple" }).slug;
|
|
605
|
+
const ledger = ledgerWithLatency({ [slow]: { ttftMs: 60_000, samples: 50 } });
|
|
606
|
+
// latencyWeight 0 → the multiplier is inert; only the hard ceiling can act.
|
|
607
|
+
const d = run({ tier: "simple", cfg: withCeiling(20_000), ledger });
|
|
608
|
+
expect(d.slug).not.toBe(slow);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test("ceiling spares an under-sampled slow model (cold-start grace)", () => {
|
|
612
|
+
const slow = run({ tier: "simple" }).slug;
|
|
613
|
+
const ledger = ledgerWithLatency({ [slow]: { ttftMs: 60_000, samples: 5 } });
|
|
614
|
+
expect(run({ tier: "simple", cfg: withCeiling(20_000), ledger }).slug).toBe(slow);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
test("ceiling unset ⇒ no latency gate (proven-slow model still wins on price)", () => {
|
|
618
|
+
const slow = run({ tier: "simple" }).slug;
|
|
619
|
+
const ledger = ledgerWithLatency({ [slow]: { ttftMs: 60_000, samples: 50 } });
|
|
620
|
+
expect(run({ tier: "simple", cfg: withWeight(0), ledger }).slug).toBe(slow);
|
|
621
|
+
});
|
|
597
622
|
});
|
|
598
623
|
|
|
599
624
|
describe("context compaction", () => {
|
package/test/turn.test.ts
CHANGED
|
@@ -56,6 +56,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
56
56
|
toolAxis: "coding",
|
|
57
57
|
chatAxis: "intelligence",
|
|
58
58
|
agenticLoopDepth: 3,
|
|
59
|
+
mechanicalRetryFactor: 0.2,
|
|
59
60
|
reasoningWeights: { medium: 0.14, high: 0.24, xhigh: 0.3, max: 0.34 },
|
|
60
61
|
},
|
|
61
62
|
escalation: {
|
package/tools/build-site.ts
CHANGED
|
@@ -298,6 +298,7 @@ const configBody = `<h2>Configuration reference</h2>
|
|
|
298
298
|
<dt>filters.minTrust / minTrustSamples</dt><dd>Demote models whose measured reliability falls below the floor once enough samples exist. <span class="default">Default: 0.7 over 12 samples.</span></dd>
|
|
299
299
|
<dt>filters.contextHeadroom</dt><dd>Require a context window this multiple of the estimated prompt. <span class="default">Default: 1.25.</span></dd>
|
|
300
300
|
<dt>filters.latencyWeight</dt><dd>Inflate a model's effective cost by expected wait (TTFT + completion time). <span class="default">Default: 0 (off) \u2014 opt in after establishing a baseline.</span></dd>
|
|
301
|
+
<dt>filters.maxExpectedWaitMs</dt><dd>Absolute expected-wait ceiling: a hard drop for models <em>proven</em> slower than this (≥ latencyMinSamples), regardless of price \u2014 the soft penalty above is multiplicative and capped, so it cannot demote a slow-but-cheap model. New models keep their cold-start turns. <span class="default">Default: unset (off).</span></dd>
|
|
301
302
|
</dl>
|
|
302
303
|
|
|
303
304
|
<h3>escalation \u2014 mid-stream recovery</h3>
|