myapikey 0.17.0 → 0.18.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.17.0",
3
+ "version": "0.18.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": [
@@ -433,135 +433,166 @@ export function proxyApi(
433
433
  }
434
434
  }
435
435
 
436
- // Skip providers that are either in circuit-breaker cooldown OR over their
437
- // RPM pacing cap. Both are heuristics: if every candidate is skipped, fall
438
- // back to the full list anyway one real attempt beats a guaranteed 502
439
- // (a skipped provider that now succeeds also resets its state). A pinned
440
- // (per-source) probe ignores both the user is testing THIS source now,
441
- // whatever its breaker/pacing state.
442
- const skipped = (slot: CandidateSlot) => {
443
- const p = slot.provider;
444
- return store.isCooling(p.id) || (!!p.rpm && store.rpmUsed(p.id) >= p.rpm);
436
+ // Selection + failover run in ROUNDS. Each round attempts the candidates
437
+ // that are neither in circuit-breaker cooldown nor over their rpm cap; a
438
+ // capped-but-healthy source still spills to the next free source (failover
439
+ // first). Only when NOTHING is immediately usable but some candidate is
440
+ // merely over its rpm cap does the request QUEUE: sleep until that source's
441
+ // soonest window slot frees and run another round — the client perceives
442
+ // only the wait (or its own timeout), never an rpm error. There is no wait
443
+ // cap: rounds terminate anyway because retryable failures escalate the
444
+ // circuit breaker, so sources converge to cooling and the final round is
445
+ // the old try-anyway fallback (full list — one real attempt beats a
446
+ // guaranteed 502, and a skipped provider that now succeeds also resets its
447
+ // state). A pinned (per-source) probe ignores all of this — the user is
448
+ // testing THIS source now, whatever its breaker/pacing state.
449
+ const cooling = (slot: CandidateSlot) => store.isCooling(slot.provider.id);
450
+ const overRpm = (slot: CandidateSlot) => !!slot.provider.rpm && store.rpmUsed(slot.provider.id) >= slot.provider.rpm;
451
+ /** Sleep until the soonest rpm slot frees among `capped`. Returns false
452
+ * when the client hung up while queued — the caller drops the request
453
+ * (nothing was sent upstream, so no log row either). */
454
+ const waitForSlot = async (capped: CandidateSlot[]): Promise<boolean> => {
455
+ const wait = Math.max(1, Math.min(...capped.map((s) => store.rpmNextFreeMs(s.provider.id))));
456
+ if (!isProbe) rt.warn(`proxy model=${model}: all sources over rpm cap — queued, next slot in ~${Math.round(wait / 1000)}s`);
457
+ await new Promise((r) => setTimeout(r, wait));
458
+ return !c.req.raw.signal?.aborted;
445
459
  };
446
- const live = pinIndex != null ? list : list.filter((slot) => !skipped(slot));
447
- const order = live.length ? live : list;
448
-
449
- for (const slot of order) {
450
- const provider = slot.provider;
451
- // Per-slot upstream model name (absent = send the public name). Read from
452
- // the slot each iteration, so failover never carries the previous slot's
453
- // upstream name.
454
- body.model = slot.model ?? model;
455
- // The actual upstream model forwarded this attempt (after the per-slot
456
- // rewrite). Recorded on the log row so history shows which real model a
457
- // routed call landed on when a source remaps the public name. `undefined`
458
- // when the public name went through verbatim — JSON.stringify drops it, so
459
- // identity + legacy rows stay clean.
460
- const upstreamModel = slot.model && slot.model !== model ? slot.model : undefined;
461
- // Count this attempt toward the source's RPM window — but not for a pinned
462
- // probe, which (like circuit state) takes no routing side-effects.
463
- if (pinIndex == null) store.recordDispatch(provider.id);
464
- let upstream: Response;
465
- try {
466
- upstream = await fetch(upstreamTarget(provider, key).url, {
467
- method: "POST",
468
- headers: upstreamHeaders(provider, wire, clientVersion),
469
- body: JSON.stringify(body),
470
- });
471
- } catch {
472
- // Network error / DNS / timeout → try next provider.
473
- lastStatus = 502;
474
- lastErr = "network error";
475
- if (pinIndex != null) break; // per-source probe: fail fast, no circuit impact.
476
- sayFailover(provider, "network error");
477
- const r = store.recordCircuitFailure(provider.id, lastStatus, lastErr);
478
- if (r.entered) {
479
- store.pushLog({ ts: Date.now(), model, upstreamModel, 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 });
480
- sayCooldown(provider, r);
481
- }
482
- continue;
483
- }
484
460
 
485
- if (upstream.ok) {
486
- // A 200 from the upstream is NOT proof the call succeeded: some backends
487
- // return 200 then truncate the stream (or emit no content) for request
488
- // shapes they mishandle. We commit the 200 status to the client right
489
- // away (headers are already sent) but OBSERVE the body as it flows and
490
- // settle once on a clean, fully-terminated stream we close the circuit
491
- // + log 200; on a truncated/errored stream we log 502, trip the circuit
492
- // (so the NEXT call fails over), and — on the anthropic wire — inject a
493
- // synthetic SSE error event so the client learns the stream died instead
494
- // of seeing a silent EOF. TTFB is captured now; logging is deferred to
495
- // the body's end (so the row reflects the real outcome, not just the
496
- // headers). See observedBody() for the detection rules.
497
- const ttfb = Date.now() - start;
498
- const out = observedBody(upstream, {
499
- stream,
500
- key,
501
- requestMessages: body.messages,
502
- onSettle: (info) => {
503
- if (info.ok) {
504
- store.recordCircuitSuccess(provider.id);
505
- store.pushLog({ ts: Date.now(), model, upstreamModel, provider: provider.name, providerId: provider.id, format: wire, status: 200, ms: ttfb, stream, usage: info.usage });
506
- } else {
507
- // A pinned per-source probe takes no circuit side-effects (a manual
508
- // test must not trip the breaker) — mirrors the retryable branch.
509
- if (pinIndex == null) store.recordCircuitFailure(provider.id, info.status, info.error || "stream failed");
510
- if (!isProbe) rt.warn(`proxy stream failed: provider '${provider.name}' status=${info.status} (${info.error || "stream failed"})`);
511
- store.pushLog({ ts: Date.now(), model, upstreamModel, provider: provider.name, providerId: provider.id, format: wire, status: info.status, ms: ttfb, stream, error: info.error });
512
- }
513
- },
514
- });
515
- return new Response(out, { status: upstream.status, headers: downHeaders(upstream, isProbe ? provider.name : undefined) });
461
+ for (;;) {
462
+ // Re-read the chain each round: config and circuit state move while queued.
463
+ const cur = pinIndex != null ? list : candidates(store, model, key);
464
+ if (pinIndex == null && !cur.length) return notFound(c, model); // disabled while queued
465
+ const live = pinIndex != null ? cur : cur.filter((s) => !cooling(s) && !overRpm(s));
466
+ const capped = pinIndex != null ? [] : cur.filter((s) => !cooling(s) && overRpm(s));
467
+ if (pinIndex == null && !live.length && capped.length) {
468
+ if (await waitForSlot(capped)) continue;
469
+ return new Response(null, { status: 499 });
516
470
  }
517
- if (RETRYABLE.has(upstream.status)) {
518
- lastStatus = upstream.status;
519
- // Drain so the connection can be reused, then move on; capture the
520
- // reason for the log (this branch never streams back to the client).
521
- const txt = await upstream.text().catch(() => "");
522
- lastErr = shortError(txt) || `HTTP ${upstream.status}`;
523
- if (pinIndex != null) break; // per-source probe: fail fast, no circuit impact.
524
- sayFailover(provider, `HTTP ${lastStatus} (${lastErr})`);
525
- // A 429/overloaded upstream usually carries Retry-After; honoring it
526
- // cools for exactly as long as asked (clamped) instead of the escalating
527
- // guess. Absent (5xx often, OR a quota error that buried the reset time
528
- // in the BODY e.g. Volcengine Ark's 1308 "您的限额将在 <datetime> 重置")
529
- // parse that deadline out of the body, else fall back to escalating.
530
- const retryAfterMs = parseRetryAfter(upstream.headers.get("retry-after"));
531
- const resetInMs = retryAfterMs ? undefined : parseResetFromBody(txt);
532
- const r = store.recordCircuitFailure(provider.id, lastStatus, lastErr, retryAfterMs ?? resetInMs, !!resetInMs);
533
- if (r.entered) {
534
- store.pushLog({ ts: Date.now(), model, upstreamModel, 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 });
535
- sayCooldown(provider, r);
471
+ const order = live.length ? live : cur;
472
+
473
+ for (const slot of order) {
474
+ const provider = slot.provider;
475
+ // Per-slot upstream model name (absent = send the public name). Read from
476
+ // the slot each iteration, so failover never carries the previous slot's
477
+ // upstream name.
478
+ body.model = slot.model ?? model;
479
+ // The actual upstream model forwarded this attempt (after the per-slot
480
+ // rewrite). Recorded on the log row so history shows which real model a
481
+ // routed call landed on when a source remaps the public name. `undefined`
482
+ // when the public name went through verbatim JSON.stringify drops it, so
483
+ // identity + legacy rows stay clean.
484
+ const upstreamModel = slot.model && slot.model !== model ? slot.model : undefined;
485
+ // Count this attempt toward the source's RPM window — but not for a pinned
486
+ // probe, which (like circuit state) takes no routing side-effects.
487
+ if (pinIndex == null) store.recordDispatch(provider.id);
488
+ let upstream: Response;
489
+ try {
490
+ upstream = await fetch(upstreamTarget(provider, key).url, {
491
+ method: "POST",
492
+ headers: upstreamHeaders(provider, wire, clientVersion),
493
+ body: JSON.stringify(body),
494
+ });
495
+ } catch {
496
+ // Network error / DNS / timeout → try next provider.
497
+ lastStatus = 502;
498
+ lastErr = "network error";
499
+ if (pinIndex != null) break; // per-source probe: fail fast, no circuit impact.
500
+ sayFailover(provider, "network error");
501
+ const r = store.recordCircuitFailure(provider.id, lastStatus, lastErr);
502
+ if (r.entered) {
503
+ store.pushLog({ ts: Date.now(), model, upstreamModel, 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 });
504
+ sayCooldown(provider, r);
505
+ }
506
+ continue;
507
+ }
508
+
509
+ if (upstream.ok) {
510
+ // A 200 from the upstream is NOT proof the call succeeded: some backends
511
+ // return 200 then truncate the stream (or emit no content) for request
512
+ // shapes they mishandle. We commit the 200 status to the client right
513
+ // away (headers are already sent) but OBSERVE the body as it flows and
514
+ // settle once — on a clean, fully-terminated stream we close the circuit
515
+ // + log 200; on a truncated/errored stream we log 502, trip the circuit
516
+ // (so the NEXT call fails over), and — on the anthropic wire — inject a
517
+ // synthetic SSE error event so the client learns the stream died instead
518
+ // of seeing a silent EOF. TTFB is captured now; logging is deferred to
519
+ // the body's end (so the row reflects the real outcome, not just the
520
+ // headers). See observedBody() for the detection rules.
521
+ const ttfb = Date.now() - start;
522
+ const out = observedBody(upstream, {
523
+ stream,
524
+ key,
525
+ requestMessages: body.messages,
526
+ onSettle: (info) => {
527
+ if (info.ok) {
528
+ store.recordCircuitSuccess(provider.id);
529
+ store.pushLog({ ts: Date.now(), model, upstreamModel, provider: provider.name, providerId: provider.id, format: wire, status: 200, ms: ttfb, stream, usage: info.usage });
530
+ } else {
531
+ // A pinned per-source probe takes no circuit side-effects (a manual
532
+ // test must not trip the breaker) — mirrors the retryable branch.
533
+ if (pinIndex == null) store.recordCircuitFailure(provider.id, info.status, info.error || "stream failed");
534
+ if (!isProbe) rt.warn(`proxy stream failed: provider '${provider.name}' status=${info.status} (${info.error || "stream failed"})`);
535
+ store.pushLog({ ts: Date.now(), model, upstreamModel, provider: provider.name, providerId: provider.id, format: wire, status: info.status, ms: ttfb, stream, error: info.error });
536
+ }
537
+ },
538
+ });
539
+ return new Response(out, { status: upstream.status, headers: downHeaders(upstream, isProbe ? provider.name : undefined) });
536
540
  }
537
- continue;
541
+ if (RETRYABLE.has(upstream.status)) {
542
+ lastStatus = upstream.status;
543
+ // Drain so the connection can be reused, then move on; capture the
544
+ // reason for the log (this branch never streams back to the client).
545
+ const txt = await upstream.text().catch(() => "");
546
+ lastErr = shortError(txt) || `HTTP ${upstream.status}`;
547
+ if (pinIndex != null) break; // per-source probe: fail fast, no circuit impact.
548
+ sayFailover(provider, `HTTP ${lastStatus} (${lastErr})`);
549
+ // A 429/overloaded upstream usually carries Retry-After; honoring it
550
+ // cools for exactly as long as asked (clamped) instead of the escalating
551
+ // guess. Absent (5xx often, OR a quota error that buried the reset time
552
+ // in the BODY — e.g. Volcengine Ark's 1308 "您的限额将在 <datetime> 重置")
553
+ // → parse that deadline out of the body, else fall back to escalating.
554
+ const retryAfterMs = parseRetryAfter(upstream.headers.get("retry-after"));
555
+ const resetInMs = retryAfterMs ? undefined : parseResetFromBody(txt);
556
+ const r = store.recordCircuitFailure(provider.id, lastStatus, lastErr, retryAfterMs ?? resetInMs, !!resetInMs);
557
+ if (r.entered) {
558
+ store.pushLog({ ts: Date.now(), model, upstreamModel, 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 });
559
+ sayCooldown(provider, r);
560
+ }
561
+ continue;
562
+ }
563
+ // Non-retryable client error: return it to the caller as-is. Read the
564
+ // error text off a CLONE so the original body still streams back.
565
+ const errText = await upstream.clone().text().catch(() => "");
566
+ store.pushLog({ ts: Date.now(), model, upstreamModel, provider: provider.name, providerId: provider.id, format: wire, status: upstream.status, ms: Date.now() - start, stream, error: shortError(errText) || `HTTP ${upstream.status}` });
567
+ return passThrough(upstream, isProbe ? provider.name : undefined);
538
568
  }
539
- // Non-retryable client error: return it to the caller as-is. Read the
540
- // error text off a CLONE so the original body still streams back.
541
- const errText = await upstream.clone().text().catch(() => "");
542
- store.pushLog({ ts: Date.now(), model, upstreamModel, provider: provider.name, providerId: provider.id, format: wire, status: upstream.status, ms: Date.now() - start, stream, error: shortError(errText) || `HTTP ${upstream.status}` });
543
- return passThrough(upstream, isProbe ? provider.name : undefined);
544
- }
545
569
 
546
- const last = order[order.length - 1];
547
- const lastUpstreamModel = last.model && last.model !== model ? last.model : undefined;
548
- if (!isProbe) rt.error(`proxy all providers failed model=${model} (last status ${lastStatus})`);
549
- store.pushLog({ ts: Date.now(), model, upstreamModel: lastUpstreamModel, 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})` });
550
- // A pinned (per-source) probe failed: surface the REAL upstream status the
551
- // one slot returned (429/500/…), not a collapsed 502, and tag it with
552
- // x-myapikey-provider so the source-row badge names the tested source.
553
- if (pinIndex != null) {
554
- const h = new Headers({ "content-type": "application/json" });
555
- if (isProbe) h.set("x-myapikey-provider", encodeTag(last.provider.name));
556
- return new Response(
557
- JSON.stringify({ error: { message: lastErr || `provider failed (status ${lastStatus})`, type: "upstream_error" } }),
558
- { status: lastStatus, headers: h },
570
+ const last = order[order.length - 1];
571
+ const lastUpstreamModel = last.model && last.model !== model ? last.model : undefined;
572
+ // Every slot in this round failed retryably. rpm-capped candidates
573
+ // remain - queue for their next slot instead of erroring the client.
574
+ if (pinIndex == null && capped.length) {
575
+ if (await waitForSlot(capped)) continue;
576
+ return new Response(null, { status: 499 });
577
+ }
578
+ if (!isProbe) rt.error(`proxy all providers failed model=${model} (last status ${lastStatus})`);
579
+ store.pushLog({ ts: Date.now(), model, upstreamModel: lastUpstreamModel, 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})` });
580
+ // A pinned (per-source) probe failed: surface the REAL upstream status the
581
+ // one slot returned (429/500/…), not a collapsed 502, and tag it with
582
+ // x-myapikey-provider so the source-row badge names the tested source.
583
+ if (pinIndex != null) {
584
+ const h = new Headers({ "content-type": "application/json" });
585
+ if (isProbe) h.set("x-myapikey-provider", encodeTag(last.provider.name));
586
+ return new Response(
587
+ JSON.stringify({ error: { message: lastErr || `provider failed (status ${lastStatus})`, type: "upstream_error" } }),
588
+ { status: lastStatus, headers: h },
589
+ );
590
+ }
591
+ return c.json(
592
+ { error: { message: `all providers for '${model}' failed (last status ${lastStatus})`, type: "upstream_error" } },
593
+ 502,
559
594
  );
560
595
  }
561
- return c.json(
562
- { error: { message: `all providers for '${model}' failed (last status ${lastStatus})`, type: "upstream_error" } },
563
- 502,
564
- );
565
596
  };
566
597
 
567
598
  // OpenAI surface: chat/completions + responses (/models is registered above,
@@ -607,6 +607,28 @@ export class Store {
607
607
  else this.rpm.set(id, [Date.now()]);
608
608
  }
609
609
 
610
+ /** Ms until one slot frees in this source's trailing window (i.e. until its
611
+ * OLDEST recorded dispatch ages out) - the soonest a call over the source's
612
+ * rpm cap could go through. 0 when the window's first entry has already
613
+ * expired or the source has no recent activity (whether a slot is actually
614
+ * free is the caller's rpmUsed-vs-rpm check). Prunes expired entries as it
615
+ * reads, like rpmUsed. Used by dispatch to sleep out an rpm cap when every
616
+ * candidate source is over its limit. */
617
+ rpmNextFreeMs(id: string): number {
618
+ const arr = this.rpm.get(id);
619
+ if (!arr || !arr.length) return 0;
620
+ const now = Date.now();
621
+ const cutoff = now - RPM_WINDOW_MS;
622
+ let i = 0;
623
+ while (i < arr.length && arr[i] < cutoff) i++;
624
+ if (i > 0) arr.splice(0, i);
625
+ if (!arr.length) {
626
+ this.rpm.delete(id);
627
+ return 0;
628
+ }
629
+ return Math.max(0, arr[0] + RPM_WINDOW_MS - now);
630
+ }
631
+
610
632
  // --- even pacing (per model, in-memory leaky-bucket queue) ---
611
633
 
612
634
  /** Reserve the next release slot for a call to `model`, paced at `rpm`