myapikey 0.40.2 → 0.41.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 +0 -1
- package/README.zh-CN.md +0 -1
- package/package.json +2 -4
- package/packages/core/src/cli/index.ts +23 -1
- package/packages/core/src/server/admin.ts +70 -1
- package/packages/core/src/server/proxy.ts +73 -11
- package/packages/core/src/shared/types.ts +18 -1
- package/packages/web/dist/assets/index-BWSp04Yf.js +330 -0
- package/packages/web/dist/assets/index-DEjSDfVg.css +1 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/assets/index-Bm_0FlSQ.js +0 -340
- package/packages/web/dist/assets/index-CB1yMjZX.css +0 -1
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "myapikey",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.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": [
|
|
@@ -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);
|
|
@@ -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)
|
|
101
|
-
* default
|
|
102
|
-
* chain — each is an independent failover slot carrying its
|
|
103
|
-
*
|
|
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
|
}
|