auto-model-router 0.17.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.
- package/.claude/skills/agentdox/SKILL.md +13 -3
- package/.mcp.json +1 -1
- package/.omp-plugin/marketplace.json +2 -2
- package/CLAUDE.md +2 -2
- package/README.md +12 -1
- package/package.json +1 -1
- package/src/catalog/composite.ts +12 -1
- package/src/lib.ts +1 -0
- package/src/router/candidates.ts +31 -16
- package/src/server/catalog-view.ts +130 -0
- package/src/server/http.ts +38 -0
- package/src/server/providers.ts +2 -2
- package/test/catalog-view.test.ts +249 -0
|
@@ -5,7 +5,7 @@ description: "Use agentdox — the shared memory, docs, and context server — t
|
|
|
5
5
|
|
|
6
6
|
# agentdox — the standard interaction protocol
|
|
7
7
|
|
|
8
|
-
agentdox is a shared memory + docs + conversation store at `http://localhost:
|
|
8
|
+
agentdox is a shared memory + docs + conversation store at `http://localhost:8790/agentdox`. It is what
|
|
9
9
|
stops you rediscovering the same project facts every session.
|
|
10
10
|
|
|
11
11
|
**Follow this protocol identically every time.** Consistency is the point: the value of the
|
|
@@ -55,7 +55,7 @@ Deterministic, so the same folder always resolves to the same slug:
|
|
|
55
55
|
repo root:
|
|
56
56
|
|
|
57
57
|
```ini
|
|
58
|
-
AGENTDOX_URL=http://localhost:
|
|
58
|
+
AGENTDOX_URL=http://localhost:8790/agentdox
|
|
59
59
|
AGENTDOX_SCOPE=<slug>
|
|
60
60
|
AGENTDOX_TOKEN=<the global PAT>
|
|
61
61
|
```
|
|
@@ -104,6 +104,16 @@ determine the scope, ask — do not guess, and do not fall back to a default.
|
|
|
104
104
|
|
|
105
105
|
## 1. Pick your transport — MCP or REST
|
|
106
106
|
|
|
107
|
+
> **Where the store lives (since 2026-09-10).** The store is the team edition's embedded agentdox,
|
|
108
|
+
> reached through the team: REST at `http://localhost:8790/agentdox/...` and MCP at
|
|
109
|
+
> `http://localhost:8790/mcp`, with a **team member key** (`amrt_…`) as `AGENTDOX_TOKEN`. The team maps
|
|
110
|
+
> the key to the member's grants (write on every project of their groups, read on the group's
|
|
111
|
+
> context), so a 401 means the key and a 403 means the project belongs to another group — not a PAT.
|
|
112
|
+
> agentdox's own `/auth` routes are not reachable through the team; projects are created on the
|
|
113
|
+
> team's Groups page or appear on a workspace's first turn. The old dev container on :3003 is retired.
|
|
114
|
+
> A member key updates entries (`PATCH /memory/:id`) but cannot delete them (agentdox wants admin on the
|
|
115
|
+
> scope for that): to retire a fact, lower its importance or leave it to the team's retention pass.
|
|
116
|
+
|
|
107
117
|
Both hit the same live store with the same RBAC. **Check which you have, then use it:**
|
|
108
118
|
|
|
109
119
|
- **MCP tools present** — use them. Claude Code and Cursor mount them as `memory_add`,
|
|
@@ -129,7 +139,7 @@ inline JSON and avoids quoting pain in `curl`:
|
|
|
129
139
|
```ts
|
|
130
140
|
const tok = /AGENTDOX_TOKEN=(.+)/.exec(await Bun.file(".env.agentdox").text())?.[1]?.trim() ?? "";
|
|
131
141
|
const H = { Authorization: `Bearer ${tok}`, "content-type": "application/json" };
|
|
132
|
-
await fetch("http://localhost:
|
|
142
|
+
await fetch("http://localhost:8790/agentdox/memory", { method: "POST", headers: H,
|
|
133
143
|
body: JSON.stringify({ content: "…", category: "<scope>", importance: 0.9 }) });
|
|
134
144
|
```
|
|
135
145
|
|
package/.mcp.json
CHANGED
|
@@ -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.18.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.18.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/CLAUDE.md
CHANGED
|
@@ -24,7 +24,7 @@ tips). Read it before using agentdox; do not improvise from this summary.
|
|
|
24
24
|
| --- | --- |
|
|
25
25
|
| Token, URL, scope | `.env.agentdox` in this repo root (gitignored — never commit) |
|
|
26
26
|
| What `.mcp.json` reads | the `AGENTDOX_TOKEN` **environment variable** (Windows *User* scope; shells opened before it was set lack it) |
|
|
27
|
-
| Server | `http://localhost:
|
|
27
|
+
| Server | `http://localhost:8790/agentdox` — the team edition's embedded agentdox (the `agentdox-server` container is retired); endpoints in `E:/projects/agentdox/packages/server/src/index.ts` |
|
|
28
28
|
| Admin token (re-mint) | `E:/projects/agentdox/deploy/.env` |
|
|
29
29
|
|
|
30
30
|
Two rules that cause silent mistakes: **memory calls take `category`, everything else takes
|
|
@@ -46,7 +46,7 @@ behind them are recorded there as they are made.
|
|
|
46
46
|
Turning the bridge on for the router itself (distinct from the MCP wiring above):
|
|
47
47
|
|
|
48
48
|
```bash
|
|
49
|
-
export AGENTDOX_URL=http://localhost:
|
|
49
|
+
export AGENTDOX_URL=http://localhost:8790/agentdox
|
|
50
50
|
export AGENTDOX_TOKEN=<the global PAT, same value .mcp.json uses>
|
|
51
51
|
export AGENTDOX_SCOPE=omp-router
|
|
52
52
|
```
|
package/README.md
CHANGED
|
@@ -716,6 +716,16 @@ escalation signal, error. Three views aggregate it, all from the same
|
|
|
716
716
|
bill, escalation signal and verdicts (`?session=` narrows to one omp session, as `/router
|
|
717
717
|
why` does). These are what a front door such as the team edition reads instead of the
|
|
718
718
|
ledger file.
|
|
719
|
+
- `GET /v1/router/catalog[?policy=<X-Omp-Policy JSON>]` — the model catalog as data: every
|
|
720
|
+
model the router knows, sorted by slug, with provider (`openrouter`, `ollama`, a named
|
|
721
|
+
upstream's id), vendor, context, capabilities, prices in USD per million tokens and quality
|
|
722
|
+
scores; a model whose upstream cannot serve right now (no key, disabled, in cooldown) is
|
|
723
|
+
listed too. With `policy` every model also carries `admitted` and, when out, `reason`
|
|
724
|
+
(`not in the allow list`, `denied by <glob>`, `pinned to <slug>`, the router's own filters,
|
|
725
|
+
or the upstream's state), judged by the same matcher a turn uses, so the view never drifts
|
|
726
|
+
from what a turn gets; tiers are per turn and do not take part. A malformed policy is a
|
|
727
|
+
400; before the first fetch it answers `{ "fetchedAtMs": 0, "models": [] }` rather than
|
|
728
|
+
waiting. A team front door reads it for its model governance views.
|
|
719
729
|
- `GET /v1/router/report?days=7&harness=<id>` for dashboards (`harness` may be
|
|
720
730
|
a comma-separated set of ids, for a group).
|
|
721
731
|
- `GET /v1/router/summary?harness=<id>` — the daily summary as JSON (`auto=1`
|
|
@@ -1287,7 +1297,8 @@ request allow list replaces the configured one, a deny list adds to it.
|
|
|
1287
1297
|
widen it. `pin` forces one model the way `/router pin` does, unless a session
|
|
1288
1298
|
override already pinned one. Every field is optional; a malformed header is
|
|
1289
1299
|
ignored rather than failing the turn. The decision trail records what the
|
|
1290
|
-
policy changed (`policy: …`)
|
|
1300
|
+
policy changed (`policy: …`), and `GET /v1/router/catalog?policy=…` shows what a
|
|
1301
|
+
policy admits, model by model, without routing a turn.
|
|
1291
1302
|
|
|
1292
1303
|
## Using a remote router
|
|
1293
1304
|
|
package/package.json
CHANGED
package/src/catalog/composite.ts
CHANGED
|
@@ -46,7 +46,7 @@ export function createCompositeCatalog(
|
|
|
46
46
|
ollama: OllamaCatalogSource,
|
|
47
47
|
availability: OllamaAvailability,
|
|
48
48
|
bias: CompositeBias = { costBias: 1, biasUntilUsage: 1, usage: NO_USAGE },
|
|
49
|
-
): CatalogSource & { ollamaModels(): CatalogModel[]; ollamaBias(): number } {
|
|
49
|
+
): CatalogSource & { ollamaModels(): CatalogModel[]; ollamaBias(): number; peekAll(): CatalogSnapshot | null } {
|
|
50
50
|
let lastBase: CatalogSnapshot | null = null;
|
|
51
51
|
let lastOllama: readonly CatalogModel[] = [];
|
|
52
52
|
let lastAvailable = true;
|
|
@@ -118,6 +118,17 @@ export function createCompositeCatalog(
|
|
|
118
118
|
return combine(base, ollama.peek());
|
|
119
119
|
},
|
|
120
120
|
find,
|
|
121
|
+
/**
|
|
122
|
+
* Every model the catalog knows, whether or not its upstream can take a
|
|
123
|
+
* turn now: OpenRouter's without a key, Ollama's in cooldown, a named
|
|
124
|
+
* upstream's in cooldown. `peek()` is what routes; this is what a front
|
|
125
|
+
* door lists so it can say why a model is out. No network.
|
|
126
|
+
*/
|
|
127
|
+
peekAll(): CatalogSnapshot | null {
|
|
128
|
+
const base = openrouter.peek();
|
|
129
|
+
if (base === null) return null;
|
|
130
|
+
return { ...base, models: [...base.models, ...ollama.peek(), ...(bias.named?.models(base.models) ?? NO_NAMED)] };
|
|
131
|
+
},
|
|
121
132
|
lastShrink(): CatalogShrink | null {
|
|
122
133
|
return openrouter.lastShrink?.() ?? null;
|
|
123
134
|
},
|
package/src/lib.ts
CHANGED
|
@@ -28,4 +28,5 @@ export { createFeedbackStore, type FeedbackStore, type FeedbackRecord } from "./
|
|
|
28
28
|
export { buildExecutable, collectPackageFiles, executableFileName, hostTarget, isExecutableTarget, EXECUTABLE_TARGETS, type ExecutableTarget, type BuildExecutableResult } from "./cli/build-executable.ts";
|
|
29
29
|
export { parseSkillsBundle, type SkillsBundle } from "./cli/skills.ts";
|
|
30
30
|
export type { RequestPolicy } from "./wire/types.ts";
|
|
31
|
+
export type { CatalogView, CatalogViewModel } from "./server/catalog-view.ts";
|
|
31
32
|
export type { Ledger, LedgerEntry } from "./cost/types.ts";
|
package/src/router/candidates.ts
CHANGED
|
@@ -48,12 +48,37 @@ export interface BuildCandidatesArgs {
|
|
|
48
48
|
excludeSlugs?: readonly string[];
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Tiny glob: `*` matches any run of characters; everything else is literal.
|
|
53
|
+
* The one matcher behind `filters.allow`/`filters.deny` and a request
|
|
54
|
+
* policy's lists; the catalog view (`GET /v1/router/catalog`) judges with it
|
|
55
|
+
* too, so what a front door shows never drifts from what a turn gets.
|
|
56
|
+
*/
|
|
57
|
+
export function globToRe(glob: string): RegExp {
|
|
53
58
|
const escaped = glob.replace(/[.*+?^${}()|[\]\\]/g, (ch) => (ch === "*" ? ".*" : `\\${ch}`));
|
|
54
59
|
return new RegExp(`^${escaped}$`);
|
|
55
60
|
}
|
|
56
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The denials that precede any user configuration, as the reason text, or
|
|
64
|
+
* null when the model may be a candidate. These slugs can never serve an
|
|
65
|
+
* interactive turn:
|
|
66
|
+
* - "~vendor/model-latest": floating aliases whose identity changes
|
|
67
|
+
* underneath us, poisoning ledger trust statistics.
|
|
68
|
+
* - ":batch": asynchronous batch endpoints, unusable for streaming.
|
|
69
|
+
* - "stealth/": cloaked models with no stable identity.
|
|
70
|
+
* - "openrouter/": their meta-routers do our job at unknown cost.
|
|
71
|
+
* - a negative price: OpenRouter's unknown/dynamic sentinel (-1), never a discount.
|
|
72
|
+
*/
|
|
73
|
+
export function builtInDenial(model: CatalogModel): string | null {
|
|
74
|
+
const slug = model.slug;
|
|
75
|
+
if (slug.startsWith("~") || slug.endsWith(":batch") || slug.startsWith("stealth/") || model.author === "openrouter") {
|
|
76
|
+
return "built-in deny: floating alias, batch endpoint, stealth, or meta-router";
|
|
77
|
+
}
|
|
78
|
+
if (model.price.prompt < 0 || model.price.completion < 0) return "dynamic pricing sentinel";
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
57
82
|
/**
|
|
58
83
|
* Quality fallback chain: score the preferred axis first, then the general
|
|
59
84
|
* intelligence index (the most widely published), then the remaining axis.
|
|
@@ -182,20 +207,10 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
182
207
|
continue;
|
|
183
208
|
}
|
|
184
209
|
|
|
185
|
-
// Hard-coded denials, before any user configuration.
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
// - ":batch": asynchronous batch endpoints, unusable for streaming.
|
|
190
|
-
// - "stealth/": cloaked models with no stable identity.
|
|
191
|
-
// - "openrouter/": their meta-routers do our job at unknown cost.
|
|
192
|
-
if (slug.startsWith("~") || slug.endsWith(":batch") || slug.startsWith("stealth/") || model.author === "openrouter") {
|
|
193
|
-
rejected.push({ slug, reason: "denylisted", detail: "built-in deny: floating alias, batch endpoint, stealth, or meta-router" });
|
|
194
|
-
continue;
|
|
195
|
-
}
|
|
196
|
-
// A negative price is OpenRouter's unknown/dynamic sentinel (-1), never a discount.
|
|
197
|
-
if (model.price.prompt < 0 || model.price.completion < 0) {
|
|
198
|
-
rejected.push({ slug, reason: "denylisted", detail: "dynamic pricing sentinel" });
|
|
210
|
+
// Hard-coded denials, before any user configuration.
|
|
211
|
+
const builtIn = builtInDenial(model);
|
|
212
|
+
if (builtIn !== null) {
|
|
213
|
+
rejected.push({ slug, reason: "denylisted", detail: builtIn });
|
|
199
214
|
continue;
|
|
200
215
|
}
|
|
201
216
|
if (allowRes.length > 0 && !allowRes.some((re) => re.test(slug))) {
|
|
@@ -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
|
+
}
|
package/src/server/http.ts
CHANGED
|
@@ -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.
|
package/src/server/providers.ts
CHANGED
|
@@ -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. */
|
|
@@ -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
|
+
});
|