auto-model-router 0.16.0 → 0.18.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.
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The router's model catalog as data, with a policy's verdict per model: what
3
+ * `GET /v1/router/catalog` answers. A team front door renders it for its
4
+ * governance views ("which models can this group reach, and why not the
5
+ * rest"), so admission is decided by the SAME matcher and in the SAME order
6
+ * as `buildCandidates` — the built-in denials, then allow, deny, free and
7
+ * tool support — over the filters `applyRequestPolicy` produced, and a pin
8
+ * takes effect only when the pinned model itself survives them, exactly as
9
+ * `select` treats a forced slug. Tiers are per turn and take no part.
10
+ */
11
+
12
+ import type { CatalogModel, Modality, QualityScores } from "../catalog/types.ts";
13
+ import type { FilterConfig } from "../config/types.ts";
14
+ import { builtInDenial, globToRe } from "../router/candidates.ts";
15
+
16
+ export interface CatalogViewModel {
17
+ slug: string;
18
+ canonicalSlug: string;
19
+ name: string;
20
+ /** The upstream that serves it: `openrouter`, `ollama`, or a named upstream's id. */
21
+ provider: string;
22
+ /**
23
+ * The slug's namespace before the first `/` (`anthropic`). For a named
24
+ * upstream's `<id>/<model>` the vendor is the model id's own namespace when
25
+ * it carries one (`vllm/meta-llama/x` ⇒ `meta-llama`), else the upstream id.
26
+ */
27
+ vendor: string;
28
+ contextLength: number;
29
+ maxCompletionTokens?: number;
30
+ supportsTools: boolean;
31
+ supportsReasoning: boolean;
32
+ reasoningMandatory: boolean;
33
+ inputModalities: Modality[];
34
+ /** USD per MILLION tokens, the catalog's own units. */
35
+ price: { prompt: number; completion: number; cacheRead?: number; cacheWrite?: number };
36
+ quality: QualityScores;
37
+ isFree: boolean;
38
+ /** Present only when a policy was asked about. */
39
+ admitted?: boolean;
40
+ /** Present only when not admitted: which filter kept the model out. */
41
+ reason?: string;
42
+ }
43
+
44
+ export interface CatalogView {
45
+ /** When the catalog was last fetched; 0 before the first fetch. */
46
+ fetchedAtMs: number;
47
+ /** Sorted by slug. */
48
+ models: CatalogViewModel[];
49
+ }
50
+
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">;
53
+
54
+ export interface CatalogViewArgs {
55
+ models: readonly CatalogModel[];
56
+ fetchedAtMs: number;
57
+ /** When given, every model carries `admitted` and, if out, `reason`. */
58
+ verdict?: { filters: AdmissionFilters; pin?: string };
59
+ /** Why a provider cannot take a turn now, or null when it can. */
60
+ unserved: (provider: string) => string | null;
61
+ }
62
+
63
+ /** Per-token catalog prices as USD per million, rounded so `0.22` does not come out as `0.22000000000000003`. */
64
+ function perMillion(usdPerToken: number): number {
65
+ return Math.round(usdPerToken * 1e6 * 1e6) / 1e6;
66
+ }
67
+
68
+ export function vendorOf(model: Pick<CatalogModel, "slug" | "provider">): string {
69
+ const slash = model.slug.indexOf("/");
70
+ const head = slash === -1 ? model.slug : model.slug.slice(0, slash);
71
+ if (model.provider === "openrouter" || model.provider === "ollama" || head !== model.provider) return head;
72
+ const rest = model.slug.slice(slash + 1);
73
+ const inner = rest.indexOf("/");
74
+ return inner === -1 ? model.provider : rest.slice(0, inner);
75
+ }
76
+
77
+ /**
78
+ * Why the filters keep a model out, or null when it passes. The order is
79
+ * `buildCandidates`' so the first reason is the one a turn would record.
80
+ */
81
+ function filterReason(model: CatalogModel, filters: AdmissionFilters, allowRes: readonly RegExp[], denyRes: readonly RegExp[]): string | null {
82
+ const builtIn = builtInDenial(model);
83
+ if (builtIn !== null) return builtIn;
84
+ if (allowRes.length > 0 && !allowRes.some((re) => re.test(model.slug))) return "not in the allow list";
85
+ const denied = denyRes.findIndex((re) => re.test(model.slug));
86
+ if (denied !== -1) return `denied by ${filters.deny[denied]}`;
87
+ if (model.isFree && !filters.includeFree) return "free models excluded (filters.includeFree)";
88
+ if (filters.requireToolSupport && !model.supportsTools) return "no tool support (filters.requireToolSupport)";
89
+ return null;
90
+ }
91
+
92
+ export function catalogView(args: CatalogViewArgs): CatalogView {
93
+ const sorted = [...args.models].sort((a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0));
94
+ const verdict = args.verdict;
95
+ const allowRes = verdict === undefined ? [] : verdict.filters.allow.map(globToRe);
96
+ const denyRes = verdict === undefined ? [] : verdict.filters.deny.map(globToRe);
97
+ // One reason per slug before the pin is considered: the pin only bites when
98
+ // the pinned model itself is in, as `select` ignores a pin the filters drop.
99
+ const reasons = new Map<string, string | null>();
100
+ if (verdict !== undefined) for (const m of sorted) reasons.set(m.slug, args.unserved(m.provider) ?? filterReason(m, verdict.filters, allowRes, denyRes));
101
+ const pin = verdict?.pin !== undefined && reasons.get(verdict.pin) === null ? verdict.pin : undefined;
102
+ const models = sorted.map((m): CatalogViewModel => {
103
+ const price: CatalogViewModel["price"] = { prompt: perMillion(m.price.prompt), completion: perMillion(m.price.completion) };
104
+ if (m.price.cacheRead !== undefined) price.cacheRead = perMillion(m.price.cacheRead);
105
+ if (m.price.cacheWrite !== undefined) price.cacheWrite = perMillion(m.price.cacheWrite);
106
+ const out: CatalogViewModel = {
107
+ slug: m.slug,
108
+ canonicalSlug: m.canonicalSlug,
109
+ name: m.name,
110
+ provider: m.provider,
111
+ vendor: vendorOf(m),
112
+ contextLength: m.contextLength,
113
+ ...(m.maxCompletionTokens === undefined ? {} : { maxCompletionTokens: m.maxCompletionTokens }),
114
+ supportsTools: m.supportsTools,
115
+ supportsReasoning: m.supportsReasoning,
116
+ reasoningMandatory: m.reasoningMandatory,
117
+ inputModalities: [...m.inputModalities],
118
+ price,
119
+ quality: { ...m.quality },
120
+ isFree: m.isFree,
121
+ };
122
+ if (verdict !== undefined) {
123
+ const reason = reasons.get(m.slug) ?? (pin !== undefined && m.slug !== pin ? `pinned to ${pin}` : null);
124
+ out.admitted = reason === null;
125
+ if (reason !== null) out.reason = reason;
126
+ }
127
+ return out;
128
+ });
129
+ return { fetchedAtMs: args.fetchedAtMs, models };
130
+ }
@@ -101,6 +101,7 @@ function syntheticRequest(req: DigestRequest, promptText: string): NormRequest {
101
101
  agentdoxScope: "",
102
102
  agentdoxGroup: "",
103
103
  agentdoxPersonal: "",
104
+ agentdoxOrigin: "",
104
105
  isSubagent: true,
105
106
  requestedModel: "digest",
106
107
  messages: [
@@ -6,6 +6,10 @@ import { createBridgeFromConfig } from "../context/index.ts";
6
6
  import { createFeedbackStore, type Verdict } from "../cost/feedback.ts";
7
7
  import { createLedger } from "../cost/ledger.ts";
8
8
  import { createSessionOverrides } from "./overrides.ts";
9
+ import { catalogView } from "./catalog-view.ts";
10
+ import { buildUpstreamModels } from "../catalog/static-catalog.ts";
11
+ import { applyRequestPolicy, resolveProfile } from "../router/index.ts";
12
+ import { parsePolicyHeader } from "../wire/openai/request.ts";
9
13
  import { createDigester } from "./digest.ts";
10
14
  import { advise } from "./advise.ts";
11
15
  import { TIER_ORDER, type Tier } from "../router/types.ts";
@@ -484,6 +488,40 @@ export function startServer(cfg: RouterConfig): StartedServer {
484
488
  if (req.method === "GET" && url.pathname === "/v1/router/stats") {
485
489
  return json(computeStats(ledger));
486
490
  }
491
+ if (req.method === "GET" && url.pathname === "/v1/router/catalog") {
492
+ // The catalog as data, judged under `?policy=` (the X-Omp-Policy
493
+ // JSON) when one is given: a front door's governance view. Never
494
+ // blocks on a fetch; before the first one it is empty.
495
+ const rawPolicy = url.searchParams.get("policy");
496
+ let verdict: { filters: RouterConfig["filters"]; pin?: string } | undefined;
497
+ if (rawPolicy !== null) {
498
+ // The header parser forgives a malformed value (a turn must not
499
+ // fail on it); a view asked about one must say so instead.
500
+ let parsed: unknown;
501
+ try {
502
+ parsed = JSON.parse(rawPolicy);
503
+ } catch {
504
+ parsed = undefined;
505
+ }
506
+ if (parsed === undefined || parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
507
+ return wireErrorResponse({ status: 400, code: "invalid_request_error", message: "policy must be a JSON object (the X-Omp-Policy shape)" });
508
+ }
509
+ const policed = applyRequestPolicy(resolveProfile(cfg, "auto"), cfg, parsePolicyHeader(rawPolicy), undefined);
510
+ verdict = { filters: policed.cfg.filters, ...(policed.forceSlug === undefined ? {} : { pin: policed.forceSlug }) };
511
+ }
512
+ const snap = catalog.peekAll?.() ?? catalog.peek();
513
+ // A disabled named upstream's models are not built for routing; list them too, so "why not" has an answer.
514
+ const disabled = snap === null ? [] : cfg.upstreams.filter((u) => !u.enabled).flatMap((u) => buildUpstreamModels(u, snap.models));
515
+ const unserved = (provider: string): string | null => {
516
+ if (provider === "openrouter") return cfg.openrouter.apiKey === "" ? "upstream openrouter has no API key" : null;
517
+ if (provider === "ollama") return !cfg.ollama.enabled ? "upstream ollama is disabled" : ollama.available() ? null : "upstream ollama is in cooldown";
518
+ const entry = cfg.upstreams.find((u) => u.id === provider);
519
+ if (entry === undefined) return `upstream ${provider} is not configured`;
520
+ if (!entry.enabled) return `upstream ${provider} is disabled`;
521
+ return providers.named(provider)?.available() ?? true ? null : `upstream ${provider} is in cooldown`;
522
+ };
523
+ return json(catalogView({ models: snap === null ? [] : [...snap.models, ...disabled], fetchedAtMs: snap?.fetchedAtMs ?? 0, ...(verdict === undefined ? {} : { verdict }), unserved }));
524
+ }
487
525
  if (req.method === "GET" && url.pathname === "/v1/router/spend") {
488
526
  // Spend since an instant over a harness set: what a front door's
489
527
  // budget check needs when it cannot read the ledger file.
@@ -11,7 +11,7 @@ import { createAnthropicClient } from "../upstream/anthropic.ts";
11
11
  import { createCompatClient, type NamedUpstreamClient } from "../upstream/compat.ts";
12
12
  import { createOllamaCatalog } from "../catalog/ollama-catalog.ts";
13
13
  import { createCatalog } from "../catalog/openrouter-catalog.ts";
14
- import type { CatalogSource } from "../catalog/types.ts";
14
+ import type { CatalogSnapshot, CatalogSource } from "../catalog/types.ts";
15
15
  import type { RouterConfig } from "../config/types.ts";
16
16
  import { createMultiUpstream } from "../upstream/multi.ts";
17
17
  import { createOllamaClient, type OllamaClient } from "../upstream/ollama.ts";
@@ -24,7 +24,7 @@ import { createLogger, type Logger } from "../util/log.ts";
24
24
 
25
25
  export interface Providers {
26
26
  upstream: UpstreamClient;
27
- catalog: CatalogSource & { ollamaModels?(): unknown[]; ollamaBias?(): number };
27
+ catalog: CatalogSource & { ollamaModels?(): unknown[]; ollamaBias?(): number; peekAll?(): CatalogSnapshot | null };
28
28
  /** Always present: it carries the circuit breaker. Whether it SERVES follows `cfg.ollama.enabled`. */
29
29
  ollama: OllamaClient;
30
30
  /** True while Ollama Cloud is enabled and out of cooldown, read live. */
@@ -1,4 +1,4 @@
1
- import { acceptScope } from "../../context/scope.ts";
1
+ import { acceptOrigin, acceptScope } from "../../context/scope.ts";
2
2
  import type {
3
3
  RequestPolicy,
4
4
  CompactionEdit,
@@ -341,6 +341,11 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
341
341
  const agentdoxGroup = acceptScope(headers.get("x-agentdox-group"));
342
342
  const agentdoxPersonal = acceptScope(headers.get("x-agentdox-personal"));
343
343
 
344
+ // The workspace's repository fingerprint, for a front door that keeps a
345
+ // project registry. Same sentinel rule as the scope: omp sends the env-var
346
+ // NAME when the variable is unset, and a name is never a fingerprint.
347
+ const agentdoxOrigin = acceptOrigin(headers.get("x-agentdox-origin"));
348
+
344
349
  // Subagent marker from the embed extension (sessions without a UI).
345
350
  const isSubagent = (headers.get("x-omp-subagent") ?? "").trim() === "1";
346
351
 
@@ -409,6 +414,7 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
409
414
  agentdoxScope,
410
415
  agentdoxGroup,
411
416
  agentdoxPersonal,
417
+ agentdoxOrigin,
412
418
  isSubagent,
413
419
  ...(policy === undefined ? {} : { policy }),
414
420
  requestedModel,
package/src/wire/types.ts CHANGED
@@ -109,6 +109,15 @@ export interface NormRequest {
109
109
  * renders LAST. Empty when absent or not a slug ⇒ no personal layer.
110
110
  */
111
111
  agentdoxPersonal: string;
112
+ /**
113
+ * The workspace's repository fingerprint from the `X-Agentdox-Origin`
114
+ * request header (`<host>/<path>` of its git remote `origin`, see
115
+ * `src/context/scope.ts`). Validated here, in one place, for a front door
116
+ * with a project registry — the team edition — which uses it to find the
117
+ * project two same-named folders are really about. The router's own bridge
118
+ * has no registry and never reads it. Empty when absent or not a fingerprint.
119
+ */
120
+ agentdoxOrigin: string;
112
121
  /** `X-Omp-Subagent: 1`: the caller is an omp subagent (a session without a UI). */
113
122
  isSubagent: boolean;
114
123
  /**
@@ -154,6 +154,12 @@ describe("parseMessagesRequest", () => {
154
154
  expect(parseMessagesRequest(CLAUDE_CODE_BODY, new Headers({ "x-agentdox-group": "Not A Slug" })).agentdoxGroup).toBe("");
155
155
  });
156
156
 
157
+ test("the origin fingerprint reaches the request on the Anthropic path too", () => {
158
+ expect(parseMessagesRequest(CLAUDE_CODE_BODY, new Headers({ "user-agent": "claude-cli/2.1.263" })).agentdoxOrigin).toBe("");
159
+ expect(parseMessagesRequest(CLAUDE_CODE_BODY, new Headers({ "x-agentdox-origin": "github.com/drewappling/omp-router" })).agentdoxOrigin).toBe("github.com/drewappling/omp-router");
160
+ expect(parseMessagesRequest(CLAUDE_CODE_BODY, new Headers({ "x-agentdox-origin": "https://github.com/a/b" })).agentdoxOrigin).toBe("");
161
+ });
162
+
157
163
  test("a request captured from Claude Code 2.1: system inside messages, JSON user_id, adaptive thinking with effort, 23 custom tools", () => {
158
164
  const fixture = JSON.parse(readFileSync("test/fixtures/harness/claude-code.json", "utf8")) as { headers: Record<string, string>; body: Record<string, unknown> };
159
165
  const norm = parseMessagesRequest(fixture.body, new Headers(fixture.headers));
@@ -0,0 +1,249 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import { normalizeCatalogModel } from "../src/catalog/openrouter-catalog.ts";
7
+ import { buildUpstreamModels } from "../src/catalog/static-catalog.ts";
8
+ import type { CatalogModel } from "../src/catalog/types.ts";
9
+ import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
10
+ import { completeUpstreamEntry } from "../src/config/upstreams.ts";
11
+ import { catalogView, vendorOf, type CatalogView, type CatalogViewModel } from "../src/server/catalog-view.ts";
12
+ import { startServer, type StartedServer } from "../src/server/http.ts";
13
+ import { openDb } from "../src/util/sqlite.ts";
14
+
15
+ /**
16
+ * The catalog as data for a front door's governance views: every model the
17
+ * router knows, and under a policy whether a turn could reach it and why not.
18
+ * Pinned over the pure view and over `GET /v1/router/catalog`, which judges
19
+ * with the router's own matcher — the same globs, the same order as
20
+ * `buildCandidates`, a pin that only bites when the pinned model is in.
21
+ */
22
+
23
+ const FIXTURE = (await Bun.file("test/fixtures/openrouter-models.json").json()) as { data: unknown[] };
24
+
25
+ function model(over: Partial<CatalogModel> & { slug: string }): CatalogModel {
26
+ return {
27
+ provider: "openrouter",
28
+ canonicalSlug: `${over.slug}-20260101`,
29
+ name: over.slug,
30
+ contextLength: 200_000,
31
+ supportsTools: true,
32
+ supportsReasoning: false,
33
+ reasoningMandatory: false,
34
+ supportsToolChoice: true,
35
+ inputModalities: ["text"],
36
+ price: { prompt: 0.000003, completion: 0.000015 },
37
+ priceTiers: [],
38
+ quality: { intelligence: 60 },
39
+ tokenizer: "Claude",
40
+ isFree: false,
41
+ createdAtMs: 0,
42
+ author: over.slug.split("/")[0] ?? "",
43
+ ...over,
44
+ };
45
+ }
46
+
47
+ const vllm = completeUpstreamEntry({ id: "vllm", kind: "openai", baseUrl: "http://vllm:8000/v1", apiKey: "", models: [{ id: "meta-llama/Llama-3", input: 0, output: 0, contextLength: 8_000 }] });
48
+ const azure = completeUpstreamEntry({ id: "azure-eu", kind: "azure", enabled: false, baseUrl: "https://r.openai.azure.com", apiKey: "k", models: [{ id: "gpt-4o-deploy", input: 2.5, output: 10, cachedInput: 1.25 }] });
49
+
50
+ const MODELS: CatalogModel[] = [
51
+ model({ slug: "openai/gpt-5", quality: { intelligence: 70, coding: 72 }, maxCompletionTokens: 128_000, price: { prompt: 0.00000125, completion: 0.00001, cacheRead: 0.000000125 } }),
52
+ model({ slug: "anthropic/claude-sonnet-5", supportsReasoning: true }),
53
+ model({ slug: "anthropic/claude-haiku-5" }),
54
+ model({ slug: "anthropic/claude-opus-5:batch" }),
55
+ model({ slug: "liquid/lfm:free", isFree: true, price: { prompt: 0, completion: 0 } }),
56
+ model({ slug: "tencent/translator", supportsTools: false }),
57
+ model({ slug: "ollama/glm-5.3-flash", provider: "ollama", author: "ollama" }),
58
+ ...buildUpstreamModels(vllm, []),
59
+ ...buildUpstreamModels(azure, []),
60
+ ];
61
+
62
+ const served = () => null;
63
+ const bySlug = (view: CatalogView): Map<string, CatalogViewModel> => new Map(view.models.map((m) => [m.slug, m]));
64
+
65
+ describe("catalogView", () => {
66
+ test("sorted by slug, prices per million, vendor per slug, and no verdict without a policy", () => {
67
+ const view = catalogView({ models: MODELS, fetchedAtMs: 123, unserved: served });
68
+ expect(view.fetchedAtMs).toBe(123);
69
+ expect(view.models.map((m) => m.slug)).toEqual([...MODELS.map((m) => m.slug)].sort());
70
+ const gpt = bySlug(view).get("openai/gpt-5")!;
71
+ expect(gpt).toEqual({
72
+ slug: "openai/gpt-5",
73
+ canonicalSlug: "openai/gpt-5-20260101",
74
+ name: "openai/gpt-5",
75
+ provider: "openrouter",
76
+ vendor: "openai",
77
+ contextLength: 200_000,
78
+ maxCompletionTokens: 128_000,
79
+ supportsTools: true,
80
+ supportsReasoning: false,
81
+ reasoningMandatory: false,
82
+ inputModalities: ["text"],
83
+ price: { prompt: 1.25, completion: 10, cacheRead: 0.125 },
84
+ quality: { intelligence: 70, coding: 72 },
85
+ isFree: false,
86
+ });
87
+ expect("admitted" in gpt).toBe(false);
88
+ expect("reason" in gpt).toBe(false);
89
+ expect("maxCompletionTokens" in bySlug(view).get("anthropic/claude-haiku-5")!).toBe(false);
90
+ // A named upstream's prices are per million already; they round-trip.
91
+ expect(bySlug(view).get("azure-eu/gpt-4o-deploy")!.price).toEqual({ prompt: 2.5, completion: 10, cacheRead: 1.25 });
92
+ });
93
+
94
+ test("vendor: the namespace before the first slash; a named upstream's model id may carry its own", () => {
95
+ expect(vendorOf({ slug: "anthropic/claude-sonnet-5", provider: "openrouter" })).toBe("anthropic");
96
+ expect(vendorOf({ slug: "ollama/glm-5.3-flash", provider: "ollama" })).toBe("ollama");
97
+ expect(vendorOf({ slug: "vllm/meta-llama/Llama-3", provider: "vllm" })).toBe("meta-llama");
98
+ expect(vendorOf({ slug: "azure-eu/gpt-4o-deploy", provider: "azure-eu" })).toBe("azure-eu");
99
+ const view = bySlug(catalogView({ models: MODELS, fetchedAtMs: 0, unserved: served }));
100
+ expect(view.get("vllm/meta-llama/Llama-3")!.vendor).toBe("meta-llama");
101
+ expect(view.get("azure-eu/gpt-4o-deploy")!.vendor).toBe("azure-eu");
102
+ });
103
+
104
+ test("an empty policy still judges: the router's own filters and the upstream's state", () => {
105
+ const unserved = (p: string) => (p === "azure-eu" ? "upstream azure-eu is disabled" : null);
106
+ const view = bySlug(catalogView({ models: MODELS, fetchedAtMs: 0, verdict: { filters: DEFAULT_CONFIG.filters }, unserved }));
107
+ expect(view.get("openai/gpt-5")).toMatchObject({ admitted: true });
108
+ expect("reason" in view.get("openai/gpt-5")!).toBe(false);
109
+ expect(view.get("anthropic/claude-opus-5:batch")).toMatchObject({ admitted: false, reason: "built-in deny: floating alias, batch endpoint, stealth, or meta-router" });
110
+ expect(view.get("liquid/lfm:free")).toMatchObject({ admitted: false, reason: "free models excluded (filters.includeFree)" });
111
+ expect(view.get("tencent/translator")).toMatchObject({ admitted: false, reason: "no tool support (filters.requireToolSupport)" });
112
+ expect(view.get("azure-eu/gpt-4o-deploy")).toMatchObject({ admitted: false, reason: "upstream azure-eu is disabled" });
113
+ // A $0 named-upstream model is self-hosted, never "free".
114
+ expect(view.get("vllm/meta-llama/Llama-3")).toMatchObject({ admitted: true });
115
+ // Filters relaxed: the same models come in.
116
+ const open = bySlug(catalogView({ models: MODELS, fetchedAtMs: 0, verdict: { filters: { ...DEFAULT_CONFIG.filters, includeFree: true, requireToolSupport: false } }, unserved: served }));
117
+ expect(open.get("liquid/lfm:free")!.admitted).toBe(true);
118
+ expect(open.get("tencent/translator")!.admitted).toBe(true);
119
+ });
120
+
121
+ test("allow list, deny glob and pin, in the order a turn applies them", () => {
122
+ const filters = { ...DEFAULT_CONFIG.filters, allow: ["anthropic/*", "vllm/*"], deny: ["*haiku*"] };
123
+ const view = bySlug(catalogView({ models: MODELS, fetchedAtMs: 0, verdict: { filters }, unserved: served }));
124
+ expect(view.get("openai/gpt-5")).toMatchObject({ admitted: false, reason: "not in the allow list" });
125
+ expect(view.get("anthropic/claude-haiku-5")).toMatchObject({ admitted: false, reason: "denied by *haiku*" });
126
+ expect(view.get("anthropic/claude-sonnet-5")).toMatchObject({ admitted: true });
127
+ expect(view.get("vllm/meta-llama/Llama-3")).toMatchObject({ admitted: true });
128
+ // A pin keeps every other model out.
129
+ const pinned = bySlug(catalogView({ models: MODELS, fetchedAtMs: 0, verdict: { filters, pin: "anthropic/claude-sonnet-5" }, unserved: served }));
130
+ expect(pinned.get("anthropic/claude-sonnet-5")).toMatchObject({ admitted: true });
131
+ expect(pinned.get("vllm/meta-llama/Llama-3")).toMatchObject({ admitted: false, reason: "pinned to anthropic/claude-sonnet-5" });
132
+ expect(pinned.get("openai/gpt-5")).toMatchObject({ admitted: false, reason: "not in the allow list" }); // the earlier reason stands
133
+ // A pin the filters drop is ignored, as select ignores it: nothing else is pinned out.
134
+ const dropped = bySlug(catalogView({ models: MODELS, fetchedAtMs: 0, verdict: { filters, pin: "anthropic/claude-haiku-5" }, unserved: served }));
135
+ expect(dropped.get("anthropic/claude-haiku-5")).toMatchObject({ admitted: false, reason: "denied by *haiku*" });
136
+ expect(dropped.get("anthropic/claude-sonnet-5")).toMatchObject({ admitted: true });
137
+ // So is a pin naming no model.
138
+ expect(bySlug(catalogView({ models: MODELS, fetchedAtMs: 0, verdict: { filters, pin: "nobody/here" }, unserved: served })).get("anthropic/claude-sonnet-5")!.admitted).toBe(true);
139
+ });
140
+ });
141
+
142
+ describe("GET /v1/router/catalog", () => {
143
+ let handle: StartedServer;
144
+ let empty: StartedServer;
145
+ const dir = mkdtempSync(join(tmpdir(), "amr-catalog-"));
146
+ const FETCHED = Date.now() - 60_000;
147
+ beforeAll(() => {
148
+ const cfg = structuredClone(DEFAULT_CONFIG);
149
+ cfg.server.host = "127.0.0.1";
150
+ cfg.server.port = 0;
151
+ cfg.server.apiKey = "k";
152
+ cfg.logLevel = "silent";
153
+ // Nothing reaches the network: the catalog is served from its on-disk
154
+ // cache (within TTL), the periodic refresh is off, and the base URL is dead.
155
+ cfg.openrouter.apiKey = "sk-test";
156
+ cfg.openrouter.baseUrl = "http://127.0.0.1:9/api/v1";
157
+ cfg.openrouter.catalogRefreshMs = 0;
158
+ cfg.benchmarks.enabled = false;
159
+ cfg.upstreams = [vllm, azure];
160
+ cfg.ledger.path = join(dir, "router.db");
161
+ const db = openDb(cfg.ledger.path);
162
+ db.run("INSERT INTO catalog_cache (id, payload, fetched_at_ms, etag, key_scoped) VALUES (1, ?, ?, NULL, 0)", [JSON.stringify(FIXTURE.data), FETCHED]);
163
+ db.close();
164
+ handle = startServer(cfg);
165
+ const bare = structuredClone(cfg);
166
+ bare.upstreams = [];
167
+ bare.ledger.path = join(dir, "empty.db");
168
+ empty = startServer(bare);
169
+ });
170
+ afterAll(async () => {
171
+ await handle.stop();
172
+ await empty.stop();
173
+ try {
174
+ rmSync(dir, { recursive: true, force: true });
175
+ } catch {
176
+ /* Windows may hold the WAL briefly */
177
+ }
178
+ });
179
+ const get = (path: string, port = handle.server.port) => fetch(`http://127.0.0.1:${port}${path}`, { headers: { authorization: "Bearer k" } });
180
+ const view = async (path: string): Promise<Map<string, CatalogViewModel>> => bySlug((await (await get(path)).json()) as CatalogView);
181
+
182
+ test("the catalog as data: authenticated, sorted, every upstream's models, no verdict without a policy", async () => {
183
+ expect((await fetch(`http://127.0.0.1:${handle.server.port}/v1/router/catalog`)).status).toBe(401);
184
+ const res = await get("/v1/router/catalog");
185
+ expect(res.status).toBe(200);
186
+ const body = (await res.json()) as CatalogView;
187
+ expect(body.fetchedAtMs).toBe(FETCHED);
188
+ const slugs = body.models.map((m) => m.slug);
189
+ expect(slugs).toEqual([...slugs].sort());
190
+ // Every OpenRouter model the fixture normalises to, plus both named upstreams' — the disabled one too.
191
+ expect(slugs).toHaveLength(FIXTURE.data.filter((m) => normalizeCatalogModel(m) !== null).length + 2);
192
+ const models = bySlug(body);
193
+ const opus = models.get("anthropic/claude-opus-5")!;
194
+ const raw = normalizeCatalogModel(FIXTURE.data.find((m) => (m as { id: string }).id === "anthropic/claude-opus-5"))!;
195
+ expect(opus).toEqual({
196
+ slug: "anthropic/claude-opus-5",
197
+ canonicalSlug: raw.canonicalSlug,
198
+ name: raw.name,
199
+ provider: "openrouter",
200
+ vendor: "anthropic",
201
+ contextLength: raw.contextLength,
202
+ maxCompletionTokens: 128_000,
203
+ supportsTools: true,
204
+ supportsReasoning: raw.supportsReasoning,
205
+ reasoningMandatory: raw.reasoningMandatory,
206
+ inputModalities: raw.inputModalities,
207
+ price: { prompt: 5, completion: raw.price.completion * 1e6, cacheRead: 0.5, ...(raw.price.cacheWrite === undefined ? {} : { cacheWrite: raw.price.cacheWrite * 1e6 }) },
208
+ quality: raw.quality,
209
+ isFree: false,
210
+ });
211
+ expect(models.get("vllm/meta-llama/Llama-3")).toMatchObject({ provider: "vllm", vendor: "meta-llama", contextLength: 8_000 });
212
+ expect(models.get("azure-eu/gpt-4o-deploy")).toMatchObject({ provider: "azure-eu", vendor: "azure-eu", price: { prompt: 2.5, completion: 10, cacheRead: 1.25 } });
213
+ expect(body.models.some((m) => "admitted" in m || "reason" in m)).toBe(false);
214
+ });
215
+
216
+ test("judged under a policy: allow, deny, pin, the router's filters and the upstream's state", async () => {
217
+ const allowed = await view(`/v1/router/catalog?policy=${encodeURIComponent(JSON.stringify({ allow: ["anthropic/*", "azure-eu/*"], deny: ["anthropic/claude-opus-5"], maxTier: "simple" }))}`);
218
+ expect(allowed.get("anthropic/claude-sonnet-4.5")).toMatchObject({ admitted: true });
219
+ expect(allowed.get("anthropic/claude-opus-5")).toMatchObject({ admitted: false, reason: "denied by anthropic/claude-opus-5" });
220
+ expect(allowed.get("anthropic/claude-opus-5:batch")).toMatchObject({ admitted: false, reason: "built-in deny: floating alias, batch endpoint, stealth, or meta-router" });
221
+ expect(allowed.get("openai/gpt-5.6-luna")).toMatchObject({ admitted: false, reason: "not in the allow list" });
222
+ expect(allowed.get("azure-eu/gpt-4o-deploy")).toMatchObject({ admitted: false, reason: "upstream azure-eu is disabled" });
223
+ // The configured deny list stays in force under a policy's, as applyRequestPolicy adds rather than replaces.
224
+ const pinned = await view(`/v1/router/catalog?policy=${encodeURIComponent(JSON.stringify({ pin: "vllm/meta-llama/Llama-3" }))}`);
225
+ expect(pinned.get("vllm/meta-llama/Llama-3")).toMatchObject({ admitted: true });
226
+ expect(pinned.get("anthropic/claude-sonnet-4.5")).toMatchObject({ admitted: false, reason: "pinned to vllm/meta-llama/Llama-3" });
227
+ expect(pinned.get("tencent/hy-mt2-1.8b")).toMatchObject({ admitted: false, reason: "no tool support (filters.requireToolSupport)" });
228
+ expect(pinned.get("liquid/lfm-2.5-2.6b:free")).toMatchObject({ admitted: false, reason: "free models excluded (filters.includeFree)" });
229
+ // `{}` is a policy too: every model carries a verdict.
230
+ const plain = await view("/v1/router/catalog?policy=%7B%7D");
231
+ expect(plain.get("anthropic/claude-sonnet-4.5")).toMatchObject({ admitted: true });
232
+ expect([...plain.values()].every((m) => typeof m.admitted === "boolean")).toBe(true);
233
+ });
234
+
235
+ test("a malformed policy is a 400 in the wire error shape", async () => {
236
+ for (const bad of ["%7B", "not-json", "%5B%5D", "null", "1"]) {
237
+ const res = await get(`/v1/router/catalog?policy=${bad}`);
238
+ expect(res.status).toBe(400);
239
+ const body = (await res.json()) as { error: { message: string; type: string; code: string } };
240
+ expect(body.error.code).toBe("invalid_request_error");
241
+ expect(body.error.message).toContain("policy");
242
+ }
243
+ });
244
+
245
+ test("before the first fetch: empty, never a wait", async () => {
246
+ expect((await (await get("/v1/router/catalog", empty.server.port)).json()) as CatalogView).toEqual({ fetchedAtMs: 0, models: [] });
247
+ expect((await (await get("/v1/router/catalog?policy=%7B%7D", empty.server.port)).json()) as CatalogView).toEqual({ fetchedAtMs: 0, models: [] });
248
+ });
249
+ });
@@ -1,11 +1,12 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
- import { mkdtempSync, rmSync } from "node:fs";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
6
  import {
7
7
  buildProviderConfig,
8
8
  deriveAgentdoxScope,
9
+ deriveWorkspaceOrigin,
9
10
  EMBED_PORT_FILE,
10
11
  EMBED_PROVIDER_ID,
11
12
  embedPortPath,
@@ -207,3 +208,87 @@ describe("agentdox scope", () => {
207
208
  expect(buildProviderConfig(1234, cfg, "/x/ashlands").agentdoxScope).toBeUndefined();
208
209
  });
209
210
  });
211
+
212
+ describe("workspace origin", () => {
213
+ const CONFIG = ['[core]', '\trepositoryformatversion = 0', '[remote "origin"]', '\turl = https://github.com/DrewAppling/omp-router.git', '\tfetch = +refs/heads/*:refs/remotes/origin/*', '[branch "main"]', '\tremote = origin', ''].join("\n");
214
+ let root: string;
215
+ beforeAll(() => {
216
+ root = mkdtempSync(join(tmpdir(), "amr-origin-"));
217
+ });
218
+ afterAll(() => {
219
+ rmSync(root, { recursive: true, force: true });
220
+ });
221
+
222
+ test("a plain repository: the remote from .git/config, also from a subdirectory", () => {
223
+ const repo = join(root, "repo");
224
+ mkdirSync(join(repo, ".git"), { recursive: true });
225
+ writeFileSync(join(repo, ".git", "config"), CONFIG);
226
+ mkdirSync(join(repo, "src", "deep"), { recursive: true });
227
+ expect(deriveWorkspaceOrigin(repo)).toBe("github.com/drewappling/omp-router");
228
+ expect(deriveWorkspaceOrigin(join(repo, "src", "deep"))).toBe("github.com/drewappling/omp-router");
229
+ // The scope is still the folder, untouched by any of this.
230
+ expect(deriveAgentdoxScope(repo)).toBe("repo");
231
+ });
232
+
233
+ test("a worktree: .git is a file naming the git dir, whose shared config is one hop further", () => {
234
+ // The main checkout holds the config; the worktree's own dir only points at it.
235
+ const main = join(root, "main");
236
+ mkdirSync(join(main, ".git", "worktrees", "wt"), { recursive: true });
237
+ writeFileSync(join(main, ".git", "config"), CONFIG.replace("github.com/DrewAppling/omp-router.git", "gitlab.example.com:2222/team/api/").replace("https://", "ssh://git@"));
238
+ writeFileSync(join(main, ".git", "worktrees", "wt", "commondir"), "../..\n");
239
+ const wt = join(root, "wt");
240
+ mkdirSync(wt, { recursive: true });
241
+ writeFileSync(join(wt, ".git"), `gitdir: ${join(main, ".git", "worktrees", "wt")}\n`);
242
+ expect(deriveWorkspaceOrigin(wt)).toBe("gitlab.example.com/team/api");
243
+ // A submodule-style pointer: the named dir has its own config, relative to the .git file.
244
+ const sub = join(root, "sub");
245
+ mkdirSync(join(sub, "modules", "lib"), { recursive: true });
246
+ mkdirSync(join(sub, "lib"), { recursive: true });
247
+ writeFileSync(join(sub, "modules", "lib", "config"), CONFIG.replace("DrewAppling/omp-router", "org/lib"));
248
+ writeFileSync(join(sub, "lib", ".git"), "gitdir: ../modules/lib\n");
249
+ expect(deriveWorkspaceOrigin(join(sub, "lib"))).toBe("github.com/org/lib");
250
+ });
251
+
252
+ test("no remote, a local remote, no repository, or nothing at all: no fingerprint, never a throw", () => {
253
+ const bare = join(root, "bare");
254
+ mkdirSync(join(bare, ".git"), { recursive: true });
255
+ writeFileSync(join(bare, ".git", "config"), "[core]\n\tbare = false\n");
256
+ expect(deriveWorkspaceOrigin(bare)).toBe("");
257
+ const local = join(root, "local");
258
+ mkdirSync(join(local, ".git"), { recursive: true });
259
+ writeFileSync(join(local, ".git", "config"), '[remote "origin"]\n\turl = /srv/git/local.git\n');
260
+ expect(deriveWorkspaceOrigin(local)).toBe("");
261
+ // A `.git` file that leads nowhere is the boundary: the walk stops there.
262
+ const dangling = join(root, "repo", "dangling");
263
+ mkdirSync(dangling, { recursive: true });
264
+ writeFileSync(join(dangling, ".git"), "gitdir: /nowhere/at/all\n");
265
+ expect(deriveWorkspaceOrigin(dangling)).toBe("");
266
+ expect(deriveWorkspaceOrigin(join(root, "not-a-repo", "missing"))).toBe("");
267
+ expect(deriveWorkspaceOrigin("")).toBe("");
268
+ expect(
269
+ deriveWorkspaceOrigin("/anywhere", () => {
270
+ throw new Error("disk on fire");
271
+ }),
272
+ ).toBe("");
273
+ });
274
+
275
+ test("the first url under [remote \"origin\"] wins; other remotes do not count", () => {
276
+ const read = () => '[remote "upstream"]\n\turl = https://github.com/other/thing.git\n[remote "origin"]\n\turl = git@github.com:me/thing.git\n\turl = https://github.com/me/second.git\n';
277
+ expect(deriveWorkspaceOrigin("/x/repo", read)).toBe("github.com/me/thing");
278
+ expect(deriveWorkspaceOrigin("/x/repo", () => '[remote "upstream"]\n\turl = https://github.com/other/thing.git\n')).toBe("");
279
+ });
280
+
281
+ test("buildProviderConfig carries the origin beside the scope, only where the scope goes", () => {
282
+ const base = {
283
+ server: { host: "127.0.0.1" },
284
+ profiles: [],
285
+ ledger: { fallbackBlend: { inputPerMtok: 1, outputPerMtok: 1 } },
286
+ };
287
+ const on = buildProviderConfig(1234, { ...base, context: { enabled: true, defaultScope: "" } }, "/x/ashlands", "github.com/me/ashlands");
288
+ expect(on.agentdoxScope).toBe("ashlands");
289
+ expect(on.agentdoxOrigin).toBe("github.com/me/ashlands");
290
+ expect(buildProviderConfig(1234, { ...base, context: { enabled: true, defaultScope: "" } }, "/x/ashlands", "").agentdoxOrigin).toBeUndefined();
291
+ expect(buildProviderConfig(1234, { ...base, context: { enabled: true, defaultScope: "" } }, "/x/ashlands").agentdoxOrigin).toBeUndefined();
292
+ expect(buildProviderConfig(1234, { ...base, context: { enabled: false, defaultScope: "" } }, "/x/ashlands", "github.com/me/ashlands").agentdoxOrigin).toBeUndefined();
293
+ });
294
+ });
@@ -26,6 +26,7 @@ function req(messages: NormMessage[] = [], over: Partial<NormRequest> = {}): Nor
26
26
  agentdoxScope: "",
27
27
  agentdoxGroup: "",
28
28
  agentdoxPersonal: "",
29
+ agentdoxOrigin: "",
29
30
  isSubagent: false,
30
31
  requestedModel: "auto",
31
32
  messages,
@@ -98,6 +98,7 @@ function mkReq(): NormRequest {
98
98
  agentdoxScope: "",
99
99
  agentdoxGroup: "",
100
100
  agentdoxPersonal: "",
101
+ agentdoxOrigin: "",
101
102
  isSubagent: false,
102
103
  requestedModel: "auto",
103
104
  messages: [{ role: "user", text: "hi", images: 0, textBytes: 2, toolCalls: [] }],