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 +1 -1
- package/packages/core/src/cli/index.ts +15 -1
- package/packages/core/src/server/admin.ts +48 -0
- package/packages/core/src/server/proxy.ts +248 -125
- package/packages/core/src/server/store.ts +22 -0
- package/packages/core/src/shared/types.ts +13 -0
- package/packages/web/dist/assets/index-L2OOJ8_e.css +1 -0
- package/packages/web/dist/assets/index-VlfUQRHs.js +298 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/assets/index-88CUfFdq.css +0 -1
- package/packages/web/dist/assets/index-DhA5duaA.js +0 -293
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "myapikey",
|
|
3
|
-
"version": "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) =>
|
|
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)
|
|
66
|
-
* same provider may occupy several slots in a
|
|
67
|
-
* failover slot carrying its own upstream
|
|
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
|
-
//
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
//
|
|
530
|
-
const
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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. */
|