myapikey 0.7.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 +1 -1
- package/packages/core/src/cli/index.ts +22 -5
- package/packages/core/src/server/admin.ts +89 -52
- package/packages/core/src/server/proxy.ts +50 -30
- package/packages/core/src/server/store.ts +46 -5
- package/packages/core/src/shared/config.ts +1 -1
- package/packages/core/src/shared/types.ts +29 -17
- package/packages/web/dist/assets/{index-TA1b5eXA.css → index-CMXty-90.css} +1 -1
- package/packages/web/dist/assets/index-DZdFX8aR.js +290 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/assets/index-bSLwqZnQ.js +0 -290
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "myapikey",
|
|
3
|
-
"version": "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("
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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((
|
|
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((
|
|
300
|
-
id:
|
|
301
|
-
name: byId.get(
|
|
302
|
-
// Upstream model name this
|
|
303
|
-
// public name verbatim).
|
|
304
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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?.
|
|
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
|
-
|
|
397
|
-
//
|
|
398
|
-
|
|
399
|
-
|
|
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 = "
|
|
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)
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
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;
|
|
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?.
|
|
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
|
|
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 (
|
|
472
|
+
if (index >= fe.providers.length) {
|
|
434
473
|
errStatus = 400;
|
|
435
|
-
errMsg = "
|
|
474
|
+
errMsg = "index out of range for this model's chain";
|
|
436
475
|
return;
|
|
437
476
|
}
|
|
438
|
-
if (upstream)
|
|
439
|
-
|
|
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
|
|
504
|
-
// whole-model /test above, but dispatch is pinned to this one
|
|
505
|
-
// x-myapikey-probe-
|
|
506
|
-
// circuit-breaker impact — a manual "is THIS source up?" check that
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
|
|
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.
|
|
525
|
-
return c.json({ result: { ok: false, status: 0, format, error: "
|
|
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-
|
|
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
|
-
/**
|
|
58
|
-
|
|
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((
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
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
|
|
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 (
|
|
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 = (
|
|
334
|
-
|
|
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
|
|
338
|
-
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
|
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 (
|
|
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
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
|
|
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 =
|
|
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
|
|
40
|
-
*
|
|
41
|
-
* every id in `providers` exists in
|
|
42
|
-
* with the slot — openai/anthropic ids
|
|
43
|
-
* ids must additionally be
|
|
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
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* `model`
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
-
/**
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|