myapikey 0.1.0 → 0.3.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.1.0",
3
+ "version": "0.3.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": [
@@ -110,15 +110,19 @@ provider
110
110
  .option("--base-url-anthropic <url>", "Anthropic base URL excl. /v1, e.g. https://api.anthropic.com", "")
111
111
  .option("--key <key>", "api key for the backend", "")
112
112
  .option("--formats <list>", "comma list: openai,anthropic", "openai")
113
- .action(async (name: string, opts: { baseUrlOpenai: string; baseUrlAnthropic: string; key: string; formats: string }) => {
113
+ .option("--rpm <n>", "optional request-per-minute cap (pace a free/limited key)", "")
114
+ .action(async (name: string, opts: { baseUrlOpenai: string; baseUrlAnthropic: string; key: string; formats: string; rpm: string }) => {
114
115
  const formats = opts.formats.split(",").map((s) => s.trim()).filter(Boolean) as ("openai" | "anthropic")[];
115
- const r = await api(ctx(), "POST", "/admin/providers", {
116
+ const rpm = Number(opts.rpm);
117
+ const body: Record<string, unknown> = {
116
118
  name,
117
119
  baseUrlOpenai: opts.baseUrlOpenai,
118
120
  baseUrlAnthropic: opts.baseUrlAnthropic,
119
121
  apiKey: opts.key,
120
122
  formats,
121
- });
123
+ };
124
+ if (Number.isFinite(rpm) && rpm > 0) body.rpm = Math.floor(rpm);
125
+ const r = await api(ctx(), "POST", "/admin/providers", body);
122
126
  console.log(`Added provider ${(r as any).provider.name} (${(r as any).provider.id})`);
123
127
  });
124
128
 
@@ -31,6 +31,13 @@ function formatsKey(f: Format[]): string {
31
31
  return [...f].sort().join(",");
32
32
  }
33
33
 
34
+ /** Coerce an RPM cap to a positive integer, or undefined (0/blank/invalid =
35
+ * unlimited). Accepts a number or a numeric string (form fields send strings). */
36
+ function coerceRpm(v: unknown): number | undefined {
37
+ const n = typeof v === "string" ? Number(v.trim()) : v;
38
+ return typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
39
+ }
40
+
34
41
  /** Whether a provider is a valid source for a routing slot: openai/anthropic
35
42
  * require that wire format; responses requires supportsResponses. */
36
43
  function providerSpeaks(p: Provider, key: RouteKey): boolean {
@@ -58,6 +65,7 @@ function toPublic(p: Provider) {
58
65
  formats: p.formats,
59
66
  supportsResponses: p.supportsResponses ?? false,
60
67
  apiKey: mask(p.apiKey),
68
+ rpm: p.rpm ?? 0,
61
69
  discoveredModels: p.discoveredModels ?? [],
62
70
  discoveredAt: p.discoveredAt ?? null,
63
71
  createdAt: p.createdAt,
@@ -176,7 +184,7 @@ export function adminApi(store: Store, auth: MiddlewareHandler, v1: Hono): Hono
176
184
  app.get("/providers", (c) => c.json({ providers: store.get().providers.map(toPublic) }));
177
185
 
178
186
  app.post("/providers", async (c) => {
179
- const body = await readJson<{ name?: string; baseUrlOpenai?: string; baseUrlAnthropic?: string; apiKey?: string; formats?: Format[]; supportsResponses?: boolean }>(c.req.raw);
187
+ const body = await readJson<{ name?: string; baseUrlOpenai?: string; baseUrlAnthropic?: string; apiKey?: string; formats?: Format[]; supportsResponses?: boolean; rpm?: number }>(c.req.raw);
180
188
  const formats = body?.formats ?? [];
181
189
  const needOpenai = formats.includes("openai");
182
190
  const needAnthropic = formats.includes("anthropic");
@@ -189,6 +197,7 @@ export function adminApi(store: Store, auth: MiddlewareHandler, v1: Hono): Hono
189
197
  const id = newProviderId();
190
198
  const baseUrlOpenai = trimBase(body.baseUrlOpenai ?? "");
191
199
  const baseUrlAnthropic = trimBase(body.baseUrlAnthropic ?? "");
200
+ const rpm = coerceRpm(body!.rpm);
192
201
  await store.update((d) => {
193
202
  d.providers.push({
194
203
  id,
@@ -198,6 +207,7 @@ export function adminApi(store: Store, auth: MiddlewareHandler, v1: Hono): Hono
198
207
  apiKey: body.apiKey!,
199
208
  formats,
200
209
  supportsResponses: body.supportsResponses === true,
210
+ ...(rpm ? { rpm } : {}),
201
211
  createdAt: Date.now(),
202
212
  });
203
213
  });
@@ -209,7 +219,7 @@ export function adminApi(store: Store, auth: MiddlewareHandler, v1: Hono): Hono
209
219
 
210
220
  app.put("/providers/:id", async (c) => {
211
221
  const id = c.req.param("id");
212
- const body = await readJson<{ name?: string; baseUrlOpenai?: string; baseUrlAnthropic?: string; apiKey?: string; formats?: Format[]; supportsResponses?: boolean }>(c.req.raw);
222
+ const body = await readJson<{ name?: string; baseUrlOpenai?: string; baseUrlAnthropic?: string; apiKey?: string; formats?: Format[]; supportsResponses?: boolean; rpm?: number }>(c.req.raw);
213
223
  const formats = body?.formats ?? [];
214
224
  const needOpenai = formats.includes("openai");
215
225
  const needAnthropic = formats.includes("anthropic");
@@ -239,6 +249,12 @@ export function adminApi(store: Store, auth: MiddlewareHandler, v1: Hono): Hono
239
249
  p.apiKey = newKey;
240
250
  p.formats = formats;
241
251
  if (body.supportsResponses !== undefined) p.supportsResponses = body.supportsResponses;
252
+ // rpm: present in the body → set (0/invalid clears to unlimited); absent → keep.
253
+ if (body!.rpm !== undefined) {
254
+ const rpm = coerceRpm(body!.rpm);
255
+ if (rpm) p.rpm = rpm;
256
+ else delete p.rpm;
257
+ }
242
258
  });
243
259
  const found = store.get().providers.find((x) => x.id === id);
244
260
  if (!found) return c.json({ error: { message: "provider not found" } }, 404);
@@ -484,6 +500,56 @@ export function adminApi(store: Store, auth: MiddlewareHandler, v1: Hono): Hono
484
500
  return c.json({ result: { ok: false, status: res.status, provider, format, error: shortError(txt) || `HTTP ${res.status}` } });
485
501
  });
486
502
 
503
+ // Probe a SINGLE source for a model+format: the same end-to-end loopback as the
504
+ // whole-model /test above, but dispatch is pinned to this one provider (via the
505
+ // x-myapikey-probe-provider header), so there is no failover and no
506
+ // circuit-breaker impact — a manual "is THIS source up?" check that reports the
507
+ // provider's real upstream status. Validation misses (source not on this route,
508
+ // wrong wire format) come back as a ProbeResult body, not an HTTP error, so the
509
+ // caller reads `result` uniformly.
510
+ app.post("/models/:name/providers/:providerId/test", async (c) => {
511
+ const name = c.req.param("name");
512
+ const pid = c.req.param("providerId");
513
+ const cfg = store.get();
514
+ const entry = cfg.models[name];
515
+ if (!entry) return c.json({ error: { message: "model not found" } }, 404);
516
+ let format = c.req.query("format") as RouteKey | undefined;
517
+ if (!format) {
518
+ // No slot requested: pick the first one the model is enabled on.
519
+ for (const k of ["openai", "anthropic", "responses"] as RouteKey[]) if (entry[k]?.enabled) { format = k; break; }
520
+ }
521
+ if (!format || !entry[format]?.enabled) {
522
+ return c.json({ result: { ok: false, status: 0, format: format ?? "openai", error: "model not enabled on that routing slot" } });
523
+ }
524
+ if (!entry[format].providers.includes(pid)) {
525
+ return c.json({ result: { ok: false, status: 0, format, error: "source is not on this route" } });
526
+ }
527
+ const provider = cfg.providers.find((p) => p.id === pid);
528
+ if (!provider || !providerSpeaks(provider, format)) {
529
+ return c.json({ result: { ok: false, status: 0, format, error: "source does not speak this format" } });
530
+ }
531
+ const path = format === "anthropic" ? "/messages" : format === "responses" ? "/responses" : "/chat/completions";
532
+ // /responses is the OpenAI Responses API — it takes `input`, not `messages`.
533
+ const body =
534
+ format === "responses"
535
+ ? { model: name, input: "ping", stream: false }
536
+ : { model: name, messages: [{ role: "user", content: "ping" }], max_tokens: 1, stream: false };
537
+ let res: Response;
538
+ try {
539
+ res = await v1.request(path, {
540
+ method: "POST",
541
+ headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiKey}`, "x-myapikey-probe": "1", "x-myapikey-probe-provider": pid },
542
+ body: JSON.stringify(body),
543
+ });
544
+ } catch (e) {
545
+ return c.json({ result: { ok: false, status: 0, format, error: `gateway loopback failed: ${(e as Error).message}` } });
546
+ }
547
+ const answeredBy = res.headers.get("x-myapikey-provider") ?? undefined;
548
+ if (res.ok) return c.json({ result: { ok: true, status: res.status, provider: answeredBy, format } });
549
+ const txt = await res.text().catch(() => "");
550
+ return c.json({ result: { ok: false, status: res.status, provider: answeredBy, format, error: shortError(txt) || `HTTP ${res.status}` } });
551
+ });
552
+
487
553
  app.delete("/models/:name", async (c) => {
488
554
  const name = c.req.param("name");
489
555
  await store.update((d) => {
@@ -9,6 +9,26 @@ const RETRYABLE = new Set([408, 425, 429, 500, 502, 503, 504]);
9
9
  /** Headers copied from upstream back to the client. */
10
10
  const COPY_DOWN = ["content-type", "cache-control", "x-request-id", "openai-organization", "anthropic-ratelimit-requests-reset"];
11
11
 
12
+ /** Parse a `Retry-After` response header (RFC 9110) into a millisecond delay.
13
+ * Two legal forms: delta-seconds (`"30"`) or an HTTP-date
14
+ * (`"Wed, 21 Oct 2026 07:28:00 GMT"`). Returns the raw ms — store clamps it to
15
+ * [CB_MIN, CB_CAP]; returns undefined for absent/empty/invalid/future-negative
16
+ * so the caller falls back to the escalating circuit backoff. OpenAI,
17
+ * Anthropic, OpenRouter, NIM and the OpenAI-compatible backends all emit this
18
+ * on a 429/overloaded, so honoring it gives an exact cooldown where the per-
19
+ * vendor `*-reset` headers would each need bespoke parsing. */
20
+ function parseRetryAfter(v: string | null | undefined): number | undefined {
21
+ if (!v) return undefined;
22
+ const s = Number(v);
23
+ if (Number.isFinite(s) && s > 0) return s * 1000;
24
+ const t = Date.parse(v);
25
+ if (Number.isFinite(t)) {
26
+ const ms = t - Date.now();
27
+ return ms > 0 ? ms : undefined;
28
+ }
29
+ return undefined;
30
+ }
31
+
12
32
  /** Resolve the ordered, compatible provider list for a model on a routing slot. */
13
33
  function candidates(store: Store, model: string, key: RouteKey): Provider[] {
14
34
  const d = store.get();
@@ -68,18 +88,23 @@ function upstreamTarget(p: Provider, key: RouteKey): { url: string; wire: Format
68
88
  return { url: `${trimBase(p.baseUrlOpenai)}/${path}`, wire: "openai" };
69
89
  }
70
90
 
71
- function passThrough(upstream: Response, servedBy?: string): Response {
91
+ /** Copy through the headers we reflect to the client (content-type, rate-limit
92
+ * hints, request id, …) and optionally tag the in-process probe with which
93
+ * source answered. The probe tag never reaches a real agent client (set only
94
+ * on isProbe calls — see dispatch). */
95
+ function downHeaders(upstream: Response, servedBy?: string): Headers {
72
96
  const headers = new Headers();
73
97
  for (const h of COPY_DOWN) {
74
98
  const v = upstream.headers.get(h);
75
99
  if (v) headers.set(h, v);
76
100
  }
77
- // Internal hook for the model-page "test": report which provider answered.
78
- // Only set on in-process probe calls (see isProbe in dispatch), so it never
79
- // appears on responses to real agent clients.
80
101
  if (servedBy) headers.set("x-myapikey-provider", servedBy);
102
+ return headers;
103
+ }
104
+
105
+ function passThrough(upstream: Response, servedBy?: string): Response {
81
106
  // Stream the upstream body straight through (handles SSE + normal JSON).
82
- return new Response(upstream.body, { status: upstream.status, headers });
107
+ return new Response(upstream.body, { status: upstream.status, headers: downHeaders(upstream, servedBy) });
83
108
  }
84
109
 
85
110
  /** Pull a short human-readable message out of an upstream error body. */
@@ -92,6 +117,127 @@ export function shortError(text: string): string {
92
117
  }
93
118
  }
94
119
 
120
+ /** Outcome of observing an upstream body to completion. A 200 at the headers is
121
+ * not proof the call succeeded — some backends 200 then truncate the stream or
122
+ * emit nothing for request shapes they mishandle. `ok` means the stream truly
123
+ * ended cleanly (terminal marker seen for streaming; clean close otherwise). */
124
+ interface SettleInfo {
125
+ ok: boolean;
126
+ status: number;
127
+ error?: string;
128
+ }
129
+
130
+ /** Wrap an upstream body so every byte is forwarded to the client VERBATIM while
131
+ * we watch — out of band — for whether the stream completed cleanly. The 200
132
+ * status is already committed before the body flows, so on a bad end we can't
133
+ * change THAT; instead we (a) [anthropic] inject a synthetic SSE `error` event
134
+ * so the client learns the stream died rather than seeing a silent EOF, and
135
+ * (b) settle {ok:false} so dispatch logs a 502 and trips the circuit (the NEXT
136
+ * call then fails over — this call can't be salvaged once streaming started).
137
+ *
138
+ * Detection keys on the stream's terminal marker (anthropic message_stop /
139
+ * openai [DONE] / responses response.completed), buffered across chunk
140
+ * boundaries — NOT on content, which would false-positive on legitimate
141
+ * tool-use responses that carry only input_json_delta. A client cancel settles
142
+ * nothing (the client walked away — not a provider failure, don't log/cool). */
143
+ /** Substrings whose presence proves a streaming response reached a REAL
144
+ * terminal event — so an absent marker at stream-end means truncation. Keyed by
145
+ * routing slot: anthropic ends on message_stop; /chat/completions on [DONE];
146
+ * /responses on any of its terminal events (completed/failed/incomplete/
147
+ * cancelled — a clean upstream FAILURE is not a truncation, just a failed call,
148
+ * so we don't cool the source for it). Empty for a non-streaming body, where
149
+ * only a reader error counts. */
150
+ function terminalMarkers(key: RouteKey, stream: boolean): string[] {
151
+ if (!stream) return [];
152
+ if (key === "anthropic") return ["message_stop"];
153
+ if (key === "responses") return ["response.completed", "response.failed", "response.incomplete", "response.cancelled"];
154
+ return ["[DONE]"]; // openai /chat/completions
155
+ }
156
+
157
+ /** Best-effort synthetic terminal error frame, so a client learns a stream died
158
+ * instead of seeing a silent EOF. Each wire's own convention:
159
+ * - anthropic + /responses use typed `event: error` (spec'd);
160
+ * - /chat/completions is a data-only SSE stream with NO spec'd mid-stream error
161
+ * event, so we emit the de-facto `data: {"error":…}` shape most compatible
162
+ * backends/SDKs raise on.
163
+ * Never emits `[DONE]` (that signals success). */
164
+ function errorFrame(key: RouteKey, reason: string): string {
165
+ const msg = reason.slice(0, 200);
166
+ if (key === "anthropic") {
167
+ return `event: error\ndata: ${JSON.stringify({ type: "error", error: { type: "api_error", message: msg } })}\n\n`;
168
+ }
169
+ if (key === "responses") {
170
+ // Plain transport-error `event: error` (response.failed would need a full
171
+ // Response object we don't have). Best-effort — /responses is opt-in.
172
+ return `event: error\ndata: ${JSON.stringify({ type: "error", message: msg })}\n\n`;
173
+ }
174
+ return `data: ${JSON.stringify({ error: { message: msg, type: "server_error" } })}\n\n`;
175
+ }
176
+
177
+ function observedBody(
178
+ upstream: Response,
179
+ opts: { stream: boolean; key: RouteKey; onSettle: (info: SettleInfo) => void },
180
+ ): ReadableStream<Uint8Array> {
181
+ const reader = upstream.body?.getReader();
182
+ const enc = new TextEncoder();
183
+ const dec = new TextDecoder();
184
+ const markers = terminalMarkers(opts.key, opts.stream);
185
+ let tail = ""; // rolling window so a marker split across chunks is still caught
186
+ let terminal = false;
187
+ let settled = false;
188
+
189
+ const settle = (info: SettleInfo) => {
190
+ if (settled) return;
191
+ settled = true;
192
+ opts.onSettle(info);
193
+ };
194
+ const injectError = (controller: ReadableStreamDefaultController<Uint8Array>, reason: string) => {
195
+ controller.enqueue(enc.encode(errorFrame(opts.key, reason)));
196
+ };
197
+
198
+ return new ReadableStream<Uint8Array>({
199
+ async pull(controller) {
200
+ if (!reader) {
201
+ settle({ ok: true, status: 200 });
202
+ controller.close();
203
+ return;
204
+ }
205
+ try {
206
+ const { done, value } = await reader.read();
207
+ if (done) {
208
+ if (opts.stream && !terminal) {
209
+ const reason = "upstream stream truncated (no terminal marker)";
210
+ injectError(controller, reason);
211
+ settle({ ok: false, status: 502, error: reason });
212
+ } else {
213
+ settle({ ok: true, status: 200 });
214
+ }
215
+ controller.close();
216
+ return;
217
+ }
218
+ if (!terminal && markers.length) {
219
+ const txt = dec.decode(value, { stream: true });
220
+ const win = tail + txt;
221
+ if (markers.some((m) => win.includes(m))) terminal = true;
222
+ tail = win.slice(-128);
223
+ }
224
+ controller.enqueue(value);
225
+ } catch (e) {
226
+ const reason = `upstream stream error: ${e instanceof Error ? e.message : String(e)}`;
227
+ injectError(controller, reason);
228
+ settle({ ok: false, status: 502, error: reason });
229
+ controller.close();
230
+ }
231
+ },
232
+ cancel() {
233
+ // Client abort (Esc / disconnect) — not a provider failure. Suppress the
234
+ // settle so we neither log nor cool down a source the client simply left.
235
+ settled = true;
236
+ reader?.cancel().catch(() => {});
237
+ },
238
+ });
239
+ }
240
+
95
241
  export function proxyApi(store: Store, auth: MiddlewareHandler): Hono {
96
242
  const app = new Hono();
97
243
  app.use("*", auth);
@@ -112,8 +258,15 @@ export function proxyApi(store: Store, auth: MiddlewareHandler): Hono {
112
258
  // answered back to the test handler (x-myapikey-provider), without leaking
113
259
  // that header to real agent clients.
114
260
  const isProbe = c.req.header("x-myapikey-probe") === "1";
261
+ // The per-source "test this source" variant pins dispatch to ONE provider:
262
+ // the candidate chain is reduced to just it, and on failure we stop
263
+ // immediately (no failover) WITHOUT recording a circuit failure — a manual
264
+ // probe must not trip the breaker. Failure surfaces the real upstream status
265
+ // (429/500/…), not a collapsed 502, so the badge shows what really happened.
266
+ const pinId = c.req.header("x-myapikey-probe-provider") || "";
115
267
  // candidates() already restricts the responses chain to supportsResponses sources.
116
- const list = candidates(store, model, key);
268
+ let list = candidates(store, model, key);
269
+ if (pinId) list = list.filter((p) => p.id === pinId);
117
270
  if (!list.length) {
118
271
  if (key === "responses") {
119
272
  return c.json(
@@ -134,11 +287,14 @@ export function proxyApi(store: Store, auth: MiddlewareHandler): Hono {
134
287
  let lastStatus = 502;
135
288
  let lastErr = "";
136
289
 
137
- // Skip providers currently in circuit-breaker cooldown. If every candidate
138
- // is cooling, fall back to the full list anyway — cooldown is a heuristic,
139
- // and one real attempt beats a guaranteed 502 (a cooled provider that now
140
- // succeeds also resets its circuit).
141
- const live = list.filter((p) => !store.isCooling(p.id));
290
+ // Skip providers that are either in circuit-breaker cooldown OR over their
291
+ // RPM pacing cap. Both are heuristics: if every candidate is skipped, fall
292
+ // back to the full list anyway — one real attempt beats a guaranteed 502
293
+ // (a skipped provider that now succeeds also resets its state). A pinned
294
+ // (per-source) probe ignores both — the user is testing THIS source now,
295
+ // whatever its breaker/pacing state.
296
+ const skipped = (p: Provider) => store.isCooling(p.id) || (!!p.rpm && store.rpmUsed(p.id) >= p.rpm);
297
+ const live = pinId ? list : list.filter((p) => !skipped(p));
142
298
  const order = live.length ? live : list;
143
299
 
144
300
  for (const provider of order) {
@@ -148,6 +304,9 @@ export function proxyApi(store: Store, auth: MiddlewareHandler): Hono {
148
304
  // previous provider's upstream name. Absent map/key → send the public name.
149
305
  const mapped = store.get().models[model]?.[key]?.modelMap?.[provider.id];
150
306
  body.model = mapped ?? model;
307
+ // Count this attempt toward the source's RPM window — but not for a pinned
308
+ // probe, which (like circuit state) takes no routing side-effects.
309
+ if (!pinId) store.recordDispatch(provider.id);
151
310
  let upstream: Response;
152
311
  try {
153
312
  upstream = await fetch(upstreamTarget(provider, key).url, {
@@ -159,6 +318,7 @@ export function proxyApi(store: Store, auth: MiddlewareHandler): Hono {
159
318
  // Network error / DNS / timeout → try next provider.
160
319
  lastStatus = 502;
161
320
  lastErr = "network error";
321
+ if (pinId) break; // per-source probe: fail fast, no circuit impact.
162
322
  const r = store.recordCircuitFailure(provider.id, lastStatus, lastErr);
163
323
  if (r.entered) {
164
324
  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 });
@@ -167,10 +327,34 @@ export function proxyApi(store: Store, auth: MiddlewareHandler): Hono {
167
327
  }
168
328
 
169
329
  if (upstream.ok) {
170
- // A success closes the circuit (provider is healthy again).
171
- store.recordCircuitSuccess(provider.id);
172
- store.pushLog({ ts: Date.now(), model, provider: provider.name, providerId: provider.id, format: wire, status: upstream.status, ms: Date.now() - start, stream });
173
- return passThrough(upstream, isProbe ? provider.name : undefined);
330
+ // A 200 from the upstream is NOT proof the call succeeded: some backends
331
+ // return 200 then truncate the stream (or emit no content) for request
332
+ // shapes they mishandle. We commit the 200 status to the client right
333
+ // away (headers are already sent) but OBSERVE the body as it flows and
334
+ // settle once — on a clean, fully-terminated stream we close the circuit
335
+ // + log 200; on a truncated/errored stream we log 502, trip the circuit
336
+ // (so the NEXT call fails over), and — on the anthropic wire — inject a
337
+ // synthetic SSE error event so the client learns the stream died instead
338
+ // of seeing a silent EOF. TTFB is captured now; logging is deferred to
339
+ // the body's end (so the row reflects the real outcome, not just the
340
+ // headers). See observedBody() for the detection rules.
341
+ const ttfb = Date.now() - start;
342
+ const body = observedBody(upstream, {
343
+ stream,
344
+ key,
345
+ onSettle: (info) => {
346
+ if (info.ok) {
347
+ store.recordCircuitSuccess(provider.id);
348
+ store.pushLog({ ts: Date.now(), model, provider: provider.name, providerId: provider.id, format: wire, status: 200, ms: ttfb, stream });
349
+ } else {
350
+ // A pinned per-source probe takes no circuit side-effects (a manual
351
+ // test must not trip the breaker) — mirrors the retryable branch.
352
+ if (!pinId) store.recordCircuitFailure(provider.id, info.status, info.error || "stream failed");
353
+ store.pushLog({ ts: Date.now(), model, provider: provider.name, providerId: provider.id, format: wire, status: info.status, ms: ttfb, stream, error: info.error });
354
+ }
355
+ },
356
+ });
357
+ return new Response(body, { status: upstream.status, headers: downHeaders(upstream, isProbe ? provider.name : undefined) });
174
358
  }
175
359
  if (RETRYABLE.has(upstream.status)) {
176
360
  lastStatus = upstream.status;
@@ -178,7 +362,12 @@ export function proxyApi(store: Store, auth: MiddlewareHandler): Hono {
178
362
  // reason for the log (this branch never streams back to the client).
179
363
  const txt = await upstream.text().catch(() => "");
180
364
  lastErr = shortError(txt) || `HTTP ${upstream.status}`;
181
- const r = store.recordCircuitFailure(provider.id, lastStatus, lastErr);
365
+ if (pinId) break; // per-source probe: fail fast, no circuit impact.
366
+ // A 429/overloaded upstream usually carries Retry-After; honoring it
367
+ // cools for exactly as long as asked (clamped) instead of the escalating
368
+ // guess. Absent (5xx often, or a proxy that stripped it) → escalate.
369
+ const retryAfterMs = parseRetryAfter(upstream.headers.get("retry-after"));
370
+ const r = store.recordCircuitFailure(provider.id, lastStatus, lastErr, retryAfterMs);
182
371
  if (r.entered) {
183
372
  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 });
184
373
  }
@@ -193,6 +382,17 @@ export function proxyApi(store: Store, auth: MiddlewareHandler): Hono {
193
382
 
194
383
  const last = order[order.length - 1];
195
384
  store.pushLog({ ts: Date.now(), model, provider: last.name, providerId: last.id, format: wire, status: lastStatus, ms: Date.now() - start, stream, error: lastErr || `all providers failed (last status ${lastStatus})` });
385
+ // A pinned (per-source) probe failed: surface the REAL upstream status the
386
+ // one provider returned (429/500/…), not a collapsed 502, and tag it with
387
+ // x-myapikey-provider so the source-row badge names the tested source.
388
+ if (pinId) {
389
+ const h = new Headers({ "content-type": "application/json" });
390
+ if (isProbe) h.set("x-myapikey-provider", last.name);
391
+ return new Response(
392
+ JSON.stringify({ error: { message: lastErr || `provider failed (status ${lastStatus})`, type: "upstream_error" } }),
393
+ { status: lastStatus, headers: h },
394
+ );
395
+ }
196
396
  return c.json(
197
397
  { error: { message: `all providers for '${model}' failed (last status ${lastStatus})`, type: "upstream_error" } },
198
398
  502,
@@ -23,6 +23,14 @@ const LOG_TAIL_BYTES = 512 * 1024;
23
23
  * doubling each consecutive failure up to CAP. Resets on the next success. */
24
24
  const CB_BASE = 30_000;
25
25
  const CB_CAP = 300_000;
26
+ // Floor for honoring an upstream Retry-After hint — small positive values (a
27
+ // lenient "Retry-After: 0"/sub-second) shouldn't read as "no cooldown" and let
28
+ // us re-hammer a just-rate-limited source in a tight loop.
29
+ const CB_MIN = 1_000;
30
+
31
+ /** RPM pacing window: a source's `rpm` cap counts calls within this trailing
32
+ * window. 60s matches the usual "requests per minute" limit. */
33
+ const RPM_WINDOW_MS = 60_000;
26
34
 
27
35
  /** Per-provider circuit state (in-memory, never persisted). */
28
36
  interface CircuitEntry {
@@ -45,6 +53,10 @@ export interface CircuitView {
45
53
  lastStatus: number;
46
54
  lastReason: string;
47
55
  lastTs: number;
56
+ /** Configured RPM cap (0 = unlimited). */
57
+ rpm: number;
58
+ /** Calls forwarded to this source within the trailing 60s window. */
59
+ rpmUsed: number;
48
60
  }
49
61
 
50
62
  /** One bucket in a stats breakdown (by model / provider / format). `id` is set
@@ -108,6 +120,9 @@ export class Store {
108
120
  * NOT persisted (resets on restart). Mutated via the circuit* methods only,
109
121
  * never through update()/persist(). */
110
122
  private circuit = new Map<string, CircuitEntry>();
123
+ /** Per-provider dispatch timestamps within the RPM pacing window. In-memory,
124
+ * NOT persisted (resets on restart). Pruned as `rpmUsed` reads. */
125
+ private rpm = new Map<string, number[]>();
111
126
 
112
127
  constructor(dataDir: string) {
113
128
  this.dataDir = dataDir;
@@ -401,15 +416,21 @@ export class Store {
401
416
  * consecutive failure (BASE * 2^(fails-1), capped at CAP); `fails` persists
402
417
  * across cooldown expirations and is reset only by success — unless the
403
418
  * provider has been quiet for > CAP, in which case it starts fresh at 1.
419
+ * 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.
404
424
  * Returns `entered` = transitioned from healthy → cooling this call (the
405
425
  * caller logs a cooldown row only then, to avoid timeline spam), plus the
406
426
  * fails count and cooldown duration for that row. */
407
- recordCircuitFailure(id: string, status: number, reason: string): { entered: boolean; fails: number; cooldownMs: number } {
427
+ recordCircuitFailure(id: string, status: number, reason: string, retryAfterMs?: number): { entered: boolean; fails: number; cooldownMs: number } {
408
428
  const now = Date.now();
409
429
  const cur = this.circuit.get(id);
410
430
  const stale = !cur || now - cur.lastTs > CB_CAP;
411
431
  const fails = stale ? 1 : cur!.fails + 1;
412
- const cooldownMs = Math.min(CB_CAP, CB_BASE * 2 ** (fails - 1));
432
+ 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));
413
434
  const until = now + cooldownMs;
414
435
  const wasCooling = !!cur && cur.until > now;
415
436
  this.circuit.set(id, { fails, until, lastStatus: status, lastReason: reason, lastTs: now });
@@ -431,6 +452,30 @@ export class Store {
431
452
  this.circuit.set(id, { ...cur, fails: 0, until: 0 });
432
453
  }
433
454
 
455
+ // --- RPM pacing (in-memory sliding window, never persisted) ---
456
+
457
+ /** Count this source's forwarded calls in the trailing RPM_WINDOW_MS, pruning
458
+ * expired entries as it reads (timestamps are appended oldest-first). Returns
459
+ * 0 for a source with no recent activity. */
460
+ rpmUsed(id: string): number {
461
+ const arr = this.rpm.get(id);
462
+ if (!arr || !arr.length) return 0;
463
+ const cutoff = Date.now() - RPM_WINDOW_MS;
464
+ let i = 0;
465
+ while (i < arr.length && arr[i] < cutoff) i++;
466
+ if (i > 0) arr.splice(0, i);
467
+ if (!arr.length) this.rpm.delete(id);
468
+ return arr.length;
469
+ }
470
+
471
+ /** Record that we forwarded a call to this source (called by dispatch right
472
+ * before the upstream fetch). Lets the next pacing check count this attempt. */
473
+ recordDispatch(id: string): void {
474
+ const arr = this.rpm.get(id);
475
+ if (arr) arr.push(Date.now());
476
+ else this.rpm.set(id, [Date.now()]);
477
+ }
478
+
434
479
  /** Snapshot of every configured provider's circuit state for GET /admin/circuit.
435
480
  * Healthy providers appear as state "open"; a provider deleted while cooling
436
481
  * simply drops out (we iterate the live config, not the map). */
@@ -449,6 +494,8 @@ export class Store {
449
494
  lastStatus: c?.lastStatus ?? 0,
450
495
  lastReason: c?.lastReason ?? "",
451
496
  lastTs: c?.lastTs ?? 0,
497
+ rpm: p.rpm ?? 0,
498
+ rpmUsed: this.rpmUsed(p.id),
452
499
  };
453
500
  });
454
501
  }
@@ -19,6 +19,13 @@ export interface Provider {
19
19
  apiKey: string;
20
20
  /** Which wire formats this backend responds to. */
21
21
  formats: Format[];
22
+ /** Optional request-per-minute cap (RPM pacing). When set, dispatch skips this
23
+ * source once it has forwarded `rpm` calls in the trailing 60s window — the
24
+ * request fails over to the next source instead of racing the upstream's own
25
+ * rate limit (and burning a free/quota-bound key). 0/absent = unlimited.
26
+ * The limit is on the key, so it's per-source and shared across every model
27
+ * routed through it. Tracked in-memory only (see Store.rpmUsed). */
28
+ rpm?: number;
22
29
  /** Whether this backend also implements the OpenAI Responses API (/responses).
23
30
  * NOT implied by `formats` — many openai-compatible backends lack it. Opt-in. */
24
31
  supportsResponses?: boolean;