myapikey 0.6.0 → 0.8.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.6.0",
3
+ "version": "0.8.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,15 +44,11 @@ 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). */
47
+ /** Drop every slot for a provider id from a routing slot used when a provider
48
+ * is deleted outright or removed from this model's chain. A provider may occupy
49
+ * more than one slot (each mapped to a different upstream model); all go. */
50
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;
55
- }
51
+ fe.providers = fe.providers.filter((s) => s.id !== pid);
56
52
  }
57
53
 
58
54
  /** Project provider for API responses: hide the full key. */
@@ -296,12 +292,13 @@ export function adminApi(store: Store, auth: MiddlewareHandler, v1: Hono): Hono
296
292
  const byId = new Map(d.providers.map((p) => [p.id, p]));
297
293
  const proj = (fe: FormatEntry) => ({
298
294
  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],
295
+ providers: fe.providers.map((s) => ({
296
+ id: s.id,
297
+ name: byId.get(s.id)?.name ?? "?",
298
+ // Upstream model name this slot rewrites the request to (undefined =
299
+ // send the public model name verbatim). Carried inline on each slot so
300
+ // a provider can appear more than once with different upstream names.
301
+ model: s.model,
305
302
  })),
306
303
  });
307
304
  const models = Object.entries(d.models).map(([name, e]) => ({
@@ -337,7 +334,10 @@ export function adminApi(store: Store, auth: MiddlewareHandler, v1: Hono): Hono
337
334
  responses: { enabled: false, providers: [] },
338
335
  });
339
336
  const fe = entry[key];
340
- for (const pid of requested) if (!fe.providers.includes(pid)) fe.providers.push(pid);
337
+ // Enable/seed dedupes by provider id: re-enabling must not pile up
338
+ // duplicate slots. (Use POST /:name/providers to intentionally add a
339
+ // second occurrence of a provider for per-model failover.)
340
+ for (const pid of requested) if (!fe.providers.some((s) => s.id === pid)) fe.providers.push({ id: pid });
341
341
  fe.enabled = true;
342
342
  });
343
343
  return c.json({ ok: true }, 201);
@@ -345,7 +345,7 @@ export function adminApi(store: Store, auth: MiddlewareHandler, v1: Hono): Hono
345
345
 
346
346
  app.post("/models/:name/providers", async (c) => {
347
347
  const name = c.req.param("name");
348
- const body = await readJson<{ format?: RouteKey; providerId?: string }>(c.req.raw);
348
+ const body = await readJson<{ format?: RouteKey; providerId?: string; model?: string }>(c.req.raw);
349
349
  if (!body?.format) return c.json({ error: { message: "format is required" } }, 400);
350
350
  if (!body?.providerId) return c.json({ error: { message: "providerId is required" } }, 400);
351
351
  const cfg = store.get();
@@ -353,6 +353,11 @@ export function adminApi(store: Store, auth: MiddlewareHandler, v1: Hono): Hono
353
353
  if (!p) return c.json({ error: { message: "provider not found" } }, 400);
354
354
  if (!providerSpeaks(p, body.format))
355
355
  return c.json({ error: { message: `provider ${p.name} does not serve ${body.format}` } }, 400);
356
+ // No dedupe guard: appending a SECOND occurrence of a provider is the whole
357
+ // point (failover across its models). An optional `model` sets the new
358
+ // slot's upstream name at attach time (add + map in one call).
359
+ const upstream = body.model?.trim();
360
+ const slot = upstream ? { id: body.providerId!, model: upstream } : { id: body.providerId! };
356
361
  let errStatus = 0;
357
362
  await store.update((d) => {
358
363
  const entry = d.models[name];
@@ -360,12 +365,39 @@ export function adminApi(store: Store, auth: MiddlewareHandler, v1: Hono): Hono
360
365
  errStatus = 404;
361
366
  return;
362
367
  }
363
- if (!entry[body.format!].providers.includes(body.providerId!)) entry[body.format!].providers.push(body.providerId!);
368
+ entry[body.format!].providers.push(slot);
364
369
  });
365
370
  if (errStatus === 404) return c.json({ error: { message: "model not found; enable it first" } }, 404);
366
371
  return c.json({ ok: true });
367
372
  });
368
373
 
374
+ // Remove a SINGLE chain slot at `index` (the web's per-row remove). Distinct
375
+ // from the id-based delete below, which removes every slot for a provider.
376
+ app.delete("/models/:name/providers", async (c) => {
377
+ const name = c.req.param("name");
378
+ const format = c.req.query("format") as RouteKey | undefined;
379
+ const index = Number(c.req.query("index"));
380
+ if (!format) return c.json({ error: { message: "?format=openai|anthropic|responses is required" } }, 400);
381
+ if (!Number.isInteger(index) || index < 0)
382
+ return c.json({ error: { message: "?index= (non-negative integer) is required" } }, 400);
383
+ let errStatus = 0;
384
+ await store.update((d) => {
385
+ const fe = d.models[name]?.[format];
386
+ if (!fe) {
387
+ errStatus = 404;
388
+ return;
389
+ }
390
+ if (index >= fe.providers.length) {
391
+ errStatus = 400;
392
+ return;
393
+ }
394
+ fe.providers.splice(index, 1);
395
+ });
396
+ if (errStatus === 404) return c.json({ error: { message: "model not found" } }, 404);
397
+ if (errStatus === 400) return c.json({ error: { message: "index out of range" } }, 400);
398
+ return c.json({ ok: true });
399
+ });
400
+
369
401
  app.delete("/models/:name/providers/:providerId", async (c) => {
370
402
  const name = c.req.param("name");
371
403
  const pid = c.req.param("providerId");
@@ -380,9 +412,10 @@ export function adminApi(store: Store, auth: MiddlewareHandler, v1: Hono): Hono
380
412
 
381
413
  app.put("/models/:name/priority", async (c) => {
382
414
  const name = c.req.param("name");
383
- const body = await readJson<{ format?: RouteKey; providers?: string[] }>(c.req.raw);
415
+ const body = await readJson<{ format?: RouteKey; order?: number[] }>(c.req.raw);
384
416
  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);
417
+ if (!Array.isArray(body?.order) || !body.order.every((n) => Number.isInteger(n)))
418
+ return c.json({ error: { message: "order[] (integers) required" } }, 400);
386
419
  const format = body.format;
387
420
  let errStatus = 0;
388
421
  let errMsg = "";
@@ -393,32 +426,38 @@ export function adminApi(store: Store, auth: MiddlewareHandler, v1: Hono): Hono
393
426
  return;
394
427
  }
395
428
  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))) {
429
+ const n = fe.providers.length;
430
+ // Reorder only: `order` must be a permutation of [0..n-1] (the current
431
+ // slot positions). Indices — not provider ids — because a provider may now
432
+ // occupy several slots. Use add-provider / remove-provider to change membership.
433
+ if (
434
+ body.order!.length !== n ||
435
+ new Set(body.order).size !== n ||
436
+ !body.order!.every((i) => i >= 0 && i < n)
437
+ ) {
400
438
  errStatus = 400;
401
- errMsg = "providers must be a reordering of the current chain (no add/drop)";
439
+ errMsg = "order must be a reordering of the current chain (no add/drop)";
402
440
  return;
403
441
  }
404
- fe.providers = body.providers!;
442
+ fe.providers = body.order!.map((i) => fe.providers[i]);
405
443
  });
406
444
  if (errStatus === 404) return c.json({ error: { message: "model not found" } }, 404);
407
445
  if (errStatus === 400) return c.json({ error: { message: errMsg } }, 400);
408
446
  return c.json({ ok: true });
409
447
  });
410
448
 
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.
449
+ // Set (or clear) the upstream-model mapping for ONE chain slot (addressed by
450
+ // `index`). An empty `model` clears it (back to identity send the public
451
+ // name). Index-addressed because a provider may occupy several slots, each
452
+ // with its own upstream model.
415
453
  app.put("/models/:name/map", async (c) => {
416
454
  const name = c.req.param("name");
417
- const body = await readJson<{ format?: RouteKey; providerId?: string; model?: string }>(c.req.raw);
455
+ const body = await readJson<{ format?: RouteKey; index?: number; model?: string }>(c.req.raw);
418
456
  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);
457
+ if (!Number.isInteger(body?.index) || (body?.index ?? -1) < 0)
458
+ return c.json({ error: { message: "index (non-negative integer) is required" } }, 400);
420
459
  const format = body.format;
421
- const pid = body.providerId;
460
+ const index = body.index!;
422
461
  const upstream = (body.model ?? "").trim();
423
462
  let errStatus = 0;
424
463
  let errMsg = "";
@@ -430,17 +469,13 @@ export function adminApi(store: Store, auth: MiddlewareHandler, v1: Hono): Hono
430
469
  return;
431
470
  }
432
471
  const fe = entry[format];
433
- if (!fe.providers.includes(pid)) {
472
+ if (index >= fe.providers.length) {
434
473
  errStatus = 400;
435
- errMsg = "provider is not in this model's chain for the given format";
474
+ errMsg = "index out of range for this model's chain";
436
475
  return;
437
476
  }
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
- }
477
+ if (upstream) fe.providers[index].model = upstream;
478
+ else delete fe.providers[index].model;
444
479
  });
445
480
  if (errStatus === 404) return c.json({ error: { message: errMsg } }, 404);
446
481
  if (errStatus === 400) return c.json({ error: { message: errMsg } }, 400);
@@ -500,19 +535,20 @@ export function adminApi(store: Store, auth: MiddlewareHandler, v1: Hono): Hono
500
535
  return c.json({ result: { ok: false, status: res.status, provider, format, error: shortError(txt) || `HTTP ${res.status}` } });
501
536
  });
502
537
 
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) => {
538
+ // Probe a SINGLE chain slot for a model+format: the same end-to-end loopback as
539
+ // the whole-model /test above, but dispatch is pinned to this one slot (via the
540
+ // x-myapikey-probe-slot header carrying the slot index), so there is no failover
541
+ // and no circuit-breaker impact — a manual "is THIS source up?" check that
542
+ // reports the slot's real upstream status (the slot's own mapped model name).
543
+ // Slot-indexed (not provider-id) because a provider may occupy several slots,
544
+ // each mapped to a different upstream model. Validation misses come back as a
545
+ // ProbeResult body, not an HTTP error, so the caller reads `result` uniformly.
546
+ app.post("/models/:name/providers/test", async (c) => {
511
547
  const name = c.req.param("name");
512
- const pid = c.req.param("providerId");
513
548
  const cfg = store.get();
514
549
  const entry = cfg.models[name];
515
550
  if (!entry) return c.json({ error: { message: "model not found" } }, 404);
551
+ const index = Number(c.req.query("index"));
516
552
  let format = c.req.query("format") as RouteKey | undefined;
517
553
  if (!format) {
518
554
  // No slot requested: pick the first one the model is enabled on.
@@ -521,9 +557,10 @@ export function adminApi(store: Store, auth: MiddlewareHandler, v1: Hono): Hono
521
557
  if (!format || !entry[format]?.enabled) {
522
558
  return c.json({ result: { ok: false, status: 0, format: format ?? "openai", error: "model not enabled on that routing slot" } });
523
559
  }
524
- if (!entry[format].providers.includes(pid)) {
525
- return c.json({ result: { ok: false, status: 0, format, error: "source is not on this route" } });
560
+ if (!Number.isInteger(index) || index < 0 || index >= entry[format].providers.length) {
561
+ return c.json({ result: { ok: false, status: 0, format, error: "slot index out of range for this route" } });
526
562
  }
563
+ const pid = entry[format].providers[index].id;
527
564
  const provider = cfg.providers.find((p) => p.id === pid);
528
565
  if (!provider || !providerSpeaks(provider, format)) {
529
566
  return c.json({ result: { ok: false, status: 0, format, error: "source does not speak this format" } });
@@ -538,7 +575,7 @@ export function adminApi(store: Store, auth: MiddlewareHandler, v1: Hono): Hono
538
575
  try {
539
576
  res = await v1.request(path, {
540
577
  method: "POST",
541
- headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiKey}`, "x-myapikey-probe": "1", "x-myapikey-probe-provider": pid },
578
+ headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiKey}`, "x-myapikey-probe": "1", "x-myapikey-probe-slot": String(index) },
542
579
  body: JSON.stringify(body),
543
580
  });
544
581
  } catch (e) {
@@ -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
 
@@ -295,15 +307,21 @@ export function proxyApi(store: Store, auth: MiddlewareHandler): Hono {
295
307
  // answered back to the test handler (x-myapikey-provider), without leaking
296
308
  // that header to real agent clients.
297
309
  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
310
+ // The per-source "test this source" variant pins dispatch to ONE slot (by
311
+ // chain index, since a provider may occupy several slots): the candidate
312
+ // chain is reduced to just that slot, and on failure we stop immediately
313
+ // (no failover) WITHOUT recording a circuit failure a manual probe must
314
+ // not trip the breaker. Failure surfaces the real upstream status
302
315
  // (429/500/…), not a collapsed 502, so the badge shows what really happened.
303
- const pinId = c.req.header("x-myapikey-probe-provider") || "";
316
+ const pinIndexRaw = c.req.header("x-myapikey-probe-slot");
317
+ const pinIndex = pinIndexRaw !== "" && Number.isInteger(Number(pinIndexRaw)) ? Number(pinIndexRaw) : null;
304
318
  // candidates() already restricts the responses chain to supportsResponses sources.
305
319
  let list = candidates(store, model, key);
306
- if (pinId) list = list.filter((p) => p.id === pinId);
320
+ if (pinIndex != null) {
321
+ // An out-of-range index → empty list → 404, so a bad probe is reported as
322
+ // unreachable rather than accidentally hitting a different slot.
323
+ list = list[pinIndex] != null ? [list[pinIndex]] : [];
324
+ }
307
325
  if (!list.length) {
308
326
  if (key === "responses") {
309
327
  return c.json(
@@ -330,20 +348,22 @@ export function proxyApi(store: Store, auth: MiddlewareHandler): Hono {
330
348
  // (a skipped provider that now succeeds also resets its state). A pinned
331
349
  // (per-source) probe ignores both — the user is testing THIS source now,
332
350
  // 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));
351
+ const skipped = (slot: CandidateSlot) => {
352
+ const p = slot.provider;
353
+ return store.isCooling(p.id) || (!!p.rpm && store.rpmUsed(p.id) >= p.rpm);
354
+ };
355
+ const live = pinIndex != null ? list : list.filter((slot) => !skipped(slot));
335
356
  const order = live.length ? live : list;
336
357
 
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;
358
+ for (const slot of order) {
359
+ const provider = slot.provider;
360
+ // Per-slot upstream model name (absent = send the public name). Read from
361
+ // the slot each iteration, so failover never carries the previous slot's
362
+ // upstream name.
363
+ body.model = slot.model ?? model;
344
364
  // Count this attempt toward the source's RPM window — but not for a pinned
345
365
  // probe, which (like circuit state) takes no routing side-effects.
346
- if (!pinId) store.recordDispatch(provider.id);
366
+ if (pinIndex == null) store.recordDispatch(provider.id);
347
367
  let upstream: Response;
348
368
  try {
349
369
  upstream = await fetch(upstreamTarget(provider, key).url, {
@@ -355,7 +375,7 @@ export function proxyApi(store: Store, auth: MiddlewareHandler): Hono {
355
375
  // Network error / DNS / timeout → try next provider.
356
376
  lastStatus = 502;
357
377
  lastErr = "network error";
358
- if (pinId) break; // per-source probe: fail fast, no circuit impact.
378
+ if (pinIndex != null) break; // per-source probe: fail fast, no circuit impact.
359
379
  const r = store.recordCircuitFailure(provider.id, lastStatus, lastErr);
360
380
  if (r.entered) {
361
381
  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 +407,7 @@ export function proxyApi(store: Store, auth: MiddlewareHandler): Hono {
387
407
  } else {
388
408
  // A pinned per-source probe takes no circuit side-effects (a manual
389
409
  // test must not trip the breaker) — mirrors the retryable branch.
390
- if (!pinId) store.recordCircuitFailure(provider.id, info.status, info.error || "stream failed");
410
+ if (pinIndex == null) store.recordCircuitFailure(provider.id, info.status, info.error || "stream failed");
391
411
  store.pushLog({ ts: Date.now(), model, provider: provider.name, providerId: provider.id, format: wire, status: info.status, ms: ttfb, stream, error: info.error });
392
412
  }
393
413
  },
@@ -400,7 +420,7 @@ export function proxyApi(store: Store, auth: MiddlewareHandler): Hono {
400
420
  // reason for the log (this branch never streams back to the client).
401
421
  const txt = await upstream.text().catch(() => "");
402
422
  lastErr = shortError(txt) || `HTTP ${upstream.status}`;
403
- if (pinId) break; // per-source probe: fail fast, no circuit impact.
423
+ if (pinIndex != null) break; // per-source probe: fail fast, no circuit impact.
404
424
  // A 429/overloaded upstream usually carries Retry-After; honoring it
405
425
  // cools for exactly as long as asked (clamped) instead of the escalating
406
426
  // guess. Absent (5xx often, OR a quota error that buried the reset time
@@ -422,13 +442,13 @@ export function proxyApi(store: Store, auth: MiddlewareHandler): Hono {
422
442
  }
423
443
 
424
444
  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})` });
445
+ 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
446
  // 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
447
+ // one slot returned (429/500/…), not a collapsed 502, and tag it with
428
448
  // x-myapikey-provider so the source-row badge names the tested source.
429
- if (pinId) {
449
+ if (pinIndex != null) {
430
450
  const h = new Headers({ "content-type": "application/json" });
431
- if (isProbe) h.set("x-myapikey-provider", last.name);
451
+ if (isProbe) h.set("x-myapikey-provider", last.provider.name);
432
452
  return new Response(
433
453
  JSON.stringify({ error: { message: lastErr || `provider failed (status ${lastStatus})`, type: "upstream_error" } }),
434
454
  { status: lastStatus, headers: h },
@@ -458,7 +478,7 @@ export function proxyApi(store: Store, auth: MiddlewareHandler): Hono {
458
478
  id,
459
479
  object: "model",
460
480
  created: 0,
461
- owned_by: byId.get(e.openai.providers[0] ?? "")?.name || "MyAPIKey",
481
+ owned_by: byId.get(e.openai.providers[0]?.id ?? "")?.name || "MyAPIKey",
462
482
  }));
463
483
  return c.json({ object: "list", data });
464
484
  });
@@ -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");
@@ -36,26 +36,38 @@ export interface Provider {
36
36
  createdAt: number;
37
37
  }
38
38
 
39
- /** One routing slot: an independent enable flag + a priority-ordered provider
40
- * chain. Invariant (enforced by admin mutations, defended by proxy candidates()):
41
- * every id in `providers` exists in `GateConfig.providers` and is compatible
42
- * with the slot — openai/anthropic ids must carry that wire format; responses
43
- * ids must additionally be supportsResponses sources (still OpenAI-format).
39
+ /** One routing slot: an independent enable flag + a priority-ordered chain of
40
+ * (provider, optional upstream model) pairs. Invariant (enforced by admin
41
+ * mutations, defended by proxy candidates()): every id in `providers` exists in
42
+ * `GateConfig.providers` and is compatible with the slot — openai/anthropic ids
43
+ * must carry that wire format; responses ids must additionally be
44
+ * supportsResponses sources (still OpenAI-format).
44
45
  *
45
- * `modelMap` (optional): per-provider UPSTREAM model name. When forwarding to
46
- * provider P, if `modelMap[P.id]` is set, the gateway rewrites the request's
47
- * `model` field to that value before the passthrough POST; otherwise the
48
- * public model name (the key in GateConfig.models) is sent verbatim. So this is
49
- * a pure name rewrite on the passthrough body still no OpenAI↔Anthropic
50
- * translation. Lets you alias (claude-sonnet-4 claude-sonnet-4-20250514) or
51
- * swap the actual model (gpt-4 → gpt-4o) per source. Absent/empty = identity. */
46
+ * A provider id may appear MORE THAN ONCE — each occurrence is an independent
47
+ * failover slot that can carry its own upstream model name. When forwarding to
48
+ * a slot, if its `model` is set the gateway rewrites the request's `model`
49
+ * field to that value before the passthrough POST; otherwise the public model
50
+ * name (the key in GateConfig.models) is sent verbatim. That is a pure name
51
+ * rewrite on the passthrough body still no OpenAI↔Anthropic translation. It
52
+ * lets you alias (claude-sonnet-4 → claude-sonnet-4-20250514), swap the actual
53
+ * model (gpt-4 → gpt-4o) per source, or fail over across several models on ONE
54
+ * backend (Ark → doubao-pro primary, Ark → doubao-lite fallback). Absent model
55
+ * = identity (send the public name). */
52
56
  export interface FormatEntry {
53
57
  enabled: boolean;
54
- /** Provider ids in priority order. First = primary, rest = fallback. */
55
- providers: string[];
56
- /** providerId → upstream model name to send to that provider. Optional;
57
- * absent key (or absent map) = send the public model name unchanged. */
58
- modelMap?: Record<string, string>;
58
+ /** Ordered chain of routing slots: first = primary, rest = fallback. The same
59
+ * provider id may repeat — each occurrence can map a different upstream model. */
60
+ providers: ChainSlot[];
61
+ }
62
+
63
+ /** One slot in a format's provider chain: which provider to forward to, and the
64
+ * optional upstream model name to rewrite the request's `model` field to.
65
+ * Duplicates of `id` are legal (distinct failover slots). */
66
+ export interface ChainSlot {
67
+ id: string;
68
+ /** Upstream model name to send to this provider. Absent = forward the public
69
+ * model name (the key in GateConfig.models) unchanged. */
70
+ model?: string;
59
71
  }
60
72
 
61
73
  /** A model's routing dimensions — one per forwarding endpoint. /chat/completions