myapikey 0.16.1 → 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 +1 -1
- package/packages/core/src/cli/index.ts +10 -0
- package/packages/core/src/server/admin.ts +23 -0
- package/packages/core/src/server/proxy.ts +185 -120
- package/packages/core/src/server/store.ts +50 -0
- package/packages/core/src/shared/types.ts +6 -0
- package/packages/web/dist/assets/index-DhA5duaA.js +293 -0
- package/packages/web/dist/index.html +1 -1
- package/packages/web/dist/assets/index-DjpCcUrO.js +0 -293
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "myapikey",
|
|
3
|
-
"version": "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": [
|
|
@@ -184,6 +184,7 @@ model.command("list").action(async () => {
|
|
|
184
184
|
const chain = fe.providers.map((p: any) => (p.model ? `${p.name}→${p.model}` : p.name)).join(" → ") || "(none)";
|
|
185
185
|
console.log(` ${f.padEnd(9)} ${fe.enabled ? "✓" : "·"} ${chain}`);
|
|
186
186
|
}
|
|
187
|
+
if (m.paceRpm) console.log(` pace ${m.paceRpm}/min (one every ${Math.round(60 / m.paceRpm)}s)`);
|
|
187
188
|
}
|
|
188
189
|
});
|
|
189
190
|
|
|
@@ -254,6 +255,15 @@ model
|
|
|
254
255
|
console.log(`Priority for ${name} [${opts.format}]: ${refs.join(" → ")}`);
|
|
255
256
|
});
|
|
256
257
|
|
|
258
|
+
model
|
|
259
|
+
.command("pace <name> [rpm]")
|
|
260
|
+
.description("set/clear the per-model even-pacing limit (requests/min, spread one every 60/rpm s; 0 = unlimited)")
|
|
261
|
+
.action(async (name: string, rpmRaw: string) => {
|
|
262
|
+
const rpm = Math.floor(Number(rpmRaw ?? 0)) || 0;
|
|
263
|
+
const r = (await api(ctx(), "PUT", `/admin/models/${encodeURIComponent(name)}/pace`, { rpm })) as { paceRpm: number };
|
|
264
|
+
console.log(r.paceRpm ? `Pacing ${name} at ${r.paceRpm}/min (one every ${Math.round(60 / r.paceRpm)}s).` : `Pacing cleared for ${name} (unlimited).`);
|
|
265
|
+
});
|
|
266
|
+
|
|
257
267
|
model.command("remove <name>").description("remove a model entirely (both formats)").action(async (name: string) => {
|
|
258
268
|
await api(ctx(), "DELETE", `/admin/models/${encodeURIComponent(name)}`);
|
|
259
269
|
console.log(`Removed ${name}.`);
|
|
@@ -319,6 +319,7 @@ export function adminApi(store: Store, auth: MiddlewareHandler, openai: Hono, an
|
|
|
319
319
|
openai: proj(e.openai),
|
|
320
320
|
anthropic: proj(e.anthropic),
|
|
321
321
|
responses: proj(e.responses),
|
|
322
|
+
paceRpm: e.paceRpm ?? 0,
|
|
322
323
|
}));
|
|
323
324
|
return c.json({ models });
|
|
324
325
|
});
|
|
@@ -481,6 +482,28 @@ export function adminApi(store: Store, auth: MiddlewareHandler, openai: Hono, an
|
|
|
481
482
|
return c.json({ ok: true });
|
|
482
483
|
});
|
|
483
484
|
|
|
485
|
+
// Set (or clear) the per-model even-pacing limit: `rpm` requests/min spread
|
|
486
|
+
// evenly (one every 60/rpm seconds; excess calls queue at the gateway, capped
|
|
487
|
+
// at a 60s wait). 0/blank/invalid clears. Model-wide - shared by all three
|
|
488
|
+
// route slots - and independent of the per-source Provider.rpm spill-over.
|
|
489
|
+
app.put("/models/:name/pace", async (c) => {
|
|
490
|
+
const name = c.req.param("name");
|
|
491
|
+
const body = await readJson<{ rpm?: number }>(c.req.raw);
|
|
492
|
+
const rpm = coerceRpm(body?.rpm);
|
|
493
|
+
let errStatus = 0;
|
|
494
|
+
await store.update((d) => {
|
|
495
|
+
const entry = d.models[name];
|
|
496
|
+
if (!entry) {
|
|
497
|
+
errStatus = 404;
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (rpm) entry.paceRpm = rpm;
|
|
501
|
+
else delete entry.paceRpm;
|
|
502
|
+
});
|
|
503
|
+
if (errStatus === 404) return c.json({ error: { message: "model not found" } }, 404);
|
|
504
|
+
return c.json({ ok: true, paceRpm: rpm ?? 0 });
|
|
505
|
+
});
|
|
506
|
+
|
|
484
507
|
// Set (or clear) the upstream-model mapping for ONE chain slot (addressed by
|
|
485
508
|
// `index`). An empty `model` clears it (back to identity — send the public
|
|
486
509
|
// name). Index-addressed because a provider may occupy several slots, each
|
|
@@ -9,6 +9,11 @@ import { UsageCollector } from "./tokens";
|
|
|
9
9
|
* for THIS source only - the same request may be fine on the next one. */
|
|
10
10
|
const RETRYABLE = new Set([401, 403, 408, 425, 429, 500, 502, 503, 504]);
|
|
11
11
|
|
|
12
|
+
/** Even pacing (per-model `paceRpm`) message constants. The queue itself lives
|
|
13
|
+
* in Store.paceClaim - 60s wait horizon, one release every 60/rpm seconds. */
|
|
14
|
+
const PACE_MAX_WAIT_S = 60;
|
|
15
|
+
const RPM_SECONDS = 60;
|
|
16
|
+
|
|
12
17
|
/** Headers copied from upstream back to the client. */
|
|
13
18
|
const COPY_DOWN = ["content-type", "cache-control", "x-request-id", "openai-organization", "anthropic-ratelimit-requests-reset"];
|
|
14
19
|
|
|
@@ -399,135 +404,195 @@ export function proxyApi(
|
|
|
399
404
|
if (r.entered && !isProbe) rt.warn(`proxy circuit open: provider '${p.name}' cooldown=${r.cooldownMs}ms fails=${r.fails}`);
|
|
400
405
|
};
|
|
401
406
|
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
body.model = slot.model ?? model;
|
|
421
|
-
// The actual upstream model forwarded this attempt (after the per-slot
|
|
422
|
-
// rewrite). Recorded on the log row so history shows which real model a
|
|
423
|
-
// routed call landed on when a source remaps the public name. `undefined`
|
|
424
|
-
// when the public name went through verbatim — JSON.stringify drops it, so
|
|
425
|
-
// identity + legacy rows stay clean.
|
|
426
|
-
const upstreamModel = slot.model && slot.model !== model ? slot.model : undefined;
|
|
427
|
-
// Count this attempt toward the source's RPM window — but not for a pinned
|
|
428
|
-
// probe, which (like circuit state) takes no routing side-effects.
|
|
429
|
-
if (pinIndex == null) store.recordDispatch(provider.id);
|
|
430
|
-
let upstream: Response;
|
|
431
|
-
try {
|
|
432
|
-
upstream = await fetch(upstreamTarget(provider, key).url, {
|
|
433
|
-
method: "POST",
|
|
434
|
-
headers: upstreamHeaders(provider, wire, clientVersion),
|
|
435
|
-
body: JSON.stringify(body),
|
|
436
|
-
});
|
|
437
|
-
} catch {
|
|
438
|
-
// Network error / DNS / timeout → try next provider.
|
|
439
|
-
lastStatus = 502;
|
|
440
|
-
lastErr = "network error";
|
|
441
|
-
if (pinIndex != null) break; // per-source probe: fail fast, no circuit impact.
|
|
442
|
-
sayFailover(provider, "network error");
|
|
443
|
-
const r = store.recordCircuitFailure(provider.id, lastStatus, lastErr);
|
|
444
|
-
if (r.entered) {
|
|
445
|
-
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 });
|
|
446
|
-
sayCooldown(provider, r);
|
|
407
|
+
// Even pacing (per-model leaky bucket, `paceRpm`): spread the model's calls
|
|
408
|
+
// one every 60/rpm seconds - excess requests QUEUE here (bounded by a 60s
|
|
409
|
+
// wait horizon; past it they're rejected 429 in the wire's own error shape).
|
|
410
|
+
// One slot per REQUEST, claimed before the failover loop, so a call that
|
|
411
|
+
// fails over to later sources never queues twice. Independent of the
|
|
412
|
+
// per-source Provider.rpm skip (that one spills to the next source). Probes
|
|
413
|
+
// go through the same queue - they are real calls and claim real slots.
|
|
414
|
+
const paceRpm = store.get().models[model]?.paceRpm;
|
|
415
|
+
if (paceRpm) {
|
|
416
|
+
const wait = store.paceClaim(model, paceRpm);
|
|
417
|
+
if (wait < 0) {
|
|
418
|
+
const retryAfter = Math.max(1, Math.ceil(RPM_SECONDS / paceRpm));
|
|
419
|
+
const message = `rate limited: even-pacing queue for '${model}' is full (max wait ${Math.round(PACE_MAX_WAIT_S)}s; retry in ~${retryAfter}s)`;
|
|
420
|
+
if (!isProbe) rt.warn(`proxy model=${model}: paced out (429)`);
|
|
421
|
+
store.pushLog({ ts: Date.now(), model, provider: "", format: wire, status: 429, ms: Date.now() - start, stream, error: "even-pacing queue full" });
|
|
422
|
+
const headers = { "content-type": "application/json", "retry-after": String(retryAfter) };
|
|
423
|
+
if (wire === "anthropic") {
|
|
424
|
+
return c.json({ type: "error", error: { type: "rate_limit_error", message } }, 429, headers);
|
|
447
425
|
}
|
|
448
|
-
|
|
426
|
+
return c.json({ error: { message, type: "rate_limit_error", code: "rate_limit_exceeded" } }, 429, headers);
|
|
449
427
|
}
|
|
428
|
+
if (wait > 0) {
|
|
429
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
430
|
+
// The client may have hung up while queued - release nothing upstream
|
|
431
|
+
// (the slot is already spent, but no need to burn provider quota too).
|
|
432
|
+
if (c.req.raw.signal?.aborted) return new Response(null, { status: 499 });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
450
435
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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;
|
|
459
|
+
};
|
|
460
|
+
|
|
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 });
|
|
482
470
|
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
if (
|
|
500
|
-
|
|
501
|
-
|
|
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;
|
|
502
507
|
}
|
|
503
|
-
|
|
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) });
|
|
540
|
+
}
|
|
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);
|
|
504
568
|
}
|
|
505
|
-
// Non-retryable client error: return it to the caller as-is. Read the
|
|
506
|
-
// error text off a CLONE so the original body still streams back.
|
|
507
|
-
const errText = await upstream.clone().text().catch(() => "");
|
|
508
|
-
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}` });
|
|
509
|
-
return passThrough(upstream, isProbe ? provider.name : undefined);
|
|
510
|
-
}
|
|
511
569
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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,
|
|
525
594
|
);
|
|
526
595
|
}
|
|
527
|
-
return c.json(
|
|
528
|
-
{ error: { message: `all providers for '${model}' failed (last status ${lastStatus})`, type: "upstream_error" } },
|
|
529
|
-
502,
|
|
530
|
-
);
|
|
531
596
|
};
|
|
532
597
|
|
|
533
598
|
// OpenAI surface: chat/completions + responses (/models is registered above,
|
|
@@ -40,6 +40,11 @@ const RESET_CAP_MS = 6 * 60 * 60 * 1000;
|
|
|
40
40
|
* window. 60s matches the usual "requests per minute" limit. */
|
|
41
41
|
const RPM_WINDOW_MS = 60_000;
|
|
42
42
|
|
|
43
|
+
/** Even-pacing queue cap: a request whose reserved slot is further out than
|
|
44
|
+
* this gets rejected (429) instead of queueing. Bounds both the client's hang
|
|
45
|
+
* time and the queue depth (at N rpm / 60s wait, at most ~N requests queue). */
|
|
46
|
+
const PACE_MAX_WAIT_MS = 60_000;
|
|
47
|
+
|
|
43
48
|
/** Per-provider circuit state (in-memory, never persisted). */
|
|
44
49
|
interface CircuitEntry {
|
|
45
50
|
fails: number;
|
|
@@ -183,6 +188,9 @@ export class Store {
|
|
|
183
188
|
/** Per-provider dispatch timestamps within the RPM pacing window. In-memory,
|
|
184
189
|
* NOT persisted (resets on restart). Pruned as `rpmUsed` reads. */
|
|
185
190
|
private rpm = new Map<string, number[]>();
|
|
191
|
+
/** Per-model even-pacing queue: model name -> epoch ms of the next free
|
|
192
|
+
* release slot. In-memory, NOT persisted (resets on restart). */
|
|
193
|
+
private pace = new Map<string, number>();
|
|
186
194
|
|
|
187
195
|
constructor(dataDir: string, opts: { logger?: Logger } = {}) {
|
|
188
196
|
this.dataDir = dataDir;
|
|
@@ -599,6 +607,48 @@ export class Store {
|
|
|
599
607
|
else this.rpm.set(id, [Date.now()]);
|
|
600
608
|
}
|
|
601
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
|
+
|
|
632
|
+
// --- even pacing (per model, in-memory leaky-bucket queue) ---
|
|
633
|
+
|
|
634
|
+
/** Reserve the next release slot for a call to `model`, paced at `rpm`
|
|
635
|
+
* requests/min (one every 60/rpm seconds). Returns the ms the caller should
|
|
636
|
+
* sleep BEFORE forwarding (0 = go now), or -1 when the next free slot is
|
|
637
|
+
* further out than PACE_MAX_WAIT_MS (caller rejects with 429 - the slot is
|
|
638
|
+
* left unclaimed so rejections never push the queue further back). Slot
|
|
639
|
+
* claiming is synchronous, so concurrent dispatches get strictly FIFO slots;
|
|
640
|
+
* after an idle period the stale slot is clamped to now (first request goes
|
|
641
|
+
* through immediately). */
|
|
642
|
+
paceClaim(model: string, rpm: number): number {
|
|
643
|
+
const interval = RPM_WINDOW_MS / rpm;
|
|
644
|
+
const now = Date.now();
|
|
645
|
+
const next = Math.max(this.pace.get(model) ?? 0, now);
|
|
646
|
+
const wait = next - now;
|
|
647
|
+
if (wait > PACE_MAX_WAIT_MS) return -1;
|
|
648
|
+
this.pace.set(model, next + interval);
|
|
649
|
+
return wait;
|
|
650
|
+
}
|
|
651
|
+
|
|
602
652
|
/** Snapshot of every configured provider's circuit state for GET /admin/circuit.
|
|
603
653
|
* Healthy providers appear as state "open"; a provider deleted while cooling
|
|
604
654
|
* simply drops out (we iterate the live config, not the map). */
|
|
@@ -81,6 +81,12 @@ export interface ModelEntry {
|
|
|
81
81
|
openai: FormatEntry;
|
|
82
82
|
anthropic: FormatEntry;
|
|
83
83
|
responses: FormatEntry;
|
|
84
|
+
/** Even-pacing rate limit (requests/min, leaky-bucket style). When set, calls
|
|
85
|
+
* to this model are released one every 60/rpm seconds - excess requests QUEUE
|
|
86
|
+
* at the gateway (up to 60s wait) instead of overflowing. Completely
|
|
87
|
+
* independent of Provider.rpm (that one skips a busy source and fails over);
|
|
88
|
+
* this one paces the model across all three route slots. Absent = unlimited. */
|
|
89
|
+
paceRpm?: number;
|
|
84
90
|
}
|
|
85
91
|
|
|
86
92
|
export interface Account {
|