auto-model-router 0.4.12 → 0.4.13

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.4.12",
10
+ "version": "0.4.13",
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.4.12",
17
+ "version": "0.4.13",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -1178,6 +1178,24 @@ text, not the conversation, so a hard task that only becomes hard three tool
1178
1178
  calls in stays on the router (the router's own escalation still applies
1179
1179
  there); and the switch happens at prompt boundaries, never mid-turn.
1180
1180
 
1181
+ ## Per-request routing policy
1182
+
1183
+ A front door in front of the router (the team edition, or any proxy that
1184
+ knows who is calling) can constrain one turn with an `X-Omp-Policy` header
1185
+ carrying JSON:
1186
+
1187
+ ```json
1188
+ { "allow": ["anthropic/*", "google/*"], "deny": ["openai/gpt-5-pro"], "minTier": "simple", "maxTier": "moderate", "pin": "anthropic/claude-sonnet-5" }
1189
+ ```
1190
+
1191
+ `allow` and `deny` are slug globs like `filters.allow`/`filters.deny`: a
1192
+ request allow list replaces the configured one, a deny list adds to it.
1193
+ `minTier`/`maxTier` narrow the requested profile's tier envelope and never
1194
+ widen it. `pin` forces one model the way `/router pin` does, unless a session
1195
+ override already pinned one. Every field is optional; a malformed header is
1196
+ ignored rather than failing the turn. The decision trail records what the
1197
+ policy changed (`policy: …`).
1198
+
1181
1199
  ## Multiple coding harnesses, one router
1182
1200
 
1183
1201
  **One router process for everything.** omp's embed extension binds a private
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.4.12",
3
+ "version": "0.4.13",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
package/src/lib.ts CHANGED
@@ -20,4 +20,6 @@ export { buildUsageReport, renderUsageReport, type UsageReport, type ReportTotal
20
20
  export { buildDailySummary, renderDailySummary, type DailySummary } from "./cost/summary.ts";
21
21
  export { openDb } from "./util/sqlite.ts";
22
22
  export { createLedger } from "./cost/ledger.ts";
23
+ export { createFeedbackStore, type FeedbackStore, type FeedbackRecord } from "./cost/feedback.ts";
24
+ export type { RequestPolicy } from "./wire/types.ts";
23
25
  export type { Ledger, LedgerEntry } from "./cost/types.ts";
@@ -15,7 +15,7 @@ import type { ProfileConfig, RouterConfig } from "../config/types.ts";
15
15
  import type { Ledger } from "../cost/types.ts";
16
16
  import { estimatePromptTokens } from "../tokens/estimate.ts";
17
17
  import type { UpstreamClient } from "../upstream/types.ts";
18
- import type { NormRequest } from "../wire/types.ts";
18
+ import type { NormRequest, RequestPolicy } from "../wire/types.ts";
19
19
  import { classify, classifyTask } from "./classify.ts";
20
20
  import { extractFeatures } from "./features.ts";
21
21
  import { select } from "./select.ts";
@@ -38,6 +38,43 @@ export interface RouterDeps {
38
38
  */
39
39
  const NEUTRAL_TOKENIZER = "gpt";
40
40
 
41
+ const TIER_RANK: Record<string, number> = { trivial: 0, simple: 1, moderate: 2, hard: 3 };
42
+
43
+ /**
44
+ * Applies a request policy on top of the resolved profile and config: the
45
+ * tier envelope is narrowed (never widened), a request allow list replaces
46
+ * the configured one, a request deny list adds to it, and a pin becomes a
47
+ * forced slug unless a session override already forced one.
48
+ */
49
+ export function applyRequestPolicy(
50
+ profile: ProfileConfig,
51
+ cfg: RouterConfig,
52
+ policy: RequestPolicy | undefined,
53
+ forceSlug: string | undefined,
54
+ ): { profile: ProfileConfig; cfg: RouterConfig; forceSlug: string | undefined; reasons: string[] } {
55
+ if (policy === undefined) return { profile, cfg, forceSlug, reasons: [] };
56
+ const reasons: string[] = [];
57
+ let minTier = profile.minTier;
58
+ let maxTier = profile.maxTier;
59
+ if (policy.minTier !== undefined && TIER_RANK[policy.minTier]! > TIER_RANK[minTier]!) minTier = policy.minTier;
60
+ if (policy.maxTier !== undefined && TIER_RANK[policy.maxTier]! < TIER_RANK[maxTier]!) maxTier = policy.maxTier;
61
+ if (TIER_RANK[minTier]! > TIER_RANK[maxTier]!) minTier = maxTier;
62
+ const narrowed = minTier !== profile.minTier || maxTier !== profile.maxTier;
63
+ const outProfile = narrowed ? { ...profile, id: `${profile.id}+policy`, minTier, maxTier } : profile;
64
+ if (narrowed) reasons.push(`policy: tiers narrowed to [${minTier}..${maxTier}]`);
65
+ let outCfg = cfg;
66
+ if (policy.allow !== undefined || policy.deny !== undefined) {
67
+ outCfg = { ...cfg, filters: { ...cfg.filters, ...(policy.allow === undefined ? {} : { allow: policy.allow }), ...(policy.deny === undefined ? {} : { deny: [...cfg.filters.deny, ...policy.deny] }) } };
68
+ reasons.push(`policy: ${policy.allow === undefined ? "" : `allow ${policy.allow.join("|")} `}${policy.deny === undefined ? "" : `deny ${policy.deny.join("|")}`}`.trim());
69
+ }
70
+ let outForce = forceSlug;
71
+ if (forceSlug === undefined && policy.pin !== undefined) {
72
+ outForce = policy.pin;
73
+ reasons.push(`policy: pinned to ${policy.pin}`);
74
+ }
75
+ return { profile: outProfile, cfg: outCfg, forceSlug: outForce, reasons };
76
+ }
77
+
41
78
  export function resolveProfile(cfg: RouterConfig, requestedModel: string, isSubagent = false): ProfileConfig {
42
79
  const fallback = cfg.profiles[0];
43
80
  if (fallback === undefined) throw new Error("no router profiles configured");
@@ -99,19 +136,22 @@ export function createRouter(deps: RouterDeps): Router {
99
136
  classification = await classify(req, features, config, { upstream, ledger, catalog });
100
137
  }
101
138
 
102
- return select({
139
+ const policed = applyRequestPolicy(resolveProfile(config, req.requestedModel, req.isSubagent), config, req.policy, opts.forceSlug);
140
+ const decision = select({
103
141
  req,
104
142
  features,
105
143
  classification,
106
- profile: resolveProfile(config, req.requestedModel, req.isSubagent),
144
+ profile: policed.profile,
107
145
  state,
108
146
  snapshot,
109
147
  ledger,
110
- cfg: config,
148
+ cfg: policed.cfg,
111
149
  nowMs: Date.now(),
112
150
  ...(opts.excludeSlugs === undefined ? {} : { excludeSlugs: opts.excludeSlugs }),
113
- ...(opts.forceSlug === undefined ? {} : { forceSlug: opts.forceSlug }),
151
+ ...(policed.forceSlug === undefined ? {} : { forceSlug: policed.forceSlug }),
114
152
  });
153
+ if (policed.reasons.length > 0) decision.reasons.unshift(...policed.reasons);
154
+ return decision;
115
155
  },
116
156
  };
117
157
  }
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ RequestPolicy,
2
3
  CompactionEdit,
3
4
  NormMessage,
4
5
  NormRequest,
@@ -285,6 +286,34 @@ function renderUpstreamBody(
285
286
  return body;
286
287
  }
287
288
 
289
+ const TIER_NAMES = new Set(["trivial", "simple", "moderate", "hard"]);
290
+
291
+ /** Parses the X-Omp-Policy header; malformed or empty ⇒ no policy (never a rejected turn). */
292
+ export function parsePolicyHeader(raw: string | null): RequestPolicy | undefined {
293
+ if (raw === null || raw.trim() === "") return undefined;
294
+ let parsed: unknown;
295
+ try {
296
+ parsed = JSON.parse(raw);
297
+ } catch {
298
+ return undefined;
299
+ }
300
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
301
+ const p = parsed as Record<string, unknown>;
302
+ const strs = (v: unknown): string[] | undefined => (Array.isArray(v) ? v.filter((s): s is string => typeof s === "string" && s.trim() !== "").map((s) => s.trim()) : undefined);
303
+ const tier = (v: unknown): RequestPolicy["minTier"] => (typeof v === "string" && TIER_NAMES.has(v) ? (v as RequestPolicy["minTier"]) : undefined);
304
+ const out: RequestPolicy = {};
305
+ const allow = strs(p.allow);
306
+ const deny = strs(p.deny);
307
+ if (allow !== undefined && allow.length > 0) out.allow = allow;
308
+ if (deny !== undefined && deny.length > 0) out.deny = deny;
309
+ const min = tier(p.minTier);
310
+ const max = tier(p.maxTier);
311
+ if (min !== undefined) out.minTier = min;
312
+ if (max !== undefined) out.maxTier = max;
313
+ if (typeof p.pin === "string" && p.pin.trim() !== "") out.pin = p.pin.trim();
314
+ return Object.keys(out).length === 0 ? undefined : out;
315
+ }
316
+
288
317
  export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
289
318
  if (typeof body !== "object" || body === null || Array.isArray(body)) {
290
319
  throw invalidRequest("Request body must be a JSON object");
@@ -306,6 +335,9 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
306
335
  // Subagent marker from the embed extension (sessions without a UI).
307
336
  const isSubagent = (headers.get("x-omp-subagent") ?? "").trim() === "1";
308
337
 
338
+ // Per-request routing policy (team edition): JSON in X-Omp-Policy.
339
+ const policy = parsePolicyHeader(headers.get("x-omp-policy"));
340
+
309
341
  if (typeof b.model !== "string" || b.model.length === 0) {
310
342
  throw invalidRequest("model must be a non-empty string");
311
343
  }
@@ -367,6 +399,7 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
367
399
  ompSessionId,
368
400
  agentdoxScope,
369
401
  isSubagent,
402
+ ...(policy === undefined ? {} : { policy }),
370
403
  requestedModel,
371
404
  messages,
372
405
  tools,
package/src/wire/types.ts CHANGED
@@ -12,6 +12,20 @@ import type { UsageCounts } from "../cost/types.ts";
12
12
 
13
13
  export type WireProtocol = "openai-chat" | "openai-responses" | "pi-native";
14
14
 
15
+ /**
16
+ * A routing policy attached to one request. `allow`/`deny` are slug globs
17
+ * like `filters.allow`/`filters.deny` (a request allow list replaces the
18
+ * configured one; a deny list adds to it); `minTier`/`maxTier` narrow the
19
+ * profile's tier envelope; `pin` forces one slug, like `/router pin`.
20
+ */
21
+ export interface RequestPolicy {
22
+ allow?: string[];
23
+ deny?: string[];
24
+ minTier?: "trivial" | "simple" | "moderate" | "hard";
25
+ maxTier?: "trivial" | "simple" | "moderate" | "hard";
26
+ pin?: string;
27
+ }
28
+
15
29
  export type Role = "system" | "developer" | "user" | "assistant" | "tool";
16
30
 
17
31
  /** One tool call requested by an assistant turn. */
@@ -84,6 +98,12 @@ export interface NormRequest {
84
98
  agentdoxScope: string;
85
99
  /** `X-Omp-Subagent: 1`: the caller is an omp subagent (a session without a UI). */
86
100
  isSubagent: boolean;
101
+ /**
102
+ * Per-request routing policy from the `X-Omp-Policy` header (JSON), set by
103
+ * a front door such as the team edition: narrows what this turn may route
104
+ * to. Absent ⇒ the configured profile and filters alone.
105
+ */
106
+ policy?: RequestPolicy;
87
107
  /** Virtual model the client selected, e.g. `auto`, `auto-cheap`, `auto-max`. */
88
108
  requestedModel: string;
89
109
  messages: NormMessage[];
@@ -0,0 +1,56 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
4
+ import { applyRequestPolicy, resolveProfile } from "../src/router/index.ts";
5
+ import { parseChatRequest, parsePolicyHeader } from "../src/wire/openai/request.ts";
6
+
7
+ /**
8
+ * The per-request routing policy (X-Omp-Policy): parsed defensively from the
9
+ * header, then applied on top of the profile and filters — tiers only narrow,
10
+ * an allow list replaces the configured one, a deny list adds to it, and a
11
+ * pin forces a slug unless a session override already did.
12
+ */
13
+
14
+ describe("parsePolicyHeader", () => {
15
+ test("accepts the documented fields, drops junk, and never rejects a turn", () => {
16
+ expect(parsePolicyHeader(null)).toBeUndefined();
17
+ expect(parsePolicyHeader("not json")).toBeUndefined();
18
+ expect(parsePolicyHeader("[]")).toBeUndefined();
19
+ expect(parsePolicyHeader("{}")).toBeUndefined();
20
+ expect(parsePolicyHeader(JSON.stringify({ allow: ["anthropic/*", " x/y "], deny: [1, "", "openai/*"], minTier: "simple", maxTier: "nope", pin: " z/w " }))).toEqual({ allow: ["anthropic/*", "x/y"], deny: ["openai/*"], minTier: "simple", pin: "z/w" });
21
+ const req = parseChatRequest({ model: "auto", messages: [{ role: "user", content: "hi" }] }, new Headers({ "X-Omp-Policy": '{"maxTier":"moderate"}' }));
22
+ expect(req.policy).toEqual({ maxTier: "moderate" });
23
+ expect("policy" in parseChatRequest({ model: "auto", messages: [{ role: "user", content: "hi" }] }, new Headers())).toBe(false);
24
+ });
25
+ });
26
+
27
+ describe("applyRequestPolicy", () => {
28
+ const cfg = DEFAULT_CONFIG;
29
+ const profile = resolveProfile(cfg, "auto");
30
+
31
+ test("narrows the tier envelope, never widens it", () => {
32
+ const r = applyRequestPolicy(profile, cfg, { maxTier: "moderate", minTier: "trivial" }, undefined);
33
+ expect(r.profile.maxTier).toBe("moderate");
34
+ expect(r.profile.minTier).toBe(profile.minTier);
35
+ expect(r.profile.id).toBe(`${profile.id}+policy`);
36
+ expect(r.reasons[0]).toContain("tiers narrowed");
37
+ // A cheap profile cannot be raised past its own ceiling.
38
+ const cheap = resolveProfile(cfg, "auto-cheap");
39
+ const up = applyRequestPolicy(cheap, cfg, { minTier: "hard" }, undefined);
40
+ expect(up.profile.minTier).toBe(cheap.maxTier);
41
+ expect(up.profile.maxTier).toBe(cheap.maxTier);
42
+ });
43
+
44
+ test("allow replaces, deny adds, and a pin forces unless a session override already did", () => {
45
+ const base = { ...cfg, filters: { ...cfg.filters, allow: ["x/*"], deny: ["bad/*"] } };
46
+ const r = applyRequestPolicy(profile, base, { allow: ["anthropic/*"], deny: ["openai/*"], pin: "anthropic/claude-sonnet-5" }, undefined);
47
+ expect(r.cfg.filters.allow).toEqual(["anthropic/*"]);
48
+ expect(r.cfg.filters.deny).toEqual(["bad/*", "openai/*"]);
49
+ expect(r.forceSlug).toBe("anthropic/claude-sonnet-5");
50
+ expect(r.profile).toBe(profile); // tiers untouched ⇒ same object
51
+ expect(applyRequestPolicy(profile, base, { pin: "a/b" }, "session/pin").forceSlug).toBe("session/pin");
52
+ expect(applyRequestPolicy(profile, base, undefined, undefined)).toEqual({ profile, cfg: base, forceSlug: undefined, reasons: [] });
53
+ // Untouched config object when the policy carries no filters.
54
+ expect(applyRequestPolicy(profile, base, { maxTier: "hard" }, undefined).cfg).toBe(base);
55
+ });
56
+ });