myapikey 0.35.0 → 0.36.1
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 +23 -16
- package/packages/core/src/server/admin.ts +16 -3
- package/packages/core/src/server/proxy.ts +74 -44
- package/packages/core/src/server/store.ts +51 -18
- package/packages/core/src/shared/types.ts +3 -1
- package/packages/web/dist/assets/index-DBb7JCKv.js +334 -0
- package/packages/web/dist/index.html +1 -1
- package/packages/web/dist/assets/index-cZhEr_b8.js +0 -334
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "myapikey",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.1",
|
|
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": [
|
|
@@ -285,7 +285,7 @@ model.command("remove <name>").description("remove a model entirely (both format
|
|
|
285
285
|
|
|
286
286
|
model
|
|
287
287
|
.command("debug <name> [action] [index]")
|
|
288
|
-
.description("debug capture: on|off toggles recording of the last 50 actual
|
|
288
|
+
.description("debug capture: on|off toggles recording of the last 50 actual requests/responses (failed attempts are ALWAYS recorded in a global net, last 50); show lists everything (add an index to dump one in full)")
|
|
289
289
|
.action(async (name: string, action = "show", indexRaw?: string) => {
|
|
290
290
|
const path = `/admin/models/${encodeURIComponent(name)}/debug`;
|
|
291
291
|
if (action === "on" || action === "off") {
|
|
@@ -293,32 +293,39 @@ model
|
|
|
293
293
|
console.log(
|
|
294
294
|
r.enabled
|
|
295
295
|
? `Debug capture ON for ${name} — the last 50 upstream attempts (request + response) are recorded in memory; turn off to clear.`
|
|
296
|
-
: `Debug capture OFF for ${name}; captured content cleared.`,
|
|
296
|
+
: `Debug capture OFF for ${name}; captured content cleared (auto-recorded failures stay until they age out of the net).`,
|
|
297
297
|
);
|
|
298
298
|
return;
|
|
299
299
|
}
|
|
300
300
|
if (action !== "show") throw new Error(`Unknown action '${action}' (use on | off | show).`);
|
|
301
|
-
const r = (await api(ctx(), "GET", path)) as { enabled: boolean; captures: any[] };
|
|
302
|
-
if (!r.enabled) console.log(`Debug capture is OFF for ${name}.`);
|
|
303
|
-
const
|
|
301
|
+
const r = (await api(ctx(), "GET", path)) as { enabled: boolean; captures: any[]; failures: any[] };
|
|
302
|
+
if (!r.enabled) console.log(`Debug capture is OFF for ${name} (failed calls are still auto-recorded).`);
|
|
303
|
+
const isFail = (c: any) => c.status >= 400 || c.status === 0;
|
|
304
|
+
// One newest-first timeline; a failure made while the switch was on sits
|
|
305
|
+
// in both server buffers — show it once, from the net, tagged AUTO.
|
|
306
|
+
const rows = [
|
|
307
|
+
...(r.failures ?? []).map((c) => ({ c, auto: true })),
|
|
308
|
+
...(r.captures ?? []).filter((c) => !isFail(c)).map((c) => ({ c, auto: false })),
|
|
309
|
+
].sort((a, b) => b.c.ts - a.c.ts);
|
|
304
310
|
if (indexRaw !== undefined) {
|
|
305
|
-
const row =
|
|
306
|
-
if (!row) throw new Error(`No capture #${indexRaw} (list is newest-first, ${
|
|
307
|
-
|
|
311
|
+
const row = rows[Number(indexRaw) - 1];
|
|
312
|
+
if (!row) throw new Error(`No capture #${indexRaw} (list is newest-first, ${rows.length} recorded).`);
|
|
313
|
+
const c = row.c;
|
|
314
|
+
console.log(`#${Number(indexRaw)}${row.auto ? " (auto)" : ""} ${new Date(c.ts).toLocaleTimeString("en-GB", { hour12: false })} ${c.provider} [${c.format}] status=${c.status} ${c.ms}ms${c.upstreamModel ? ` model=${c.upstreamModel}` : ""}${c.error ? ` error=${c.error}` : ""}${c.truncated ? " (truncated)" : ""}`);
|
|
308
315
|
console.log("\n--- request (forwarded verbatim) ---");
|
|
309
|
-
console.log(prettyJson(
|
|
310
|
-
if (
|
|
316
|
+
console.log(prettyJson(c.request));
|
|
317
|
+
if (c.response !== undefined) {
|
|
311
318
|
console.log("\n--- response (upstream) ---");
|
|
312
|
-
console.log(prettyJson(
|
|
319
|
+
console.log(prettyJson(c.response));
|
|
313
320
|
}
|
|
314
321
|
return;
|
|
315
322
|
}
|
|
316
|
-
if (!
|
|
317
|
-
console.log(`${name}: ${
|
|
318
|
-
for (let i = 0; i <
|
|
319
|
-
const c =
|
|
323
|
+
if (!rows.length) return console.log("Nothing recorded yet — call the model, then `show` again (or pass an index to dump one).");
|
|
324
|
+
console.log(`${name}: ${rows.length} recorded (newest first; AUTO = failed attempt, always recorded)`);
|
|
325
|
+
for (let i = 0; i < rows.length; i++) {
|
|
326
|
+
const { c, auto } = rows[i];
|
|
320
327
|
console.log(
|
|
321
|
-
` #${i + 1} ${new Date(c.ts).toLocaleTimeString("en-GB", { hour12: false })} ${c.provider} [${c.format}] status=${c.status} ${c.ms}ms ${fmtBytes(c.request)} req / ${fmtBytes(c.response ?? "")} resp${c.upstreamModel ? ` model=${c.upstreamModel}` : ""}${c.error ? ` ${c.error}` : ""}`,
|
|
328
|
+
` #${i + 1}${auto ? " AUTO" : " "} ${new Date(c.ts).toLocaleTimeString("en-GB", { hour12: false })} ${c.provider} [${c.format}] status=${c.status} ${c.ms}ms ${fmtBytes(c.request)} req / ${fmtBytes(c.response ?? "")} resp${c.upstreamModel ? ` model=${c.upstreamModel}` : ""}${c.error ? ` ${c.error}` : ""}`,
|
|
322
329
|
);
|
|
323
330
|
}
|
|
324
331
|
console.log("\nDump one: myapikey model debug <name> show <index>");
|
|
@@ -705,12 +705,15 @@ export function adminApi(store: Store, auth: MiddlewareHandler, openai: Hono, an
|
|
|
705
705
|
// ON: dispatch records every upstream attempt for this model (exact forwarded
|
|
706
706
|
// request + response bodies) into an in-memory ring buffer, last 50. OFF: the
|
|
707
707
|
// buffer is cleared immediately — captured conversations shouldn't outlive the
|
|
708
|
-
// debugging session.
|
|
709
|
-
//
|
|
708
|
+
// debugging session. Independently, FAILED attempts always land in a global
|
|
709
|
+
// in-memory net (last 50 across all models) so an error can be inspected even
|
|
710
|
+
// when the switch was never on; `failures` here is that net filtered to this
|
|
711
|
+
// model. The switch is config state (persists in data.json); the captured
|
|
712
|
+
// bodies do not (in-memory only, restart clears them too).
|
|
710
713
|
app.get("/models/:name/debug", (c) => {
|
|
711
714
|
const name = c.req.param("name");
|
|
712
715
|
if (!store.get().models[name]) return c.json({ error: { message: "model not found" } }, 404);
|
|
713
|
-
return c.json({ enabled: store.isDebug(name), captures: store.getCaptures(name) });
|
|
716
|
+
return c.json({ enabled: store.isDebug(name), captures: store.getCaptures(name), failures: store.getFailCaptures(name) });
|
|
714
717
|
});
|
|
715
718
|
|
|
716
719
|
app.put("/models/:name/debug", async (c) => {
|
|
@@ -732,6 +735,16 @@ app.put("/models/:name/debug", async (c) => {
|
|
|
732
735
|
return c.json({ ok: true, enabled });
|
|
733
736
|
});
|
|
734
737
|
|
|
738
|
+
// Manual scrub of this model's rows in the always-on failure net (the switch
|
|
739
|
+
// only governs the per-model buffer; failures age out of the global ring on
|
|
740
|
+
// their own — this drops them now).
|
|
741
|
+
app.delete("/models/:name/debug/fails", (c) => {
|
|
742
|
+
const name = c.req.param("name");
|
|
743
|
+
if (!store.get().models[name]) return c.json({ error: { message: "model not found" } }, 404);
|
|
744
|
+
store.clearFailCaptures(name);
|
|
745
|
+
return c.json({ ok: true });
|
|
746
|
+
});
|
|
747
|
+
|
|
735
748
|
// Set (or clear) the upstream-model mapping for ONE chain slot (addressed by
|
|
736
749
|
// `index`). An empty `model` clears it (back to identity — send the public
|
|
737
750
|
// name). Index-addressed because a provider may occupy several slots, each
|
|
@@ -6,8 +6,43 @@ import { UsageCollector } from "./tokens";
|
|
|
6
6
|
|
|
7
7
|
/** HTTP statuses that should trigger failover to the next provider. 401/403
|
|
8
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
|
-
|
|
9
|
+
* for THIS source only - the same request may be fine on the next one. 402 is
|
|
10
|
+
* always account-level (exhausted balance). */
|
|
11
|
+
const RETRYABLE = new Set([401, 402, 403, 408, 425, 429, 500, 502, 503, 504]);
|
|
12
|
+
|
|
13
|
+
/** Error-body markers that turn a NON-retryable 4xx into an account-level
|
|
14
|
+
* failure worth failing over: dead credential / expired subscription / no
|
|
15
|
+
* balance, which some vendors smuggle under a 400 instead of 401/403
|
|
16
|
+
* (Volcengine Ark's expired coding-plan subscription is the case that bit
|
|
17
|
+
* us). Deliberately narrow — a genuine request-shape 400 (bad params, context
|
|
18
|
+
* overflow, "max_tokens too large") still returns to the client as-is, since
|
|
19
|
+
* every other source would 400 identically. Matched case-insensitively. */
|
|
20
|
+
const ACCOUNT_HINTS = [
|
|
21
|
+
"subscription",
|
|
22
|
+
"unauthorized",
|
|
23
|
+
"invalid_api_key",
|
|
24
|
+
"invalid api key",
|
|
25
|
+
"incorrect api key",
|
|
26
|
+
"quota",
|
|
27
|
+
"insufficient",
|
|
28
|
+
"balance",
|
|
29
|
+
"credit",
|
|
30
|
+
"expired",
|
|
31
|
+
"令牌",
|
|
32
|
+
"订阅",
|
|
33
|
+
"额度",
|
|
34
|
+
"余额",
|
|
35
|
+
"欠费",
|
|
36
|
+
"未开通",
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/** True when `status`/`bodyText` describe an account-level failure on an
|
|
40
|
+
* otherwise non-retryable 4xx (see ACCOUNT_HINTS). */
|
|
41
|
+
function isAccountLevelError(status: number, bodyText: string): boolean {
|
|
42
|
+
if (RETRYABLE.has(status) || status < 400 || status >= 500) return false;
|
|
43
|
+
const t = bodyText.toLowerCase();
|
|
44
|
+
return ACCOUNT_HINTS.some((h) => t.includes(h));
|
|
45
|
+
}
|
|
11
46
|
|
|
12
47
|
/** Even pacing (per-model `paceRpm`) message constants. The queue itself lives
|
|
13
48
|
* in Store.paceClaim - 60s wait horizon, one release every 60/rpm seconds. */
|
|
@@ -259,11 +294,6 @@ function downHeaders(upstream: Response, servedBy?: string): Headers {
|
|
|
259
294
|
return headers;
|
|
260
295
|
}
|
|
261
296
|
|
|
262
|
-
function passThrough(upstream: Response, servedBy?: string): Response {
|
|
263
|
-
// Stream the upstream body straight through (handles SSE + normal JSON).
|
|
264
|
-
return new Response(upstream.body, { status: upstream.status, headers: downHeaders(upstream, servedBy) });
|
|
265
|
-
}
|
|
266
|
-
|
|
267
297
|
/** Pull a short human-readable message out of an upstream error body. */
|
|
268
298
|
export function shortError(text: string): string {
|
|
269
299
|
try {
|
|
@@ -613,17 +643,19 @@ export function proxyApi(
|
|
|
613
643
|
// Count this attempt toward the source's RPM window — but not for a pinned
|
|
614
644
|
// probe, which (like circuit state) takes no routing side-effects.
|
|
615
645
|
if (pinIndex == null) store.recordDispatch(provider.id);
|
|
616
|
-
// Debug capture
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
|
|
646
|
+
// Debug capture: EVERY upstream attempt is offered to the store —
|
|
647
|
+
// failed attempts always land in the global failure net (the safety
|
|
648
|
+
// net for after-the-fact debugging), everything lands in the model's
|
|
649
|
+
// own buffer while its switch is on. The row carries the exact
|
|
650
|
+
// forwarded body (this slot's model rewrite + thinking injection are
|
|
651
|
+
// already applied) and the response as it flowed. UI probes are
|
|
652
|
+
// excluded (not real conversations). One entry per attempt: a
|
|
653
|
+
// failover chain writes several, each showing what THAT source
|
|
654
|
+
// actually received.
|
|
623
655
|
const attemptStart = Date.now();
|
|
624
656
|
const reqText = JSON.stringify(body);
|
|
625
657
|
const capture = (status: number, response: string | undefined, truncated: boolean, error?: string) => {
|
|
626
|
-
if (
|
|
658
|
+
if (isProbe) return;
|
|
627
659
|
const row: DebugCapture = {
|
|
628
660
|
ts: Date.now(),
|
|
629
661
|
model,
|
|
@@ -679,48 +711,49 @@ export function proxyApi(
|
|
|
679
711
|
const ttfb = Date.now() - start;
|
|
680
712
|
// Debug capture tee: accumulate the decoded chunks into a bounded
|
|
681
713
|
// string (the proxy keeps forwarding bytes verbatim regardless).
|
|
682
|
-
const capAcc =
|
|
714
|
+
const capAcc = { text: "", truncated: false };
|
|
683
715
|
const out = observedBody(upstream, {
|
|
684
716
|
stream,
|
|
685
717
|
key,
|
|
686
718
|
requestMessages: body.messages,
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
capAcc.text += txt.slice(0, room);
|
|
697
|
-
},
|
|
698
|
-
}
|
|
699
|
-
: {}),
|
|
719
|
+
onText: (txt: string) => {
|
|
720
|
+
const room = CAPTURE_BODY_MAX - capAcc.text.length;
|
|
721
|
+
if (room <= 0) {
|
|
722
|
+
capAcc.truncated = true;
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
if (txt.length > room) capAcc.truncated = true;
|
|
726
|
+
capAcc.text += txt.slice(0, room);
|
|
727
|
+
},
|
|
700
728
|
onSettle: (info) => {
|
|
701
729
|
if (info.ok) {
|
|
702
730
|
store.recordCircuitSuccess(provider.id);
|
|
703
731
|
store.pushLog({ ts: Date.now(), model, upstreamModel, provider: provider.name, providerId: provider.id, format: wire, status: 200, ms: ttfb, stream, thinking: think, usage: info.usage });
|
|
704
|
-
capture(200, capAcc
|
|
732
|
+
capture(200, capAcc.text, capAcc.truncated);
|
|
705
733
|
} else {
|
|
706
734
|
// A pinned per-source probe takes no circuit side-effects (a manual
|
|
707
735
|
// test must not trip the breaker) — mirrors the retryable branch.
|
|
708
736
|
if (pinIndex == null) store.recordCircuitFailure(provider.id, info.status, info.error || "stream failed");
|
|
709
737
|
if (!isProbe) rt.warn(`proxy stream failed: provider '${provider.name}' status=${info.status} (${info.error || "stream failed"})`);
|
|
710
738
|
store.pushLog({ ts: Date.now(), model, upstreamModel, provider: provider.name, providerId: provider.id, format: wire, status: info.status, ms: ttfb, stream, thinking: think, error: info.error });
|
|
711
|
-
capture(info.status, capAcc
|
|
739
|
+
capture(info.status, capAcc.text, capAcc.truncated, info.error);
|
|
712
740
|
}
|
|
713
741
|
},
|
|
714
742
|
});
|
|
715
743
|
return new Response(out, { status: upstream.status, headers: downHeaders(upstream, isProbe ? provider.name : undefined) });
|
|
716
744
|
}
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
745
|
+
// Error from the upstream. Read the body ONCE (drains the connection
|
|
746
|
+
// for reuse), then either fail over or pass the error back verbatim —
|
|
747
|
+
// both branches share this text (log row + debug capture).
|
|
748
|
+
const txt = await upstream.text().catch(() => "");
|
|
749
|
+
lastStatus = upstream.status;
|
|
750
|
+
lastErr = shortError(txt) || `HTTP ${upstream.status}`;
|
|
751
|
+
capture(upstream.status, txt, false, lastErr);
|
|
752
|
+
// Fail over on a retryable status OR an account-level failure smuggled
|
|
753
|
+
// under a non-retryable 4xx (expired subscription / dead key / no
|
|
754
|
+
// balance returned as 400) — the source is dead for THIS request only,
|
|
755
|
+
// exactly like a 401/403.
|
|
756
|
+
if (RETRYABLE.has(upstream.status) || isAccountLevelError(upstream.status, txt)) {
|
|
724
757
|
if (pinIndex != null) break; // per-source probe: fail fast, no circuit impact.
|
|
725
758
|
sayFailover(provider, `HTTP ${lastStatus} (${lastErr})`);
|
|
726
759
|
// A 429/overloaded upstream usually carries Retry-After; honoring it
|
|
@@ -737,12 +770,9 @@ export function proxyApi(
|
|
|
737
770
|
}
|
|
738
771
|
continue;
|
|
739
772
|
}
|
|
740
|
-
// Non-retryable client error: return it to the caller as-is.
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
store.pushLog({ ts: Date.now(), model, upstreamModel, provider: provider.name, providerId: provider.id, format: wire, status: upstream.status, ms: Date.now() - start, stream, thinking: think, error: shortError(errText) || `HTTP ${upstream.status}` });
|
|
744
|
-
capture(upstream.status, errText, false, shortError(errText) || `HTTP ${upstream.status}`);
|
|
745
|
-
return passThrough(upstream, isProbe ? provider.name : undefined);
|
|
773
|
+
// Non-retryable client error: return it to the caller as-is.
|
|
774
|
+
store.pushLog({ ts: Date.now(), model, upstreamModel, provider: provider.name, providerId: provider.id, format: wire, status: upstream.status, ms: Date.now() - start, stream, thinking: think, error: lastErr });
|
|
775
|
+
return new Response(txt, { status: upstream.status, headers: downHeaders(upstream, isProbe ? provider.name : undefined) });
|
|
746
776
|
}
|
|
747
777
|
|
|
748
778
|
const last = order[order.length - 1];
|
|
@@ -45,12 +45,18 @@ const RPM_WINDOW_MS = 60_000;
|
|
|
45
45
|
* time and the queue depth (at N rpm / 60s wait, at most ~N requests queue). */
|
|
46
46
|
const PACE_MAX_WAIT_MS = 60_000;
|
|
47
47
|
|
|
48
|
-
/** Debug capture (
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
48
|
+
/** Debug capture (in-memory ring buffers, never persisted). Two tiers:
|
|
49
|
+
* - FAIL_NET (global): the last CAPTURE_FAIL_MAX FAILED upstream attempts
|
|
50
|
+
* across ALL models, recorded ALWAYS (that's the safety net — errors are
|
|
51
|
+
* debugged after the fact, when nobody pre-armed a switch). 50 × 2 × 256KB
|
|
52
|
+
* ≈ 25MB worst case, typical far less (failed responses are tiny).
|
|
53
|
+
* - per-model switch buffer: last CAPTURE_MAX attempts of a model whose
|
|
54
|
+
* ModelEntry.debugCapture is on — success AND failure.
|
|
55
|
+
* Bodies (conversations) are sensitive: everything lives only in memory,
|
|
56
|
+
* never touches disk, ages out of the rings, and the switch-off / clear
|
|
57
|
+
* endpoints drop it on demand. */
|
|
53
58
|
export const CAPTURE_MAX = 50;
|
|
59
|
+
export const CAPTURE_FAIL_MAX = 50;
|
|
54
60
|
export const CAPTURE_BODY_MAX = 256 * 1024;
|
|
55
61
|
|
|
56
62
|
/** Per-provider circuit state (in-memory, never persisted). */
|
|
@@ -200,10 +206,14 @@ export class Store {
|
|
|
200
206
|
* release slot. In-memory, NOT persisted (resets on restart). */
|
|
201
207
|
private pace = new Map<string, number>();
|
|
202
208
|
/** Per-model debug-capture ring buffers: model name -> last CAPTURE_MAX
|
|
203
|
-
* upstream attempts (oldest first)
|
|
204
|
-
* conversation content, so they live
|
|
205
|
-
* is on and evaporate on restart or toggle-off. */
|
|
209
|
+
* upstream attempts (oldest first) while the model's switch is on.
|
|
210
|
+
* In-memory, NOT persisted — bodies are conversation content, so they live
|
|
211
|
+
* only while the switch is on and evaporate on restart or toggle-off. */
|
|
206
212
|
private captures = new Map<string, DebugCapture[]>();
|
|
213
|
+
/** The always-on failure net: last CAPTURE_FAIL_MAX FAILED upstream attempts
|
|
214
|
+
* across all models (oldest first), regardless of any switch. In-memory,
|
|
215
|
+
* NOT persisted; entries age out as new failures push them off. */
|
|
216
|
+
private failCaptures: DebugCapture[] = [];
|
|
207
217
|
|
|
208
218
|
constructor(dataDir: string, opts: { logger?: Logger } = {}) {
|
|
209
219
|
this.dataDir = dataDir;
|
|
@@ -669,12 +679,27 @@ export class Store {
|
|
|
669
679
|
return !!this.data.models[model]?.debugCapture;
|
|
670
680
|
}
|
|
671
681
|
|
|
672
|
-
/** Record one upstream attempt
|
|
673
|
-
*
|
|
674
|
-
*
|
|
675
|
-
*
|
|
682
|
+
/** Record one upstream attempt. Routing: FAILED attempts (4xx/5xx/network)
|
|
683
|
+
* always land in the global failure net (the safety net for after-the-fact
|
|
684
|
+
* debugging); EVERYTHING lands in the model's own buffer while its switch
|
|
685
|
+
* is on. When the switch is off, successes are dropped. Bodies are capped
|
|
686
|
+
* at CAPTURE_BODY_MAX chars each (flagging `truncated`). */
|
|
676
687
|
pushCapture(model: string, entry: DebugCapture): void {
|
|
688
|
+
const e = this.capped({ ...entry, model }); // row's model = the routing key
|
|
689
|
+
const failed = e.status >= 400 || e.status === 0;
|
|
690
|
+
if (failed) {
|
|
691
|
+
this.failCaptures.push(e);
|
|
692
|
+
if (this.failCaptures.length > CAPTURE_FAIL_MAX) this.failCaptures.splice(0, this.failCaptures.length - CAPTURE_FAIL_MAX);
|
|
693
|
+
}
|
|
677
694
|
if (!this.isDebug(model)) return;
|
|
695
|
+
let arr = this.captures.get(model);
|
|
696
|
+
if (!arr) this.captures.set(model, (arr = []));
|
|
697
|
+
arr.push(e);
|
|
698
|
+
if (arr.length > CAPTURE_MAX) arr.splice(0, arr.length - CAPTURE_MAX);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** Copy of an entry with both bodies clamped to CAPTURE_BODY_MAX. */
|
|
702
|
+
private capped(entry: DebugCapture): DebugCapture {
|
|
678
703
|
const e = { ...entry };
|
|
679
704
|
if (e.request.length > CAPTURE_BODY_MAX) {
|
|
680
705
|
e.request = e.request.slice(0, CAPTURE_BODY_MAX);
|
|
@@ -684,23 +709,31 @@ export class Store {
|
|
|
684
709
|
e.response = e.response.slice(0, CAPTURE_BODY_MAX);
|
|
685
710
|
e.truncated = true;
|
|
686
711
|
}
|
|
687
|
-
|
|
688
|
-
if (!arr) this.captures.set(model, (arr = []));
|
|
689
|
-
arr.push(e);
|
|
690
|
-
if (arr.length > CAPTURE_MAX) arr.splice(0, arr.length - CAPTURE_MAX);
|
|
712
|
+
return e;
|
|
691
713
|
}
|
|
692
714
|
|
|
693
|
-
/** The model's captured attempts, newest first. */
|
|
715
|
+
/** The model's switch-captured attempts, newest first. */
|
|
694
716
|
getCaptures(model: string): DebugCapture[] {
|
|
695
717
|
const arr = this.captures.get(model);
|
|
696
718
|
return arr ? [...arr].reverse() : [];
|
|
697
719
|
}
|
|
698
720
|
|
|
699
|
-
/**
|
|
721
|
+
/** The model's always-recorded failed attempts (from the global net),
|
|
722
|
+
* newest first. */
|
|
723
|
+
getFailCaptures(model: string): DebugCapture[] {
|
|
724
|
+
return this.failCaptures.filter((c) => c.model === model).reverse();
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** Drop a model's switch-capture buffer (toggle-off, model delete/rename). */
|
|
700
728
|
clearCaptures(model: string): void {
|
|
701
729
|
this.captures.delete(model);
|
|
702
730
|
}
|
|
703
731
|
|
|
732
|
+
/** Scrub a model's entries from the global failure net (manual clear). */
|
|
733
|
+
clearFailCaptures(model: string): void {
|
|
734
|
+
this.failCaptures = this.failCaptures.filter((c) => c.model !== model);
|
|
735
|
+
}
|
|
736
|
+
|
|
704
737
|
/** Snapshot of every configured provider's circuit state for GET /admin/circuit.
|
|
705
738
|
* Healthy providers appear as state "open"; a provider deleted while cooling
|
|
706
739
|
* simply drops out (we iterate the live config, not the map). */
|
|
@@ -104,7 +104,9 @@ export interface ModelEntry {
|
|
|
104
104
|
* records every UPSTREAM ATTEMPT for this model — the exact forwarded request
|
|
105
105
|
* body and the response body — into an in-memory ring buffer (last 50, see
|
|
106
106
|
* Store.pushCapture). Turning it off clears the buffer; restarts clear it too.
|
|
107
|
-
*
|
|
107
|
+
* Independently of this switch, FAILED attempts always land in a global
|
|
108
|
+
* in-memory net (last 50 across all models) so an error can be inspected
|
|
109
|
+
* after the fact. Nothing here ever touches logs.jsonl. */
|
|
108
110
|
debugCapture?: boolean;
|
|
109
111
|
}
|
|
110
112
|
|