myapikey 0.4.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myapikey",
3
- "version": "0.4.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, or a proxy that stripped it) escalate.
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 r = store.recordCircuitFailure(provider.id, lastStatus, lastErr, retryAfterMs);
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), honor it clamped to
421
- * [CB_MIN, CAP] instead of the escalating guess: the source isn't sicker,
422
- * it just said when it'll be ready. `fails` still increments either way so a
423
- * later hint-less failure continues the escalation from where it left off.
424
- * Returns `entered` = transitioned from healthy cooling this call (the
425
- * caller logs a cooldown row only then, to avoid timeline spam), plus the
426
- * fails count and cooldown duration for that row. */
427
- recordCircuitFailure(id: string, status: number, reason: string, retryAfterMs?: number): { entered: boolean; fails: number; cooldownMs: number } {
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
- const cooldownMs = hint ? Math.min(CB_CAP, Math.max(CB_MIN, Math.round(hint))) : Math.min(CB_CAP, CB_BASE * 2 ** (fails - 1));
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 });