@nvae/llmswitch 0.7.0 → 0.9.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.
@@ -6,6 +6,7 @@ import { getPreset, presetsForTool } from "../presets/index.js";
6
6
  import { detectApiFormat } from "../utils/detect-format.js";
7
7
  import { assertValidProfileName, listProfiles, profileExists, saveProfile, } from "../store/profiles.js";
8
8
  import { fetchModelList, preferResolvedBaseUrl, } from "../utils/fetch-models.js";
9
+ import { collectModelMeta, fetchModelMetadata, lookupModelMeta, } from "../utils/model-metadata.js";
9
10
  import { normalizeBaseUrlForFormat } from "../utils/base-url.js";
10
11
  import { maskSecret } from "../utils/fs.js";
11
12
  export function isCancel(value) {
@@ -391,6 +392,7 @@ export async function promptProfileDraft(tool, partial = {}) {
391
392
  models: {
392
393
  default: defaultModel,
393
394
  list: Array.from(new Set(modelList)),
395
+ meta: resolved.modelMeta,
394
396
  },
395
397
  proxy,
396
398
  bridgeMode: tool === "codex" && apiFormat === "openai-chat"
@@ -475,37 +477,76 @@ export async function promptEditProfile(tool, current) {
475
477
  /**
476
478
  * Fetch models from the provider API (when possible), then let the user
477
479
  * pick a default + a saved list. Falls back to manual text entry.
480
+ * Model metadata (modalities/attachment) is fetched from models.lonae.com
481
+ * while the model list is loading and attached to the final selection.
478
482
  */
479
483
  export async function resolveModelsInteractive(input) {
484
+ // Best-effort: fetch metadata in the background so it overlaps with /models.
485
+ const metaPromise = tryFetchModelMetadata(input);
480
486
  if (input.fixedDefault && input.fixedList?.length) {
481
487
  const list = [...input.fixedList];
482
488
  if (!list.includes(input.fixedDefault))
483
489
  list.unshift(input.fixedDefault);
484
- return { defaultModel: input.fixedDefault, modelList: list };
490
+ return {
491
+ defaultModel: input.fixedDefault,
492
+ modelList: list,
493
+ modelMeta: collectModelMeta(await metaPromise, list),
494
+ };
485
495
  }
486
496
  if (input.fixedDefault && !input.fixedList) {
487
497
  const fetched = await tryFetchModels(input);
488
- if (fetched?.models.length) {
489
- const list = Array.from(new Set([input.fixedDefault, ...fetched.models]));
490
- return {
491
- defaultModel: input.fixedDefault,
492
- modelList: list,
493
- resolvedBaseUrl: fetched.resolvedBaseUrl,
494
- };
495
- }
498
+ const list = fetched?.models.length
499
+ ? Array.from(new Set([input.fixedDefault, ...fetched.models]))
500
+ : [input.fixedDefault];
496
501
  return {
497
502
  defaultModel: input.fixedDefault,
498
- modelList: [input.fixedDefault],
503
+ modelList: list,
504
+ resolvedBaseUrl: fetched?.resolvedBaseUrl,
505
+ modelMeta: collectModelMeta(await metaPromise, list),
499
506
  };
500
507
  }
508
+ const catalog = await metaPromise;
501
509
  const fetched = await tryFetchModels(input);
502
510
  if (fetched && fetched.models.length > 0) {
503
- const selected = await selectModelsFromFetched(fetched.models, input);
511
+ const selected = await selectModelsFromFetched(fetched.models, input, catalog);
504
512
  return { ...selected, resolvedBaseUrl: fetched.resolvedBaseUrl };
505
513
  }
506
- return manualModelsEntry(input);
514
+ return manualModelsEntry(input, catalog);
507
515
  }
508
- async function selectModelsFromFetched(fetched, input) {
516
+ /**
517
+ * Best-effort metadata fetch from models.lonae.com.
518
+ * Returns undefined (with a warning) when unreachable.
519
+ */
520
+ async function tryFetchModelMetadata(input) {
521
+ try {
522
+ const catalog = await fetchModelMetadata({ proxy: input.proxy });
523
+ p.log.info(`已从 models.lonae.com 获取 ${Object.keys(catalog.full).length} 个模型的元数据`);
524
+ return catalog;
525
+ }
526
+ catch (err) {
527
+ const msg = err instanceof Error ? err.message : String(err);
528
+ p.log.warn(`获取模型元数据失败(models.lonae.com):${msg}`);
529
+ return undefined;
530
+ }
531
+ }
532
+ /** Human-readable modality hint for a model, e.g. "text/image → text · 支持附件". */
533
+ function metadataHint(modelId, catalog) {
534
+ const meta = catalog ? lookupModelMeta(catalog, modelId) : undefined;
535
+ if (!meta)
536
+ return undefined;
537
+ const parts = [];
538
+ if (meta.modalities) {
539
+ parts.push(`${meta.modalities.input.join("/")} → ${meta.modalities.output.join("/")}`);
540
+ }
541
+ if (meta.attachment === true)
542
+ parts.push("支持附件");
543
+ return parts.length > 0 ? parts.join(" · ") : undefined;
544
+ }
545
+ function joinHints(...hints) {
546
+ const joined = hints.filter(Boolean).join(" · ");
547
+ return joined || undefined;
548
+ }
549
+ async function selectModelsFromFetched(fetched, input, catalog) {
509
550
  const preferredDefault = (input.preferredDefault && fetched.includes(input.preferredDefault)
510
551
  ? input.preferredDefault
511
552
  : undefined) ||
@@ -518,7 +559,7 @@ async function selectModelsFromFetched(fetched, input) {
518
559
  options: fetched.map((id) => ({
519
560
  value: id,
520
561
  label: id,
521
- hint: id === input.preferredDefault ? "当前" : undefined,
562
+ hint: joinHints(id === input.preferredDefault ? "当前" : undefined, metadataHint(id, catalog)),
522
563
  })),
523
564
  initialValue: preferredDefault,
524
565
  });
@@ -530,7 +571,7 @@ async function selectModelsFromFetched(fetched, input) {
530
571
  options: fetched.map((id) => ({
531
572
  value: id,
532
573
  label: id,
533
- hint: (input.preferredList || []).includes(id) ? "当前" : undefined,
574
+ hint: joinHints((input.preferredList || []).includes(id) ? "当前" : undefined, metadataHint(id, catalog)),
534
575
  })),
535
576
  initialValues: Array.from(new Set([defaultModel, ...preferredList, ...presetList])),
536
577
  required: true,
@@ -540,9 +581,9 @@ async function selectModelsFromFetched(fetched, input) {
540
581
  process.exit(0);
541
582
  }
542
583
  const modelList = Array.from(new Set([defaultModel, ...picked]));
543
- return { defaultModel, modelList };
584
+ return { defaultModel, modelList, modelMeta: collectModelMeta(catalog, modelList) };
544
585
  }
545
- async function manualModelsEntry(input) {
586
+ async function manualModelsEntry(input, catalog) {
546
587
  p.log.warn("未能自动获取模型列表,改为手动输入。");
547
588
  let defaultModel = input.fixedDefault || input.preferredDefault;
548
589
  if (!defaultModel) {
@@ -571,7 +612,11 @@ async function manualModelsEntry(input) {
571
612
  if (!modelList.includes(defaultModel)) {
572
613
  modelList = [defaultModel, ...modelList];
573
614
  }
574
- return { defaultModel, modelList };
615
+ return {
616
+ defaultModel,
617
+ modelList,
618
+ modelMeta: collectModelMeta(catalog, modelList),
619
+ };
575
620
  }
576
621
  export async function tryFetchModels(input) {
577
622
  const spin = p.spinner();
@@ -376,6 +376,7 @@ async function configureProfileModels(tool, profile) {
376
376
  });
377
377
  profile.models.default = resolved.defaultModel;
378
378
  profile.models.list = resolved.modelList;
379
+ profile.models.meta = resolved.modelMeta;
379
380
  if (resolved.resolvedBaseUrl &&
380
381
  resolved.resolvedBaseUrl !== profile.baseUrl) {
381
382
  p.log.info(`已根据可用接口将 Base URL 规范为 ${resolved.resolvedBaseUrl}(原:${profile.baseUrl})`);
@@ -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
+ }