myapikey 0.16.0 → 0.17.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.16.0",
3
+ "version": "0.17.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
@@ -4,8 +4,15 @@ import type { Format, Provider, RouteKey, Usage } from "../shared/types";
4
4
  import type { Store } from "./store";
5
5
  import { UsageCollector } from "./tokens";
6
6
 
7
- /** HTTP statuses that should trigger failover to the next provider. */
8
- const RETRYABLE = new Set([408, 425, 429, 500, 502, 503, 504]);
7
+ /** HTTP statuses that should trigger failover to the next provider. 401/403
8
+ * included: a banned/invalid credential (e.g. "User has been banned") is dead
9
+ * for THIS source only - the same request may be fine on the next one. */
10
+ const RETRYABLE = new Set([401, 403, 408, 425, 429, 500, 502, 503, 504]);
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;
9
16
 
10
17
  /** Headers copied from upstream back to the client. */
11
18
  const COPY_DOWN = ["content-type", "cache-control", "x-request-id", "openai-organization", "anthropic-ratelimit-requests-reset"];
@@ -397,6 +404,35 @@ export function proxyApi(
397
404
  if (r.entered && !isProbe) rt.warn(`proxy circuit open: provider '${p.name}' cooldown=${r.cooldownMs}ms fails=${r.fails}`);
398
405
  };
399
406
 
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);
425
+ }
426
+ return c.json({ error: { message, type: "rate_limit_error", code: "rate_limit_exceeded" } }, 429, headers);
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
+ }
435
+
400
436
  // Skip providers that are either in circuit-breaker cooldown OR over their
401
437
  // RPM pacing cap. Both are heuristics: if every candidate is skipped, fall
402
438
  // back to the full list anyway — one real attempt beats a guaranteed 502
@@ -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,26 @@ export class Store {
599
607
  else this.rpm.set(id, [Date.now()]);
600
608
  }
601
609
 
610
+ // --- even pacing (per model, in-memory leaky-bucket queue) ---
611
+
612
+ /** Reserve the next release slot for a call to `model`, paced at `rpm`
613
+ * requests/min (one every 60/rpm seconds). Returns the ms the caller should
614
+ * sleep BEFORE forwarding (0 = go now), or -1 when the next free slot is
615
+ * further out than PACE_MAX_WAIT_MS (caller rejects with 429 - the slot is
616
+ * left unclaimed so rejections never push the queue further back). Slot
617
+ * claiming is synchronous, so concurrent dispatches get strictly FIFO slots;
618
+ * after an idle period the stale slot is clamped to now (first request goes
619
+ * through immediately). */
620
+ paceClaim(model: string, rpm: number): number {
621
+ const interval = RPM_WINDOW_MS / rpm;
622
+ const now = Date.now();
623
+ const next = Math.max(this.pace.get(model) ?? 0, now);
624
+ const wait = next - now;
625
+ if (wait > PACE_MAX_WAIT_MS) return -1;
626
+ this.pace.set(model, next + interval);
627
+ return wait;
628
+ }
629
+
602
630
  /** Snapshot of every configured provider's circuit state for GET /admin/circuit.
603
631
  * Healthy providers appear as state "open"; a provider deleted while cooling
604
632
  * 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 {