@nvae/llmswitch 0.6.0 → 0.8.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.
@@ -0,0 +1,45 @@
1
+ /**
2
+ * In-process failure breaker per provider.
3
+ *
4
+ * Without it, a dead primary upstream absorbs one failed attempt on every
5
+ * request before fallback kicks in. After repeated failures the provider is put
6
+ * on an exponentially growing cooldown (capped); during cooldown routing skips
7
+ * it straight to the next candidate. If every candidate is cooling down the
8
+ * gateway tries them anyway — a delayed answer beats a hard failure.
9
+ *
10
+ * State is owned by the running server instance: process-local by design, so a
11
+ * restart clears it and tests never leak between servers.
12
+ */
13
+ const COOLDOWN_BASE_MS = 5_000;
14
+ const COOLDOWN_MAX_MS = 120_000;
15
+ export class ProviderBreaker {
16
+ states = new Map();
17
+ allows(provider, now = Date.now()) {
18
+ const state = this.states.get(provider);
19
+ if (!state)
20
+ return true;
21
+ return now >= state.openUntil;
22
+ }
23
+ failure(provider, message, now = Date.now()) {
24
+ const state = this.states.get(provider) ??
25
+ { consecutiveFailures: 0, openUntil: 0, lastError: "" };
26
+ state.consecutiveFailures += 1;
27
+ state.lastError = message.slice(0, 300);
28
+ const backoff = Math.min(COOLDOWN_BASE_MS * 2 ** (state.consecutiveFailures - 1), COOLDOWN_MAX_MS);
29
+ state.openUntil = now + backoff;
30
+ this.states.set(provider, state);
31
+ }
32
+ success(provider) {
33
+ this.states.delete(provider);
34
+ }
35
+ snapshot(now = Date.now()) {
36
+ return [...this.states.entries()]
37
+ .map(([provider, state]) => ({
38
+ provider,
39
+ consecutiveFailures: state.consecutiveFailures,
40
+ coolingMsRemaining: Math.max(0, state.openUntil - now),
41
+ lastError: state.lastError,
42
+ }))
43
+ .sort((a, b) => b.coolingMsRemaining - a.coolingMsRemaining);
44
+ }
45
+ }
@@ -0,0 +1,433 @@
1
+ /**
2
+ * Gateway API key issuance and verification.
3
+ *
4
+ * Keys are high-entropy random secrets, so a single salted SHA-256 is enough at
5
+ * rest: there is no low-entropy password to grind. A slow KDF would only add
6
+ * per-request latency on the hot auth path. The plaintext is returned once at
7
+ * creation and never persisted.
8
+ *
9
+ * Plaintext layout: `llmsk-<keyId>-<secret>`. The embedded id makes lookup O(1)
10
+ * so verification hashes exactly one candidate.
11
+ */
12
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
13
+ import { chmodSync, existsSync, readFileSync } from "node:fs";
14
+ import { atomicWriteFile, ensureDir } from "../utils/fs.js";
15
+ import { getGatewayDir, getGatewayKeysPath } from "../utils/paths.js";
16
+ import { checkDailyQuota, checkRateLimit } from "./rate-limit.js";
17
+ import { isGatewayFormat, } from "./types.js";
18
+ const KEY_PREFIX = "llmsk";
19
+ const KEY_ID_BYTES = 6;
20
+ const KEY_SECRET_BYTES = 32;
21
+ const SALT_BYTES = 16;
22
+ /**
23
+ * Explicit "never rate-limit this key" marker. `0` keeps the legacy meaning of
24
+ * inheriting the global default so existing key files behave unchanged.
25
+ */
26
+ export const UNLIMITED_RATE_LIMIT = -1;
27
+ /** Minimum gap between lastUsedAt persists for one key. */
28
+ const TOUCH_INTERVAL_MS = 60_000;
29
+ const lastTouchedAt = new Map();
30
+ export class GatewayKeyError extends Error {
31
+ constructor(message) {
32
+ super(message);
33
+ this.name = "GatewayKeyError";
34
+ }
35
+ }
36
+ function asRecord(value) {
37
+ if (value && typeof value === "object" && !Array.isArray(value)) {
38
+ return value;
39
+ }
40
+ return null;
41
+ }
42
+ function stringList(value) {
43
+ if (!Array.isArray(value))
44
+ return [];
45
+ return Array.from(new Set(value
46
+ .filter((item) => typeof item === "string")
47
+ .map((item) => item.trim())
48
+ .filter(Boolean)));
49
+ }
50
+ function hashSecret(secret, salt) {
51
+ return createHash("sha256").update(`${salt}:${secret}`, "utf8").digest("hex");
52
+ }
53
+ function constantTimeEqual(a, b) {
54
+ const bufA = Buffer.from(a, "utf8");
55
+ const bufB = Buffer.from(b, "utf8");
56
+ if (bufA.length !== bufB.length)
57
+ return false;
58
+ return timingSafeEqual(bufA, bufB);
59
+ }
60
+ function normalizeFormats(value) {
61
+ const list = stringList(value);
62
+ if (!list.length)
63
+ return ["*"];
64
+ if (list.includes("*"))
65
+ return ["*"];
66
+ const out = list.filter((item) => isGatewayFormat(item));
67
+ return out.length ? out : ["*"];
68
+ }
69
+ /**
70
+ * Strict format validation for newly issued keys: an invalid value must fail
71
+ * loudly instead of silently widening the scope to every format.
72
+ */
73
+ function assertValidFormats(value) {
74
+ const list = stringList(value);
75
+ for (const item of list) {
76
+ if (item !== "*" && !isGatewayFormat(item)) {
77
+ throw new GatewayKeyError(`无效的接口格式「${item}」。可用:openai-chat, anthropic, openai-responses, *`);
78
+ }
79
+ }
80
+ }
81
+ /** Non-negative integer or 0. */
82
+ function nonNegative(value) {
83
+ return typeof value === "number" && Number.isFinite(value) && value > 0
84
+ ? Math.floor(value)
85
+ : 0;
86
+ }
87
+ /** Per-key rate limit: >= 1 is a cap, 0 inherits the global default, -1 is unlimited. */
88
+ function normalizeRateLimit(value) {
89
+ if (value === UNLIMITED_RATE_LIMIT)
90
+ return UNLIMITED_RATE_LIMIT;
91
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
92
+ return Math.floor(value);
93
+ }
94
+ return 0;
95
+ }
96
+ function normalizeKey(raw) {
97
+ const row = asRecord(raw);
98
+ if (!row)
99
+ return null;
100
+ const id = String(row.id || "").trim();
101
+ const hash = String(row.hash || "").trim();
102
+ const salt = String(row.salt || "").trim();
103
+ if (!id || !hash || !salt)
104
+ return null;
105
+ return {
106
+ id,
107
+ name: typeof row.name === "string" && row.name ? row.name : id,
108
+ hash,
109
+ salt,
110
+ hint: typeof row.hint === "string" ? row.hint : "",
111
+ createdAt: typeof row.createdAt === "string"
112
+ ? row.createdAt
113
+ : new Date(0).toISOString(),
114
+ expiresAt: typeof row.expiresAt === "string" ? row.expiresAt : null,
115
+ revokedAt: typeof row.revokedAt === "string" ? row.revokedAt : null,
116
+ providers: stringList(row.providers),
117
+ models: stringList(row.models),
118
+ formats: normalizeFormats(row.formats),
119
+ rateLimitPerMinute: normalizeRateLimit(row.rateLimitPerMinute),
120
+ requestsPerDay: nonNegative(row.requestsPerDay),
121
+ lastUsedAt: typeof row.lastUsedAt === "string" ? row.lastUsedAt : null,
122
+ };
123
+ }
124
+ export function listGatewayKeys() {
125
+ const path = getGatewayKeysPath();
126
+ if (!existsSync(path))
127
+ return [];
128
+ try {
129
+ const raw = JSON.parse(readFileSync(path, "utf8"));
130
+ const rows = Array.isArray(raw)
131
+ ? raw
132
+ : Array.isArray(asRecord(raw)?.keys)
133
+ ? asRecord(raw).keys
134
+ : [];
135
+ return rows
136
+ .map(normalizeKey)
137
+ .filter((key) => key !== null);
138
+ }
139
+ catch {
140
+ return [];
141
+ }
142
+ }
143
+ function writeGatewayKeys(keys) {
144
+ ensureDir(getGatewayDir());
145
+ try {
146
+ chmodSync(getGatewayDir(), 0o700);
147
+ }
148
+ catch {
149
+ // Windows relies on user-directory ACLs.
150
+ }
151
+ atomicWriteFile(getGatewayKeysPath(), JSON.stringify({ version: 1, keys }, null, 2) + "\n");
152
+ }
153
+ export function createGatewayKey(options = {}) {
154
+ assertValidFormats(options.formats);
155
+ const id = randomBytes(KEY_ID_BYTES).toString("hex");
156
+ const secret = randomBytes(KEY_SECRET_BYTES).toString("base64url");
157
+ const salt = randomBytes(SALT_BYTES).toString("hex");
158
+ const plaintext = `${KEY_PREFIX}-${id}-${secret}`;
159
+ const days = options.expiresInDays ?? 0;
160
+ if (days < 0 || !Number.isFinite(days)) {
161
+ throw new GatewayKeyError("有效期天数必须是非负数");
162
+ }
163
+ const expiresAt = days > 0
164
+ ? new Date(Date.now() + days * 86_400_000).toISOString()
165
+ : null;
166
+ const key = {
167
+ id,
168
+ name: options.name?.trim() || `key-${id.slice(0, 4)}`,
169
+ hash: hashSecret(secret, salt),
170
+ salt,
171
+ hint: `${KEY_PREFIX}-${id}-${secret.slice(0, 4)}…${secret.slice(-4)}`,
172
+ createdAt: new Date().toISOString(),
173
+ expiresAt,
174
+ revokedAt: null,
175
+ providers: stringList(options.providers),
176
+ models: stringList(options.models),
177
+ formats: normalizeFormats(options.formats),
178
+ rateLimitPerMinute: normalizeRateLimit(options.rateLimitPerMinute),
179
+ requestsPerDay: nonNegative(options.requestsPerDay),
180
+ lastUsedAt: null,
181
+ };
182
+ writeGatewayKeys([...listGatewayKeys(), key]);
183
+ return { key, plaintext };
184
+ }
185
+ export function revokeGatewayKey(idOrName) {
186
+ const keys = listGatewayKeys();
187
+ const target = findKeyByIdOrName(keys, idOrName);
188
+ if (!target)
189
+ throw new GatewayKeyError(`未找到 API Key「${idOrName}」`);
190
+ if (target.revokedAt)
191
+ return target;
192
+ const revoked = {
193
+ ...target,
194
+ revokedAt: new Date().toISOString(),
195
+ };
196
+ writeGatewayKeys(keys.map((key) => (key.id === target.id ? revoked : key)));
197
+ return revoked;
198
+ }
199
+ export function deleteGatewayKey(idOrName) {
200
+ const keys = listGatewayKeys();
201
+ const target = findKeyByIdOrName(keys, idOrName);
202
+ if (!target)
203
+ throw new GatewayKeyError(`未找到 API Key「${idOrName}」`);
204
+ writeGatewayKeys(keys.filter((key) => key.id !== target.id));
205
+ return target;
206
+ }
207
+ export function updateGatewayKey(idOrName, patch) {
208
+ assertValidFormats(patch.formats ?? []);
209
+ const days = patch.expiresInDays;
210
+ if (days !== undefined && (days < 0 || !Number.isFinite(days))) {
211
+ throw new GatewayKeyError("有效期天数必须是非负数");
212
+ }
213
+ const keys = listGatewayKeys();
214
+ const target = findKeyByIdOrName(keys, idOrName);
215
+ if (!target)
216
+ throw new GatewayKeyError(`未找到 API Key「${idOrName}」`);
217
+ const next = {
218
+ ...target,
219
+ name: patch.name?.trim() || target.name,
220
+ ...(patch.providers ? { providers: stringList(patch.providers) } : {}),
221
+ ...(patch.models ? { models: stringList(patch.models) } : {}),
222
+ ...(patch.formats ? { formats: normalizeFormats(patch.formats) } : {}),
223
+ ...(patch.rateLimitPerMinute !== undefined
224
+ ? { rateLimitPerMinute: normalizeRateLimit(patch.rateLimitPerMinute) }
225
+ : {}),
226
+ ...(patch.requestsPerDay !== undefined
227
+ ? { requestsPerDay: nonNegative(patch.requestsPerDay) }
228
+ : {}),
229
+ ...(days !== undefined
230
+ ? {
231
+ expiresAt: days > 0
232
+ ? new Date(Date.now() + days * 86_400_000).toISOString()
233
+ : null,
234
+ }
235
+ : {}),
236
+ };
237
+ writeGatewayKeys(keys.map((key) => (key.id === target.id ? next : key)));
238
+ return next;
239
+ }
240
+ /**
241
+ * Re-issue a key with a fresh id and secret while keeping its scopes. The old
242
+ * plaintext stops working immediately; the new one is shown once.
243
+ */
244
+ export function rotateGatewayKey(idOrName) {
245
+ const keys = listGatewayKeys();
246
+ const target = findKeyByIdOrName(keys, idOrName);
247
+ if (!target)
248
+ throw new GatewayKeyError(`未找到 API Key「${idOrName}」`);
249
+ const id = randomBytes(KEY_ID_BYTES).toString("hex");
250
+ const secret = randomBytes(KEY_SECRET_BYTES).toString("base64url");
251
+ const salt = randomBytes(SALT_BYTES).toString("hex");
252
+ const rotated = {
253
+ ...target,
254
+ id,
255
+ hash: hashSecret(secret, salt),
256
+ salt,
257
+ hint: `${KEY_PREFIX}-${id}-${secret.slice(0, 4)}…${secret.slice(-4)}`,
258
+ createdAt: new Date().toISOString(),
259
+ lastUsedAt: null,
260
+ };
261
+ writeGatewayKeys(keys.map((key) => (key.id === target.id ? rotated : key)));
262
+ return {
263
+ key: rotated,
264
+ plaintext: `${KEY_PREFIX}-${id}-${secret}`,
265
+ };
266
+ }
267
+ function findKeyByIdOrName(keys, idOrName) {
268
+ const query = idOrName.trim();
269
+ if (!query)
270
+ return undefined;
271
+ return (keys.find((key) => key.id === query) ||
272
+ keys.find((key) => key.name === query) ||
273
+ keys.find((key) => key.id.startsWith(query)));
274
+ }
275
+ export function publicKeyView(key) {
276
+ return {
277
+ id: key.id,
278
+ name: key.name,
279
+ hint: key.hint,
280
+ createdAt: key.createdAt,
281
+ expiresAt: key.expiresAt,
282
+ revokedAt: key.revokedAt,
283
+ status: keyStatus(key),
284
+ providers: key.providers,
285
+ models: key.models,
286
+ formats: key.formats,
287
+ rateLimitPerMinute: key.rateLimitPerMinute,
288
+ requestsPerDay: key.requestsPerDay,
289
+ lastUsedAt: key.lastUsedAt,
290
+ };
291
+ }
292
+ export function keyStatus(key) {
293
+ if (key.revokedAt)
294
+ return "revoked";
295
+ if (key.expiresAt && Date.parse(key.expiresAt) <= Date.now()) {
296
+ return "expired";
297
+ }
298
+ return "active";
299
+ }
300
+ export function hasAnyActiveKey() {
301
+ return listGatewayKeys().some((key) => keyStatus(key) === "active");
302
+ }
303
+ function parsePlaintext(plaintext) {
304
+ const parts = plaintext.trim().split("-");
305
+ if (parts.length < 3)
306
+ return null;
307
+ if (parts[0] !== KEY_PREFIX)
308
+ return null;
309
+ const id = parts[1] ?? "";
310
+ const secret = parts.slice(2).join("-");
311
+ if (!id || !secret)
312
+ return null;
313
+ return { id, secret };
314
+ }
315
+ /** In-memory fixed-window counters were replaced by a cross-process store. */
316
+ export { checkDailyQuota, checkRateLimit, peekDailyQuota, peekRateLimit, resetRateLimits, } from "./rate-limit.js";
317
+ /** -1 = unlimited, > 0 = per-key cap, otherwise fall back to the global default. */
318
+ export function resolveKeyRateLimit(keyLimit, defaultLimit) {
319
+ if (keyLimit === UNLIMITED_RATE_LIMIT)
320
+ return 0;
321
+ if (keyLimit > 0)
322
+ return keyLimit;
323
+ return defaultLimit > 0 ? defaultLimit : 0;
324
+ }
325
+ export function authenticateGatewayKey(presented, options = {}) {
326
+ if (!presented || !presented.trim())
327
+ return { ok: false, reason: "missing" };
328
+ const parsed = parsePlaintext(presented);
329
+ if (!parsed)
330
+ return { ok: false, reason: "malformed" };
331
+ const keys = options.keys ?? listGatewayKeys();
332
+ const candidate = keys.find((key) => key.id === parsed.id);
333
+ if (!candidate)
334
+ return { ok: false, reason: "unknown" };
335
+ if (!constantTimeEqual(candidate.hash, hashSecret(parsed.secret, candidate.salt))) {
336
+ return { ok: false, reason: "unknown" };
337
+ }
338
+ const now = options.now ?? Date.now();
339
+ if (candidate.revokedAt)
340
+ return { ok: false, reason: "revoked" };
341
+ if (candidate.expiresAt && Date.parse(candidate.expiresAt) <= now) {
342
+ return { ok: false, reason: "expired" };
343
+ }
344
+ if (options.format && !candidate.formats.includes("*")) {
345
+ if (!candidate.formats.includes(options.format)) {
346
+ return { ok: false, reason: "format_denied" };
347
+ }
348
+ }
349
+ const limit = resolveKeyRateLimit(candidate.rateLimitPerMinute, options.defaultRateLimitPerMinute ?? 0);
350
+ const rate = checkRateLimit(candidate.id, limit, now);
351
+ if (!rate.allowed) {
352
+ return {
353
+ ok: false,
354
+ reason: "rate_limited",
355
+ retryAfterSeconds: rate.retryAfterSeconds,
356
+ rate,
357
+ };
358
+ }
359
+ if (candidate.requestsPerDay > 0) {
360
+ const daily = checkDailyQuota(candidate.id, candidate.requestsPerDay, now);
361
+ if (!daily.allowed) {
362
+ return {
363
+ ok: false,
364
+ reason: "rate_limited",
365
+ retryAfterSeconds: daily.retryAfterSeconds,
366
+ rate: daily,
367
+ };
368
+ }
369
+ }
370
+ return { ok: true, key: candidate, rate };
371
+ }
372
+ /**
373
+ * Best-effort last-used bookkeeping. Debounced: a busy key would otherwise
374
+ * rewrite the whole key file on every single request.
375
+ */
376
+ export function touchGatewayKey(id, now = Date.now()) {
377
+ const last = lastTouchedAt.get(id) ?? 0;
378
+ if (now - last < TOUCH_INTERVAL_MS)
379
+ return;
380
+ lastTouchedAt.set(id, now);
381
+ try {
382
+ const keys = listGatewayKeys();
383
+ const next = keys.map((key) => key.id === id ? { ...key, lastUsedAt: new Date(now).toISOString() } : key);
384
+ writeGatewayKeys(next);
385
+ }
386
+ catch {
387
+ // Non-fatal.
388
+ }
389
+ }
390
+ /**
391
+ * Whether a key may use the resolved provider and model.
392
+ *
393
+ * `model` is the upstream id resolved by the router; `requestedModel` is the
394
+ * raw id the client sent (an alias or a qualified `provider/model` reference).
395
+ * A scope entry matches when it equals either id, so keys scoped to an alias
396
+ * keep working after the alias is remapped to a different upstream id.
397
+ */
398
+ export function keyAllowsTarget(key, providerName, model, requestedModel) {
399
+ if (key.providers.length && !key.providers.includes(providerName)) {
400
+ return false;
401
+ }
402
+ if (!key.models.length)
403
+ return true;
404
+ return modelScopeMatches(key.models, providerName, model, requestedModel);
405
+ }
406
+ function modelScopeMatches(scope, providerName, model, requestedModel) {
407
+ const provider = providerName.trim().toLowerCase();
408
+ // Every spelling that should count as "this model on this provider".
409
+ const wanted = new Set();
410
+ const add = (value) => {
411
+ const id = value?.trim().toLowerCase();
412
+ if (!id)
413
+ return;
414
+ wanted.add(id);
415
+ wanted.add(`${provider}/${id}`);
416
+ };
417
+ add(model);
418
+ add(requestedModel);
419
+ return scope.some((entry) => {
420
+ const id = entry.trim().toLowerCase();
421
+ if (!id)
422
+ return false;
423
+ if (wanted.has(id))
424
+ return true;
425
+ // A scoped entry may itself be qualified (`provider/model`); compare both
426
+ // halves so an entry for another provider never matches by accident.
427
+ const separator = id.indexOf("/");
428
+ if (separator <= 0)
429
+ return false;
430
+ return (id.slice(0, separator) === provider &&
431
+ wanted.has(id.slice(separator + 1)));
432
+ });
433
+ }