myapikey 0.40.2 → 0.42.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/README.md CHANGED
@@ -124,7 +124,6 @@ npm run build:web # build the Vue UI into packages/web/dist
124
124
  npm run dev # gateway with watch reload
125
125
  npm run dev:web # vite dev server (proxies API calls to :7800)
126
126
  npm test # vitest unit + integration
127
- npm run test:e2e # playwright against a real gateway process
128
127
  npm run typecheck # tsc + vue-tsc
129
128
  ```
130
129
 
package/README.zh-CN.md CHANGED
@@ -124,7 +124,6 @@ npm run build:web # 把 Vue 界面构建到 packages/web/dist
124
124
  npm run dev # 网关,带 watch 热重载
125
125
  npm run dev:web # vite 开发服务器(API 代理到 :7800)
126
126
  npm test # vitest 单测 + 集成
127
- npm run test:e2e # playwright 打真实网关进程
128
127
  npm run typecheck # tsc + vue-tsc
129
128
  ```
130
129
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myapikey",
3
- "version": "0.40.2",
3
+ "version": "0.42.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": [
@@ -52,7 +52,7 @@
52
52
  "packages/*"
53
53
  ],
54
54
  "scripts": {
55
- "dev": "tsx watch packages/core/src/cli/index.ts serve",
55
+ "dev": "NODE_ENV=development tsx watch packages/core/src/cli/index.ts serve",
56
56
  "dev:web": "npm run dev -w @myapikey/web",
57
57
  "start": "npm run build:web && npm run serve",
58
58
  "serve": "tsx packages/core/src/cli/index.ts serve",
@@ -60,8 +60,7 @@
60
60
  "build:web": "npm run build -w @myapikey/web",
61
61
  "test": "vitest run",
62
62
  "test:watch": "vitest",
63
- "test:coverage": "vitest run --coverage",
64
- "test:e2e": "playwright test"
63
+ "test:coverage": "vitest run --coverage"
65
64
  },
66
65
  "engines": {
67
66
  "node": ">=18"
@@ -76,7 +75,6 @@
76
75
  "zod": "^3.23.8"
77
76
  },
78
77
  "devDependencies": {
79
- "@playwright/test": "^1.62.1",
80
78
  "@types/node": "^22.7.0",
81
79
  "@vitest/coverage-v8": "^2.1.9",
82
80
  "jsdom": "^30.0.1",
@@ -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}${p.thinking ? ` thinking=${p.thinking}` : ""}`).join(" → ") || "(none)";
184
+ const chain = fe.providers.map((p: any) => `${p.model ? `${p.name}→${p.model}` : p.name}${p.thinking ? ` thinking=${p.thinking}` : ""}${p.sampling ? ` sampling=${JSON.stringify(p.sampling)}` : ""}`).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)`);
@@ -278,6 +278,28 @@ model
278
278
  );
279
279
  });
280
280
 
281
+ model
282
+ .command("sampling <name> <index> [json]")
283
+ .description(`set/clear the default sampling parameters of one chain slot as a JSON object (e.g. '{"temperature":0.2,"top_p":0.9}'; each field overrides the request's own value; empty = clear)`)
284
+ .addOption(fmtOption())
285
+ .action(async (name: string, indexRaw: string, value: string | undefined, opts: { format: "openai" | "anthropic" | "responses" }) => {
286
+ const index = Number(indexRaw);
287
+ let sampling: Record<string, unknown> | undefined;
288
+ if (value !== undefined && value.trim()) {
289
+ try {
290
+ sampling = JSON.parse(value) as Record<string, unknown>;
291
+ } catch {
292
+ throw new Error(`sampling must be a JSON object, e.g. '{"temperature":0.2}'`);
293
+ }
294
+ }
295
+ const r = (await api(ctx(), "PUT", `/admin/models/${encodeURIComponent(name)}/sampling`, { format: opts.format, index, sampling })) as { sampling?: Record<string, unknown> };
296
+ console.log(
297
+ r.sampling
298
+ ? `Default sampling for ${name} [${opts.format}] slot ${index}: ${JSON.stringify(r.sampling)}.`
299
+ : `Default sampling cleared for ${name} [${opts.format}] slot ${index}.`,
300
+ );
301
+ });
302
+
281
303
  model.command("remove <name>").description("remove a model entirely (both formats)").action(async (name: string) => {
282
304
  await api(ctx(), "DELETE", `/admin/models/${encodeURIComponent(name)}`);
283
305
  console.log(`Removed ${name}.`);
@@ -44,6 +44,31 @@ function providerSpeaks(p: Provider, key: RouteKey): boolean {
44
44
  return key === "responses" ? !!p.supportsResponses : p.formats.includes(key);
45
45
  }
46
46
 
47
+ /** The body fields a chain slot's sampling default may set — the wire-agnostic
48
+ * names, identical on all three routes (see applySlotSampling in proxy.ts). */
49
+ const SAMPLING_KEYS = ["temperature", "top_p", "top_k", "presence_penalty", "frequency_penalty", "seed"] as const;
50
+
51
+ /** Validate a slot's sampling default: whitelisted keys only, every value a
52
+ * finite number (`seed` an integer). Numeric strings are coerced (form fields
53
+ * send strings). Returns the cleaned record; undefined when absent/empty
54
+ * (clear); null = invalid (unknown key, non-numeric value, fractional seed). */
55
+ function sanitizeSampling(v: unknown): Record<string, number> | undefined | null {
56
+ if (v === undefined || v === null) return undefined;
57
+ if (typeof v !== "object" || Array.isArray(v)) return null;
58
+ const out: Record<string, number> = {};
59
+ for (const [k, raw] of Object.entries(v as Record<string, unknown>)) {
60
+ if (!(SAMPLING_KEYS as readonly string[]).includes(k)) return null;
61
+ const n = typeof raw === "string" ? Number(raw.trim()) : raw;
62
+ if (typeof n !== "number" || !Number.isFinite(n)) return null;
63
+ if (k === "seed" && !Number.isInteger(n)) return null;
64
+ out[k] = n;
65
+ }
66
+ return Object.keys(out).length ? out : undefined;
67
+ }
68
+
69
+ const SAMPLING_ERR =
70
+ "sampling: keys must be temperature/top_p/top_k/presence_penalty/frequency_penalty/seed with finite numeric values (seed an integer)";
71
+
47
72
  /** Inverse of proxy's encodeTag: the probe's x-myapikey-provider header carries
48
73
  * a %-encoded provider name (HTTP headers are Latin-1, so a name like "商汤"
49
74
  * can't travel raw). Decode it back for display; fall back to the raw value if
@@ -155,6 +180,8 @@ function projectModel(name: string, e: ModelEntry, byId: Map<string, Provider>)
155
180
  model: s.model,
156
181
  // Default thinking level for this slot (undefined = pure passthrough).
157
182
  thinking: s.thinking,
183
+ // Default sampling parameters for this slot (undefined = pure passthrough).
184
+ sampling: s.sampling,
158
185
  })),
159
186
  });
160
187
  return {
@@ -514,7 +541,7 @@ export function adminApi(store: Store, auth: MiddlewareHandler, openai: Hono, an
514
541
  if (!raw) continue;
515
542
  const chain: ChainSlot[] = [];
516
543
  for (const s of Array.isArray(raw.slots) ? raw.slots : []) {
517
- const slot = s as { id?: unknown; model?: unknown; thinking?: unknown };
544
+ const slot = s as { id?: unknown; model?: unknown; thinking?: unknown; sampling?: unknown };
518
545
  const pid = typeof slot?.id === "string" ? slot.id : "";
519
546
  const p = cfg.providers.find((x) => x.id === pid);
520
547
  if (!p) return c.json({ error: { message: `provider not found: ${pid || "(empty)"}` } }, 400);
@@ -526,9 +553,12 @@ export function adminApi(store: Store, auth: MiddlewareHandler, openai: Hono, an
526
553
  return c.json({ error: { message: "anthropic thinking default must be a positive integer (thinking budget tokens, e.g. 8192)" } }, 400);
527
554
  if (thinkRaw && key !== "anthropic" && thinkRaw.length > 32)
528
555
  return c.json({ error: { message: "thinking default too long (max 32 chars)" } }, 400);
556
+ const sampling = sanitizeSampling(slot.sampling);
557
+ if (sampling === null) return c.json({ error: { message: SAMPLING_ERR } }, 400);
529
558
  const cs: ChainSlot = { id: pid };
530
559
  if (model) cs.model = model;
531
560
  if (thinkRaw) cs.thinking = key === "anthropic" ? String(Number(thinkRaw)) : thinkRaw;
561
+ if (sampling) cs.sampling = sampling;
532
562
  chain.push(cs);
533
563
  }
534
564
  parsed[key] = { enabled: raw.enabled !== false, providers: chain };
@@ -827,6 +857,45 @@ app.delete("/models/:name/debug/fails", (c) => {
827
857
  return c.json({ ok: true, thinking: value || undefined });
828
858
  });
829
859
 
860
+ // Set (or clear) the default sampling parameters for ONE chain slot (addressed
861
+ // by `index`, like /thinking above). The value is a JSON object of wire-agnostic
862
+ // sampling fields — temperature/top_p/top_k/presence_penalty/frequency_penalty/
863
+ // seed, each a finite number (seed an integer). On dispatch each configured
864
+ // field REPLACES the request's own same-named parameter; unconfigured fields
865
+ // pass through. An absent or empty object clears it (pure passthrough).
866
+ app.put("/models/:name/sampling", async (c) => {
867
+ const name = c.req.param("name");
868
+ const body = await readJson<{ format?: RouteKey; index?: number; sampling?: unknown }>(c.req.raw);
869
+ if (!body?.format) return c.json({ error: { message: "format is required" } }, 400);
870
+ if (!Number.isInteger(body?.index) || (body?.index ?? -1) < 0)
871
+ return c.json({ error: { message: "index (non-negative integer) is required" } }, 400);
872
+ const value = sanitizeSampling(body.sampling);
873
+ if (value === null) return c.json({ error: { message: SAMPLING_ERR } }, 400);
874
+ const format = body.format;
875
+ const index = body.index!;
876
+ let errStatus = 0;
877
+ let errMsg = "";
878
+ await store.update((d) => {
879
+ const entry = d.models[name];
880
+ if (!entry) {
881
+ errStatus = 404;
882
+ errMsg = "model not found";
883
+ return;
884
+ }
885
+ const fe = entry[format];
886
+ if (index >= fe.providers.length) {
887
+ errStatus = 400;
888
+ errMsg = "index out of range for this model's chain";
889
+ return;
890
+ }
891
+ if (value) fe.providers[index].sampling = value;
892
+ else delete fe.providers[index].sampling;
893
+ });
894
+ if (errStatus === 404) return c.json({ error: { message: errMsg } }, 404);
895
+ if (errStatus === 400) return c.json({ error: { message: errMsg } }, 400);
896
+ return c.json({ ok: true, sampling: value ?? undefined });
897
+ });
898
+
830
899
  app.post("/models/:name/disable", async (c) => {
831
900
  const name = c.req.param("name");
832
901
  const body = await readJson<{ format?: RouteKey }>(c.req.raw);
@@ -38,6 +38,10 @@ export function extractSecret(c: Context): { password: string; username?: string
38
38
  /** Hono middleware: require the single account/password. */
39
39
  export function authMiddleware(getUser: () => string, getPass: () => string, logger?: Logger): MiddlewareHandler {
40
40
  return async (c, next) => {
41
+ // Dev convenience: `npm run dev` sets NODE_ENV=development, and iterating on
42
+ // the UI/CLI against scratch data dirs shouldn't fight a fresh random
43
+ // password on every run. Production (`serve`) never sees this.
44
+ if (process.env.NODE_ENV === "development") return next();
41
45
  const cred = extractSecret(c);
42
46
  const ok =
43
47
  !!cred &&
@@ -97,14 +97,15 @@ function parseResetFromBody(text: string): number | undefined {
97
97
  }
98
98
 
99
99
  /** One resolved routing slot: the provider to forward to plus THIS slot's
100
- * optional upstream model name (absent = send the public model name) and
101
- * default thinking level. The same provider may occupy several slots in a
102
- * chain — each is an independent failover slot carrying its own upstream
103
- * model. */
100
+ * optional upstream model name (absent = send the public model name), default
101
+ * thinking level and default sampling parameters. The same provider may occupy
102
+ * several slots in a chain — each is an independent failover slot carrying its
103
+ * own overrides. */
104
104
  interface CandidateSlot {
105
105
  provider: Provider;
106
106
  model?: string;
107
107
  thinking?: string;
108
+ sampling?: Record<string, unknown>;
108
109
  }
109
110
 
110
111
  /** Resolve the ordered, compatible provider slots for a model on a routing slot. */
@@ -120,7 +121,7 @@ function candidates(store: Store, model: string, key: RouteKey): CandidateSlot[]
120
121
  return fe.providers
121
122
  .map((s): CandidateSlot | null => {
122
123
  const p = byId.get(s.id);
123
- return p ? { provider: p, model: s.model, thinking: s.thinking } : null;
124
+ return p ? { provider: p, model: s.model, thinking: s.thinking, sampling: s.sampling } : null;
124
125
  })
125
126
  .filter((slot): slot is CandidateSlot => {
126
127
  if (!slot) return false;
@@ -244,6 +245,54 @@ function applySlotThinking(
244
245
  return { value: def, from: "default" };
245
246
  }
246
247
 
248
+ // --- per-slot default sampling parameters -------------------------------------
249
+ // Like the thinking level, each routing slot can carry default sampling
250
+ // parameters — but the override semantics differ where the wires differ.
251
+ // Thinking owns EVERY thinking switch (a forced level alongside a stale client
252
+ // switch would contradict itself), while sampling overrides ONLY the fields it
253
+ // configures: temperature=0.2 with the client's own top_p intact is coherent.
254
+ // A configured field replaces the request's value; an unconfigured field — and
255
+ // everything on a slot without defaults — passes through, restored verbatim
256
+ // when an earlier failover slot overrode it. The names are identical on all
257
+ // three wires (openai chat/completions, /responses, anthropic messages), so
258
+ // there is no per-format dialect and no translation.
259
+
260
+ /** The body fields a slot's sampling default may set — the wire-agnostic names,
261
+ * the same set on every route. */
262
+ const SAMPLING_FIELDS = ["temperature", "top_p", "top_k", "presence_penalty", "frequency_penalty", "seed"] as const;
263
+
264
+ /** Apply THIS slot's sampling defaults to the body (mutating it), and return
265
+ * the log row's `sampling` field: the fields the gateway injected. Undefined =
266
+ * nothing injected (no default configured, or none of the fields set). A slot
267
+ * WITH defaults overrides exactly those fields; all others restore the
268
+ * request's originals — same recompute-per-attempt discipline as the model
269
+ * rewrite and the thinking level, so slot A's values never leak into slot B. */
270
+ function applySlotSampling(
271
+ body: Record<string, unknown>,
272
+ def: Record<string, unknown> | undefined,
273
+ orig: Record<string, unknown>,
274
+ ): Record<string, unknown> | undefined {
275
+ if (!def) {
276
+ for (const f of SAMPLING_FIELDS) {
277
+ if (orig[f] === undefined) delete body[f];
278
+ else body[f] = orig[f];
279
+ }
280
+ return undefined;
281
+ }
282
+ const applied: Record<string, unknown> = {};
283
+ for (const f of SAMPLING_FIELDS) {
284
+ const v = def[f];
285
+ if (v === undefined) {
286
+ if (orig[f] === undefined) delete body[f];
287
+ else body[f] = orig[f];
288
+ } else {
289
+ body[f] = v;
290
+ applied[f] = v;
291
+ }
292
+ }
293
+ return Object.keys(applied).length ? applied : undefined;
294
+ }
295
+
247
296
  /** Auth headers for the Anthropic wire format. Sends BOTH x-api-key and
248
297
  * Authorization: Bearer (same key). Native Anthropic (api.anthropic.com)
249
298
  * accepts either; anthropic- COMPATIBLE surfaces (sensenova, Volcengine Ark,
@@ -502,6 +551,10 @@ export function proxyApi(
502
551
  fields: Object.fromEntries(thinkingFields(key).map((f) => [f, body[f]])),
503
552
  maxTokens: body.max_tokens,
504
553
  };
554
+ // The request's own sampling parameters, snapshotted alongside the thinking
555
+ // switches (same reason: each attempt mutates the shared body, and a slot
556
+ // without defaults must restore these originals).
557
+ const samplingOrig: Record<string, unknown> = Object.fromEntries(SAMPLING_FIELDS.map((f) => [f, body[f]]));
505
558
  // The model-page "test" button drives dispatch via an in-process loopback
506
559
  // (adminApi calls v1.request). The probe is a real call in every respect —
507
560
  // including being logged — so we only tag it to report WHICH provider
@@ -545,6 +598,9 @@ export function proxyApi(
545
598
  // Thinking level of the most recent attempt (for the all-failed row after
546
599
  // the rounds loop; per-attempt rows capture the loop's own `think` const).
547
600
  let lastThink: { value: string; from: "client" | "default" } | undefined;
601
+ // Sampling fields the most recent attempt injected (for the all-failed row
602
+ // after the rounds loop; per-attempt rows capture the loop's own `sampling`).
603
+ let lastSampling: Record<string, unknown> | undefined;
548
604
  // Runtime log (console + server.log). Errors and notable events only — the
549
605
  // per-call history these lines summarize goes to pushLog/logs.jsonl anyway.
550
606
  // UI-triggered probes are excluded: their outcome is shown inline already.
@@ -640,6 +696,11 @@ export function proxyApi(
640
696
  // applySlotThinking), so slot A's level never leaks into slot B.
641
697
  const think = applySlotThinking(body, key, slot.thinking, thinkOrig);
642
698
  lastThink = think;
699
+ // Per-slot sampling defaults: same recompute-per-attempt discipline as
700
+ // the model rewrite and the thinking level (see applySlotSampling). A
701
+ // configured field overrides the request's own; the rest pass through.
702
+ const sampling = applySlotSampling(body, slot.sampling, samplingOrig);
703
+ lastSampling = sampling;
643
704
  // Count this attempt toward the source's RPM window — but not for a pinned
644
705
  // probe, which (like circuit state) takes no routing side-effects.
645
706
  if (pinIndex == null) store.recordDispatch(provider.id);
@@ -668,6 +729,7 @@ export function proxyApi(
668
729
  request: reqText,
669
730
  ...(upstreamModel ? { upstreamModel } : {}),
670
731
  ...(think ? { thinking: think } : {}),
732
+ ...(sampling ? { sampling } : {}),
671
733
  ...(response ? { response } : {}),
672
734
  ...(truncated ? { truncated: true } : {}),
673
735
  ...(error ? { error } : {}),
@@ -690,7 +752,7 @@ export function proxyApi(
690
752
  sayFailover(provider, "network error");
691
753
  const r = store.recordCircuitFailure(provider.id, lastStatus, lastErr);
692
754
  if (r.entered) {
693
- 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 });
755
+ store.pushLog({ ts: Date.now(), model, upstreamModel, provider: provider.name, providerId: provider.id, format: wire, status: lastStatus, ms: Date.now() - start, stream, thinking: think, sampling, kind: "cooldown", cooldownMs: r.cooldownMs, fails: r.fails, error: lastErr });
694
756
  sayCooldown(provider, r);
695
757
  }
696
758
  continue;
@@ -728,14 +790,14 @@ export function proxyApi(
728
790
  onSettle: (info) => {
729
791
  if (info.ok) {
730
792
  store.recordCircuitSuccess(provider.id);
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 });
793
+ store.pushLog({ ts: Date.now(), model, upstreamModel, provider: provider.name, providerId: provider.id, format: wire, status: 200, ms: ttfb, stream, thinking: think, sampling, usage: info.usage });
732
794
  capture(200, capAcc.text, capAcc.truncated);
733
795
  } else {
734
796
  // A pinned per-source probe takes no circuit side-effects (a manual
735
797
  // test must not trip the breaker) — mirrors the retryable branch.
736
798
  if (pinIndex == null) store.recordCircuitFailure(provider.id, info.status, info.error || "stream failed");
737
799
  if (!isProbe) rt.warn(`proxy stream failed: provider '${provider.name}' status=${info.status} (${info.error || "stream failed"})`);
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 });
800
+ store.pushLog({ ts: Date.now(), model, upstreamModel, provider: provider.name, providerId: provider.id, format: wire, status: info.status, ms: ttfb, stream, thinking: think, sampling, error: info.error });
739
801
  capture(info.status, capAcc.text, capAcc.truncated, info.error);
740
802
  }
741
803
  },
@@ -765,13 +827,13 @@ export function proxyApi(
765
827
  const resetInMs = retryAfterMs ? undefined : parseResetFromBody(txt);
766
828
  const r = store.recordCircuitFailure(provider.id, lastStatus, lastErr, retryAfterMs ?? resetInMs, !!resetInMs);
767
829
  if (r.entered) {
768
- 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 });
830
+ store.pushLog({ ts: Date.now(), model, upstreamModel, provider: provider.name, providerId: provider.id, format: wire, status: lastStatus, ms: Date.now() - start, stream, thinking: think, sampling, kind: "cooldown", cooldownMs: r.cooldownMs, fails: r.fails, error: lastErr });
769
831
  sayCooldown(provider, r);
770
832
  }
771
833
  continue;
772
834
  }
773
835
  // 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 });
836
+ 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, sampling, error: lastErr });
775
837
  return new Response(txt, { status: upstream.status, headers: downHeaders(upstream, isProbe ? provider.name : undefined) });
776
838
  }
777
839
 
@@ -784,7 +846,7 @@ export function proxyApi(
784
846
  return new Response(null, { status: 499 });
785
847
  }
786
848
  if (!isProbe) rt.error(`proxy all providers failed model=${model} (last status ${lastStatus})`);
787
- 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})` });
849
+ 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, sampling: lastSampling, error: lastErr || `all providers failed (last status ${lastStatus})` });
788
850
  // A pinned (per-source) probe failed: surface the REAL upstream status the
789
851
  // one slot returned (429/500/…), not a collapsed 502, and tag it with
790
852
  // x-myapikey-provider so the source-row badge names the tested source.
@@ -81,6 +81,15 @@ export interface ChainSlot {
81
81
  * ("low"/"medium"/"high"/…) on openai/responses slots, a thinking budget
82
82
  * in tokens (positive integer) on anthropic slots. */
83
83
  thinking?: string;
84
+ /** Default sampling parameters for this slot. Unlike thinking (which owns
85
+ * EVERY thinking switch, since a forced level plus a stale client switch
86
+ * would contradict), each configured field here replaces ONLY the request's
87
+ * same-named value — an unconfigured field passes through untouched.
88
+ * temperature=0.2 with the client's own top_p intact is coherent, so the
89
+ * override is per-field. Keys are the wire-agnostic names (temperature,
90
+ * top_p, top_k, presence_penalty, frequency_penalty, seed) — identical on
91
+ * all three wires, so no per-format dialect. Absent = pure passthrough. */
92
+ sampling?: Record<string, unknown>;
84
93
  }
85
94
 
86
95
  /** A model's routing dimensions — one per forwarding endpoint. /chat/completions
@@ -180,6 +189,12 @@ export interface LogEntry {
180
189
  * ran, OVERRIDING whatever the request carried. Absent when neither
181
190
  * applied (no default configured, request carried nothing). */
182
191
  thinking?: { value: string; from: "client" | "default" };
192
+ /** The sampling parameters the gateway injected on this call (the answering
193
+ * slot's configured `sampling` defaults). Absent when the slot had none —
194
+ * the request's own parameters then ran untouched. (Deliberately no
195
+ * "client" variant: logging every passthrough call's sampling values would
196
+ * be noise; the debug capture holds the exact forwarded body anyway.) */
197
+ sampling?: Record<string, unknown>;
183
198
  /** Row kind. Absent on legacy lines → treated as a normal call. "cooldown"
184
199
  * marks a circuit-breaker event (a provider just entered cooldown), shown
185
200
  * distinctly in the timeline alongside the failures that caused it. */
@@ -194,7 +209,7 @@ export interface LogEntry {
194
209
  * persisted to logs.jsonl or data.json; see ModelEntry.debugCapture). One
195
210
  * client call that fails over produces several entries, one per attempt,
196
211
  * because each attempt's forwarded body can differ (per-slot model rewrite +
197
- * thinking injection). Bodies are captured VERBATIM: `request` is the exact
212
+ * thinking/sampling injection). Bodies are captured VERBATIM: `request` is the exact
198
213
  * JSON string sent upstream, `response` the upstream body as it flowed (raw
199
214
  * SSE text for streams). Headers are never captured — provider api keys stay
200
215
  * out of the buffer. */
@@ -223,4 +238,6 @@ export interface DebugCapture {
223
238
  error?: string;
224
239
  /** The thinking level this attempt ran with (same shape as LogEntry.thinking). */
225
240
  thinking?: { value: string; from: "client" | "default" };
241
+ /** The sampling parameters injected this attempt (same shape as LogEntry.sampling). */
242
+ sampling?: Record<string, unknown>;
226
243
  }