myapikey 0.18.0 → 0.19.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.18.0",
3
+ "version": "0.19.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": [
@@ -181,7 +181,7 @@ model.command("list").action(async () => {
181
181
  console.log(m.name);
182
182
  for (const f of fmts) {
183
183
  const fe = m[f];
184
- const chain = fe.providers.map((p: any) => (p.model ? `${p.name}→${p.model}` : p.name)).join(" → ") || "(none)";
184
+ const chain = fe.providers.map((p: any) => `${p.model ? `${p.name}→${p.model}` : p.name}${p.thinking ? ` thinking=${p.thinking}` : ""}`).join(" → ") || "(none)";
185
185
  console.log(` ${f.padEnd(9)} ${fe.enabled ? "✓" : "·"} ${chain}`);
186
186
  }
187
187
  if (m.paceRpm) console.log(` pace ${m.paceRpm}/min (one every ${Math.round(60 / m.paceRpm)}s)`);
@@ -264,6 +264,20 @@ model
264
264
  console.log(r.paceRpm ? `Pacing ${name} at ${r.paceRpm}/min (one every ${Math.round(60 / r.paceRpm)}s).` : `Pacing cleared for ${name} (unlimited).`);
265
265
  });
266
266
 
267
+ model
268
+ .command("thinking <name> <index> [value]")
269
+ .description("set/clear the default thinking level of one chain slot (effort on openai/responses, budget tokens on anthropic; overrides the request's own setting; empty = clear)")
270
+ .addOption(fmtOption())
271
+ .action(async (name: string, indexRaw: string, value: string | undefined, opts: { format: "openai" | "anthropic" | "responses" }) => {
272
+ const index = Number(indexRaw);
273
+ const r = (await api(ctx(), "PUT", `/admin/models/${encodeURIComponent(name)}/thinking`, { format: opts.format, index, thinking: value ?? "" })) as { thinking?: string };
274
+ console.log(
275
+ r.thinking
276
+ ? `Default thinking for ${name} [${opts.format}] slot ${index}: ${r.thinking}.`
277
+ : `Default thinking cleared for ${name} [${opts.format}] slot ${index}.`,
278
+ );
279
+ });
280
+
267
281
  model.command("remove <name>").description("remove a model entirely (both formats)").action(async (name: string) => {
268
282
  await api(ctx(), "DELETE", `/admin/models/${encodeURIComponent(name)}`);
269
283
  console.log(`Removed ${name}.`);
@@ -312,6 +312,8 @@ export function adminApi(store: Store, auth: MiddlewareHandler, openai: Hono, an
312
312
  // send the public model name verbatim). Carried inline on each slot so
313
313
  // a provider can appear more than once with different upstream names.
314
314
  model: s.model,
315
+ // Default thinking level for this slot (undefined = pure passthrough).
316
+ thinking: s.thinking,
315
317
  })),
316
318
  });
317
319
  const models = Object.entries(d.models).map(([name, e]) => ({
@@ -540,6 +542,52 @@ export function adminApi(store: Store, auth: MiddlewareHandler, openai: Hono, an
540
542
  return c.json({ ok: true });
541
543
  });
542
544
 
545
+ // Set (or clear) the default thinking level for ONE chain slot (addressed by
546
+ // `index`, like /map above). The value is whatever the slot's wire format
547
+ // takes natively: an effort token ("low"/"medium"/"high"/…) on openai/
548
+ // responses slots; a thinking budget in TOKENS (positive integer) on
549
+ // anthropic slots (Anthropic has no named levels). When set, dispatch
550
+ // applies it IN PLACE OF whatever thinking parameters the request carried
551
+ // (the gateway's level wins); an empty value clears it (pure passthrough).
552
+ app.put("/models/:name/thinking", async (c) => {
553
+ const name = c.req.param("name");
554
+ const body = await readJson<{ format?: RouteKey; index?: number; thinking?: string | number }>(c.req.raw);
555
+ if (!body?.format) return c.json({ error: { message: "format is required" } }, 400);
556
+ if (!Number.isInteger(body?.index) || (body?.index ?? -1) < 0)
557
+ return c.json({ error: { message: "index (non-negative integer) is required" } }, 400);
558
+ const format = body.format;
559
+ const index = body.index!;
560
+ const raw = body.thinking === undefined ? "" : String(body.thinking).trim();
561
+ if (raw && format === "anthropic" && (!/^\d+$/.test(raw) || Number(raw) < 1)) {
562
+ return c.json({ error: { message: "anthropic thinking default must be a positive integer (thinking budget tokens, e.g. 8192)" } }, 400);
563
+ }
564
+ if (raw && format !== "anthropic" && raw.length > 32) {
565
+ return c.json({ error: { message: "thinking default too long (max 32 chars)" } }, 400);
566
+ }
567
+ const value = raw && format === "anthropic" ? String(Number(raw)) : raw;
568
+ let errStatus = 0;
569
+ let errMsg = "";
570
+ await store.update((d) => {
571
+ const entry = d.models[name];
572
+ if (!entry) {
573
+ errStatus = 404;
574
+ errMsg = "model not found";
575
+ return;
576
+ }
577
+ const fe = entry[format];
578
+ if (index >= fe.providers.length) {
579
+ errStatus = 400;
580
+ errMsg = "index out of range for this model's chain";
581
+ return;
582
+ }
583
+ if (value) fe.providers[index].thinking = value;
584
+ else delete fe.providers[index].thinking;
585
+ });
586
+ if (errStatus === 404) return c.json({ error: { message: errMsg } }, 404);
587
+ if (errStatus === 400) return c.json({ error: { message: errMsg } }, 400);
588
+ return c.json({ ok: true, thinking: value || undefined });
589
+ });
590
+
543
591
  app.post("/models/:name/disable", async (c) => {
544
592
  const name = c.req.param("name");
545
593
  const body = await readJson<{ format?: RouteKey }>(c.req.raw);
@@ -62,12 +62,14 @@ function parseResetFromBody(text: string): number | undefined {
62
62
  }
63
63
 
64
64
  /** One resolved routing slot: the provider to forward to plus THIS slot's
65
- * optional upstream model name (absent = send the public model name). The
66
- * same provider may occupy several slots in a chain — each is an independent
67
- * failover slot carrying its own upstream model. */
65
+ * optional upstream model name (absent = send the public model name) and
66
+ * default thinking level. The same provider may occupy several slots in a
67
+ * chain — each is an independent failover slot carrying its own upstream
68
+ * model. */
68
69
  interface CandidateSlot {
69
70
  provider: Provider;
70
71
  model?: string;
72
+ thinking?: string;
71
73
  }
72
74
 
73
75
  /** Resolve the ordered, compatible provider slots for a model on a routing slot. */
@@ -83,7 +85,7 @@ function candidates(store: Store, model: string, key: RouteKey): CandidateSlot[]
83
85
  return fe.providers
84
86
  .map((s): CandidateSlot | null => {
85
87
  const p = byId.get(s.id);
86
- return p ? { provider: p, model: s.model } : null;
88
+ return p ? { provider: p, model: s.model, thinking: s.thinking } : null;
87
89
  })
88
90
  .filter((slot): slot is CandidateSlot => {
89
91
  if (!slot) return false;
@@ -104,6 +106,109 @@ function notFound(c: Context, model: string) {
104
106
  );
105
107
  }
106
108
 
109
+ // --- per-slot default thinking level -----------------------------------------
110
+ // Each routing slot can carry a default thinking level. When a slot HAS one it
111
+ // takes precedence over EVERYTHING the request carried — the gateway's own
112
+ // config is the authority, so the default replaces the request's thinking
113
+ // parameters outright (all of them, including compat-backend switches, leaving
114
+ // exactly one thinking instruction in the body). A slot WITHOUT a default is
115
+ // pure passthrough: whatever the request carried goes through untouched —
116
+ // restored verbatim if an earlier failover slot overrode it. Like the model
117
+ // rewrite, the default is written in the wire's own dialect, never translated
118
+ // across formats:
119
+ // openai /chat/completions → `reasoning_effort: "<token>"` (low/medium/high/…)
120
+ // /responses → `reasoning: { effort: "<token>" }`
121
+ // anthropic /v1/messages → `thinking: { type: "enabled", budget_tokens: N }`
122
+ // (Anthropic has no named levels — the stored default IS the budget, a
123
+ // positive integer. The API also demands max_tokens > budget_tokens, so an
124
+ // at-or-below cap is lifted to budget+1 (and restored on a passthrough
125
+ // slot); otherwise the call would 400 — including our own max_tokens:1
126
+ // probe.)
127
+
128
+ /** The body fields that steer thinking on a slot. The FIRST is the wire's
129
+ * canonical parameter — the one a default is written into; the rest are
130
+ * compat-backend switches (`thinking`, `enable_thinking` on chat/completions)
131
+ * that an override clears so the forced level is the only instruction left. */
132
+ function thinkingFields(key: RouteKey): string[] {
133
+ if (key === "anthropic") return ["thinking"];
134
+ if (key === "responses") return ["reasoning"];
135
+ return ["reasoning_effort", "reasoning", "thinking", "enable_thinking"];
136
+ }
137
+
138
+ /** Best-effort display of what a body's own thinking switches ask for (for the
139
+ * log row on a passthrough slot, where the request's setting is what ran). */
140
+ function clientThinking(body: Record<string, unknown>, key: RouteKey): { set: boolean; value?: string } {
141
+ const show = (v: unknown): string | undefined => (typeof v === "object" && v !== null ? JSON.stringify(v) : String(v));
142
+ if (key === "responses") {
143
+ const r = body.reasoning;
144
+ if (r === undefined || r === null) return { set: false };
145
+ const effort = (r as { effort?: unknown }).effort;
146
+ return { set: true, value: effort === undefined ? show(r) : show(effort) };
147
+ }
148
+ if (key === "anthropic") {
149
+ const t = body.thinking;
150
+ if (t === undefined || t === null) return { set: false };
151
+ const budget = (t as { budget_tokens?: unknown }).budget_tokens;
152
+ return { set: true, value: budget === undefined ? show(t) : show(budget) };
153
+ }
154
+ const found = [
155
+ body.reasoning_effort,
156
+ (body.reasoning as { effort?: unknown } | undefined)?.effort,
157
+ (body.thinking as { budget_tokens?: unknown } | undefined)?.budget_tokens,
158
+ (body.thinking as { type?: unknown } | undefined)?.type,
159
+ body.enable_thinking,
160
+ ].find((v) => v !== undefined && v !== null);
161
+ return { set: found !== undefined, value: found === undefined ? undefined : show(found) };
162
+ }
163
+
164
+ /** Snapshot of the request's own thinking switches, taken once before the
165
+ * failover loop — every attempt mutates the shared body, so a passthrough
166
+ * slot needs the originals kept aside to restore. */
167
+ interface ThinkingOrig {
168
+ fields: Record<string, unknown>;
169
+ /** anthropic only: the request's original max_tokens (restored alongside,
170
+ * since an override may have lifted it above the forced budget). */
171
+ maxTokens: unknown;
172
+ }
173
+
174
+ /** Apply THIS slot's thinking level to the body (mutating it), and return the
175
+ * log row's `thinking` field: the level that ran and where it came from.
176
+ * Undefined = nothing applied. A slot WITH a default overrides the request's
177
+ * own parameters entirely; a slot WITHOUT one restores them (undoing any
178
+ * override an earlier failover slot applied — same recompute-per-attempt
179
+ * discipline as the model rewrite, so slot A's level never leaks into B). */
180
+ function applySlotThinking(
181
+ body: Record<string, unknown>,
182
+ key: RouteKey,
183
+ def: string | undefined,
184
+ orig: ThinkingOrig,
185
+ ): { value: string; from: "client" | "default" } | undefined {
186
+ const fields = thinkingFields(key);
187
+ if (!def) {
188
+ for (const f of fields) {
189
+ if (orig.fields[f] === undefined) delete body[f];
190
+ else body[f] = orig.fields[f];
191
+ }
192
+ if (key === "anthropic") {
193
+ if (orig.maxTokens === undefined) delete body.max_tokens;
194
+ else body.max_tokens = orig.maxTokens;
195
+ }
196
+ const own = clientThinking(orig.fields, key);
197
+ return own.set ? { value: own.value ?? "", from: "client" } : undefined;
198
+ }
199
+ for (const f of fields) delete body[f];
200
+ if (key === "anthropic") {
201
+ const budget = Number(def);
202
+ body.thinking = { type: "enabled", budget_tokens: budget };
203
+ if (typeof body.max_tokens === "number" && body.max_tokens <= budget) body.max_tokens = budget + 1;
204
+ } else if (key === "responses") {
205
+ body.reasoning = { effort: def };
206
+ } else {
207
+ body.reasoning_effort = def;
208
+ }
209
+ return { value: def, from: "default" };
210
+ }
211
+
107
212
  /** Auth headers for the Anthropic wire format. Sends BOTH x-api-key and
108
213
  * Authorization: Bearer (same key). Native Anthropic (api.anthropic.com)
109
214
  * accepts either; anthropic- COMPATIBLE surfaces (sensenova, Volcengine Ark,
@@ -353,6 +458,13 @@ export function proxyApi(
353
458
  const model: string = body.model;
354
459
  const wire: Format = key === "anthropic" ? "anthropic" : "openai";
355
460
  const stream = body.stream === true;
461
+ // The request's own thinking switches, snapshotted BEFORE the failover
462
+ // loop — each attempt mutates the shared body, and a passthrough slot
463
+ // (no default) must restore these originals.
464
+ const thinkOrig: ThinkingOrig = {
465
+ fields: Object.fromEntries(thinkingFields(key).map((f) => [f, body[f]])),
466
+ maxTokens: body.max_tokens,
467
+ };
356
468
  // The model-page "test" button drives dispatch via an in-process loopback
357
469
  // (adminApi calls v1.request). The probe is a real call in every respect —
358
470
  // including being logged — so we only tag it to report WHICH provider
@@ -393,6 +505,9 @@ export function proxyApi(
393
505
  const start = Date.now();
394
506
  let lastStatus = 502;
395
507
  let lastErr = "";
508
+ // Thinking level of the most recent attempt (for the all-failed row after
509
+ // the rounds loop; per-attempt rows capture the loop's own `think` const).
510
+ let lastThink: { value: string; from: "client" | "default" } | undefined;
396
511
  // Runtime log (console + server.log). Errors and notable events only — the
397
512
  // per-call history these lines summarize goes to pushLog/logs.jsonl anyway.
398
513
  // UI-triggered probes are excluded: their outcome is shown inline already.
@@ -482,6 +597,12 @@ export function proxyApi(
482
597
  // when the public name went through verbatim — JSON.stringify drops it, so
483
598
  // identity + legacy rows stay clean.
484
599
  const upstreamModel = slot.model && slot.model !== model ? slot.model : undefined;
600
+ // Per-slot thinking level: re-read from the slot and re-applied every
601
+ // attempt. A configured default OVERRIDES whatever the request carried;
602
+ // a slot without one restores the request's own switches (see
603
+ // applySlotThinking), so slot A's level never leaks into slot B.
604
+ const think = applySlotThinking(body, key, slot.thinking, thinkOrig);
605
+ lastThink = think;
485
606
  // Count this attempt toward the source's RPM window — but not for a pinned
486
607
  // probe, which (like circuit state) takes no routing side-effects.
487
608
  if (pinIndex == null) store.recordDispatch(provider.id);
@@ -500,7 +621,7 @@ export function proxyApi(
500
621
  sayFailover(provider, "network error");
501
622
  const r = store.recordCircuitFailure(provider.id, lastStatus, lastErr);
502
623
  if (r.entered) {
503
- store.pushLog({ ts: Date.now(), model, upstreamModel, 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 });
624
+ store.pushLog({ ts: Date.now(), model, upstreamModel, provider: provider.name, providerId: provider.id, format: wire, status: lastStatus, ms: Date.now() - start, stream, thinking: think, kind: "cooldown", cooldownMs: r.cooldownMs, fails: r.fails, error: lastErr });
504
625
  sayCooldown(provider, r);
505
626
  }
506
627
  continue;
@@ -526,13 +647,13 @@ export function proxyApi(
526
647
  onSettle: (info) => {
527
648
  if (info.ok) {
528
649
  store.recordCircuitSuccess(provider.id);
529
- store.pushLog({ ts: Date.now(), model, upstreamModel, provider: provider.name, providerId: provider.id, format: wire, status: 200, ms: ttfb, stream, usage: info.usage });
650
+ 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 });
530
651
  } else {
531
652
  // A pinned per-source probe takes no circuit side-effects (a manual
532
653
  // test must not trip the breaker) — mirrors the retryable branch.
533
654
  if (pinIndex == null) store.recordCircuitFailure(provider.id, info.status, info.error || "stream failed");
534
655
  if (!isProbe) rt.warn(`proxy stream failed: provider '${provider.name}' status=${info.status} (${info.error || "stream failed"})`);
535
- store.pushLog({ ts: Date.now(), model, upstreamModel, provider: provider.name, providerId: provider.id, format: wire, status: info.status, ms: ttfb, stream, error: info.error });
656
+ 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 });
536
657
  }
537
658
  },
538
659
  });
@@ -555,7 +676,7 @@ export function proxyApi(
555
676
  const resetInMs = retryAfterMs ? undefined : parseResetFromBody(txt);
556
677
  const r = store.recordCircuitFailure(provider.id, lastStatus, lastErr, retryAfterMs ?? resetInMs, !!resetInMs);
557
678
  if (r.entered) {
558
- store.pushLog({ ts: Date.now(), model, upstreamModel, 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 });
679
+ store.pushLog({ ts: Date.now(), model, upstreamModel, provider: provider.name, providerId: provider.id, format: wire, status: lastStatus, ms: Date.now() - start, stream, thinking: think, kind: "cooldown", cooldownMs: r.cooldownMs, fails: r.fails, error: lastErr });
559
680
  sayCooldown(provider, r);
560
681
  }
561
682
  continue;
@@ -563,7 +684,7 @@ export function proxyApi(
563
684
  // Non-retryable client error: return it to the caller as-is. Read the
564
685
  // error text off a CLONE so the original body still streams back.
565
686
  const errText = await upstream.clone().text().catch(() => "");
566
- store.pushLog({ ts: Date.now(), model, upstreamModel, provider: provider.name, providerId: provider.id, format: wire, status: upstream.status, ms: Date.now() - start, stream, error: shortError(errText) || `HTTP ${upstream.status}` });
687
+ 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}` });
567
688
  return passThrough(upstream, isProbe ? provider.name : undefined);
568
689
  }
569
690
 
@@ -576,7 +697,7 @@ export function proxyApi(
576
697
  return new Response(null, { status: 499 });
577
698
  }
578
699
  if (!isProbe) rt.error(`proxy all providers failed model=${model} (last status ${lastStatus})`);
579
- store.pushLog({ ts: Date.now(), model, upstreamModel: lastUpstreamModel, provider: last.provider.name, providerId: last.provider.id, format: wire, status: lastStatus, ms: Date.now() - start, stream, error: lastErr || `all providers failed (last status ${lastStatus})` });
700
+ store.pushLog({ ts: Date.now(), model, upstreamModel: lastUpstreamModel, provider: last.provider.name, providerId: last.provider.id, format: wire, status: lastStatus, ms: Date.now() - start, stream, thinking: lastThink, error: lastErr || `all providers failed (last status ${lastStatus})` });
580
701
  // A pinned (per-source) probe failed: surface the REAL upstream status the
581
702
  // one slot returned (429/500/…), not a collapsed 502, and tag it with
582
703
  // x-myapikey-provider so the source-row badge names the tested source.
@@ -68,6 +68,14 @@ export interface ChainSlot {
68
68
  /** Upstream model name to send to this provider. Absent = forward the public
69
69
  * model name (the key in GateConfig.models) unchanged. */
70
70
  model?: string;
71
+ /** Default thinking level for this slot. When set it takes PRECEDENCE over
72
+ * the request's own thinking parameters — dispatch replaces them outright
73
+ * (the gateway's configured level is the authority). When absent, the
74
+ * request's parameters pass through untouched. The value is whatever the
75
+ * slot's wire format takes natively — no translation: an effort token
76
+ * ("low"/"medium"/"high"/…) on openai/responses slots, a thinking budget
77
+ * in tokens (positive integer) on anthropic slots. */
78
+ thinking?: string;
71
79
  }
72
80
 
73
81
  /** A model's routing dimensions — one per forwarding endpoint. /chat/completions
@@ -148,6 +156,12 @@ export interface LogEntry {
148
156
  usage?: Usage;
149
157
  /** Short upstream error text on non-2xx (omitted on success). */
150
158
  error?: string;
159
+ /** The thinking level this call ran with, and where it came from: "client" =
160
+ * the answering slot had no default, so the request's own thinking setting
161
+ * ran (forwarded untouched); "default" = the slot's configured `thinking`
162
+ * ran, OVERRIDING whatever the request carried. Absent when neither
163
+ * applied (no default configured, request carried nothing). */
164
+ thinking?: { value: string; from: "client" | "default" };
151
165
  /** Row kind. Absent on legacy lines → treated as a normal call. "cooldown"
152
166
  * marks a circuit-breaker event (a provider just entered cooldown), shown
153
167
  * distinctly in the timeline alongside the failures that caused it. */