myapikey 0.47.1 → 0.48.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myapikey",
3
- "version": "0.47.1",
3
+ "version": "0.48.1",
4
4
  "type": "module",
5
5
  "description": "Personal LLM API gateway & proxy — one address + one API key for all your models. Forwards OpenAI & Anthropic calls to your backends with failover and a circuit breaker. Pure passthrough, no format translation. Self-hosted (CLI + web UI).",
6
6
  "keywords": [
@@ -27,7 +27,7 @@ function mask(key: string): string {
27
27
  }
28
28
 
29
29
  /** Order-insensitive signature of a formats list, for change detection. */
30
- function formatsKey(f: Format[]): string {
30
+ function formatsKey(f: RouteKey[]): string {
31
31
  return [...f].sort().join(",");
32
32
  }
33
33
 
@@ -38,10 +38,10 @@ function coerceRpm(v: unknown): number | undefined {
38
38
  return typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
39
39
  }
40
40
 
41
- /** Whether a provider is a valid source for a routing slot: openai/anthropic
42
- * require that wire format; responses requires supportsResponses. */
41
+ /** Whether a provider is a valid source for a routing slot: it must still offer
42
+ * that format (formats and base URLs live and move together). */
43
43
  function providerSpeaks(p: Provider, key: RouteKey): boolean {
44
- return key === "responses" ? !!p.supportsResponses : p.formats.includes(key);
44
+ return p.formats.includes(key);
45
45
  }
46
46
 
47
47
  /** The body fields a chain slot's sampling default may set — the wire-agnostic
@@ -96,8 +96,8 @@ function toPublic(p: Provider) {
96
96
  name: p.name,
97
97
  baseUrlOpenai: p.baseUrlOpenai,
98
98
  baseUrlAnthropic: p.baseUrlAnthropic,
99
+ baseUrlResponses: p.baseUrlResponses,
99
100
  formats: p.formats,
100
- supportsResponses: p.supportsResponses ?? false,
101
101
  apiKey: mask(p.apiKey),
102
102
  rpm: p.rpm ?? 0,
103
103
  discoveredModels: p.discoveredModels ?? [],
@@ -129,6 +129,10 @@ export async function discoverModels(p: Provider): Promise<string[]> {
129
129
  const attempts: { base: string; suffix: string; headers: Record<string, string> }[] = [];
130
130
  if (p.formats.includes("openai"))
131
131
  attempts.push({ base: p.baseUrlOpenai, suffix: "models", headers: { authorization: `Bearer ${p.apiKey}` } });
132
+ // Responses usually shares the chat list, so it's tried after openai — only
133
+ // reached when the chat base is unset (responses-only source) or empty-handed.
134
+ if (p.formats.includes("responses") && p.baseUrlResponses)
135
+ attempts.push({ base: p.baseUrlResponses, suffix: "models", headers: { authorization: `Bearer ${p.apiKey}` } });
132
136
  if (p.formats.includes("anthropic"))
133
137
  attempts.push({ base: p.baseUrlAnthropic, suffix: "v1/models", headers: anthropicAuthHeaders(p.apiKey, "2023-06-01") });
134
138
  if (!attempts.length)
@@ -246,19 +250,18 @@ export function adminApi(store: Store, auth: MiddlewareHandler, chat: Hono, resp
246
250
  app.get("/providers", (c) => c.json({ providers: store.get().providers.map(toPublic) }));
247
251
 
248
252
  app.post("/providers", async (c) => {
249
- const body = await readJson<{ name?: string; baseUrlOpenai?: string; baseUrlAnthropic?: string; apiKey?: string; formats?: Format[]; supportsResponses?: boolean; rpm?: number }>(c.req.raw);
253
+ const body = await readJson<{ name?: string; baseUrlOpenai?: string; baseUrlAnthropic?: string; baseUrlResponses?: string; apiKey?: string; formats?: RouteKey[]; rpm?: number }>(c.req.raw);
250
254
  const formats = body?.formats ?? [];
251
- const needOpenai = formats.includes("openai");
252
- const needAnthropic = formats.includes("anthropic");
253
255
  if (!body?.name || !body?.apiKey || !formats.length) {
254
256
  return c.json({ error: { message: "name, apiKey, formats are required" } }, 400);
255
257
  }
256
- if ((needOpenai && !body.baseUrlOpenai) || (needAnthropic && !body.baseUrlAnthropic)) {
258
+ if ((formats.includes("openai") && !body.baseUrlOpenai) || (formats.includes("anthropic") && !body.baseUrlAnthropic) || (formats.includes("responses") && !body.baseUrlResponses)) {
257
259
  return c.json({ error: { message: "a base URL is required for each selected format" } }, 400);
258
260
  }
259
261
  const id = newProviderId();
260
262
  const baseUrlOpenai = trimBase(body.baseUrlOpenai ?? "");
261
263
  const baseUrlAnthropic = trimBase(body.baseUrlAnthropic ?? "");
264
+ const baseUrlResponses = trimBase(body.baseUrlResponses ?? "");
262
265
  const rpm = coerceRpm(body!.rpm);
263
266
  await store.update((d) => {
264
267
  d.providers.push({
@@ -266,9 +269,9 @@ export function adminApi(store: Store, auth: MiddlewareHandler, chat: Hono, resp
266
269
  name: body.name!,
267
270
  baseUrlOpenai,
268
271
  baseUrlAnthropic,
272
+ baseUrlResponses,
269
273
  apiKey: body.apiKey!,
270
274
  formats,
271
- supportsResponses: body.supportsResponses === true,
272
275
  ...(rpm ? { rpm } : {}),
273
276
  createdAt: Date.now(),
274
277
  });
@@ -281,18 +284,17 @@ export function adminApi(store: Store, auth: MiddlewareHandler, chat: Hono, resp
281
284
 
282
285
  app.put("/providers/:id", async (c) => {
283
286
  const id = c.req.param("id");
284
- const body = await readJson<{ name?: string; baseUrlOpenai?: string; baseUrlAnthropic?: string; apiKey?: string; formats?: Format[]; supportsResponses?: boolean; rpm?: number }>(c.req.raw);
287
+ const body = await readJson<{ name?: string; baseUrlOpenai?: string; baseUrlAnthropic?: string; baseUrlResponses?: string; apiKey?: string; formats?: RouteKey[]; rpm?: number }>(c.req.raw);
285
288
  const formats = body?.formats ?? [];
286
- const needOpenai = formats.includes("openai");
287
- const needAnthropic = formats.includes("anthropic");
288
289
  if (!body?.name || !formats.length) {
289
290
  return c.json({ error: { message: "name, formats are required" } }, 400);
290
291
  }
291
- if ((needOpenai && !body.baseUrlOpenai) || (needAnthropic && !body.baseUrlAnthropic)) {
292
+ if ((formats.includes("openai") && !body.baseUrlOpenai) || (formats.includes("anthropic") && !body.baseUrlAnthropic) || (formats.includes("responses") && !body.baseUrlResponses)) {
292
293
  return c.json({ error: { message: "a base URL is required for each selected format" } }, 400);
293
294
  }
294
295
  const baseUrlOpenai = trimBase(body.baseUrlOpenai ?? "");
295
296
  const baseUrlAnthropic = trimBase(body.baseUrlAnthropic ?? "");
297
+ const baseUrlResponses = trimBase(body.baseUrlResponses ?? "");
296
298
  let rediscover = false;
297
299
  await store.update((d) => {
298
300
  const p = d.providers.find((x) => x.id === id);
@@ -303,14 +305,15 @@ export function adminApi(store: Store, auth: MiddlewareHandler, chat: Hono, resp
303
305
  rediscover =
304
306
  p.baseUrlOpenai !== baseUrlOpenai ||
305
307
  p.baseUrlAnthropic !== baseUrlAnthropic ||
308
+ p.baseUrlResponses !== baseUrlResponses ||
306
309
  (!!body.apiKey && p.apiKey !== body.apiKey) ||
307
310
  formatsKey(p.formats) !== formatsKey(formats);
308
311
  p.name = body.name!;
309
312
  p.baseUrlOpenai = baseUrlOpenai;
310
313
  p.baseUrlAnthropic = baseUrlAnthropic;
314
+ p.baseUrlResponses = baseUrlResponses;
311
315
  p.apiKey = newKey;
312
316
  p.formats = formats;
313
- if (body.supportsResponses !== undefined) p.supportsResponses = body.supportsResponses;
314
317
  // rpm: present in the body → set (0/invalid clears to unlimited); absent → keep.
315
318
  if (body!.rpm !== undefined) {
316
319
  const rpm = coerceRpm(body!.rpm);
@@ -385,7 +388,7 @@ export function adminApi(store: Store, auth: MiddlewareHandler, chat: Hono, resp
385
388
  // unrouted source can be checked too — and it takes no routing side-effects
386
389
  // (no logs, no circuit, no pacing). ?model= is the upstream name sent
387
390
  // verbatim; ?format= (repeatable) narrows the protocols, defaulting to every
388
- // one the source supports (responses only when supportsResponses).
391
+ // one the source supports.
389
392
  app.post("/providers/:id/test", async (c) => {
390
393
  const p = store.get().providers.find((x) => x.id === c.req.param("id"));
391
394
  if (!p) return c.json({ error: { message: "provider not found" } }, 404);
@@ -394,12 +397,10 @@ export function adminApi(store: Store, auth: MiddlewareHandler, chat: Hono, resp
394
397
  const wanted = (c.req.queries("format") ?? []).filter(
395
398
  (f): f is RouteKey => f === "openai" || f === "anthropic" || f === "responses",
396
399
  );
397
- const formats: RouteKey[] = wanted.length
398
- ? wanted
399
- : [...p.formats, ...(p.supportsResponses ? ["responses" as const] : [])];
400
+ const formats: RouteKey[] = wanted.length ? wanted : [...p.formats];
400
401
  const results = await Promise.all(
401
402
  formats.map(async (format) => {
402
- if (format === "responses" ? !p.supportsResponses : !p.formats.includes(format as Format)) {
403
+ if (!p.formats.includes(format)) {
403
404
  return { format, ok: false, status: 0, ms: 0, error: "source does not speak this format" };
404
405
  }
405
406
  const start = Date.now();
@@ -513,8 +514,8 @@ export function adminApi(store: Store, auth: MiddlewareHandler, chat: Hono, resp
513
514
  // chains, save once" flow the web editor is built around. Each format key
514
515
  // present in the body REPLACES that FormatEntry wholesale; keys absent from
515
516
  // the body are left untouched (a fresh entry seeds them disabled + empty).
516
- // Every slot's provider must exist and speak that slot's format (responses
517
- // additionally requires supportsResponses), mirroring the granular endpoints.
517
+ // Every slot's provider must exist and speak that slot's format, mirroring
518
+ // the granular endpoints.
518
519
  // The response carries the projected model (GET /models shape) so clients can
519
520
  // update in place without a refetch.
520
521
  app.put("/models/:name", async (c) => {
@@ -1,6 +1,6 @@
1
1
  import { Hono, type Context, type MiddlewareHandler } from "hono";
2
2
  import { trimBase } from "../shared/config";
3
- import type { DebugCapture, Format, FormatEntry, Provider, RouteKey, Usage } from "../shared/types";
3
+ import type { DebugCapture, Format, Provider, RouteKey, Usage } from "../shared/types";
4
4
  import { CAPTURE_BODY_MAX, type Store } from "./store";
5
5
  import { UsageCollector } from "./tokens";
6
6
 
@@ -108,6 +108,14 @@ interface CandidateSlot {
108
108
  sampling?: Record<string, unknown>;
109
109
  }
110
110
 
111
+ /** Which Provider base URL serves each routing format (all non-empty for a
112
+ * format the source actually offers — admin validation enforces it). */
113
+ const FORMAT_BASE: Record<RouteKey, "baseUrlOpenai" | "baseUrlAnthropic" | "baseUrlResponses"> = {
114
+ openai: "baseUrlOpenai",
115
+ anthropic: "baseUrlAnthropic",
116
+ responses: "baseUrlResponses",
117
+ };
118
+
111
119
  /** Resolve the ordered, compatible provider slots for a model on a routing slot. */
112
120
  function candidates(store: Store, model: string, key: RouteKey): CandidateSlot[] {
113
121
  const d = store.get();
@@ -115,9 +123,9 @@ function candidates(store: Store, model: string, key: RouteKey): CandidateSlot[]
115
123
  const fe = entry?.[key];
116
124
  if (!fe?.enabled) return [];
117
125
  const byId = new Map(d.providers.map((p) => [p.id, p]));
118
- // Defense-in-depth: openai/anthropic require that wire format; responses
119
- // requires supportsResponses. (Admin keeps chains pure, but a provider's
120
- // formats/flag can be edited afterwards.)
126
+ // Defense-in-depth: a slot's source must still carry that format AND a base
127
+ // URL for it. (Admin keeps chains pure, but a provider's formats/URLs can be
128
+ // edited afterwards a source that dropped responses must stop serving it.)
121
129
  return fe.providers
122
130
  .map((s): CandidateSlot | null => {
123
131
  const p = byId.get(s.id);
@@ -125,7 +133,7 @@ function candidates(store: Store, model: string, key: RouteKey): CandidateSlot[]
125
133
  })
126
134
  .filter((slot): slot is CandidateSlot => {
127
135
  if (!slot) return false;
128
- return key === "responses" ? !!slot.provider.supportsResponses : slot.provider.formats.includes(key);
136
+ return slot.provider.formats.includes(key) && !!slot.provider[FORMAT_BASE[key]];
129
137
  });
130
138
  }
131
139
 
@@ -311,16 +319,17 @@ export function upstreamHeaders(provider: Provider, format: Format, clientVersio
311
319
  return h;
312
320
  }
313
321
 
314
- /** Resolve the upstream URL + wire format for a routing slot. The OpenAI base
315
- * includes the version segment (we append the bare resource); the Anthropic
316
- * base excludes /v1 (we append v1/messages). /responses reuses the OpenAI base.
317
- * Exported for the admin source-test (direct upstream ping, no routing). */
322
+ /** Resolve the upstream URL + wire format for a routing slot. Each format has
323
+ * its own base URL: the OpenAI and Responses bases include the version segment
324
+ * (we append the bare resource); the Anthropic base excludes /v1 (we append
325
+ * v1/messages). Exported for the admin source-test (direct upstream ping). */
318
326
  export function upstreamTarget(p: Provider, key: RouteKey): { url: string; wire: Format } {
319
327
  if (key === "anthropic") {
320
328
  return { url: `${trimBase(p.baseUrlAnthropic)}/v1/messages`, wire: "anthropic" };
321
329
  }
330
+ const base = key === "responses" ? p.baseUrlResponses : p.baseUrlOpenai;
322
331
  const path = key === "responses" ? "responses" : "chat/completions";
323
- return { url: `${trimBase(p.baseUrlOpenai)}/${path}`, wire: "openai" };
332
+ return { url: `${trimBase(base)}/${path}`, wire: "openai" };
324
333
  }
325
334
 
326
335
  /** HTTP header values are ByteStrings (Latin-1, code points ≤ 255) — a value
@@ -499,8 +508,7 @@ function observedBody(
499
508
  * first_id, last_id, has_more}`. We can't know real created_at / capabilities,
500
509
  * so the anthropic shape carries only the honest minimal fields rather than
501
510
  * fabricating them. Each surface lists exactly what dispatch would route on
502
- * it: the entry's chain for that RouteKey must be enabled (and, for
503
- * responses, keep at least one slot on a supportsResponses source). */
511
+ * it: the entry's chain for that RouteKey must be enabled. */
504
512
  function modelsList(c: Context, store: Store, key: RouteKey) {
505
513
  const d = store.get();
506
514
  const byId = new Map(d.providers.map((p) => [p.id, p]));
@@ -509,19 +517,12 @@ function modelsList(c: Context, store: Store, key: RouteKey) {
509
517
  const data = enabled.map(([id]) => ({ id, display_name: id, created_at: "1970-01-01T00:00:00Z", type: "model" }));
510
518
  return c.json({ data, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null, has_more: false });
511
519
  }
512
- const routable = (e: FormatEntry) =>
513
- key === "responses" ? e.providers.some((s) => byId.get(s.id)?.supportsResponses) : true;
514
- // owned_by follows the first slot dispatch would actually use on this surface.
515
- const firstSlot = (e: FormatEntry) =>
516
- key === "responses" ? e.providers.find((s) => byId.get(s.id)?.supportsResponses) : e.providers[0];
517
- const data = enabled
518
- .filter(([, e]) => e[key] && routable(e[key]))
519
- .map(([id, e]) => ({
520
- id,
521
- object: "model",
522
- created: 0,
523
- owned_by: byId.get(firstSlot(e[key])?.id ?? "")?.name || "MyAPIKey",
524
- }));
520
+ const data = enabled.map(([id, e]) => ({
521
+ id,
522
+ object: "model",
523
+ created: 0,
524
+ owned_by: byId.get(e[key].providers[0]?.id ?? "")?.name || "MyAPIKey",
525
+ }));
525
526
  return c.json({ object: "list", data });
526
527
  }
527
528
 
@@ -583,7 +584,7 @@ export function proxyApi(
583
584
  // (429/500/…), not a collapsed 502, so the badge shows what really happened.
584
585
  const pinIndexRaw = c.req.header("x-myapikey-probe-slot");
585
586
  const pinIndex = pinIndexRaw !== "" && Number.isInteger(Number(pinIndexRaw)) ? Number(pinIndexRaw) : null;
586
- // candidates() already restricts the responses chain to supportsResponses sources.
587
+ // candidates() already restricts each chain to sources still offering that format.
587
588
  let list = candidates(store, model, key);
588
589
  if (pinIndex != null) {
589
590
  // An out-of-range index → empty list → 404, so a bad probe is reported as
@@ -882,7 +883,7 @@ export function proxyApi(
882
883
  // One call endpoint per surface (/models is registered above, before the
883
884
  // auth middleware, so it stays public).
884
885
  chat.post("/chat/completions", (c) => dispatch(c, "openai"));
885
- // OpenAI Responses API — its own routing slot (sources must be supportsResponses).
886
+ // OpenAI Responses API — its own routing slot (sources must offer responses).
886
887
  responses.post("/responses", (c) => dispatch(c, "responses"));
887
888
 
888
889
  // Anthropic surface: messages.
@@ -305,7 +305,8 @@ export class Store {
305
305
  const m1 = migrateModels(raw);
306
306
  const m2 = migrateProviders(raw);
307
307
  const m3 = migrateFormatEntries(raw);
308
- if (m1 || m2 || m3 || !raw.version || raw.version < CONFIG_VERSION) {
308
+ const m4 = migrateProviderFormats(raw);
309
+ if (m1 || m2 || m3 || m4 || !raw.version || raw.version < CONFIG_VERSION) {
309
310
  raw.version = CONFIG_VERSION;
310
311
  this.persist(raw);
311
312
  } else if (raw.version > CONFIG_VERSION) {
@@ -835,7 +836,7 @@ function hitRate(cacheRead: number, input: number, cacheCreation: number): numbe
835
836
  function migrateModels(raw: GateConfig): boolean {
836
837
  const models = raw.models as Record<string, unknown>;
837
838
  if (!models || typeof models !== "object") return false;
838
- const byId = new Map(raw.providers.map((p) => [p.id, p]));
839
+ const byId = new Map(raw.providers.map((p) => [p.id, p as Provider & { supportsResponses?: boolean }]));
839
840
  let changed = false;
840
841
  for (const [name, entry] of Object.entries(models)) {
841
842
  if (!entry || typeof entry !== "object") continue;
@@ -927,3 +928,28 @@ function migrateFormatEntries(raw: GateConfig): boolean {
927
928
  }
928
929
  return changed;
929
930
  }
931
+
932
+ /**
933
+ * Provider migration to three parallel formats (v5 → v6).
934
+ * - v5 expressed Responses support as `supportsResponses: boolean` on top of
935
+ * `formats: ("openai"|"anthropic")[]`, with /responses reusing the OpenAI
936
+ * base URL.
937
+ * - v6 makes responses a third peer format with its OWN base URL:
938
+ * `formats` gains "responses" when the flag was set, `baseUrlResponses`
939
+ * starts as a COPY of baseUrlOpenai (preserves existing routing exactly —
940
+ * the user can point it elsewhere afterwards), and the flag is deleted.
941
+ * A responses-only backend is now expressible (empty openai base).
942
+ * Idempotent: v6 providers (string `baseUrlResponses`) are skipped. Runs LAST
943
+ * so the older migrations can still read `supportsResponses`/`formats`.
944
+ */
945
+ function migrateProviderFormats(raw: GateConfig): boolean {
946
+ let changed = false;
947
+ for (const p of raw.providers as (Provider & { supportsResponses?: boolean })[]) {
948
+ if (typeof p.baseUrlResponses === "string") continue; // already v6
949
+ p.baseUrlResponses = p.supportsResponses ? p.baseUrlOpenai : "";
950
+ if (p.supportsResponses && !p.formats.includes("responses")) p.formats.push("responses");
951
+ delete p.supportsResponses;
952
+ changed = true;
953
+ }
954
+ return changed;
955
+ }
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import type { GateConfig } from "./types";
5
5
 
6
- export const CONFIG_VERSION = 5;
6
+ export const CONFIG_VERSION = 6;
7
7
  export const DEFAULT_PORT = 7800;
8
8
  /** Default on-disk home for the gateway's data: data.json + logs.jsonl live here. */
9
9
  export const DEFAULT_DATA_DIR = join(homedir(), ".myapikey");
@@ -11,14 +11,19 @@ export interface Provider {
11
11
  id: string; // prv_<rand>
12
12
  name: string; // human label, also used as CLI handle
13
13
  /** OpenAI-family base INCLUDING the version segment, e.g. https://api.openai.com/v1
14
- * or Ark's /api/v3. Used for /chat/completions, /responses, /models. */
14
+ * or Ark's /api/v3. Used for /chat/completions (and /models discovery). */
15
15
  baseUrlOpenai: string;
16
16
  /** Anthropic base EXCLUDING /v1, e.g. https://api.anthropic.com or Ark's /api/coding.
17
17
  * The gateway appends /v1/messages (and /v1/models for discovery). */
18
18
  baseUrlAnthropic: string;
19
+ /** OpenAI-family base for the Responses API, INCLUDING the version segment.
20
+ * Independent of baseUrlOpenai — a backend may serve /responses at a
21
+ * different address (or serve ONLY responses). The gateway appends
22
+ * /responses (and /models for discovery). Empty = responses not served. */
23
+ baseUrlResponses: string;
19
24
  apiKey: string;
20
- /** Which wire formats this backend responds to. */
21
- formats: Format[];
25
+ /** Which routing formats this backend responds to (one per agent surface). */
26
+ formats: RouteKey[];
22
27
  /** Optional request-per-minute cap (RPM pacing). When set, dispatch skips this
23
28
  * source once it has forwarded `rpm` calls in the trailing 60s window — the
24
29
  * request fails over to the next source instead of racing the upstream's own
@@ -26,9 +31,6 @@ export interface Provider {
26
31
  * The limit is on the key, so it's per-source and shared across every model
27
32
  * routed through it. Tracked in-memory only (see Store.rpmUsed). */
28
33
  rpm?: number;
29
- /** Whether this backend also implements the OpenAI Responses API (/responses).
30
- * NOT implied by `formats` — many openai-compatible backends lack it. Opt-in. */
31
- supportsResponses?: boolean;
32
34
  /** Model ids this provider offered at last discovery (cached, may be stale). */
33
35
  discoveredModels?: string[];
34
36
  /** Manually supplemented upstream model ids — names a backend's /models list
@@ -44,9 +46,8 @@ export interface Provider {
44
46
  /** One routing slot: an independent enable flag + a priority-ordered chain of
45
47
  * (provider, optional upstream model) pairs. Invariant (enforced by admin
46
48
  * mutations, defended by proxy candidates()): every id in `providers` exists in
47
- * `GateConfig.providers` and is compatible with the slot — openai/anthropic ids
48
- * must carry that wire format; responses ids must additionally be
49
- * supportsResponses sources (still OpenAI-format).
49
+ * `GateConfig.providers` and is compatible with the slot — it must carry that
50
+ * format in `formats` (with the matching base URL filled in).
50
51
  *
51
52
  * A provider id may appear MORE THAN ONCE — each occurrence is an independent
52
53
  * failover slot that can carry its own upstream model name. When forwarding to
@@ -92,10 +93,10 @@ export interface ChainSlot {
92
93
  sampling?: Record<string, unknown>;
93
94
  }
94
95
 
95
- /** A model's routing dimensions — one per forwarding endpoint. /chat/completions
96
- * (openai) and /responses are separate OpenAI-family endpoints with different
97
- * URLs, so they route independently: each has its own enable + chain. A
98
- * supportsResponses source can sit in BOTH the openai and the responses chains. */
96
+ /** A model's routing dimensions — one per forwarding endpoint. The three are
97
+ * fully independent surfaces with independent per-source base URLs: a source
98
+ * can serve any subset (responses-only and chat-only are both legal), and the
99
+ * same source can sit in several chains at once. */
99
100
  export type RouteKey = "openai" | "anthropic" | "responses";
100
101
 
101
102
  /** Per-model routing entry, keyed by model name (e.g. "gpt-4o"). */