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 +1 -1
- package/packages/core/src/cli/index.ts +22 -5
- package/packages/core/src/server/admin.ts +114 -56
- package/packages/core/src/server/proxy.ts +57 -31
- 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-BxnR60oo.css +1 -0
- package/packages/web/dist/assets/index-XCZs4yvq.js +290 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/assets/index-TA1b5eXA.css +0 -1
- 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.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("
|
|
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,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
|
-
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
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((
|
|
300
|
-
id:
|
|
301
|
-
name: byId.get(
|
|
302
|
-
// Upstream model name this
|
|
303
|
-
// public name verbatim).
|
|
304
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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?.
|
|
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
|
-
|
|
397
|
-
//
|
|
398
|
-
|
|
399
|
-
|
|
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 = "
|
|
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)
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
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;
|
|
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?.
|
|
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
|
|
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 (
|
|
485
|
+
if (index >= fe.providers.length) {
|
|
434
486
|
errStatus = 400;
|
|
435
|
-
errMsg = "
|
|
487
|
+
errMsg = "index out of range for this model's chain";
|
|
436
488
|
return;
|
|
437
489
|
}
|
|
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
|
-
}
|
|
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")
|
|
498
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
525
|
-
return c.json({ result: { ok: false, status: 0, format, error: "
|
|
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-
|
|
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")
|
|
548
|
-
|
|
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
|
-
/**
|
|
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
|
|
|
@@ -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
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
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
|
|
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 (
|
|
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 = (
|
|
334
|
-
|
|
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
|
|
338
|
-
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
|
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 (
|
|
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
|
-
//
|
|
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");
|