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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myapikey",
3
- "version": "0.35.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 upstream requests/responses; show lists them (add an index to dump one in full)")
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 cs = r.captures ?? [];
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 = cs[Number(indexRaw) - 1];
306
- if (!row) throw new Error(`No capture #${indexRaw} (list is newest-first, ${cs.length} captured).`);
307
- console.log(`#${Number(indexRaw)} ${new Date(row.ts).toLocaleTimeString("en-GB", { hour12: false })} ${row.provider} [${row.format}] status=${row.status} ${row.ms}ms${row.upstreamModel ? ` model=${row.upstreamModel}` : ""}${row.error ? ` error=${row.error}` : ""}${row.truncated ? " (truncated)" : ""}`);
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(row.request));
310
- if (row.response !== undefined) {
316
+ console.log(prettyJson(c.request));
317
+ if (c.response !== undefined) {
311
318
  console.log("\n--- response (upstream) ---");
312
- console.log(prettyJson(row.response));
319
+ console.log(prettyJson(c.response));
313
320
  }
314
321
  return;
315
322
  }
316
- if (!cs.length) return console.log("No captures yet — call the model, then `show` again (or pass an index to dump one).");
317
- console.log(`${name}: ${cs.length} captured (newest first)`);
318
- for (let i = 0; i < cs.length; i++) {
319
- const c = cs[i];
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. The switch itself is config state (persists in data.json);
709
- // the captured bodies do not (in-memory only, restart clears them too).
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
- const RETRYABLE = new Set([401, 403, 408, 425, 429, 500, 502, 503, 504]);
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 (ModelEntry.debugCapture): when the model's switch is
617
- // on, record EVERY upstream attempt the exact forwarded body (this
618
- // slot's model rewrite + thinking injection are already applied) and
619
- // the response as it flowed. UI probes are excluded (not real
620
- // conversations). One entry per attempt: a failover chain writes
621
- // several, each showing what THAT source actually received.
622
- const debugNow = !isProbe && store.isDebug(model);
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 (!debugNow) return;
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 = debugNow ? { text: "", truncated: false } : undefined;
714
+ const capAcc = { text: "", truncated: false };
683
715
  const out = observedBody(upstream, {
684
716
  stream,
685
717
  key,
686
718
  requestMessages: body.messages,
687
- ...(capAcc
688
- ? {
689
- onText: (txt: string) => {
690
- const room = CAPTURE_BODY_MAX - capAcc.text.length;
691
- if (room <= 0) {
692
- capAcc.truncated = true;
693
- return;
694
- }
695
- if (txt.length > room) capAcc.truncated = true;
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?.text, capAcc?.truncated ?? false);
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?.text, capAcc?.truncated ?? false, info.error);
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
- if (RETRYABLE.has(upstream.status)) {
718
- lastStatus = upstream.status;
719
- // Drain so the connection can be reused, then move on; capture the
720
- // reason for the log (this branch never streams back to the client).
721
- const txt = await upstream.text().catch(() => "");
722
- lastErr = shortError(txt) || `HTTP ${upstream.status}`;
723
- capture(upstream.status, txt, false, lastErr);
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. Read the
741
- // error text off a CLONE so the original body still streams back.
742
- const errText = await upstream.clone().text().catch(() => "");
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 (per-model, in-memory ring buffer): how many upstream attempts
49
- * to keep, and how many CHARS of each request/response body. 50 × 2 × 256KB ≈
50
- * 25MB worst case per debugged model acceptable for a home-server debug aid.
51
- * Bodies (conversations) are sensitive: the buffer lives only in memory, is
52
- * dropped when the model's debug switch turns off, and never hits disk. */
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). In-memory, NOT persisted bodies are
204
- * conversation content, so they live only while the model's debug switch
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 for a debugged model. Ignores silently when
673
- * the switch is off (defense against a stale caller); bodies are capped at
674
- * CAPTURE_BODY_MAX chars each (flagging `truncated`), and the buffer keeps
675
- * only the most recent CAPTURE_MAX attempts per model. */
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
- let arr = this.captures.get(model);
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
- /** Drop a model's capture buffer (toggle-off, model delete/rename). */
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
- * Nothing here ever touches logs.jsonl. */
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