myapikey 0.35.0 → 0.36.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.35.0",
3
+ "version": "0.36.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": [
@@ -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
@@ -613,17 +613,19 @@ export function proxyApi(
613
613
  // Count this attempt toward the source's RPM window — but not for a pinned
614
614
  // probe, which (like circuit state) takes no routing side-effects.
615
615
  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);
616
+ // Debug capture: EVERY upstream attempt is offered to the store
617
+ // failed attempts always land in the global failure net (the safety
618
+ // net for after-the-fact debugging), everything lands in the model's
619
+ // own buffer while its switch is on. The row carries the exact
620
+ // forwarded body (this slot's model rewrite + thinking injection are
621
+ // already applied) and the response as it flowed. UI probes are
622
+ // excluded (not real conversations). One entry per attempt: a
623
+ // failover chain writes several, each showing what THAT source
624
+ // actually received.
623
625
  const attemptStart = Date.now();
624
626
  const reqText = JSON.stringify(body);
625
627
  const capture = (status: number, response: string | undefined, truncated: boolean, error?: string) => {
626
- if (!debugNow) return;
628
+ if (isProbe) return;
627
629
  const row: DebugCapture = {
628
630
  ts: Date.now(),
629
631
  model,
@@ -679,36 +681,32 @@ export function proxyApi(
679
681
  const ttfb = Date.now() - start;
680
682
  // Debug capture tee: accumulate the decoded chunks into a bounded
681
683
  // string (the proxy keeps forwarding bytes verbatim regardless).
682
- const capAcc = debugNow ? { text: "", truncated: false } : undefined;
684
+ const capAcc = { text: "", truncated: false };
683
685
  const out = observedBody(upstream, {
684
686
  stream,
685
687
  key,
686
688
  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
- : {}),
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
+ },
700
698
  onSettle: (info) => {
701
699
  if (info.ok) {
702
700
  store.recordCircuitSuccess(provider.id);
703
701
  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);
702
+ capture(200, capAcc.text, capAcc.truncated);
705
703
  } else {
706
704
  // A pinned per-source probe takes no circuit side-effects (a manual
707
705
  // test must not trip the breaker) — mirrors the retryable branch.
708
706
  if (pinIndex == null) store.recordCircuitFailure(provider.id, info.status, info.error || "stream failed");
709
707
  if (!isProbe) rt.warn(`proxy stream failed: provider '${provider.name}' status=${info.status} (${info.error || "stream failed"})`);
710
708
  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);
709
+ capture(info.status, capAcc.text, capAcc.truncated, info.error);
712
710
  }
713
711
  },
714
712
  });
@@ -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