auto-model-router 0.32.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +29 -0
- package/package.json +1 -1
- package/src/router/candidates.ts +9 -0
- package/src/router/types.ts +2 -0
- package/src/server/http.ts +6 -0
- package/src/server/turn.ts +3 -1
- package/src/upstream/anthropic.ts +5 -4
- package/src/upstream/compat.ts +13 -7
- package/src/upstream/ollama.ts +4 -3
- package/src/upstream/openrouter.ts +4 -3
- package/src/upstream/types.ts +7 -0
- package/src/wire/openai/request.ts +29 -0
- package/src/wire/types.ts +15 -0
- package/test/failover.test.ts +41 -0
- package/test/upstream-keys.test.ts +240 -0
|
@@ -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.33.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.33.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/README.md
CHANGED
|
@@ -1426,6 +1426,35 @@ ignored rather than failing the turn. The decision trail records what the
|
|
|
1426
1426
|
policy changed (`policy: …`), and `GET /v1/router/catalog?policy=…` shows what a
|
|
1427
1427
|
policy admits, model by model, without routing a turn.
|
|
1428
1428
|
|
|
1429
|
+
## Per-turn upstream credentials
|
|
1430
|
+
|
|
1431
|
+
The same front door can also send the credentials one turn dispatches with, in
|
|
1432
|
+
an `X-Omp-Upstream-Keys` header carrying JSON that maps an upstream id to a
|
|
1433
|
+
credential:
|
|
1434
|
+
|
|
1435
|
+
```json
|
|
1436
|
+
{ "openrouter": "sk-or-v1-…", "azure-eu": "…" }
|
|
1437
|
+
```
|
|
1438
|
+
|
|
1439
|
+
The ids are the ones the catalog and `/health` use: `openrouter`, `ollama`, or
|
|
1440
|
+
a named entry's `id`. An upstream named here dispatches with that credential
|
|
1441
|
+
for the whole turn — every retry, same-tier failover and tier escalation
|
|
1442
|
+
included — instead of its configured `apiKey`; one not named keeps the
|
|
1443
|
+
configured key. An upstream mapped to `""` has **no** credential this turn and
|
|
1444
|
+
is excluded from selection rather than dispatched keyless, so a turn that
|
|
1445
|
+
carries nothing usable for a provider simply routes elsewhere.
|
|
1446
|
+
|
|
1447
|
+
Nothing is stored: the override lives on the parsed request and is read when a
|
|
1448
|
+
header is built, so the shared configuration is never written to and concurrent
|
|
1449
|
+
turns carrying different callers' keys cannot see each other's. The credential
|
|
1450
|
+
is a secret and is treated as one — it is never logged, never recorded in the
|
|
1451
|
+
ledger, and never repeated in an error or a decision reason. A malformed header
|
|
1452
|
+
is ignored like a malformed `X-Omp-Policy`, leaving the configured keys in
|
|
1453
|
+
force.
|
|
1454
|
+
|
|
1455
|
+
This is what lets one router serve callers who bring their own keys — the team
|
|
1456
|
+
edition's per-user credentials — without a process per credential set.
|
|
1457
|
+
|
|
1429
1458
|
## Data governance
|
|
1430
1459
|
|
|
1431
1460
|
Two things an operator with a compliance obligation needs from a router: that
|
package/package.json
CHANGED
package/src/router/candidates.ts
CHANGED
|
@@ -214,6 +214,15 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
214
214
|
continue;
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
// A per-turn credential map that names this model's upstream with an
|
|
218
|
+
// empty string says the turn HAS no key for it: dispatching would 401,
|
|
219
|
+
// so it is not a candidate this turn (and is a candidate again on the
|
|
220
|
+
// next turn that does carry one).
|
|
221
|
+
if (req.upstreamKeys?.[model.provider] === "") {
|
|
222
|
+
rejected.push({ slug, reason: "no_credential", detail: `upstream ${model.provider} has no credential on this turn` });
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
217
226
|
// Hard-coded denials, before any user configuration.
|
|
218
227
|
const builtIn = builtInDenial(model);
|
|
219
228
|
if (builtIn !== null) {
|
package/src/router/types.ts
CHANGED
|
@@ -164,6 +164,8 @@ export type RejectionReason =
|
|
|
164
164
|
| "free_tier_excluded"
|
|
165
165
|
| "reasoning_mandatory"
|
|
166
166
|
| "untrusted"
|
|
167
|
+
/** The turn's `X-Omp-Upstream-Keys` names this model's upstream with an empty credential: it cannot be dispatched to. */
|
|
168
|
+
| "no_credential"
|
|
167
169
|
/** Already failed on this turn; excluded so failover picks a different model. */
|
|
168
170
|
| "failed_this_turn";
|
|
169
171
|
|
package/src/server/http.ts
CHANGED
|
@@ -918,6 +918,12 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
918
918
|
const snap = catalog.peek();
|
|
919
919
|
return json({
|
|
920
920
|
status: "ok",
|
|
921
|
+
// What a front door may rely on, by name rather than by version: a
|
|
922
|
+
// team edition that sends per-turn credentials to a router without
|
|
923
|
+
// `upstream-keys` would have them ignored, and its tenants served on
|
|
924
|
+
// the deployment's own credential — a silent cross-charge. A name it
|
|
925
|
+
// can check turns that into a refusal it can explain.
|
|
926
|
+
features: ["upstream-keys"],
|
|
921
927
|
apiKeyConfigured: cfg.openrouter.apiKey !== "",
|
|
922
928
|
// Which upstreams turns can actually be served from: OpenRouter needs
|
|
923
929
|
// its key; Ollama needs to be on and out of cooldown.
|
package/src/server/turn.ts
CHANGED
|
@@ -472,7 +472,9 @@ export async function runTurn(
|
|
|
472
472
|
let streamEnded = false;
|
|
473
473
|
|
|
474
474
|
try {
|
|
475
|
-
|
|
475
|
+
// Inside the attempt loop, so a retry, a same-tier failover and a tier
|
|
476
|
+
// escalation all dispatch with the same per-turn credentials.
|
|
477
|
+
dispatch = await upstream.dispatch({ body, sessionId: decision.sessionId, signal: attemptSignal, ...(req.upstreamKeys === undefined ? {} : { upstreamKeys: req.upstreamKeys }) });
|
|
476
478
|
} catch (err) {
|
|
477
479
|
streamError = err;
|
|
478
480
|
}
|
|
@@ -410,13 +410,13 @@ export function createAnthropicClient(cfg: RouterConfig, id: string, fetchImpl:
|
|
|
410
410
|
if (caller && timeout) return AbortSignal.any([caller, timeout]);
|
|
411
411
|
return caller ?? timeout;
|
|
412
412
|
}
|
|
413
|
-
async function post(e: UpstreamEntry, body: Record<string, unknown>, signal: AbortSignal | undefined): Promise<Response> {
|
|
413
|
+
async function post(e: UpstreamEntry, body: Record<string, unknown>, signal: AbortSignal | undefined, apiKey: string = e.apiKey): Promise<Response> {
|
|
414
414
|
const headers: Record<string, string> = { "content-type": "application/json", "anthropic-version": ANTHROPIC_VERSION, ...e.headers };
|
|
415
415
|
if (e.auth === "oauth-bearer") {
|
|
416
416
|
// A Claude Pro/Max subscription token: Bearer auth at the first-party API, with the OAuth beta. No per-token cost is reported.
|
|
417
|
-
headers["authorization"] = `Bearer ${
|
|
417
|
+
headers["authorization"] = `Bearer ${apiKey}`;
|
|
418
418
|
headers["anthropic-beta"] = e.headers["anthropic-beta"] ?? "oauth-2025-04-20,claude-code-20250219";
|
|
419
|
-
} else if (
|
|
419
|
+
} else if (apiKey !== "") headers["x-api-key"] = apiKey;
|
|
420
420
|
try {
|
|
421
421
|
return await fetchImpl(`${e.baseUrl.replace(/\/+$/, "")}/v1/messages`, { method: "POST", headers, body: JSON.stringify(body), signal: composeSignal(e, signal) });
|
|
422
422
|
} catch (err) {
|
|
@@ -444,7 +444,8 @@ export function createAnthropicClient(cfg: RouterConfig, id: string, fetchImpl:
|
|
|
444
444
|
async dispatch(opts: DispatchOptions): Promise<Dispatch> {
|
|
445
445
|
const e = entry();
|
|
446
446
|
const { rendered, servedSlug } = render(e, { ...opts.body, stream: true });
|
|
447
|
-
|
|
447
|
+
// A per-turn credential for this upstream wins for this dispatch only; the shared entry is never touched.
|
|
448
|
+
const res = await post(e, rendered, opts.signal, opts.upstreamKeys?.[id] ?? e.apiKey);
|
|
448
449
|
if (!res.ok) throw await httpError(res);
|
|
449
450
|
if (!res.body) throw new UpstreamError("upstream_error", res.status, "response had no body", true);
|
|
450
451
|
const translator = createAnthropicTranslator(servedSlug);
|
package/src/upstream/compat.ts
CHANGED
|
@@ -113,15 +113,20 @@ function transportError(id: string, err: unknown): UpstreamError {
|
|
|
113
113
|
return new UpstreamError("network", 0, err instanceof Error ? err.message : String(err), true);
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
/**
|
|
117
|
-
|
|
116
|
+
/**
|
|
117
|
+
* The chat-completions URL and auth for an entry: Azure names the deployment in the path and keys with `api-key`.
|
|
118
|
+
*
|
|
119
|
+
* `apiKey` overrides the entry's own credential for one dispatch (a per-turn
|
|
120
|
+
* key from the front door). The entry itself is never written to.
|
|
121
|
+
*/
|
|
122
|
+
export function compatEndpoint(entry: UpstreamEntry, modelId: string, apiKey: string = entry.apiKey): { url: string; headers: Record<string, string> } {
|
|
118
123
|
const base = entry.baseUrl.replace(/\/+$/, "");
|
|
119
124
|
const headers: Record<string, string> = { "content-type": "application/json", ...entry.headers };
|
|
120
125
|
if (entry.kind === "azure") {
|
|
121
|
-
if (
|
|
126
|
+
if (apiKey !== "") headers["api-key"] = apiKey;
|
|
122
127
|
return { url: `${base}/openai/deployments/${encodeURIComponent(modelId)}/chat/completions?api-version=${encodeURIComponent(entry.apiVersion)}`, headers };
|
|
123
128
|
}
|
|
124
|
-
if (
|
|
129
|
+
if (apiKey !== "") headers.authorization = `Bearer ${apiKey}`;
|
|
125
130
|
return { url: `${base}/chat/completions`, headers };
|
|
126
131
|
}
|
|
127
132
|
|
|
@@ -195,8 +200,8 @@ export function createCompatClient(cfg: RouterConfig, id: string, fetchImpl: Fet
|
|
|
195
200
|
return err;
|
|
196
201
|
}
|
|
197
202
|
|
|
198
|
-
async function post(e: UpstreamEntry, body: Record<string, unknown>, signal: AbortSignal | undefined): Promise<Response> {
|
|
199
|
-
const { url, headers } = compatEndpoint(e, typeof body.model === "string" ? body.model : "");
|
|
203
|
+
async function post(e: UpstreamEntry, body: Record<string, unknown>, signal: AbortSignal | undefined, apiKey: string = e.apiKey): Promise<Response> {
|
|
204
|
+
const { url, headers } = compatEndpoint(e, typeof body.model === "string" ? body.model : "", apiKey);
|
|
200
205
|
try {
|
|
201
206
|
return await fetchImpl(url, { method: "POST", headers, body: JSON.stringify(body), signal: composeSignal(e, signal) });
|
|
202
207
|
} catch (err) {
|
|
@@ -212,7 +217,8 @@ export function createCompatClient(cfg: RouterConfig, id: string, fetchImpl: Fet
|
|
|
212
217
|
|
|
213
218
|
async dispatch(opts: DispatchOptions): Promise<Dispatch> {
|
|
214
219
|
const e = entry();
|
|
215
|
-
|
|
220
|
+
// A per-turn credential for this upstream wins for this dispatch only; the shared entry is never touched.
|
|
221
|
+
const res = await post(e, toCompatBody(id, { ...opts.body, stream: true }), opts.signal, opts.upstreamKeys?.[id] ?? e.apiKey);
|
|
216
222
|
if (!res.ok) throw await httpError(res);
|
|
217
223
|
if (!res.body) throw new UpstreamError("upstream_error", res.status, "response had no body", true);
|
|
218
224
|
const parsed = parseSse(res.body, (msg, fields) => log.warn(msg, fields));
|
package/src/upstream/ollama.ts
CHANGED
|
@@ -146,9 +146,9 @@ export function createOllamaClient(cfg: RouterConfig, fetchImpl: FetchLike = fet
|
|
|
146
146
|
log.warn("ollama cloud unavailable; routing around it", { kind: err.kind, cooldownMs: ms, message: err.message });
|
|
147
147
|
};
|
|
148
148
|
|
|
149
|
-
function headers(extra: Record<string, string> = {}): Record<string, string> {
|
|
149
|
+
function headers(extra: Record<string, string> = {}, apiKey: string = o.apiKey): Record<string, string> {
|
|
150
150
|
const h: Record<string, string> = { "content-type": "application/json", ...extra };
|
|
151
|
-
if (
|
|
151
|
+
if (apiKey !== "") h.authorization = `Bearer ${apiKey}`;
|
|
152
152
|
return h;
|
|
153
153
|
}
|
|
154
154
|
|
|
@@ -195,7 +195,8 @@ export function createOllamaClient(cfg: RouterConfig, fetchImpl: FetchLike = fet
|
|
|
195
195
|
try {
|
|
196
196
|
res = await fetchImpl(`${baseUrl()}/chat/completions`, {
|
|
197
197
|
method: "POST",
|
|
198
|
-
|
|
198
|
+
// A per-turn credential wins for this dispatch only; cfg is never written to.
|
|
199
|
+
headers: headers({}, opts.upstreamKeys?.ollama ?? o.apiKey),
|
|
199
200
|
body: JSON.stringify(body),
|
|
200
201
|
signal: composeSignal(opts.signal),
|
|
201
202
|
});
|
|
@@ -114,14 +114,14 @@ export function createOpenRouterClient(cfg: RouterConfig): UpstreamClient {
|
|
|
114
114
|
return caller ?? timeout;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
function headers(extra: Record<string, string
|
|
117
|
+
function headers(extra: Record<string, string>, apiKey: string = cfg.openrouter.apiKey): Record<string, string> {
|
|
118
118
|
const h: Record<string, string> = {
|
|
119
119
|
"content-type": "application/json",
|
|
120
120
|
"x-title": cfg.openrouter.title,
|
|
121
121
|
...extra,
|
|
122
122
|
};
|
|
123
123
|
// /models is public; an empty key must not produce a broken Bearer header.
|
|
124
|
-
if (
|
|
124
|
+
if (apiKey !== "") h.authorization = `Bearer ${apiKey}`;
|
|
125
125
|
if (cfg.openrouter.referer) h["http-referer"] = cfg.openrouter.referer;
|
|
126
126
|
return h;
|
|
127
127
|
}
|
|
@@ -139,7 +139,8 @@ export function createOpenRouterClient(cfg: RouterConfig): UpstreamClient {
|
|
|
139
139
|
try {
|
|
140
140
|
res = await fetch(`${baseUrl}/chat/completions`, {
|
|
141
141
|
method: "POST",
|
|
142
|
-
|
|
142
|
+
// A per-turn credential wins for this dispatch only; cfg is never written to.
|
|
143
|
+
headers: headers({ "x-session-id": opts.sessionId }, opts.upstreamKeys?.openrouter ?? cfg.openrouter.apiKey),
|
|
143
144
|
body: JSON.stringify(body),
|
|
144
145
|
signal,
|
|
145
146
|
});
|
package/src/upstream/types.ts
CHANGED
|
@@ -43,6 +43,13 @@ export interface DispatchOptions {
|
|
|
43
43
|
body: Record<string, unknown>;
|
|
44
44
|
/** Forwarded as the `x-session-id` header, mirroring body `session_id`. */
|
|
45
45
|
sessionId: string;
|
|
46
|
+
/**
|
|
47
|
+
* Per-turn credentials by upstream id (`NormRequest.upstreamKeys`). The
|
|
48
|
+
* client dispatching this body prefers its own entry over the configured
|
|
49
|
+
* `apiKey`, without ever writing to the shared config: concurrent turns
|
|
50
|
+
* carry different tenants' keys over the same `UpstreamEntry`.
|
|
51
|
+
*/
|
|
52
|
+
upstreamKeys?: Readonly<Record<string, string>>;
|
|
46
53
|
signal: AbortSignal;
|
|
47
54
|
}
|
|
48
55
|
|
|
@@ -315,6 +315,31 @@ export function parsePolicyHeader(raw: string | null): RequestPolicy | undefined
|
|
|
315
315
|
return Object.keys(out).length === 0 ? undefined : out;
|
|
316
316
|
}
|
|
317
317
|
|
|
318
|
+
/**
|
|
319
|
+
* Parses the X-Omp-Upstream-Keys header: `{ "<upstream id>": "<credential>" }`.
|
|
320
|
+
* Malformed, not an object, or empty ⇒ no overrides, exactly like a malformed
|
|
321
|
+
* X-Omp-Policy (never a rejected turn — the configured keys still serve).
|
|
322
|
+
* Non-string values are dropped; `""` is KEPT, meaning "this turn has no
|
|
323
|
+
* credential for that upstream", which excludes it from selection.
|
|
324
|
+
*/
|
|
325
|
+
export function parseUpstreamKeysHeader(raw: string | null): Record<string, string> | undefined {
|
|
326
|
+
if (raw === null || raw.trim() === "") return undefined;
|
|
327
|
+
let parsed: unknown;
|
|
328
|
+
try {
|
|
329
|
+
parsed = JSON.parse(raw);
|
|
330
|
+
} catch {
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
333
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
334
|
+
const out: Record<string, string> = {};
|
|
335
|
+
for (const [id, value] of Object.entries(parsed as Record<string, unknown>)) {
|
|
336
|
+
// The id is config-shaped, so trim it; the credential is copied verbatim.
|
|
337
|
+
const key = id.trim();
|
|
338
|
+
if (key !== "" && typeof value === "string") out[key] = value;
|
|
339
|
+
}
|
|
340
|
+
return Object.keys(out).length === 0 ? undefined : out;
|
|
341
|
+
}
|
|
342
|
+
|
|
318
343
|
export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
|
|
319
344
|
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
320
345
|
throw invalidRequest("Request body must be a JSON object");
|
|
@@ -352,6 +377,9 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
|
|
|
352
377
|
// Per-request routing policy (team edition): JSON in X-Omp-Policy.
|
|
353
378
|
const policy = parsePolicyHeader(headers.get("x-omp-policy"));
|
|
354
379
|
|
|
380
|
+
// Per-turn upstream credentials (team edition): JSON in X-Omp-Upstream-Keys.
|
|
381
|
+
const upstreamKeys = parseUpstreamKeysHeader(headers.get("x-omp-upstream-keys"));
|
|
382
|
+
|
|
355
383
|
if (typeof b.model !== "string" || b.model.length === 0) {
|
|
356
384
|
throw invalidRequest("model must be a non-empty string");
|
|
357
385
|
}
|
|
@@ -417,6 +445,7 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
|
|
|
417
445
|
agentdoxOrigin,
|
|
418
446
|
isSubagent,
|
|
419
447
|
...(policy === undefined ? {} : { policy }),
|
|
448
|
+
...(upstreamKeys === undefined ? {} : { upstreamKeys }),
|
|
420
449
|
requestedModel,
|
|
421
450
|
requestedModelFull: b.model,
|
|
422
451
|
messages,
|
package/src/wire/types.ts
CHANGED
|
@@ -126,6 +126,21 @@ export interface NormRequest {
|
|
|
126
126
|
* to. Absent ⇒ the configured profile and filters alone.
|
|
127
127
|
*/
|
|
128
128
|
policy?: RequestPolicy;
|
|
129
|
+
/**
|
|
130
|
+
* Per-turn upstream credentials from the `X-Omp-Upstream-Keys` header
|
|
131
|
+
* (JSON `{ "<upstream id>": "<credential>" }`), set by a front door whose
|
|
132
|
+
* callers bring their own keys: one router fleet then serves every tenant
|
|
133
|
+
* instead of one process per credential set. Keyed by upstream id —
|
|
134
|
+
* `openrouter`, `ollama`, or a named entry's `id`.
|
|
135
|
+
*
|
|
136
|
+
* An upstream named here dispatches with this credential for the whole
|
|
137
|
+
* turn, every retry and failover included; one not named keeps its
|
|
138
|
+
* configured `apiKey`; one named with `""` carries no credential and is
|
|
139
|
+
* excluded from candidate selection rather than dispatched keyless.
|
|
140
|
+
*
|
|
141
|
+
* A secret: never logged, recorded, or repeated in an error.
|
|
142
|
+
*/
|
|
143
|
+
upstreamKeys?: Readonly<Record<string, string>>;
|
|
129
144
|
/** Virtual model the client selected, e.g. `auto`, `auto-cheap`, `auto-max`. */
|
|
130
145
|
requestedModel: string;
|
|
131
146
|
/**
|
package/test/failover.test.ts
CHANGED
|
@@ -703,3 +703,44 @@ describe("400 classification (review 2026-09-05 follow-up)", () => {
|
|
|
703
703
|
});
|
|
704
704
|
});
|
|
705
705
|
|
|
706
|
+
describe("per-turn upstream credentials", () => {
|
|
707
|
+
test("the turn's credentials reach the first dispatch, the failover retry, and nothing else", async () => {
|
|
708
|
+
const { router } = mkRouter([mkDecision("moderate", "a/model"), mkDecision("moderate", "b/model")]);
|
|
709
|
+
const { upstream, calls: dispatches } = mkUpstream([
|
|
710
|
+
{ kind: "fail", error: new UpstreamError("model_unavailable", 404, "no endpoints found", true) },
|
|
711
|
+
{ kind: "chunks", chunks: okChunks("b/model") },
|
|
712
|
+
]);
|
|
713
|
+
const { ledger, entries } = mkLedger();
|
|
714
|
+
const { store } = mkConversations();
|
|
715
|
+
const { sink, errors, finishes } = mkSink();
|
|
716
|
+
const upstreamKeys = { "up-a": "sk-tenant-secret", "up-b": "sk-other-secret" };
|
|
717
|
+
|
|
718
|
+
await runTurn({ ...mkReq(), upstreamKeys }, sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog, context: createDisabledBridge() }, new AbortController().signal);
|
|
719
|
+
|
|
720
|
+
expect(errors).toHaveLength(0);
|
|
721
|
+
expect(finishes).toHaveLength(1);
|
|
722
|
+
// Both attempts carry them: a retry that dropped the credential would 401.
|
|
723
|
+
expect(dispatches).toHaveLength(2);
|
|
724
|
+
for (const d of dispatches) expect(d.upstreamKeys).toEqual(upstreamKeys);
|
|
725
|
+
|
|
726
|
+
// A secret: it is in no ledger row, no decision reason, and no error text.
|
|
727
|
+
const recorded = JSON.stringify(entries);
|
|
728
|
+
expect(recorded).not.toContain("sk-tenant-secret");
|
|
729
|
+
expect(recorded).not.toContain("sk-other-secret");
|
|
730
|
+
expect(JSON.stringify(finishes)).not.toContain("sk-tenant-secret");
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
test("a turn without the header dispatches with no override at all", async () => {
|
|
734
|
+
const { router } = mkRouter([mkDecision("moderate", "a/model")]);
|
|
735
|
+
const { upstream, calls: dispatches } = mkUpstream([{ kind: "chunks", chunks: okChunks("a/model") }]);
|
|
736
|
+
const { ledger } = mkLedger();
|
|
737
|
+
const { store } = mkConversations();
|
|
738
|
+
const { sink, errors } = mkSink();
|
|
739
|
+
|
|
740
|
+
await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog, context: createDisabledBridge() }, new AbortController().signal);
|
|
741
|
+
|
|
742
|
+
expect(errors).toHaveLength(0);
|
|
743
|
+
expect("upstreamKeys" in dispatches[0]!).toBe(false);
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { buildUpstreamModels } from "../src/catalog/static-catalog.ts";
|
|
4
|
+
import type { CatalogSnapshot } from "../src/catalog/types.ts";
|
|
5
|
+
import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
|
|
6
|
+
import type { RouterConfig, UpstreamEntry } from "../src/config/types.ts";
|
|
7
|
+
import { completeUpstreamEntry } from "../src/config/upstreams.ts";
|
|
8
|
+
import { buildCandidates } from "../src/router/candidates.ts";
|
|
9
|
+
import { extractFeatures } from "../src/router/features.ts";
|
|
10
|
+
import type { Rejection } from "../src/router/types.ts";
|
|
11
|
+
import { createAnthropicClient } from "../src/upstream/anthropic.ts";
|
|
12
|
+
import { compatEndpoint, createCompatClient } from "../src/upstream/compat.ts";
|
|
13
|
+
import { createOllamaClient } from "../src/upstream/ollama.ts";
|
|
14
|
+
import { createOpenRouterClient } from "../src/upstream/openrouter.ts";
|
|
15
|
+
import { parseMessagesRequest } from "../src/wire/anthropic/messages.ts";
|
|
16
|
+
import { parseChatRequest, parseUpstreamKeysHeader } from "../src/wire/openai/request.ts";
|
|
17
|
+
import { parseResponsesRequest } from "../src/wire/openai/responses.ts";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Per-turn upstream credentials (X-Omp-Upstream-Keys): a front door whose
|
|
21
|
+
* callers bring their own keys sends the turn's credentials in a header, so one
|
|
22
|
+
* router fleet serves every tenant instead of one process per credential set.
|
|
23
|
+
*
|
|
24
|
+
* The property everything else rests on: the credential belongs to the TURN.
|
|
25
|
+
* The shared `UpstreamEntry` is never written to, so two concurrent turns
|
|
26
|
+
* cannot see each other's key.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const NL = String.fromCharCode(10);
|
|
30
|
+
|
|
31
|
+
function entry(over: Partial<UpstreamEntry> & { id: string; kind: UpstreamEntry["kind"] }): UpstreamEntry {
|
|
32
|
+
return completeUpstreamEntry({ baseUrl: "https://api.example/v1", apiKey: "sk-configured", models: [{ id: "m1", input: 1, output: 4 }], ...over });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function cfgWith(upstreams: UpstreamEntry[]): RouterConfig {
|
|
36
|
+
return { ...structuredClone(DEFAULT_CONFIG), upstreams, logLevel: "silent" };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sse(frames: string[]): Response {
|
|
40
|
+
return new Response(
|
|
41
|
+
new ReadableStream({
|
|
42
|
+
start: (c) => {
|
|
43
|
+
for (const f of frames) c.enqueue(new TextEncoder().encode(`${f}${NL}${NL}`));
|
|
44
|
+
c.close();
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
{ status: 200, headers: { "content-type": "text/event-stream" } },
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Drains a dispatch so the fake upstream's stream is consumed like a real turn's. */
|
|
52
|
+
async function drain(d: { chunks: AsyncIterable<unknown> }): Promise<void> {
|
|
53
|
+
for await (const c of d.chunks) void c;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const OK_FRAMES = [`data: ${JSON.stringify({ id: "gen-1", model: "m1", choices: [{ delta: { content: "hi" } }] })}`, "data: [DONE]"];
|
|
57
|
+
const ANTHROPIC_FRAMES = [
|
|
58
|
+
`event: message_start${NL}data: ${JSON.stringify({ type: "message_start", message: { id: "msg_1", model: "claude-sonnet-4", usage: { input_tokens: 1, output_tokens: 0 } } })}`,
|
|
59
|
+
`event: message_stop${NL}data: ${JSON.stringify({ type: "message_stop" })}`,
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const SIGNAL = new AbortController().signal;
|
|
63
|
+
|
|
64
|
+
describe("parseUpstreamKeysHeader", () => {
|
|
65
|
+
test("accepts an id→credential object, drops junk, and never rejects a turn", async () => {
|
|
66
|
+
// Same defensive contract as X-Omp-Policy: anything unusable is simply no override.
|
|
67
|
+
expect(parseUpstreamKeysHeader(null)).toBeUndefined();
|
|
68
|
+
expect(parseUpstreamKeysHeader(" ")).toBeUndefined();
|
|
69
|
+
expect(parseUpstreamKeysHeader("not json")).toBeUndefined();
|
|
70
|
+
expect(parseUpstreamKeysHeader('["sk-x"]')).toBeUndefined();
|
|
71
|
+
expect(parseUpstreamKeysHeader('"sk-x"')).toBeUndefined();
|
|
72
|
+
expect(parseUpstreamKeysHeader("{}")).toBeUndefined();
|
|
73
|
+
// Non-string values are dropped; an empty string is KEPT — it means "no credential this turn".
|
|
74
|
+
expect(parseUpstreamKeysHeader(JSON.stringify({ openrouter: "sk-or-1", " azure-eu ": "az", bad: 7, worse: null, "": "x", off: "" }))).toEqual({
|
|
75
|
+
openrouter: "sk-or-1",
|
|
76
|
+
"azure-eu": "az",
|
|
77
|
+
off: "",
|
|
78
|
+
});
|
|
79
|
+
// The credential is copied verbatim: trimming one would break a key whose bytes matter.
|
|
80
|
+
expect(parseUpstreamKeysHeader(JSON.stringify({ up: " sk-pad " }))).toEqual({ up: " sk-pad " });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("every wire carries the map, and its absence leaves the property off", async () => {
|
|
84
|
+
const header = new Headers({ "X-Omp-Upstream-Keys": '{"openrouter":"sk-or-tenant"}' });
|
|
85
|
+
const chat = parseChatRequest({ model: "auto", messages: [{ role: "user", content: "hi" }] }, header);
|
|
86
|
+
const messages = parseMessagesRequest({ model: "claude-sonnet-4", messages: [{ role: "user", content: "hi" }], max_tokens: 16 }, header);
|
|
87
|
+
const responses = parseResponsesRequest({ model: "auto", input: "hi" }, header);
|
|
88
|
+
for (const req of [chat, messages, responses]) expect(req.upstreamKeys).toEqual({ openrouter: "sk-or-tenant" });
|
|
89
|
+
expect([chat.protocol, messages.protocol, responses.protocol]).toEqual(["openai-chat", "anthropic-messages", "openai-responses"]);
|
|
90
|
+
// exactOptionalPropertyTypes: absent means absent, not `undefined`.
|
|
91
|
+
expect("upstreamKeys" in parseChatRequest({ model: "auto", messages: [{ role: "user", content: "hi" }] }, new Headers())).toBe(false);
|
|
92
|
+
expect("upstreamKeys" in parseMessagesRequest({ model: "claude-sonnet-4", messages: [{ role: "user", content: "hi" }], max_tokens: 16 }, new Headers())).toBe(false);
|
|
93
|
+
expect("upstreamKeys" in parseResponsesRequest({ model: "auto", input: "hi" }, new Headers())).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("per-turn credentials at dispatch", () => {
|
|
98
|
+
test("compatEndpoint prefers the per-turn key without touching the entry", async () => {
|
|
99
|
+
const e = entry({ id: "openai-direct", kind: "openai" });
|
|
100
|
+
expect(compatEndpoint(e, "m1").headers.authorization).toBe("Bearer sk-configured");
|
|
101
|
+
expect(compatEndpoint(e, "m1", "sk-turn").headers.authorization).toBe("Bearer sk-turn");
|
|
102
|
+
// Azure keys with its own header, and an empty credential sends none at all.
|
|
103
|
+
const az = entry({ id: "azure-eu", kind: "azure" });
|
|
104
|
+
expect(compatEndpoint(az, "dep", "az-turn").headers["api-key"]).toBe("az-turn");
|
|
105
|
+
expect(compatEndpoint(az, "dep", "").headers["api-key"]).toBeUndefined();
|
|
106
|
+
expect(e.apiKey).toBe("sk-configured");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("an OpenAI-compatible upstream uses the turn's key, else the configured one", async () => {
|
|
110
|
+
const seen: Array<string | null> = [];
|
|
111
|
+
const cfg = cfgWith([entry({ id: "openai-direct", kind: "openai" })]);
|
|
112
|
+
const client = createCompatClient(cfg, "openai-direct", async (_url, init) => {
|
|
113
|
+
seen.push(new Headers(init?.headers).get("authorization"));
|
|
114
|
+
return sse(OK_FRAMES);
|
|
115
|
+
});
|
|
116
|
+
const body = { model: "openai-direct/m1", messages: [] };
|
|
117
|
+
await drain(await client.dispatch({ body, sessionId: "s", signal: SIGNAL, upstreamKeys: { "openai-direct": "sk-turn" } }));
|
|
118
|
+
await drain(await client.dispatch({ body, sessionId: "s", signal: SIGNAL }));
|
|
119
|
+
// A map that names a DIFFERENT upstream leaves this one on its own key.
|
|
120
|
+
await drain(await client.dispatch({ body, sessionId: "s", signal: SIGNAL, upstreamKeys: { openrouter: "sk-or" } }));
|
|
121
|
+
// An empty credential is "none", never a fallback to the configured key.
|
|
122
|
+
await drain(await client.dispatch({ body, sessionId: "s", signal: SIGNAL, upstreamKeys: { "openai-direct": "" } }));
|
|
123
|
+
expect(seen).toEqual(["Bearer sk-turn", "Bearer sk-configured", "Bearer sk-configured", null]);
|
|
124
|
+
expect(cfg.upstreams[0]!.apiKey).toBe("sk-configured");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("an Anthropic upstream uses the turn's key on both auth shapes", async () => {
|
|
128
|
+
const seen: Array<Record<string, string | null>> = [];
|
|
129
|
+
const cfg = cfgWith([
|
|
130
|
+
entry({ id: "anthropic-direct", kind: "anthropic", apiKey: "sk-ant-configured" }),
|
|
131
|
+
entry({ id: "claude-sub", kind: "anthropic", auth: "oauth-bearer", apiKey: "oauth-configured" }),
|
|
132
|
+
]);
|
|
133
|
+
const fetchImpl = async (_url: unknown, init?: RequestInit): Promise<Response> => {
|
|
134
|
+
const h = new Headers(init?.headers);
|
|
135
|
+
seen.push({ "x-api-key": h.get("x-api-key"), authorization: h.get("authorization") });
|
|
136
|
+
return sse(ANTHROPIC_FRAMES);
|
|
137
|
+
};
|
|
138
|
+
const direct = createAnthropicClient(cfg, "anthropic-direct", fetchImpl);
|
|
139
|
+
const sub = createAnthropicClient(cfg, "claude-sub", fetchImpl);
|
|
140
|
+
await drain(await direct.dispatch({ body: { model: "anthropic-direct/m1", messages: [{ role: "user", content: "hi" }], max_tokens: 16 }, sessionId: "s", signal: SIGNAL, upstreamKeys: { "anthropic-direct": "sk-ant-turn" } }));
|
|
141
|
+
await drain(await direct.dispatch({ body: { model: "anthropic-direct/m1", messages: [{ role: "user", content: "hi" }], max_tokens: 16 }, sessionId: "s", signal: SIGNAL }));
|
|
142
|
+
await drain(await sub.dispatch({ body: { model: "claude-sub/m1", messages: [{ role: "user", content: "hi" }], max_tokens: 16 }, sessionId: "s", signal: SIGNAL, upstreamKeys: { "claude-sub": "oauth-turn" } }));
|
|
143
|
+
expect(seen).toEqual([
|
|
144
|
+
{ "x-api-key": "sk-ant-turn", authorization: null },
|
|
145
|
+
{ "x-api-key": "sk-ant-configured", authorization: null },
|
|
146
|
+
{ "x-api-key": null, authorization: "Bearer oauth-turn" },
|
|
147
|
+
]);
|
|
148
|
+
expect(cfg.upstreams.map((u) => u.apiKey)).toEqual(["sk-ant-configured", "oauth-configured"]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("OpenRouter and Ollama take an override under their own reserved ids", async () => {
|
|
152
|
+
const cfg = cfgWith([]);
|
|
153
|
+
cfg.openrouter.apiKey = "sk-or-configured";
|
|
154
|
+
cfg.ollama = { ...cfg.ollama, apiKey: "sk-ollama-configured" };
|
|
155
|
+
const seen: Array<string | null> = [];
|
|
156
|
+
const realFetch = globalThis.fetch;
|
|
157
|
+
globalThis.fetch = (async (_url: unknown, init?: RequestInit) => {
|
|
158
|
+
seen.push(new Headers(init?.headers).get("authorization"));
|
|
159
|
+
return sse(OK_FRAMES);
|
|
160
|
+
}) as unknown as typeof fetch;
|
|
161
|
+
try {
|
|
162
|
+
const or = createOpenRouterClient(cfg);
|
|
163
|
+
await drain(await or.dispatch({ body: { model: "a/b", messages: [] }, sessionId: "s", signal: SIGNAL, upstreamKeys: { openrouter: "sk-or-turn" } }));
|
|
164
|
+
await drain(await or.dispatch({ body: { model: "a/b", messages: [] }, sessionId: "s", signal: SIGNAL }));
|
|
165
|
+
} finally {
|
|
166
|
+
globalThis.fetch = realFetch;
|
|
167
|
+
}
|
|
168
|
+
const ollama = createOllamaClient(cfg, async (_url, init) => {
|
|
169
|
+
seen.push(new Headers(init?.headers).get("authorization"));
|
|
170
|
+
return sse(OK_FRAMES);
|
|
171
|
+
});
|
|
172
|
+
await drain(await ollama.dispatch({ body: { model: "ollama/m", messages: [] }, sessionId: "s", signal: SIGNAL, upstreamKeys: { ollama: "sk-ollama-turn" } }));
|
|
173
|
+
await drain(await ollama.dispatch({ body: { model: "ollama/m", messages: [] }, sessionId: "s", signal: SIGNAL }));
|
|
174
|
+
expect(seen).toEqual(["Bearer sk-or-turn", "Bearer sk-or-configured", "Bearer sk-ollama-turn", "Bearer sk-ollama-configured"]);
|
|
175
|
+
expect(cfg.openrouter.apiKey).toBe("sk-or-configured");
|
|
176
|
+
expect(cfg.ollama.apiKey).toBe("sk-ollama-configured");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("two concurrent turns over one entry never see each other's credential", async () => {
|
|
180
|
+
const cfg = cfgWith([entry({ id: "openai-direct", kind: "openai" })]);
|
|
181
|
+
let release!: () => void;
|
|
182
|
+
const gate = new Promise<void>((resolve) => {
|
|
183
|
+
release = resolve;
|
|
184
|
+
});
|
|
185
|
+
const seen: Array<string | null> = [];
|
|
186
|
+
let inFlight = 0;
|
|
187
|
+
const client = createCompatClient(cfg, "openai-direct", async (_url, init) => {
|
|
188
|
+
const auth = new Headers(init?.headers).get("authorization");
|
|
189
|
+
// Hold both requests open at once: a mutation-based implementation
|
|
190
|
+
// would have overwritten the first turn's key by the time it reads.
|
|
191
|
+
inFlight++;
|
|
192
|
+
if (inFlight === 1) await gate;
|
|
193
|
+
else release();
|
|
194
|
+
seen.push(auth);
|
|
195
|
+
return sse(OK_FRAMES);
|
|
196
|
+
});
|
|
197
|
+
const turn = async (key: string): Promise<void> => {
|
|
198
|
+
const d = await client.dispatch({ body: { model: "openai-direct/m1", messages: [] }, sessionId: "s", signal: SIGNAL, upstreamKeys: { "openai-direct": key } });
|
|
199
|
+
await drain(d);
|
|
200
|
+
};
|
|
201
|
+
await Promise.all([turn("sk-tenant-a"), turn("sk-tenant-b")]);
|
|
202
|
+
expect(seen.sort()).toEqual(["Bearer sk-tenant-a", "Bearer sk-tenant-b"]);
|
|
203
|
+
// The shared config is exactly as it was configured.
|
|
204
|
+
expect(cfg.upstreams[0]!.apiKey).toBe("sk-configured");
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("candidate selection with per-turn credentials", () => {
|
|
209
|
+
const twoUpstreams = [
|
|
210
|
+
...buildUpstreamModels(entry({ id: "up-a", kind: "openai", models: [{ id: "m1", input: 1, output: 4, quality: { coding: 80, intelligence: 80, agentic: 80 } }] }), []),
|
|
211
|
+
...buildUpstreamModels(entry({ id: "up-b", kind: "openai", models: [{ id: "m1", input: 1, output: 4, quality: { coding: 80, intelligence: 80, agentic: 80 } }] }), []),
|
|
212
|
+
];
|
|
213
|
+
const snapshot: CatalogSnapshot = { models: twoUpstreams, fetchedAtMs: Date.now(), keyScoped: true };
|
|
214
|
+
const cfg: RouterConfig = { ...DEFAULT_CONFIG, adaptiveTierFloors: false, filters: { ...DEFAULT_CONFIG.filters, minTrust: 0 }, tiers: { ...DEFAULT_CONFIG.tiers, moderate: { ...DEFAULT_CONFIG.tiers.moderate, minQuality: 0, maxInputPerMtok: 10 } } };
|
|
215
|
+
|
|
216
|
+
function build(keys: Record<string, string> | undefined): { slugs: string[]; rejected: Rejection[] } {
|
|
217
|
+
const req = parseChatRequest(
|
|
218
|
+
{ model: "auto", messages: [{ role: "user", content: "hi" }] },
|
|
219
|
+
new Headers(keys === undefined ? {} : { "x-omp-upstream-keys": JSON.stringify(keys) }),
|
|
220
|
+
);
|
|
221
|
+
const { candidates, rejected } = buildCandidates({ req, features: extractFeatures(req, 100), tier: "moderate", task: "chat", snapshot, cfg, expectedCompletionTokens: 128, warmSlug: null });
|
|
222
|
+
return { slugs: candidates.map((c) => c.model.slug), rejected };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
test("an upstream with an empty credential is excluded for that turn only", async () => {
|
|
226
|
+
expect(build(undefined).slugs.sort()).toEqual(["up-a/m1", "up-b/m1"]);
|
|
227
|
+
// A real credential changes nothing about who may be selected.
|
|
228
|
+
expect(build({ "up-a": "sk-turn" }).slugs.sort()).toEqual(["up-a/m1", "up-b/m1"]);
|
|
229
|
+
const off = build({ "up-a": "" });
|
|
230
|
+
expect(off.slugs).toEqual(["up-b/m1"]);
|
|
231
|
+
expect(off.rejected).toContainEqual({ slug: "up-a/m1", reason: "no_credential", detail: "upstream up-a has no credential on this turn" });
|
|
232
|
+
// The next turn, carrying a key, sees it again: nothing was recorded anywhere.
|
|
233
|
+
expect(build({ "up-a": "sk-turn" }).slugs.sort()).toEqual(["up-a/m1", "up-b/m1"]);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("no rejection reason repeats the credential", async () => {
|
|
237
|
+
const { rejected } = build({ "up-a": "", "up-b": "sk-secret-value" });
|
|
238
|
+
expect(JSON.stringify(rejected)).not.toContain("sk-secret-value");
|
|
239
|
+
});
|
|
240
|
+
});
|