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.
@@ -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.35.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.35.0",
17
+ "version": "0.36.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.35.0",
3
+ "version": "0.36.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",
@@ -105,6 +105,7 @@ export const DEFAULT_CONFIG: RouterConfig = {
105
105
  filters: {
106
106
  allow: [],
107
107
  deny: [],
108
+ providerLocks: {},
108
109
  // Free models are rate-limited hard enough that retries cost more than they save.
109
110
  includeFree: false,
110
111
  requireToolSupport: true,
@@ -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. */
@@ -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;
@@ -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());
@@ -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;
@@ -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: "",
@@ -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: "",