myapikey 0.7.0 → 0.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myapikey",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
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": [
@@ -179,7 +179,7 @@ model.command("list").action(async () => {
179
179
  console.log(m.name);
180
180
  for (const f of fmts) {
181
181
  const fe = m[f];
182
- const chain = fe.providers.map((p: any) => p.name).join(" → ") || "(none)";
182
+ const chain = fe.providers.map((p: any) => (p.model ? `${p.name}→${p.model}` : p.name)).join(" → ") || "(none)";
183
183
  console.log(` ${f.padEnd(9)} ${fe.enabled ? "✓" : "·"} ${chain}`);
184
184
  }
185
185
  }
@@ -226,12 +226,29 @@ model
226
226
 
227
227
  model
228
228
  .command("prioritize <name> <refs...>")
229
- .description("set provider priority order (left = primary)")
229
+ .description("reorder sources on a route (left = primary); list every source on the route, in order")
230
230
  .addOption(fmtOption())
231
231
  .action(async (name: string, refs: string[], opts: { format: "openai" | "anthropic" }) => {
232
- const ids: string[] = [];
233
- for (const ref of refs) ids.push(await resolveProviderId(ref));
234
- await api(ctx(), "PUT", `/admin/models/${encodeURIComponent(name)}/priority`, { format: opts.format, providers: ids });
232
+ // The server reorders EXISTING slots (a duplicate id can occupy several), so
233
+ // each ref maps onto the chain left-to-right — a second ref with the same id
234
+ // consumes the next slot for that id. The refs must cover the whole route
235
+ // (the resulting indices are a permutation of [0..n-1]).
236
+ const wanted: string[] = [];
237
+ for (const ref of refs) wanted.push(await resolveProviderId(ref));
238
+ const r = (await api(ctx(), "GET", "/admin/models")) as { models: any[] };
239
+ const entry = (r.models as any[]).find((m) => m.name === name);
240
+ if (!entry) throw new Error(`No model '${name}'.`);
241
+ const chain: { id: string }[] = entry[opts.format]?.providers ?? [];
242
+ const used = new Set<number>();
243
+ const order: number[] = [];
244
+ for (const id of wanted) {
245
+ const idx = chain.findIndex((s, i) => s.id === id && !used.has(i));
246
+ if (idx === -1)
247
+ throw new Error(`'${name}' [${opts.format}] has no remaining slot for that source — list every source on the route exactly once.`);
248
+ used.add(idx);
249
+ order.push(idx);
250
+ }
251
+ await api(ctx(), "PUT", `/admin/models/${encodeURIComponent(name)}/priority`, { format: opts.format, order });
235
252
  console.log(`Priority for ${name} [${opts.format}]: ${refs.join(" → ")}`);
236
253
  });
237
254
 
@@ -44,17 +44,26 @@ function providerSpeaks(p: Provider, key: RouteKey): boolean {
44
44
  return key === "responses" ? !!p.supportsResponses : p.formats.includes(key);
45
45
  }
46
46
 
47
- /** Drop a provider id from a routing slot: remove it from the chain AND from any
48
- * modelMap (so a stale upstream-name override doesn't linger after the provider
49
- * is gone or removed from this model's chain). */
50
- function purgeProvider(fe: FormatEntry, pid: string): void {
51
- fe.providers = fe.providers.filter((x) => x !== pid);
52
- if (fe.modelMap && pid in fe.modelMap) {
53
- delete fe.modelMap[pid];
54
- if (!Object.keys(fe.modelMap).length) delete fe.modelMap;
47
+ /** Inverse of proxy's encodeTag: the probe's x-myapikey-provider header carries
48
+ * a %-encoded provider name (HTTP headers are Latin-1, so a name like "商汤"
49
+ * can't travel raw). Decode it back for display; fall back to the raw value if
50
+ * it wasn't encoded (older gateway / already-ASCII). */
51
+ function decodeTag(v: string | null): string | undefined {
52
+ if (!v) return undefined;
53
+ try {
54
+ return decodeURIComponent(v);
55
+ } catch {
56
+ return v;
55
57
  }
56
58
  }
57
59
 
60
+ /** Drop every slot for a provider id from a routing slot — used when a provider
61
+ * is deleted outright or removed from this model's chain. A provider may occupy
62
+ * more than one slot (each mapped to a different upstream model); all go. */
63
+ function purgeProvider(fe: FormatEntry, pid: string): void {
64
+ fe.providers = fe.providers.filter((s) => s.id !== pid);
65
+ }
66
+
58
67
  /** Project provider for API responses: hide the full key. */
59
68
  function toPublic(p: Provider) {
60
69
  return {
@@ -296,12 +305,13 @@ export function adminApi(store: Store, auth: MiddlewareHandler, v1: Hono): Hono
296
305
  const byId = new Map(d.providers.map((p) => [p.id, p]));
297
306
  const proj = (fe: FormatEntry) => ({
298
307
  enabled: fe.enabled,
299
- providers: fe.providers.map((pid) => ({
300
- id: pid,
301
- name: byId.get(pid)?.name ?? "?",
302
- // Upstream model name this source is mapped to (undefined = send the
303
- // public name verbatim). Flattened out of modelMap for the client.
304
- model: fe.modelMap?.[pid],
308
+ providers: fe.providers.map((s) => ({
309
+ id: s.id,
310
+ name: byId.get(s.id)?.name ?? "?",
311
+ // Upstream model name this slot rewrites the request to (undefined =
312
+ // send the public model name verbatim). Carried inline on each slot so
313
+ // a provider can appear more than once with different upstream names.
314
+ model: s.model,
305
315
  })),
306
316
  });
307
317
  const models = Object.entries(d.models).map(([name, e]) => ({
@@ -337,7 +347,10 @@ export function adminApi(store: Store, auth: MiddlewareHandler, v1: Hono): Hono
337
347
  responses: { enabled: false, providers: [] },
338
348
  });
339
349
  const fe = entry[key];
340
- for (const pid of requested) if (!fe.providers.includes(pid)) fe.providers.push(pid);
350
+ // Enable/seed dedupes by provider id: re-enabling must not pile up
351
+ // duplicate slots. (Use POST /:name/providers to intentionally add a
352
+ // second occurrence of a provider for per-model failover.)
353
+ for (const pid of requested) if (!fe.providers.some((s) => s.id === pid)) fe.providers.push({ id: pid });
341
354
  fe.enabled = true;
342
355
  });
343
356
  return c.json({ ok: true }, 201);
@@ -345,7 +358,7 @@ export function adminApi(store: Store, auth: MiddlewareHandler, v1: Hono): Hono
345
358
 
346
359
  app.post("/models/:name/providers", async (c) => {
347
360
  const name = c.req.param("name");
348
- const body = await readJson<{ format?: RouteKey; providerId?: string }>(c.req.raw);
361
+ const body = await readJson<{ format?: RouteKey; providerId?: string; model?: string }>(c.req.raw);
349
362
  if (!body?.format) return c.json({ error: { message: "format is required" } }, 400);
350
363
  if (!body?.providerId) return c.json({ error: { message: "providerId is required" } }, 400);
351
364
  const cfg = store.get();
@@ -353,6 +366,11 @@ export function adminApi(store: Store, auth: MiddlewareHandler, v1: Hono): Hono
353
366
  if (!p) return c.json({ error: { message: "provider not found" } }, 400);
354
367
  if (!providerSpeaks(p, body.format))
355
368
  return c.json({ error: { message: `provider ${p.name} does not serve ${body.format}` } }, 400);
369
+ // No dedupe guard: appending a SECOND occurrence of a provider is the whole
370
+ // point (failover across its models). An optional `model` sets the new
371
+ // slot's upstream name at attach time (add + map in one call).
372
+ const upstream = body.model?.trim();
373
+ const slot = upstream ? { id: body.providerId!, model: upstream } : { id: body.providerId! };
356
374
  let errStatus = 0;
357
375
  await store.update((d) => {
358
376
  const entry = d.models[name];
@@ -360,12 +378,39 @@ export function adminApi(store: Store, auth: MiddlewareHandler, v1: Hono): Hono
360
378
  errStatus = 404;
361
379
  return;
362
380
  }
363
- if (!entry[body.format!].providers.includes(body.providerId!)) entry[body.format!].providers.push(body.providerId!);
381
+ entry[body.format!].providers.push(slot);
364
382
  });
365
383
  if (errStatus === 404) return c.json({ error: { message: "model not found; enable it first" } }, 404);
366
384
  return c.json({ ok: true });
367
385
  });
368
386
 
387
+ // Remove a SINGLE chain slot at `index` (the web's per-row remove). Distinct
388
+ // from the id-based delete below, which removes every slot for a provider.
389
+ app.delete("/models/:name/providers", async (c) => {
390
+ const name = c.req.param("name");
391
+ const format = c.req.query("format") as RouteKey | undefined;
392
+ const index = Number(c.req.query("index"));
393
+ if (!format) return c.json({ error: { message: "?format=openai|anthropic|responses is required" } }, 400);
394
+ if (!Number.isInteger(index) || index < 0)
395
+ return c.json({ error: { message: "?index= (non-negative integer) is required" } }, 400);
396
+ let errStatus = 0;
397
+ await store.update((d) => {
398
+ const fe = d.models[name]?.[format];
399
+ if (!fe) {
400
+ errStatus = 404;
401
+ return;
402
+ }
403
+ if (index >= fe.providers.length) {
404
+ errStatus = 400;
405
+ return;
406
+ }
407
+ fe.providers.splice(index, 1);
408
+ });
409
+ if (errStatus === 404) return c.json({ error: { message: "model not found" } }, 404);
410
+ if (errStatus === 400) return c.json({ error: { message: "index out of range" } }, 400);
411
+ return c.json({ ok: true });
412
+ });
413
+
369
414
  app.delete("/models/:name/providers/:providerId", async (c) => {
370
415
  const name = c.req.param("name");
371
416
  const pid = c.req.param("providerId");
@@ -380,9 +425,10 @@ export function adminApi(store: Store, auth: MiddlewareHandler, v1: Hono): Hono
380
425
 
381
426
  app.put("/models/:name/priority", async (c) => {
382
427
  const name = c.req.param("name");
383
- const body = await readJson<{ format?: RouteKey; providers?: string[] }>(c.req.raw);
428
+ const body = await readJson<{ format?: RouteKey; order?: number[] }>(c.req.raw);
384
429
  if (!body?.format) return c.json({ error: { message: "format is required" } }, 400);
385
- if (!Array.isArray(body?.providers)) return c.json({ error: { message: "providers[] required" } }, 400);
430
+ if (!Array.isArray(body?.order) || !body.order.every((n) => Number.isInteger(n)))
431
+ return c.json({ error: { message: "order[] (integers) required" } }, 400);
386
432
  const format = body.format;
387
433
  let errStatus = 0;
388
434
  let errMsg = "";
@@ -393,32 +439,38 @@ export function adminApi(store: Store, auth: MiddlewareHandler, v1: Hono): Hono
393
439
  return;
394
440
  }
395
441
  const fe = entry[format];
396
- // Reorder only: the submitted list must be a permutation of the current
397
- // chain (use add-provider / remove-provider to change membership).
398
- const cur = new Set(fe.providers);
399
- if (body.providers!.length !== cur.size || body.providers!.some((pid) => !cur.has(pid))) {
442
+ const n = fe.providers.length;
443
+ // Reorder only: `order` must be a permutation of [0..n-1] (the current
444
+ // slot positions). Indices — not provider ids — because a provider may now
445
+ // occupy several slots. Use add-provider / remove-provider to change membership.
446
+ if (
447
+ body.order!.length !== n ||
448
+ new Set(body.order).size !== n ||
449
+ !body.order!.every((i) => i >= 0 && i < n)
450
+ ) {
400
451
  errStatus = 400;
401
- errMsg = "providers must be a reordering of the current chain (no add/drop)";
452
+ errMsg = "order must be a reordering of the current chain (no add/drop)";
402
453
  return;
403
454
  }
404
- fe.providers = body.providers!;
455
+ fe.providers = body.order!.map((i) => fe.providers[i]);
405
456
  });
406
457
  if (errStatus === 404) return c.json({ error: { message: "model not found" } }, 404);
407
458
  if (errStatus === 400) return c.json({ error: { message: errMsg } }, 400);
408
459
  return c.json({ ok: true });
409
460
  });
410
461
 
411
- // Set (or clear) a model×source upstream-model mapping. `model` is the name
412
- // sent upstream when forwarding this public model to this provider; an empty
413
- // string clears it (back to identity send the public name). The provider
414
- // must already be in this slot's chain: you map a source already attached.
462
+ // Set (or clear) the upstream-model mapping for ONE chain slot (addressed by
463
+ // `index`). An empty `model` clears it (back to identity send the public
464
+ // name). Index-addressed because a provider may occupy several slots, each
465
+ // with its own upstream model.
415
466
  app.put("/models/:name/map", async (c) => {
416
467
  const name = c.req.param("name");
417
- const body = await readJson<{ format?: RouteKey; providerId?: string; model?: string }>(c.req.raw);
468
+ const body = await readJson<{ format?: RouteKey; index?: number; model?: string }>(c.req.raw);
418
469
  if (!body?.format) return c.json({ error: { message: "format is required" } }, 400);
419
- if (!body?.providerId) return c.json({ error: { message: "providerId is required" } }, 400);
470
+ if (!Number.isInteger(body?.index) || (body?.index ?? -1) < 0)
471
+ return c.json({ error: { message: "index (non-negative integer) is required" } }, 400);
420
472
  const format = body.format;
421
- const pid = body.providerId;
473
+ const index = body.index!;
422
474
  const upstream = (body.model ?? "").trim();
423
475
  let errStatus = 0;
424
476
  let errMsg = "";
@@ -430,17 +482,13 @@ export function adminApi(store: Store, auth: MiddlewareHandler, v1: Hono): Hono
430
482
  return;
431
483
  }
432
484
  const fe = entry[format];
433
- if (!fe.providers.includes(pid)) {
485
+ if (index >= fe.providers.length) {
434
486
  errStatus = 400;
435
- errMsg = "provider is not in this model's chain for the given format";
487
+ errMsg = "index out of range for this model's chain";
436
488
  return;
437
489
  }
438
- if (upstream) {
439
- (fe.modelMap ??= {})[pid] = upstream;
440
- } else if (fe.modelMap && pid in fe.modelMap) {
441
- delete fe.modelMap[pid];
442
- if (!Object.keys(fe.modelMap).length) delete fe.modelMap;
443
- }
490
+ if (upstream) fe.providers[index].model = upstream;
491
+ else delete fe.providers[index].model;
444
492
  });
445
493
  if (errStatus === 404) return c.json({ error: { message: errMsg } }, 404);
446
494
  if (errStatus === 400) return c.json({ error: { message: errMsg } }, 400);
@@ -494,25 +542,32 @@ export function adminApi(store: Store, auth: MiddlewareHandler, v1: Hono): Hono
494
542
  } catch (e) {
495
543
  return c.json({ result: { ok: false, status: 0, format, error: `gateway loopback failed: ${(e as Error).message}` } });
496
544
  }
497
- const provider = res.headers.get("x-myapikey-provider") ?? undefined;
498
- if (res.ok) return c.json({ result: { ok: true, status: res.status, provider, format } });
545
+ const provider = decodeTag(res.headers.get("x-myapikey-provider"));
546
+ // Drain the loopback body so a SUCCESSFUL probe's log row is actually
547
+ // written: the success log lives in the response stream's completion
548
+ // callback (observedBody's onSettle), which only fires once the body is
549
+ // consumed — a 200 at the headers is committed before the body flows, so
550
+ // returning here without reading would silently drop the log. The text is
551
+ // reused for the failure message below.
499
552
  const txt = await res.text().catch(() => "");
553
+ if (res.ok) return c.json({ result: { ok: true, status: res.status, provider, format } });
500
554
  return c.json({ result: { ok: false, status: res.status, provider, format, error: shortError(txt) || `HTTP ${res.status}` } });
501
555
  });
502
556
 
503
- // Probe a SINGLE source for a model+format: the same end-to-end loopback as the
504
- // whole-model /test above, but dispatch is pinned to this one provider (via the
505
- // x-myapikey-probe-provider header), so there is no failover and no
506
- // circuit-breaker impact — a manual "is THIS source up?" check that reports the
507
- // provider's real upstream status. Validation misses (source not on this route,
508
- // wrong wire format) come back as a ProbeResult body, not an HTTP error, so the
509
- // caller reads `result` uniformly.
510
- app.post("/models/:name/providers/:providerId/test", async (c) => {
557
+ // Probe a SINGLE chain slot for a model+format: the same end-to-end loopback as
558
+ // the whole-model /test above, but dispatch is pinned to this one slot (via the
559
+ // x-myapikey-probe-slot header carrying the slot index), so there is no failover
560
+ // and no circuit-breaker impact — a manual "is THIS source up?" check that
561
+ // reports the slot's real upstream status (the slot's own mapped model name).
562
+ // Slot-indexed (not provider-id) because a provider may occupy several slots,
563
+ // each mapped to a different upstream model. Validation misses come back as a
564
+ // ProbeResult body, not an HTTP error, so the caller reads `result` uniformly.
565
+ app.post("/models/:name/providers/test", async (c) => {
511
566
  const name = c.req.param("name");
512
- const pid = c.req.param("providerId");
513
567
  const cfg = store.get();
514
568
  const entry = cfg.models[name];
515
569
  if (!entry) return c.json({ error: { message: "model not found" } }, 404);
570
+ const index = Number(c.req.query("index"));
516
571
  let format = c.req.query("format") as RouteKey | undefined;
517
572
  if (!format) {
518
573
  // No slot requested: pick the first one the model is enabled on.
@@ -521,9 +576,10 @@ export function adminApi(store: Store, auth: MiddlewareHandler, v1: Hono): Hono
521
576
  if (!format || !entry[format]?.enabled) {
522
577
  return c.json({ result: { ok: false, status: 0, format: format ?? "openai", error: "model not enabled on that routing slot" } });
523
578
  }
524
- if (!entry[format].providers.includes(pid)) {
525
- return c.json({ result: { ok: false, status: 0, format, error: "source is not on this route" } });
579
+ if (!Number.isInteger(index) || index < 0 || index >= entry[format].providers.length) {
580
+ return c.json({ result: { ok: false, status: 0, format, error: "slot index out of range for this route" } });
526
581
  }
582
+ const pid = entry[format].providers[index].id;
527
583
  const provider = cfg.providers.find((p) => p.id === pid);
528
584
  if (!provider || !providerSpeaks(provider, format)) {
529
585
  return c.json({ result: { ok: false, status: 0, format, error: "source does not speak this format" } });
@@ -538,15 +594,17 @@ export function adminApi(store: Store, auth: MiddlewareHandler, v1: Hono): Hono
538
594
  try {
539
595
  res = await v1.request(path, {
540
596
  method: "POST",
541
- headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiKey}`, "x-myapikey-probe": "1", "x-myapikey-probe-provider": pid },
597
+ headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiKey}`, "x-myapikey-probe": "1", "x-myapikey-probe-slot": String(index) },
542
598
  body: JSON.stringify(body),
543
599
  });
544
600
  } catch (e) {
545
601
  return c.json({ result: { ok: false, status: 0, format, error: `gateway loopback failed: ${(e as Error).message}` } });
546
602
  }
547
- const answeredBy = res.headers.get("x-myapikey-provider") ?? undefined;
548
- if (res.ok) return c.json({ result: { ok: true, status: res.status, provider: answeredBy, format } });
603
+ const answeredBy = decodeTag(res.headers.get("x-myapikey-provider"));
604
+ // Drain the loopback body (see /test above) so a successful pinned probe's
605
+ // log row is written — the success log fires only when the body is consumed.
549
606
  const txt = await res.text().catch(() => "");
607
+ if (res.ok) return c.json({ result: { ok: true, status: res.status, provider: answeredBy, format } });
550
608
  return c.json({ result: { ok: false, status: res.status, provider: answeredBy, format, error: shortError(txt) || `HTTP ${res.status}` } });
551
609
  });
552
610
 
@@ -54,8 +54,17 @@ function parseResetFromBody(text: string): number | undefined {
54
54
  return ms > 0 ? ms : undefined;
55
55
  }
56
56
 
57
- /** Resolve the ordered, compatible provider list for a model on a routing slot. */
58
- function candidates(store: Store, model: string, key: RouteKey): Provider[] {
57
+ /** One resolved routing slot: the provider to forward to plus THIS slot's
58
+ * optional upstream model name (absent = send the public model name). The
59
+ * same provider may occupy several slots in a chain — each is an independent
60
+ * failover slot carrying its own upstream model. */
61
+ interface CandidateSlot {
62
+ provider: Provider;
63
+ model?: string;
64
+ }
65
+
66
+ /** Resolve the ordered, compatible provider slots for a model on a routing slot. */
67
+ function candidates(store: Store, model: string, key: RouteKey): CandidateSlot[] {
59
68
  const d = store.get();
60
69
  const entry = d.models[model];
61
70
  const fe = entry?.[key];
@@ -65,10 +74,13 @@ function candidates(store: Store, model: string, key: RouteKey): Provider[] {
65
74
  // requires supportsResponses. (Admin keeps chains pure, but a provider's
66
75
  // formats/flag can be edited afterwards.)
67
76
  return fe.providers
68
- .map((id) => byId.get(id))
69
- .filter((p): p is Provider => {
70
- if (!p) return false;
71
- return key === "responses" ? !!p.supportsResponses : p.formats.includes(key);
77
+ .map((s): CandidateSlot | null => {
78
+ const p = byId.get(s.id);
79
+ return p ? { provider: p, model: s.model } : null;
80
+ })
81
+ .filter((slot): slot is CandidateSlot => {
82
+ if (!slot) return false;
83
+ return key === "responses" ? !!slot.provider.supportsResponses : slot.provider.formats.includes(key);
72
84
  });
73
85
  }
74
86
 
@@ -113,6 +125,12 @@ function upstreamTarget(p: Provider, key: RouteKey): { url: string; wire: Format
113
125
  return { url: `${trimBase(p.baseUrlOpenai)}/${path}`, wire: "openai" };
114
126
  }
115
127
 
128
+ /** HTTP header values are ByteStrings (Latin-1, code points ≤ 255) — a value
129
+ * with any wider char throws at Headers.set time. Provider names can be any
130
+ * unicode (e.g. "商汤"), so %-encode the probe tag and %-decode it on the admin
131
+ * read side. encodeURIComponent is a no-op on plain-ASCII names. */
132
+ const encodeTag = (s: string): string => encodeURIComponent(s);
133
+
116
134
  /** Copy through the headers we reflect to the client (content-type, rate-limit
117
135
  * hints, request id, …) and optionally tag the in-process probe with which
118
136
  * source answered. The probe tag never reaches a real agent client (set only
@@ -123,7 +141,7 @@ function downHeaders(upstream: Response, servedBy?: string): Headers {
123
141
  const v = upstream.headers.get(h);
124
142
  if (v) headers.set(h, v);
125
143
  }
126
- if (servedBy) headers.set("x-myapikey-provider", servedBy);
144
+ if (servedBy) headers.set("x-myapikey-provider", encodeTag(servedBy));
127
145
  return headers;
128
146
  }
129
147
 
@@ -295,15 +313,21 @@ export function proxyApi(store: Store, auth: MiddlewareHandler): Hono {
295
313
  // answered back to the test handler (x-myapikey-provider), without leaking
296
314
  // that header to real agent clients.
297
315
  const isProbe = c.req.header("x-myapikey-probe") === "1";
298
- // The per-source "test this source" variant pins dispatch to ONE provider:
299
- // the candidate chain is reduced to just it, and on failure we stop
300
- // immediately (no failover) WITHOUT recording a circuit failure a manual
301
- // probe must not trip the breaker. Failure surfaces the real upstream status
316
+ // The per-source "test this source" variant pins dispatch to ONE slot (by
317
+ // chain index, since a provider may occupy several slots): the candidate
318
+ // chain is reduced to just that slot, and on failure we stop immediately
319
+ // (no failover) WITHOUT recording a circuit failure a manual probe must
320
+ // not trip the breaker. Failure surfaces the real upstream status
302
321
  // (429/500/…), not a collapsed 502, so the badge shows what really happened.
303
- const pinId = c.req.header("x-myapikey-probe-provider") || "";
322
+ const pinIndexRaw = c.req.header("x-myapikey-probe-slot");
323
+ const pinIndex = pinIndexRaw !== "" && Number.isInteger(Number(pinIndexRaw)) ? Number(pinIndexRaw) : null;
304
324
  // candidates() already restricts the responses chain to supportsResponses sources.
305
325
  let list = candidates(store, model, key);
306
- if (pinId) list = list.filter((p) => p.id === pinId);
326
+ if (pinIndex != null) {
327
+ // An out-of-range index → empty list → 404, so a bad probe is reported as
328
+ // unreachable rather than accidentally hitting a different slot.
329
+ list = list[pinIndex] != null ? [list[pinIndex]] : [];
330
+ }
307
331
  if (!list.length) {
308
332
  if (key === "responses") {
309
333
  return c.json(
@@ -330,20 +354,22 @@ export function proxyApi(store: Store, auth: MiddlewareHandler): Hono {
330
354
  // (a skipped provider that now succeeds also resets its state). A pinned
331
355
  // (per-source) probe ignores both — the user is testing THIS source now,
332
356
  // whatever its breaker/pacing state.
333
- const skipped = (p: Provider) => store.isCooling(p.id) || (!!p.rpm && store.rpmUsed(p.id) >= p.rpm);
334
- const live = pinId ? list : list.filter((p) => !skipped(p));
357
+ const skipped = (slot: CandidateSlot) => {
358
+ const p = slot.provider;
359
+ return store.isCooling(p.id) || (!!p.rpm && store.rpmUsed(p.id) >= p.rpm);
360
+ };
361
+ const live = pinIndex != null ? list : list.filter((slot) => !skipped(slot));
335
362
  const order = live.length ? live : list;
336
363
 
337
- for (const provider of order) {
338
- // Model mapping (per model×source): rewrite the passthrough body's model
339
- // to this provider's configured upstream name. Recomputed from the ORIGINAL
340
- // `model` each iteration, so failover to the next provider never carries the
341
- // previous provider's upstream name. Absent map/key → send the public name.
342
- const mapped = store.get().models[model]?.[key]?.modelMap?.[provider.id];
343
- body.model = mapped ?? model;
364
+ for (const slot of order) {
365
+ const provider = slot.provider;
366
+ // Per-slot upstream model name (absent = send the public name). Read from
367
+ // the slot each iteration, so failover never carries the previous slot's
368
+ // upstream name.
369
+ body.model = slot.model ?? model;
344
370
  // Count this attempt toward the source's RPM window — but not for a pinned
345
371
  // probe, which (like circuit state) takes no routing side-effects.
346
- if (!pinId) store.recordDispatch(provider.id);
372
+ if (pinIndex == null) store.recordDispatch(provider.id);
347
373
  let upstream: Response;
348
374
  try {
349
375
  upstream = await fetch(upstreamTarget(provider, key).url, {
@@ -355,7 +381,7 @@ export function proxyApi(store: Store, auth: MiddlewareHandler): Hono {
355
381
  // Network error / DNS / timeout → try next provider.
356
382
  lastStatus = 502;
357
383
  lastErr = "network error";
358
- if (pinId) break; // per-source probe: fail fast, no circuit impact.
384
+ if (pinIndex != null) break; // per-source probe: fail fast, no circuit impact.
359
385
  const r = store.recordCircuitFailure(provider.id, lastStatus, lastErr);
360
386
  if (r.entered) {
361
387
  store.pushLog({ ts: Date.now(), model, provider: provider.name, providerId: provider.id, format: wire, status: lastStatus, ms: Date.now() - start, stream, kind: "cooldown", cooldownMs: r.cooldownMs, fails: r.fails, error: lastErr });
@@ -387,7 +413,7 @@ export function proxyApi(store: Store, auth: MiddlewareHandler): Hono {
387
413
  } else {
388
414
  // A pinned per-source probe takes no circuit side-effects (a manual
389
415
  // test must not trip the breaker) — mirrors the retryable branch.
390
- if (!pinId) store.recordCircuitFailure(provider.id, info.status, info.error || "stream failed");
416
+ if (pinIndex == null) store.recordCircuitFailure(provider.id, info.status, info.error || "stream failed");
391
417
  store.pushLog({ ts: Date.now(), model, provider: provider.name, providerId: provider.id, format: wire, status: info.status, ms: ttfb, stream, error: info.error });
392
418
  }
393
419
  },
@@ -400,7 +426,7 @@ export function proxyApi(store: Store, auth: MiddlewareHandler): Hono {
400
426
  // reason for the log (this branch never streams back to the client).
401
427
  const txt = await upstream.text().catch(() => "");
402
428
  lastErr = shortError(txt) || `HTTP ${upstream.status}`;
403
- if (pinId) break; // per-source probe: fail fast, no circuit impact.
429
+ if (pinIndex != null) break; // per-source probe: fail fast, no circuit impact.
404
430
  // A 429/overloaded upstream usually carries Retry-After; honoring it
405
431
  // cools for exactly as long as asked (clamped) instead of the escalating
406
432
  // guess. Absent (5xx often, OR a quota error that buried the reset time
@@ -422,13 +448,13 @@ export function proxyApi(store: Store, auth: MiddlewareHandler): Hono {
422
448
  }
423
449
 
424
450
  const last = order[order.length - 1];
425
- store.pushLog({ ts: Date.now(), model, provider: last.name, providerId: last.id, format: wire, status: lastStatus, ms: Date.now() - start, stream, error: lastErr || `all providers failed (last status ${lastStatus})` });
451
+ store.pushLog({ ts: Date.now(), model, provider: last.provider.name, providerId: last.provider.id, format: wire, status: lastStatus, ms: Date.now() - start, stream, error: lastErr || `all providers failed (last status ${lastStatus})` });
426
452
  // A pinned (per-source) probe failed: surface the REAL upstream status the
427
- // one provider returned (429/500/…), not a collapsed 502, and tag it with
453
+ // one slot returned (429/500/…), not a collapsed 502, and tag it with
428
454
  // x-myapikey-provider so the source-row badge names the tested source.
429
- if (pinId) {
455
+ if (pinIndex != null) {
430
456
  const h = new Headers({ "content-type": "application/json" });
431
- if (isProbe) h.set("x-myapikey-provider", last.name);
457
+ if (isProbe) h.set("x-myapikey-provider", encodeTag(last.provider.name));
432
458
  return new Response(
433
459
  JSON.stringify({ error: { message: lastErr || `provider failed (status ${lastStatus})`, type: "upstream_error" } }),
434
460
  { status: lastStatus, headers: h },
@@ -458,7 +484,7 @@ export function proxyApi(store: Store, auth: MiddlewareHandler): Hono {
458
484
  id,
459
485
  object: "model",
460
486
  created: 0,
461
- owned_by: byId.get(e.openai.providers[0] ?? "")?.name || "MyAPIKey",
487
+ owned_by: byId.get(e.openai.providers[0]?.id ?? "")?.name || "MyAPIKey",
462
488
  }));
463
489
  return c.json({ object: "list", data });
464
490
  });
@@ -248,11 +248,17 @@ export class Store {
248
248
  raw.apiKey = newApiKey();
249
249
  this.persist(raw);
250
250
  }
251
- // Migration: model entries were { enabled, providers[] } (v1), then
252
- // { openai, anthropic } (v2). Split into three routing slots
253
- // { openai, anthropic, responses } so /responses routes independently.
254
- // Idempotent; persisted immediately so the upgrade is stable across restarts.
255
- if (migrateModels(raw) || migrateProviders(raw) || !raw.version || raw.version < CONFIG_VERSION) {
251
+ // Run every migrator unconditionally. Each is idempotent and returns
252
+ // whether it rewrote anything. We deliberately do NOT chain them with ||
253
+ // (which short-circuits): a v1 config makes migrateModels return true, which
254
+ // would skip migrateFormatEntries, yet the version still bumps to
255
+ // CONFIG_VERSION below leaving v5-versioned data with unconverted string[]
256
+ // chains that crash candidates() on the very same boot. Calling all three
257
+ // every boot is cheap (they no-op once migrated) and closes that window.
258
+ const m1 = migrateModels(raw);
259
+ const m2 = migrateProviders(raw);
260
+ const m3 = migrateFormatEntries(raw);
261
+ if (m1 || m2 || m3 || !raw.version || raw.version < CONFIG_VERSION) {
256
262
  raw.version = CONFIG_VERSION;
257
263
  this.persist(raw);
258
264
  } else if (raw.version > CONFIG_VERSION) {
@@ -734,3 +740,38 @@ function migrateProviders(raw: GateConfig): boolean {
734
740
  }
735
741
  return changed;
736
742
  }
743
+
744
+ /**
745
+ * FormatEntry migration to inline (provider, model) slots (v4 → v5).
746
+ * - v4 stored each route's chain as `providers: string[]` (unique provider ids)
747
+ * plus an optional side-channel `modelMap: { providerId → upstream model }`.
748
+ * - v5 collapses both into `providers: { id, model? }[]`, so a provider id may
749
+ * now REPEAT — each occurrence carries its own upstream model — enabling
750
+ * failover across several models on one backend.
751
+ * Idempotent: slots already in the {id,model?} shape (no string elements and no
752
+ * modelMap) are skipped. Returns true if any entry was rewritten.
753
+ */
754
+ function migrateFormatEntries(raw: GateConfig): boolean {
755
+ const models = raw.models as Record<string, unknown>;
756
+ if (!models || typeof models !== "object") return false;
757
+ let changed = false;
758
+ for (const entry of Object.values(models)) {
759
+ if (!entry || typeof entry !== "object") continue;
760
+ for (const f of ["openai", "anthropic", "responses"] as const) {
761
+ const fe = (entry as Record<string, unknown>)[f] as
762
+ | { providers?: unknown[]; modelMap?: Record<string, string> }
763
+ | undefined;
764
+ if (!fe || !Array.isArray(fe.providers)) continue;
765
+ const hasStrings = fe.providers.some((x) => typeof x === "string");
766
+ const hasMap = !!fe.modelMap && Object.keys(fe.modelMap).length > 0;
767
+ if (!hasStrings && !hasMap) continue; // already v5
768
+ const map = fe.modelMap ?? {};
769
+ fe.providers = (fe.providers as unknown[]).map((x) =>
770
+ typeof x === "string" ? (map[x] ? { id: x, model: map[x] } : { id: x }) : x,
771
+ );
772
+ delete fe.modelMap;
773
+ changed = true;
774
+ }
775
+ }
776
+ return changed;
777
+ }
@@ -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 = 4;
6
+ export const CONFIG_VERSION = 5;
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");