auto-model-router 0.35.0 → 0.36.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/config/defaults.ts +1 -0
- package/src/config/types.ts +9 -0
- package/src/router/candidates.ts +17 -0
- package/src/router/index.ts +4 -0
- package/src/router/types.ts +2 -0
- package/src/server/catalog-view.ts +8 -2
- package/src/wire/types.ts +6 -0
- package/test/catalog-view.test.ts +11 -0
- package/test/failover.test.ts +1 -1
- package/test/tier-plan.test.ts +51 -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.36.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.36.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/package.json
CHANGED
package/src/config/defaults.ts
CHANGED
package/src/config/types.ts
CHANGED
|
@@ -226,6 +226,15 @@ export interface FilterConfig {
|
|
|
226
226
|
allow: string[];
|
|
227
227
|
/** Glob patterns; matching models are dropped. Applied after `allow`. */
|
|
228
228
|
deny: string[];
|
|
229
|
+
/**
|
|
230
|
+
* Model-glob → provider-glob. A model matching a key may only dispatch
|
|
231
|
+
* through a provider whose id matches the value, so a team can keep a
|
|
232
|
+
* subscription's models on that subscription instead of its OpenRouter
|
|
233
|
+
* twins (`{"anthropic/*": "anthropic-subscription"}`). Applied before
|
|
234
|
+
* ranking; a model matching several locks must satisfy each. An empty
|
|
235
|
+
* object locks nothing.
|
|
236
|
+
*/
|
|
237
|
+
providerLocks: Record<string, string>;
|
|
229
238
|
/** Consider zero-price models. Off by default: rate limits make them expensive in retries. */
|
|
230
239
|
includeFree: boolean;
|
|
231
240
|
/** Require `supported_parameters` to include `tools` whenever the request offers tools. */
|
package/src/router/candidates.ts
CHANGED
|
@@ -164,6 +164,12 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
164
164
|
const relaxTrust = relaxLevel >= 3;
|
|
165
165
|
const allowRes = filters.allow.map(globToRe);
|
|
166
166
|
const denyRes = filters.deny.map(globToRe);
|
|
167
|
+
// Compiled once per turn: pairs of [model glob, provider glob] regexes.
|
|
168
|
+
// Null when there are no locks, so the common path allocates nothing.
|
|
169
|
+
const lockRes =
|
|
170
|
+
filters.providerLocks && Object.keys(filters.providerLocks).length > 0
|
|
171
|
+
? Object.entries(filters.providerLocks).map(([m, p]) => [globToRe(m), globToRe(p)] as const)
|
|
172
|
+
: null;
|
|
167
173
|
const needTools = req.tools.length > 0 && filters.requireToolSupport;
|
|
168
174
|
const minContext = Math.ceil(features.promptTokens * filters.contextHeadroom) + expectedCompletionTokens;
|
|
169
175
|
// Task selects the quality axis and capability filters; the tier still
|
|
@@ -237,6 +243,17 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
237
243
|
rejected.push({ slug, reason: "denylisted", detail: "filters.deny" });
|
|
238
244
|
continue;
|
|
239
245
|
}
|
|
246
|
+
// Provider locks: where a model may be served from. Checked like a
|
|
247
|
+
// filter (a lock the model cannot satisfy drops it before ranking)
|
|
248
|
+
// rather than at dispatch, so the catalog view, the rejection list and
|
|
249
|
+
// the turn all agree on what was available.
|
|
250
|
+
if (lockRes !== null) {
|
|
251
|
+
const violated = lockRes.find(([modelRe, providerRe]) => modelRe.test(slug) && !providerRe.test(model.provider));
|
|
252
|
+
if (violated !== undefined) {
|
|
253
|
+
rejected.push({ slug, reason: "provider_locked", detail: `${slug} is locked to providers matching "${violated[1].source}"` });
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
240
257
|
if (model.isFree && !filters.includeFree) {
|
|
241
258
|
rejected.push({ slug, reason: "free_tier_excluded" });
|
|
242
259
|
continue;
|
package/src/router/index.ts
CHANGED
|
@@ -65,6 +65,10 @@ export function applyRequestPolicy(
|
|
|
65
65
|
const outProfile = narrowed ? { ...profile, id: `${profile.id}+policy`, minTier, maxTier } : profile;
|
|
66
66
|
if (narrowed) reasons.push(`policy: tiers narrowed to [${minTier}..${maxTier}]`);
|
|
67
67
|
let outCfg = cfg;
|
|
68
|
+
if (policy.providerLocks !== undefined) {
|
|
69
|
+
outCfg = { ...cfg, filters: { ...cfg.filters, providerLocks: { ...cfg.filters.providerLocks, ...policy.providerLocks } } };
|
|
70
|
+
reasons.push(`policy: provider locks ${Object.entries(policy.providerLocks).map(([m, p]) => `${m}→${p}`).join(" ")}`);
|
|
71
|
+
}
|
|
68
72
|
if (policy.allow !== undefined || policy.deny !== undefined) {
|
|
69
73
|
outCfg = { ...cfg, filters: { ...cfg.filters, ...(policy.allow === undefined ? {} : { allow: policy.allow }), ...(policy.deny === undefined ? {} : { deny: [...cfg.filters.deny, ...policy.deny] }) } };
|
|
70
74
|
reasons.push(`policy: ${policy.allow === undefined ? "" : `allow ${policy.allow.join("|")} `}${policy.deny === undefined ? "" : `deny ${policy.deny.join("|")}`}`.trim());
|
package/src/router/types.ts
CHANGED
|
@@ -162,6 +162,8 @@ export type RejectionReason =
|
|
|
162
162
|
| "denylisted"
|
|
163
163
|
| "not_allowlisted"
|
|
164
164
|
| "free_tier_excluded"
|
|
165
|
+
/** filters.providerLocks: the model matches a lock whose provider glob excludes its upstream. */
|
|
166
|
+
| "provider_locked"
|
|
165
167
|
| "reasoning_mandatory"
|
|
166
168
|
| "untrusted"
|
|
167
169
|
/** The turn's `X-Omp-Upstream-Keys` names this model's upstream with an empty credential: it cannot be dispatched to. */
|
|
@@ -49,7 +49,7 @@ export interface CatalogView {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
/** The filters a turn routes under: the configured ones, or those `applyRequestPolicy` merged a policy into. */
|
|
52
|
-
export type AdmissionFilters = Pick<FilterConfig, "allow" | "deny" | "includeFree" | "requireToolSupport">;
|
|
52
|
+
export type AdmissionFilters = Pick<FilterConfig, "allow" | "deny" | "providerLocks" | "includeFree" | "requireToolSupport">;
|
|
53
53
|
|
|
54
54
|
export interface CatalogViewArgs {
|
|
55
55
|
models: readonly CatalogModel[];
|
|
@@ -84,11 +84,17 @@ function filterReason(model: CatalogModel, filters: AdmissionFilters, allowRes:
|
|
|
84
84
|
if (allowRes.length > 0 && !allowRes.some((re) => re.test(model.slug))) return "not in the allow list";
|
|
85
85
|
const denied = denyRes.findIndex((re) => re.test(model.slug));
|
|
86
86
|
if (denied !== -1) return `denied by ${filters.deny[denied]}`;
|
|
87
|
+
if (filters.providerLocks) {
|
|
88
|
+
for (const [modelGlob, providerGlob] of Object.entries(filters.providerLocks)) {
|
|
89
|
+
if (globToRe(modelGlob).test(model.slug) && !globToRe(providerGlob).test(model.provider)) {
|
|
90
|
+
return `locked to providers matching ${providerGlob} (filters.providerLocks)`;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
87
94
|
if (model.isFree && !filters.includeFree) return "free models excluded (filters.includeFree)";
|
|
88
95
|
if (filters.requireToolSupport && !model.supportsTools) return "no tool support (filters.requireToolSupport)";
|
|
89
96
|
return null;
|
|
90
97
|
}
|
|
91
|
-
|
|
92
98
|
export function catalogView(args: CatalogViewArgs): CatalogView {
|
|
93
99
|
const sorted = [...args.models].sort((a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0));
|
|
94
100
|
const verdict = args.verdict;
|
package/src/wire/types.ts
CHANGED
|
@@ -17,6 +17,11 @@ export type WireProtocol = "openai-chat" | "openai-responses" | "anthropic-messa
|
|
|
17
17
|
* like `filters.allow`/`filters.deny` (a request allow list replaces the
|
|
18
18
|
* configured one; a deny list adds to it); `minTier`/`maxTier` narrow the
|
|
19
19
|
* profile's tier envelope; `pin` forces one slug, like `/router pin`.
|
|
20
|
+
* `providerLocks` restricts WHERE a model may be served from: a model whose
|
|
21
|
+
* slug matches the key may only dispatch through a provider whose id matches
|
|
22
|
+
* the value (`{"anthropic/*": "anthropic-subscription"}` keeps Claude on the
|
|
23
|
+
* subscription upstream and away from OpenRouter's billed twins). Keys and
|
|
24
|
+
* values are slug globs; a model matching several locks must satisfy each.
|
|
20
25
|
*/
|
|
21
26
|
export interface RequestPolicy {
|
|
22
27
|
allow?: string[];
|
|
@@ -24,6 +29,7 @@ export interface RequestPolicy {
|
|
|
24
29
|
minTier?: "trivial" | "simple" | "moderate" | "hard";
|
|
25
30
|
maxTier?: "trivial" | "simple" | "moderate" | "hard";
|
|
26
31
|
pin?: string;
|
|
32
|
+
providerLocks?: Record<string, string>;
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
export type Role = "system" | "developer" | "user" | "assistant" | "tool";
|
|
@@ -137,8 +137,19 @@ describe("catalogView", () => {
|
|
|
137
137
|
// So is a pin naming no model.
|
|
138
138
|
expect(bySlug(catalogView({ models: MODELS, fetchedAtMs: 0, verdict: { filters, pin: "nobody/here" }, unserved: served })).get("anthropic/claude-sonnet-5")!.admitted).toBe(true);
|
|
139
139
|
});
|
|
140
|
+
|
|
141
|
+
test("a provider lock drops a model whose upstream cannot serve it", async () => {
|
|
142
|
+
// MODELS are all openrouter-served except vllm/ and azure-eu/; a lock
|
|
143
|
+
// confining anthropic to a named subscription upstream admits only the
|
|
144
|
+
// models that upstream actually carries.
|
|
145
|
+
const filters = { ...DEFAULT_CONFIG.filters, providerLocks: { "anthropic/*": "claude-sub" } };
|
|
146
|
+
const view = bySlug(catalogView({ models: MODELS, fetchedAtMs: 0, verdict: { filters }, unserved: served }));
|
|
147
|
+
expect(view.get("anthropic/claude-sonnet-5")).toMatchObject({ admitted: false, reason: "locked to providers matching claude-sub (filters.providerLocks)" });
|
|
148
|
+
expect(view.get("openai/gpt-5")).toMatchObject({ admitted: true }); // no key matches
|
|
149
|
+
});
|
|
140
150
|
});
|
|
141
151
|
|
|
152
|
+
|
|
142
153
|
describe("GET /v1/router/catalog", () => {
|
|
143
154
|
let handle: StartedServer;
|
|
144
155
|
let empty: StartedServer;
|
package/test/failover.test.ts
CHANGED
|
@@ -48,7 +48,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
48
48
|
data: { axis: "intelligence", minQuality: 0 },
|
|
49
49
|
chat: { axis: "intelligence", minQuality: 0 },
|
|
50
50
|
},
|
|
51
|
-
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
|
+
filters: { allow: [], deny: [], providerLocks: {}, 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 },
|
|
52
52
|
classifier: {
|
|
53
53
|
ambiguityThreshold: 0,
|
|
54
54
|
model: "test/adjudicator", learnedModelPath: "",
|
package/test/tier-plan.test.ts
CHANGED
|
@@ -404,6 +404,57 @@ describe("adaptive price ceilings", () => {
|
|
|
404
404
|
});
|
|
405
405
|
});
|
|
406
406
|
|
|
407
|
+
describe("provider locks", () => {
|
|
408
|
+
const req = parseChatRequest(
|
|
409
|
+
{
|
|
410
|
+
model: "auto",
|
|
411
|
+
messages: [{ role: "user", content: "summarize the release notes" }],
|
|
412
|
+
},
|
|
413
|
+
new Headers(),
|
|
414
|
+
);
|
|
415
|
+
const features = extractFeatures(req, 100);
|
|
416
|
+
|
|
417
|
+
// claude/opus from the subscription upstream (cheap for a subscriber) and
|
|
418
|
+
// its expensive OpenRouter twin, priced as OpenRouter really bills.
|
|
419
|
+
const mk = (slug: string, provider: string, inPerMtok: number) => ({
|
|
420
|
+
slug, canonicalSlug: slug, name: slug, provider, vendor: provider,
|
|
421
|
+
contextLength: 200_000, maxCompletionTokens: 32_000, supportsTools: true, supportsReasoning: false,
|
|
422
|
+
reasoningMandatory: false, supportsToolChoice: true, inputModalities: ["text" as const],
|
|
423
|
+
price: { prompt: inPerMtok / 1e6, completion: (inPerMtok * 3) / 1e6 }, priceTiers: [],
|
|
424
|
+
quality: { intelligence: 80, coding: 80, agentic: 60 }, tokenizer: "Other",
|
|
425
|
+
isFree: false, createdAtMs: 0, author: "vendor",
|
|
426
|
+
});
|
|
427
|
+
const snap = { models: [mk("anthropic/claude-opus", "anthropic-subscription", 0.5), mk("anthropic/claude-opus-openrouter", "openrouter", 15)], fetchedAtMs: Date.now(), keyScoped: false };
|
|
428
|
+
|
|
429
|
+
const run = (locks: Record<string, string>) =>
|
|
430
|
+
buildCandidates({
|
|
431
|
+
req, features, tier: "moderate", task: "chat", snapshot: snap,
|
|
432
|
+
cfg: { ...BASE, filters: { ...BASE.filters, providerLocks: locks } },
|
|
433
|
+
expectedCompletionTokens: 512, warmSlug: null,
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("a model matching a lock key may only come from a provider matching the value", () => {
|
|
437
|
+
const { candidates, rejected } = run({ "anthropic/*": "anthropic-subscription" });
|
|
438
|
+
expect(candidates).toHaveLength(1);
|
|
439
|
+
expect(candidates[0]!.model.slug).toBe("anthropic/claude-opus");
|
|
440
|
+
expect(candidates[0]!.model.provider).toBe("anthropic-subscription");
|
|
441
|
+
expect(rejected.some((r) => r.slug === "anthropic/claude-opus-openrouter" && r.reason === "provider_locked")).toBe(true);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("a model no key matches is untouched, and a provider glob keeps other providers serving", () => {
|
|
445
|
+
const only = { models: [mk("xai/grok", "openrouter", 2), mk("anthropic/claude-opus", "openrouter", 15)], fetchedAtMs: Date.now(), keyScoped: false };
|
|
446
|
+
const out = buildCandidates({
|
|
447
|
+
req, features, tier: "moderate", task: "chat", snapshot: only,
|
|
448
|
+
cfg: { ...BASE, filters: { ...BASE.filters, providerLocks: { "anthropic/*": "anthropic-subscription" } } },
|
|
449
|
+
expectedCompletionTokens: 512, warmSlug: null,
|
|
450
|
+
});
|
|
451
|
+
// grok matches no key: served from openrouter as usual. The claude slug
|
|
452
|
+
// matches the lock and openrouter does not match the value: dropped.
|
|
453
|
+
expect(out.candidates.map((c) => c.model.slug)).toEqual(["xai/grok"]);
|
|
454
|
+
expect(out.rejected.some((r) => r.slug === "anthropic/claude-opus" && r.reason === "provider_locked")).toBe(true);
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
407
458
|
describe("quality normalization and capability floor (benchmark findings 4/6)", () => {
|
|
408
459
|
const req = parseChatRequest(
|
|
409
460
|
{
|
package/test/turn.test.ts
CHANGED
|
@@ -51,7 +51,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
51
51
|
data: { axis: "intelligence", minQuality: 0 },
|
|
52
52
|
chat: { axis: "intelligence", minQuality: 0 },
|
|
53
53
|
},
|
|
54
|
-
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 },
|
|
54
|
+
filters: { allow: [], deny: [], providerLocks: {}, 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 },
|
|
55
55
|
classifier: {
|
|
56
56
|
ambiguityThreshold: 0,
|
|
57
57
|
model: "test/adjudicator", learnedModelPath: "",
|