myapikey 0.1.0 → 0.2.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 +7 -3
- package/packages/core/src/server/admin.ts +68 -2
- package/packages/core/src/server/proxy.ts +216 -16
- package/packages/core/src/server/store.ts +49 -2
- package/packages/core/src/shared/types.ts +7 -0
- package/packages/web/dist/assets/index-DbWj0oao.css +1 -0
- package/packages/web/dist/assets/index-Dihwbrfs.js +290 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/assets/index-5_bJkp0p.css +0 -1
- package/packages/web/dist/assets/index-tDr_JIKI.js +0 -285
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "myapikey",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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
|
-
.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
// succeeds also resets its
|
|
141
|
-
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
|
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;
|