myapikey 0.17.0 → 0.19.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.17.0",
3
+ "version": "0.19.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": [
@@ -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; 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). An empty value clears it
550
+ // (back to pure passthrough). Dispatch applies it only when the request
551
+ // carries no thinking parameter of its own.
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,84 @@ 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, injected ONLY when the
111
+ // request itself carries no thinking parameter (a request's own setting always
112
+ // wins). No cross-format translation — the default is applied in the wire's own
113
+ // dialect, matching how the model rewrite stays a pure passthrough body edit:
114
+ // openai /chat/completions → `reasoning_effort: "<token>"` (low/medium/high/…)
115
+ // /responses → `reasoning: { effort: "<token>" }`
116
+ // anthropic /v1/messages → `thinking: { type: "enabled", budget_tokens: N }`
117
+ // (Anthropic has no named levels — the stored default IS the budget, a
118
+ // positive integer. The API also demands max_tokens > budget_tokens, so an
119
+ // at-or-below cap is lifted to budget+1; otherwise the call would 400 —
120
+ // including our own max_tokens:1 probe.)
121
+
122
+ /** The body field this slot's thinking parameter travels in. */
123
+ function thinkingField(key: RouteKey): "reasoning_effort" | "reasoning" | "thinking" {
124
+ if (key === "anthropic") return "thinking";
125
+ if (key === "responses") return "reasoning";
126
+ return "reasoning_effort";
127
+ }
128
+
129
+ /** Whether the CLIENT's own body already steers thinking on this slot, plus a
130
+ * short best-effort display of what it asked for (for the log row). On openai
131
+ * chat, any of the ecosystem's switches counts — compat backends also honor
132
+ * `thinking` / `enable_thinking`, and double-specifying alongside a default
133
+ * would only confuse them. */
134
+ function clientThinking(body: Record<string, unknown>, key: RouteKey): { set: boolean; value?: string } {
135
+ const show = (v: unknown): string | undefined => (typeof v === "object" && v !== null ? JSON.stringify(v) : String(v));
136
+ if (key === "responses") {
137
+ const r = body.reasoning;
138
+ if (r === undefined || r === null) return { set: false };
139
+ const effort = (r as { effort?: unknown }).effort;
140
+ return { set: true, value: effort === undefined ? show(r) : show(effort) };
141
+ }
142
+ if (key === "anthropic") {
143
+ const t = body.thinking;
144
+ if (t === undefined || t === null) return { set: false };
145
+ const budget = (t as { budget_tokens?: unknown }).budget_tokens;
146
+ return { set: true, value: budget === undefined ? show(t) : show(budget) };
147
+ }
148
+ const found = [
149
+ body.reasoning_effort,
150
+ (body.reasoning as { effort?: unknown } | undefined)?.effort,
151
+ (body.thinking as { budget_tokens?: unknown } | undefined)?.budget_tokens,
152
+ (body.thinking as { type?: unknown } | undefined)?.type,
153
+ body.enable_thinking,
154
+ ].find((v) => v !== undefined && v !== null);
155
+ return { set: found !== undefined, value: found === undefined ? undefined : show(found) };
156
+ }
157
+
158
+ /** Apply THIS slot's thinking default to the body (mutating it), and return the
159
+ * log row's `thinking` field: the level that applied and where it came from.
160
+ * Undefined = nothing applied. A slot without a default CLEARS whatever an
161
+ * earlier failover slot injected, so a chain never leaks slot A's level into
162
+ * slot B (the model rewrite has the same recompute-per-attempt discipline). */
163
+ function applySlotThinking(
164
+ body: Record<string, unknown>,
165
+ key: RouteKey,
166
+ def: string | undefined,
167
+ client: { set: boolean; value?: string },
168
+ ): { value: string; from: "client" | "default" } | undefined {
169
+ if (client.set) return { value: client.value ?? "", from: "client" };
170
+ const field = thinkingField(key);
171
+ if (!def) {
172
+ delete body[field]; // no-op unless a previous slot injected one
173
+ return undefined;
174
+ }
175
+ if (key === "anthropic") {
176
+ const budget = Number(def);
177
+ body.thinking = { type: "enabled", budget_tokens: budget };
178
+ if (typeof body.max_tokens === "number" && body.max_tokens <= budget) body.max_tokens = budget + 1;
179
+ } else if (key === "responses") {
180
+ body.reasoning = { effort: def };
181
+ } else {
182
+ body.reasoning_effort = def;
183
+ }
184
+ return { value: def, from: "default" };
185
+ }
186
+
107
187
  /** Auth headers for the Anthropic wire format. Sends BOTH x-api-key and
108
188
  * Authorization: Bearer (same key). Native Anthropic (api.anthropic.com)
109
189
  * accepts either; anthropic- COMPATIBLE surfaces (sensenova, Volcengine Ark,
@@ -353,6 +433,9 @@ export function proxyApi(
353
433
  const model: string = body.model;
354
434
  const wire: Format = key === "anthropic" ? "anthropic" : "openai";
355
435
  const stream = body.stream === true;
436
+ // The client's own thinking setting, read ONCE from the original body (the
437
+ // per-slot default injection below would otherwise read back as "client").
438
+ const clientThink = clientThinking(body, key);
356
439
  // The model-page "test" button drives dispatch via an in-process loopback
357
440
  // (adminApi calls v1.request). The probe is a real call in every respect —
358
441
  // including being logged — so we only tag it to report WHICH provider
@@ -393,6 +476,9 @@ export function proxyApi(
393
476
  const start = Date.now();
394
477
  let lastStatus = 502;
395
478
  let lastErr = "";
479
+ // Thinking level of the most recent attempt (for the all-failed row after
480
+ // the rounds loop; per-attempt rows capture the loop's own `think` const).
481
+ let lastThink: { value: string; from: "client" | "default" } | undefined;
396
482
  // Runtime log (console + server.log). Errors and notable events only — the
397
483
  // per-call history these lines summarize goes to pushLog/logs.jsonl anyway.
398
484
  // UI-triggered probes are excluded: their outcome is shown inline already.
@@ -433,135 +519,172 @@ export function proxyApi(
433
519
  }
434
520
  }
435
521
 
436
- // Skip providers that are either in circuit-breaker cooldown OR over their
437
- // RPM pacing cap. Both are heuristics: if every candidate is skipped, fall
438
- // back to the full list anyway one real attempt beats a guaranteed 502
439
- // (a skipped provider that now succeeds also resets its state). A pinned
440
- // (per-source) probe ignores both the user is testing THIS source now,
441
- // whatever its breaker/pacing state.
442
- const skipped = (slot: CandidateSlot) => {
443
- const p = slot.provider;
444
- return store.isCooling(p.id) || (!!p.rpm && store.rpmUsed(p.id) >= p.rpm);
522
+ // Selection + failover run in ROUNDS. Each round attempts the candidates
523
+ // that are neither in circuit-breaker cooldown nor over their rpm cap; a
524
+ // capped-but-healthy source still spills to the next free source (failover
525
+ // first). Only when NOTHING is immediately usable but some candidate is
526
+ // merely over its rpm cap does the request QUEUE: sleep until that source's
527
+ // soonest window slot frees and run another round — the client perceives
528
+ // only the wait (or its own timeout), never an rpm error. There is no wait
529
+ // cap: rounds terminate anyway because retryable failures escalate the
530
+ // circuit breaker, so sources converge to cooling and the final round is
531
+ // the old try-anyway fallback (full list — one real attempt beats a
532
+ // guaranteed 502, and a skipped provider that now succeeds also resets its
533
+ // state). A pinned (per-source) probe ignores all of this — the user is
534
+ // testing THIS source now, whatever its breaker/pacing state.
535
+ const cooling = (slot: CandidateSlot) => store.isCooling(slot.provider.id);
536
+ const overRpm = (slot: CandidateSlot) => !!slot.provider.rpm && store.rpmUsed(slot.provider.id) >= slot.provider.rpm;
537
+ /** Sleep until the soonest rpm slot frees among `capped`. Returns false
538
+ * when the client hung up while queued — the caller drops the request
539
+ * (nothing was sent upstream, so no log row either). */
540
+ const waitForSlot = async (capped: CandidateSlot[]): Promise<boolean> => {
541
+ const wait = Math.max(1, Math.min(...capped.map((s) => store.rpmNextFreeMs(s.provider.id))));
542
+ if (!isProbe) rt.warn(`proxy model=${model}: all sources over rpm cap — queued, next slot in ~${Math.round(wait / 1000)}s`);
543
+ await new Promise((r) => setTimeout(r, wait));
544
+ return !c.req.raw.signal?.aborted;
445
545
  };
446
- const live = pinIndex != null ? list : list.filter((slot) => !skipped(slot));
447
- const order = live.length ? live : list;
448
-
449
- for (const slot of order) {
450
- const provider = slot.provider;
451
- // Per-slot upstream model name (absent = send the public name). Read from
452
- // the slot each iteration, so failover never carries the previous slot's
453
- // upstream name.
454
- body.model = slot.model ?? model;
455
- // The actual upstream model forwarded this attempt (after the per-slot
456
- // rewrite). Recorded on the log row so history shows which real model a
457
- // routed call landed on when a source remaps the public name. `undefined`
458
- // when the public name went through verbatim — JSON.stringify drops it, so
459
- // identity + legacy rows stay clean.
460
- const upstreamModel = slot.model && slot.model !== model ? slot.model : undefined;
461
- // Count this attempt toward the source's RPM window — but not for a pinned
462
- // probe, which (like circuit state) takes no routing side-effects.
463
- if (pinIndex == null) store.recordDispatch(provider.id);
464
- let upstream: Response;
465
- try {
466
- upstream = await fetch(upstreamTarget(provider, key).url, {
467
- method: "POST",
468
- headers: upstreamHeaders(provider, wire, clientVersion),
469
- body: JSON.stringify(body),
470
- });
471
- } catch {
472
- // Network error / DNS / timeout → try next provider.
473
- lastStatus = 502;
474
- lastErr = "network error";
475
- if (pinIndex != null) break; // per-source probe: fail fast, no circuit impact.
476
- sayFailover(provider, "network error");
477
- const r = store.recordCircuitFailure(provider.id, lastStatus, lastErr);
478
- if (r.entered) {
479
- 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 });
480
- sayCooldown(provider, r);
481
- }
482
- continue;
483
- }
484
546
 
485
- if (upstream.ok) {
486
- // A 200 from the upstream is NOT proof the call succeeded: some backends
487
- // return 200 then truncate the stream (or emit no content) for request
488
- // shapes they mishandle. We commit the 200 status to the client right
489
- // away (headers are already sent) but OBSERVE the body as it flows and
490
- // settle once on a clean, fully-terminated stream we close the circuit
491
- // + log 200; on a truncated/errored stream we log 502, trip the circuit
492
- // (so the NEXT call fails over), and — on the anthropic wire — inject a
493
- // synthetic SSE error event so the client learns the stream died instead
494
- // of seeing a silent EOF. TTFB is captured now; logging is deferred to
495
- // the body's end (so the row reflects the real outcome, not just the
496
- // headers). See observedBody() for the detection rules.
497
- const ttfb = Date.now() - start;
498
- const out = observedBody(upstream, {
499
- stream,
500
- key,
501
- requestMessages: body.messages,
502
- onSettle: (info) => {
503
- if (info.ok) {
504
- store.recordCircuitSuccess(provider.id);
505
- store.pushLog({ ts: Date.now(), model, upstreamModel, provider: provider.name, providerId: provider.id, format: wire, status: 200, ms: ttfb, stream, usage: info.usage });
506
- } else {
507
- // A pinned per-source probe takes no circuit side-effects (a manual
508
- // test must not trip the breaker) — mirrors the retryable branch.
509
- if (pinIndex == null) store.recordCircuitFailure(provider.id, info.status, info.error || "stream failed");
510
- if (!isProbe) rt.warn(`proxy stream failed: provider '${provider.name}' status=${info.status} (${info.error || "stream failed"})`);
511
- store.pushLog({ ts: Date.now(), model, upstreamModel, provider: provider.name, providerId: provider.id, format: wire, status: info.status, ms: ttfb, stream, error: info.error });
512
- }
513
- },
514
- });
515
- return new Response(out, { status: upstream.status, headers: downHeaders(upstream, isProbe ? provider.name : undefined) });
547
+ for (;;) {
548
+ // Re-read the chain each round: config and circuit state move while queued.
549
+ const cur = pinIndex != null ? list : candidates(store, model, key);
550
+ if (pinIndex == null && !cur.length) return notFound(c, model); // disabled while queued
551
+ const live = pinIndex != null ? cur : cur.filter((s) => !cooling(s) && !overRpm(s));
552
+ const capped = pinIndex != null ? [] : cur.filter((s) => !cooling(s) && overRpm(s));
553
+ if (pinIndex == null && !live.length && capped.length) {
554
+ if (await waitForSlot(capped)) continue;
555
+ return new Response(null, { status: 499 });
516
556
  }
517
- if (RETRYABLE.has(upstream.status)) {
518
- lastStatus = upstream.status;
519
- // Drain so the connection can be reused, then move on; capture the
520
- // reason for the log (this branch never streams back to the client).
521
- const txt = await upstream.text().catch(() => "");
522
- lastErr = shortError(txt) || `HTTP ${upstream.status}`;
523
- if (pinIndex != null) break; // per-source probe: fail fast, no circuit impact.
524
- sayFailover(provider, `HTTP ${lastStatus} (${lastErr})`);
525
- // A 429/overloaded upstream usually carries Retry-After; honoring it
526
- // cools for exactly as long as asked (clamped) instead of the escalating
527
- // guess. Absent (5xx often, OR a quota error that buried the reset time
528
- // in the BODY e.g. Volcengine Ark's 1308 "您的限额将在 <datetime> 重置")
529
- // parse that deadline out of the body, else fall back to escalating.
530
- const retryAfterMs = parseRetryAfter(upstream.headers.get("retry-after"));
531
- const resetInMs = retryAfterMs ? undefined : parseResetFromBody(txt);
532
- const r = store.recordCircuitFailure(provider.id, lastStatus, lastErr, retryAfterMs ?? resetInMs, !!resetInMs);
533
- if (r.entered) {
534
- 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 });
535
- sayCooldown(provider, r);
557
+ const order = live.length ? live : cur;
558
+
559
+ for (const slot of order) {
560
+ const provider = slot.provider;
561
+ // Per-slot upstream model name (absent = send the public name). Read from
562
+ // the slot each iteration, so failover never carries the previous slot's
563
+ // upstream name.
564
+ body.model = slot.model ?? model;
565
+ // The actual upstream model forwarded this attempt (after the per-slot
566
+ // rewrite). Recorded on the log row so history shows which real model a
567
+ // routed call landed on when a source remaps the public name. `undefined`
568
+ // when the public name went through verbatim JSON.stringify drops it, so
569
+ // identity + legacy rows stay clean.
570
+ const upstreamModel = slot.model && slot.model !== model ? slot.model : undefined;
571
+ // Per-slot default thinking level: re-read from the slot and re-applied
572
+ // every attempt (an earlier slot's injection is cleared when this one
573
+ // has no default — see applySlotThinking). The request's own thinking
574
+ // parameter, when present, was captured once above and always wins.
575
+ const think = applySlotThinking(body, key, slot.thinking, clientThink);
576
+ lastThink = think;
577
+ // Count this attempt toward the source's RPM window — but not for a pinned
578
+ // probe, which (like circuit state) takes no routing side-effects.
579
+ if (pinIndex == null) store.recordDispatch(provider.id);
580
+ let upstream: Response;
581
+ try {
582
+ upstream = await fetch(upstreamTarget(provider, key).url, {
583
+ method: "POST",
584
+ headers: upstreamHeaders(provider, wire, clientVersion),
585
+ body: JSON.stringify(body),
586
+ });
587
+ } catch {
588
+ // Network error / DNS / timeout → try next provider.
589
+ lastStatus = 502;
590
+ lastErr = "network error";
591
+ if (pinIndex != null) break; // per-source probe: fail fast, no circuit impact.
592
+ sayFailover(provider, "network error");
593
+ const r = store.recordCircuitFailure(provider.id, lastStatus, lastErr);
594
+ if (r.entered) {
595
+ 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 });
596
+ sayCooldown(provider, r);
597
+ }
598
+ continue;
599
+ }
600
+
601
+ if (upstream.ok) {
602
+ // A 200 from the upstream is NOT proof the call succeeded: some backends
603
+ // return 200 then truncate the stream (or emit no content) for request
604
+ // shapes they mishandle. We commit the 200 status to the client right
605
+ // away (headers are already sent) but OBSERVE the body as it flows and
606
+ // settle once — on a clean, fully-terminated stream we close the circuit
607
+ // + log 200; on a truncated/errored stream we log 502, trip the circuit
608
+ // (so the NEXT call fails over), and — on the anthropic wire — inject a
609
+ // synthetic SSE error event so the client learns the stream died instead
610
+ // of seeing a silent EOF. TTFB is captured now; logging is deferred to
611
+ // the body's end (so the row reflects the real outcome, not just the
612
+ // headers). See observedBody() for the detection rules.
613
+ const ttfb = Date.now() - start;
614
+ const out = observedBody(upstream, {
615
+ stream,
616
+ key,
617
+ requestMessages: body.messages,
618
+ onSettle: (info) => {
619
+ if (info.ok) {
620
+ store.recordCircuitSuccess(provider.id);
621
+ 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 });
622
+ } else {
623
+ // A pinned per-source probe takes no circuit side-effects (a manual
624
+ // test must not trip the breaker) — mirrors the retryable branch.
625
+ if (pinIndex == null) store.recordCircuitFailure(provider.id, info.status, info.error || "stream failed");
626
+ if (!isProbe) rt.warn(`proxy stream failed: provider '${provider.name}' status=${info.status} (${info.error || "stream failed"})`);
627
+ 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 });
628
+ }
629
+ },
630
+ });
631
+ return new Response(out, { status: upstream.status, headers: downHeaders(upstream, isProbe ? provider.name : undefined) });
536
632
  }
537
- continue;
633
+ if (RETRYABLE.has(upstream.status)) {
634
+ lastStatus = upstream.status;
635
+ // Drain so the connection can be reused, then move on; capture the
636
+ // reason for the log (this branch never streams back to the client).
637
+ const txt = await upstream.text().catch(() => "");
638
+ lastErr = shortError(txt) || `HTTP ${upstream.status}`;
639
+ if (pinIndex != null) break; // per-source probe: fail fast, no circuit impact.
640
+ sayFailover(provider, `HTTP ${lastStatus} (${lastErr})`);
641
+ // A 429/overloaded upstream usually carries Retry-After; honoring it
642
+ // cools for exactly as long as asked (clamped) instead of the escalating
643
+ // guess. Absent (5xx often, OR a quota error that buried the reset time
644
+ // in the BODY — e.g. Volcengine Ark's 1308 "您的限额将在 <datetime> 重置")
645
+ // → parse that deadline out of the body, else fall back to escalating.
646
+ const retryAfterMs = parseRetryAfter(upstream.headers.get("retry-after"));
647
+ const resetInMs = retryAfterMs ? undefined : parseResetFromBody(txt);
648
+ const r = store.recordCircuitFailure(provider.id, lastStatus, lastErr, retryAfterMs ?? resetInMs, !!resetInMs);
649
+ if (r.entered) {
650
+ 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 });
651
+ sayCooldown(provider, r);
652
+ }
653
+ continue;
654
+ }
655
+ // Non-retryable client error: return it to the caller as-is. Read the
656
+ // error text off a CLONE so the original body still streams back.
657
+ const errText = await upstream.clone().text().catch(() => "");
658
+ 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}` });
659
+ return passThrough(upstream, isProbe ? provider.name : undefined);
538
660
  }
539
- // Non-retryable client error: return it to the caller as-is. Read the
540
- // error text off a CLONE so the original body still streams back.
541
- const errText = await upstream.clone().text().catch(() => "");
542
- 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}` });
543
- return passThrough(upstream, isProbe ? provider.name : undefined);
544
- }
545
661
 
546
- const last = order[order.length - 1];
547
- const lastUpstreamModel = last.model && last.model !== model ? last.model : undefined;
548
- if (!isProbe) rt.error(`proxy all providers failed model=${model} (last status ${lastStatus})`);
549
- 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})` });
550
- // A pinned (per-source) probe failed: surface the REAL upstream status the
551
- // one slot returned (429/500/…), not a collapsed 502, and tag it with
552
- // x-myapikey-provider so the source-row badge names the tested source.
553
- if (pinIndex != null) {
554
- const h = new Headers({ "content-type": "application/json" });
555
- if (isProbe) h.set("x-myapikey-provider", encodeTag(last.provider.name));
556
- return new Response(
557
- JSON.stringify({ error: { message: lastErr || `provider failed (status ${lastStatus})`, type: "upstream_error" } }),
558
- { status: lastStatus, headers: h },
662
+ const last = order[order.length - 1];
663
+ const lastUpstreamModel = last.model && last.model !== model ? last.model : undefined;
664
+ // Every slot in this round failed retryably. rpm-capped candidates
665
+ // remain - queue for their next slot instead of erroring the client.
666
+ if (pinIndex == null && capped.length) {
667
+ if (await waitForSlot(capped)) continue;
668
+ return new Response(null, { status: 499 });
669
+ }
670
+ if (!isProbe) rt.error(`proxy all providers failed model=${model} (last status ${lastStatus})`);
671
+ 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})` });
672
+ // A pinned (per-source) probe failed: surface the REAL upstream status the
673
+ // one slot returned (429/500/…), not a collapsed 502, and tag it with
674
+ // x-myapikey-provider so the source-row badge names the tested source.
675
+ if (pinIndex != null) {
676
+ const h = new Headers({ "content-type": "application/json" });
677
+ if (isProbe) h.set("x-myapikey-provider", encodeTag(last.provider.name));
678
+ return new Response(
679
+ JSON.stringify({ error: { message: lastErr || `provider failed (status ${lastStatus})`, type: "upstream_error" } }),
680
+ { status: lastStatus, headers: h },
681
+ );
682
+ }
683
+ return c.json(
684
+ { error: { message: `all providers for '${model}' failed (last status ${lastStatus})`, type: "upstream_error" } },
685
+ 502,
559
686
  );
560
687
  }
561
- return c.json(
562
- { error: { message: `all providers for '${model}' failed (last status ${lastStatus})`, type: "upstream_error" } },
563
- 502,
564
- );
565
688
  };
566
689
 
567
690
  // OpenAI surface: chat/completions + responses (/models is registered above,
@@ -607,6 +607,28 @@ export class Store {
607
607
  else this.rpm.set(id, [Date.now()]);
608
608
  }
609
609
 
610
+ /** Ms until one slot frees in this source's trailing window (i.e. until its
611
+ * OLDEST recorded dispatch ages out) - the soonest a call over the source's
612
+ * rpm cap could go through. 0 when the window's first entry has already
613
+ * expired or the source has no recent activity (whether a slot is actually
614
+ * free is the caller's rpmUsed-vs-rpm check). Prunes expired entries as it
615
+ * reads, like rpmUsed. Used by dispatch to sleep out an rpm cap when every
616
+ * candidate source is over its limit. */
617
+ rpmNextFreeMs(id: string): number {
618
+ const arr = this.rpm.get(id);
619
+ if (!arr || !arr.length) return 0;
620
+ const now = Date.now();
621
+ const cutoff = now - RPM_WINDOW_MS;
622
+ let i = 0;
623
+ while (i < arr.length && arr[i] < cutoff) i++;
624
+ if (i > 0) arr.splice(0, i);
625
+ if (!arr.length) {
626
+ this.rpm.delete(id);
627
+ return 0;
628
+ }
629
+ return Math.max(0, arr[0] + RPM_WINDOW_MS - now);
630
+ }
631
+
610
632
  // --- even pacing (per model, in-memory leaky-bucket queue) ---
611
633
 
612
634
  /** Reserve the next release slot for a call to `model`, paced at `rpm`
@@ -68,6 +68,13 @@ 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, applied when the REQUEST carries no
72
+ * thinking parameter of its own (a request's own setting always wins). The
73
+ * value is whatever the slot's wire format takes natively — no translation:
74
+ * an effort token ("low"/"medium"/"high"/…) on openai/responses slots, a
75
+ * thinking budget in tokens (positive integer) on anthropic slots. Absent =
76
+ * pure passthrough. */
77
+ thinking?: string;
71
78
  }
72
79
 
73
80
  /** A model's routing dimensions — one per forwarding endpoint. /chat/completions
@@ -148,6 +155,12 @@ export interface LogEntry {
148
155
  usage?: Usage;
149
156
  /** Short upstream error text on non-2xx (omitted on success). */
150
157
  error?: string;
158
+ /** The thinking level this call ran with, and where it came from: "client" =
159
+ * the request carried its own thinking parameter (forwarded untouched);
160
+ * "default" = the gateway injected the routing slot's `thinking` default
161
+ * because the request had none. Absent when neither applied (no default
162
+ * configured, request carried nothing). */
163
+ thinking?: { value: string; from: "client" | "default" };
151
164
  /** Row kind. Absent on legacy lines → treated as a normal call. "cooldown"
152
165
  * marks a circuit-breaker event (a provider just entered cooldown), shown
153
166
  * distinctly in the timeline alongside the failures that caused it. */