myapikey 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/core/src/server/proxy.ts +29 -2
- package/packages/core/src/server/store.ts +25 -9
- package/packages/web/dist/assets/index-4IcQkKZI.js +290 -0
- package/packages/web/dist/assets/{index-HG56nCKJ.css → index-CSq0O4fa.css} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/assets/index-CCZx1TIp.js +0 -290
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "myapikey",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Personal LLM API gateway & proxy — one address + one API key for all your models. Forwards OpenAI & Anthropic calls to your backends with failover and a circuit breaker. Pure passthrough, no format translation. Self-hosted (CLI + web UI).",
|
|
6
6
|
"keywords": [
|
|
@@ -29,6 +29,30 @@ function parseRetryAfter(v: string | null | undefined): number | undefined {
|
|
|
29
29
|
return undefined;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/** Parse a quota-reset DATETIME out of an upstream error body, for backends that
|
|
33
|
+
* put it in the message instead of a Retry-After header. Volcengine Ark's 1308
|
|
34
|
+
* ("已达到 5 小时的使用上限。您的限额将在 2026-08-11 18:33:11 重置。") is the case
|
|
35
|
+
* that bit us: no Retry-After, so the cooldown fell back to the escalating guess
|
|
36
|
+
* and re-hit the limit every 30/60/120…s. Returns ms-until-reset so the caller
|
|
37
|
+
* can cool for the real remaining window.
|
|
38
|
+
*
|
|
39
|
+
* Bare datetimes in these Chinese-vendor bodies are Beijing time (UTC+8); force
|
|
40
|
+
* that zone so the cooldown is right no matter what TZ the gateway itself runs
|
|
41
|
+
* in (Date.parse on a zone-less space-separated string would otherwise read it
|
|
42
|
+
* as the gateway's LOCAL time). An explicit zone (Z / ±HH:MM) is honored as-is.
|
|
43
|
+
* Returns undefined for no match / unparseable / already-in-the-past so the
|
|
44
|
+
* caller falls back to the escalating backoff. */
|
|
45
|
+
function parseResetFromBody(text: string): number | undefined {
|
|
46
|
+
if (!text) return undefined;
|
|
47
|
+
const m = text.match(/(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}(?::\d{2})?)(Z|[+-]\d{2}:?\d{2})?/);
|
|
48
|
+
if (!m) return undefined;
|
|
49
|
+
const zone = m[3] ?? "+08:00";
|
|
50
|
+
const t = Date.parse(`${m[1]}T${m[2]}${zone}`);
|
|
51
|
+
if (!Number.isFinite(t)) return undefined;
|
|
52
|
+
const ms = t - Date.now();
|
|
53
|
+
return ms > 0 ? ms : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
32
56
|
/** Resolve the ordered, compatible provider list for a model on a routing slot. */
|
|
33
57
|
function candidates(store: Store, model: string, key: RouteKey): Provider[] {
|
|
34
58
|
const d = store.get();
|
|
@@ -365,9 +389,12 @@ export function proxyApi(store: Store, auth: MiddlewareHandler): Hono {
|
|
|
365
389
|
if (pinId) break; // per-source probe: fail fast, no circuit impact.
|
|
366
390
|
// A 429/overloaded upstream usually carries Retry-After; honoring it
|
|
367
391
|
// cools for exactly as long as asked (clamped) instead of the escalating
|
|
368
|
-
// guess. Absent (5xx often,
|
|
392
|
+
// guess. Absent (5xx often, OR a quota error that buried the reset time
|
|
393
|
+
// in the BODY — e.g. Volcengine Ark's 1308 "您的限额将在 <datetime> 重置")
|
|
394
|
+
// → parse that deadline out of the body, else fall back to escalating.
|
|
369
395
|
const retryAfterMs = parseRetryAfter(upstream.headers.get("retry-after"));
|
|
370
|
-
const
|
|
396
|
+
const resetInMs = retryAfterMs ? undefined : parseResetFromBody(txt);
|
|
397
|
+
const r = store.recordCircuitFailure(provider.id, lastStatus, lastErr, retryAfterMs ?? resetInMs, !!resetInMs);
|
|
371
398
|
if (r.entered) {
|
|
372
399
|
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 });
|
|
373
400
|
}
|
|
@@ -27,6 +27,13 @@ const CB_CAP = 300_000;
|
|
|
27
27
|
// lenient "Retry-After: 0"/sub-second) shouldn't read as "no cooldown" and let
|
|
28
28
|
// us re-hammer a just-rate-limited source in a tight loop.
|
|
29
29
|
const CB_MIN = 1_000;
|
|
30
|
+
// Ceiling for a reset DEADLINE parsed out of an error body (e.g. Volcengine Ark's
|
|
31
|
+
// 1308 "您的限额将在 <datetime> 重置" — a quota window, not a backoff guess).
|
|
32
|
+
// Larger than CB_CAP because a quota reset is a real future event the source
|
|
33
|
+
// explicitly told us about: while cooling the source is SKIPPED, so honoring the
|
|
34
|
+
// true reset avoids re-probing a source we KNOW is rate-limited. Sanity-bound so a
|
|
35
|
+
// malformed body can't take a source offline for more than a work day.
|
|
36
|
+
const RESET_CAP_MS = 6 * 60 * 60 * 1000;
|
|
30
37
|
|
|
31
38
|
/** RPM pacing window: a source's `rpm` cap counts calls within this trailing
|
|
32
39
|
* window. 60s matches the usual "requests per minute" limit. */
|
|
@@ -417,20 +424,29 @@ export class Store {
|
|
|
417
424
|
* across cooldown expirations and is reset only by success — unless the
|
|
418
425
|
* provider has been quiet for > CAP, in which case it starts fresh at 1.
|
|
419
426
|
* When the upstream told us exactly how long to back off (`retryAfterMs`,
|
|
420
|
-
* parsed from a 429/overloaded Retry-After header
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
|
|
427
|
+
* parsed from a 429/overloaded Retry-After header, OR a reset deadline parsed
|
|
428
|
+
* from a Volcengine-Ark-style error body — `resetDeadline` selects the larger
|
|
429
|
+
* RESET_CAP_MS ceiling for the latter), honor it — clamped to [CB_MIN, cap] —
|
|
430
|
+
* instead of the escalating guess: the source isn't sicker, it just said when
|
|
431
|
+
* it'll be ready. `fails` still increments either way so a later hint-less
|
|
432
|
+
* failure continues the escalation from where it left off. Returns `entered` =
|
|
433
|
+
* transitioned from healthy → cooling this call (the caller logs a cooldown
|
|
434
|
+
* row only then, to avoid timeline spam), plus the fails count and cooldown
|
|
435
|
+
* duration for that row. */
|
|
436
|
+
recordCircuitFailure(id: string, status: number, reason: string, retryAfterMs?: number, resetDeadline?: boolean): { entered: boolean; fails: number; cooldownMs: number } {
|
|
428
437
|
const now = Date.now();
|
|
429
438
|
const cur = this.circuit.get(id);
|
|
430
439
|
const stale = !cur || now - cur.lastTs > CB_CAP;
|
|
431
440
|
const fails = stale ? 1 : cur!.fails + 1;
|
|
432
441
|
const hint = retryAfterMs && Number.isFinite(retryAfterMs) && retryAfterMs > 0 ? retryAfterMs : 0;
|
|
433
|
-
|
|
442
|
+
// A hint's ceiling depends on what it represents: a Retry-After backoff guess
|
|
443
|
+
// caps at CB_CAP (5min — OpenAI-style org-quota Retry-Afters can span
|
|
444
|
+
// hours/days, and we'd rather re-probe than write the source off that long);
|
|
445
|
+
// a reset DEADLINE parsed from an Ark-style body caps at RESET_CAP_MS (a real
|
|
446
|
+
// future event worth waiting for). The no-hint escalating guess always caps at
|
|
447
|
+
// CB_CAP.
|
|
448
|
+
const cap = resetDeadline ? RESET_CAP_MS : CB_CAP;
|
|
449
|
+
const cooldownMs = hint ? Math.min(cap, Math.max(CB_MIN, Math.round(hint))) : Math.min(CB_CAP, CB_BASE * 2 ** (fails - 1));
|
|
434
450
|
const until = now + cooldownMs;
|
|
435
451
|
const wasCooling = !!cur && cur.until > now;
|
|
436
452
|
this.circuit.set(id, { fails, until, lastStatus: status, lastReason: reason, lastTs: now });
|