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.
@@ -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.32.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.32.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -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) {
@@ -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
 
@@ -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.
@@ -472,7 +472,9 @@ export async function runTurn(
472
472
  let streamEnded = false;
473
473
 
474
474
  try {
475
- dispatch = await upstream.dispatch({ body, sessionId: decision.sessionId, signal: attemptSignal });
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 ${e.apiKey}`;
417
+ headers["authorization"] = `Bearer ${apiKey}`;
418
418
  headers["anthropic-beta"] = e.headers["anthropic-beta"] ?? "oauth-2025-04-20,claude-code-20250219";
419
- } else if (e.apiKey !== "") headers["x-api-key"] = e.apiKey;
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
- const res = await post(e, rendered, opts.signal);
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);
@@ -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
- /** The chat-completions URL and auth for an entry: Azure names the deployment in the path and keys with `api-key`. */
117
- export function compatEndpoint(entry: UpstreamEntry, modelId: string): { url: string; headers: Record<string, string> } {
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 (entry.apiKey !== "") headers["api-key"] = entry.apiKey;
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 (entry.apiKey !== "") headers.authorization = `Bearer ${entry.apiKey}`;
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
- const res = await post(e, toCompatBody(id, { ...opts.body, stream: true }), opts.signal);
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));
@@ -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 (o.apiKey !== "") h.authorization = `Bearer ${o.apiKey}`;
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
- headers: headers(),
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>): 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 (cfg.openrouter.apiKey !== "") h.authorization = `Bearer ${cfg.openrouter.apiKey}`;
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
- headers: headers({ "x-session-id": opts.sessionId }),
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
  });
@@ -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
  /**
@@ -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
+ });