@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,285 @@
1
+ /**
2
+ * Cross-process rate limiting.
3
+ *
4
+ * Counters live in a small JSON file guarded by an exclusive lock, so limits
5
+ * survive a daemon restart and hold across several gateway processes sharing one
6
+ * config directory (the common case being an accidental double start, or a
7
+ * foreground `serve` alongside a daemon).
8
+ *
9
+ * Two deliberate trade-offs:
10
+ * - The critical section is a tiny read-modify-write. Upstream LLM calls take
11
+ * seconds, so a sub-millisecond file operation per request is irrelevant.
12
+ * - If the lock cannot be acquired quickly the request is allowed rather than
13
+ * blocked: a slightly loose limit is preferable to stalling live traffic on
14
+ * lock contention.
15
+ */
16
+ import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
17
+ import { randomBytes } from "node:crypto";
18
+ import { join } from "node:path";
19
+ import { atomicWriteFile, ensureDir } from "../utils/fs.js";
20
+ import { getGatewayDir } from "../utils/paths.js";
21
+ const WINDOW_MS = 60_000;
22
+ const DAY_MS = 86_400_000;
23
+ const LOCK_TIMEOUT_MS = 500;
24
+ const LOCK_STALE_MS = 5_000;
25
+ const LOCK_SPIN_MS = 2;
26
+ export function getRateLimitPath() {
27
+ return join(getGatewayDir(), "rate-limit.json");
28
+ }
29
+ export function getRateLimitLockPath() {
30
+ return join(getGatewayDir(), "rate-limit.lock");
31
+ }
32
+ const UNLIMITED = {
33
+ allowed: true,
34
+ limit: 0,
35
+ remaining: -1,
36
+ resetAt: 0,
37
+ retryAfterSeconds: 0,
38
+ };
39
+ function sleepSync(ms) {
40
+ const shared = new Int32Array(new SharedArrayBuffer(4));
41
+ Atomics.wait(shared, 0, 0, ms);
42
+ }
43
+ function pidAlive(pid) {
44
+ if (!Number.isInteger(pid) || pid < 1)
45
+ return false;
46
+ try {
47
+ process.kill(pid, 0);
48
+ return true;
49
+ }
50
+ catch (err) {
51
+ return err.code === "EPERM";
52
+ }
53
+ }
54
+ /**
55
+ * Acquire the counter lock, or return null when it stays busy past the timeout.
56
+ * A lock whose owner process is gone and which is older than the stale age is
57
+ * reclaimed.
58
+ */
59
+ function acquireLock(now) {
60
+ const path = getRateLimitLockPath();
61
+ const id = randomBytes(8).toString("hex");
62
+ const payload = JSON.stringify({ id, pid: process.pid, at: now });
63
+ const deadline = now + LOCK_TIMEOUT_MS;
64
+ // The directory may not exist yet on a fresh install; without this the
65
+ // exclusive create below fails with ENOENT and persistence never engages.
66
+ try {
67
+ ensureDir(getGatewayDir());
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ for (;;) {
73
+ try {
74
+ writeFileSync(path, payload, { encoding: "utf8", flag: "wx", mode: 0o600 });
75
+ return makeLock(path, id);
76
+ }
77
+ catch (err) {
78
+ if (err.code !== "EEXIST")
79
+ return null;
80
+ }
81
+ if (reclaimable(path)) {
82
+ try {
83
+ const tmp = `${path}.${id}.tmp`;
84
+ writeFileSync(tmp, payload, { encoding: "utf8", mode: 0o600 });
85
+ renameSync(tmp, path);
86
+ if (readLockId(path) === id)
87
+ return makeLock(path, id);
88
+ }
89
+ catch {
90
+ // Someone else won the race; fall through and retry.
91
+ }
92
+ }
93
+ if (Date.now() >= deadline)
94
+ return null;
95
+ sleepSync(LOCK_SPIN_MS);
96
+ }
97
+ }
98
+ function readLockId(path) {
99
+ try {
100
+ const raw = JSON.parse(readFileSync(path, "utf8"));
101
+ return typeof raw.id === "string" ? raw.id : null;
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
107
+ /**
108
+ * A lock is reclaimable when its owner is gone. A dead owner can never release
109
+ * the lock, so that case is reclaimed immediately; when ownership cannot be
110
+ * determined we fall back to an age check that trusts whichever of the file
111
+ * mtime or the recorded timestamp looks older.
112
+ */
113
+ function reclaimable(path) {
114
+ let record = {};
115
+ let readable = false;
116
+ try {
117
+ record = JSON.parse(readFileSync(path, "utf8"));
118
+ readable = true;
119
+ }
120
+ catch {
121
+ // Unreadable lock file: fall back to the age check below.
122
+ }
123
+ if (readable && typeof record.pid === "number") {
124
+ return !pidAlive(record.pid);
125
+ }
126
+ let fileAge = Number.POSITIVE_INFINITY;
127
+ try {
128
+ fileAge = Date.now() - statSync(path).mtimeMs;
129
+ }
130
+ catch {
131
+ return true;
132
+ }
133
+ const recordedAge = typeof record.at === "number"
134
+ ? Date.now() - record.at
135
+ : Number.NEGATIVE_INFINITY;
136
+ return Math.max(fileAge, recordedAge) > LOCK_STALE_MS;
137
+ }
138
+ function makeLock(path, id) {
139
+ return {
140
+ release() {
141
+ if (readLockId(path) === id)
142
+ rmSync(path, { force: true });
143
+ },
144
+ };
145
+ }
146
+ function readWindows() {
147
+ const path = getRateLimitPath();
148
+ if (!existsSync(path))
149
+ return {};
150
+ try {
151
+ const raw = JSON.parse(readFileSync(path, "utf8"));
152
+ const windows = raw.windows;
153
+ if (!windows || typeof windows !== "object")
154
+ return {};
155
+ const out = {};
156
+ for (const [key, value] of Object.entries(windows)) {
157
+ if (!value || typeof value !== "object")
158
+ continue;
159
+ const row = value;
160
+ if (typeof row.windowStart !== "number" || typeof row.count !== "number") {
161
+ continue;
162
+ }
163
+ out[key] = { windowStart: row.windowStart, count: row.count };
164
+ }
165
+ return out;
166
+ }
167
+ catch {
168
+ return {};
169
+ }
170
+ }
171
+ function writeWindows(windows, now) {
172
+ // Drop windows that can no longer affect a decision.
173
+ const pruned = {};
174
+ for (const [key, record] of Object.entries(windows)) {
175
+ const span = key.endsWith("#day") ? DAY_MS * 2 : WINDOW_MS * 2;
176
+ if (now - record.windowStart < span)
177
+ pruned[key] = record;
178
+ }
179
+ ensureDir(getGatewayDir());
180
+ atomicWriteFile(getRateLimitPath(), JSON.stringify({ version: 1, windows: pruned }, null, 2) + "\n");
181
+ }
182
+ /** In-process fallback used when the lock cannot be taken. */
183
+ const memoryWindows = new Map();
184
+ function decide(record, limit, windowMs, now) {
185
+ const fresh = !record || now - record.windowStart >= windowMs;
186
+ const windowStart = fresh ? now : record.windowStart;
187
+ const used = fresh ? 0 : record.count;
188
+ const resetAt = Math.ceil((windowStart + windowMs) / 1000);
189
+ if (used >= limit) {
190
+ return {
191
+ next: { windowStart, count: used },
192
+ decision: {
193
+ allowed: false,
194
+ limit,
195
+ remaining: 0,
196
+ resetAt,
197
+ retryAfterSeconds: Math.max(1, Math.ceil((windowStart + windowMs - now) / 1000)),
198
+ },
199
+ };
200
+ }
201
+ const count = used + 1;
202
+ return {
203
+ next: { windowStart, count },
204
+ decision: {
205
+ allowed: true,
206
+ limit,
207
+ remaining: Math.max(0, limit - count),
208
+ resetAt,
209
+ retryAfterSeconds: 0,
210
+ },
211
+ };
212
+ }
213
+ /**
214
+ * Consume one request slot for `keyId`. A limit of 0 or less is unlimited and
215
+ * records nothing.
216
+ */
217
+ export function checkRateLimit(keyId, limitPerMinute, now = Date.now()) {
218
+ return checkWindow(keyId, limitPerMinute, WINDOW_MS, now);
219
+ }
220
+ /** Clear all counters (test seam and `llms gateway ratelimit reset`). */
221
+ export function resetRateLimits() {
222
+ memoryWindows.clear();
223
+ try {
224
+ rmSync(getRateLimitPath(), { force: true });
225
+ rmSync(getRateLimitLockPath(), { force: true });
226
+ }
227
+ catch {
228
+ // Nothing persisted yet.
229
+ }
230
+ }
231
+ /**
232
+ * Consume one request slot from the UTC-day window for `keyId`. A limit of 0
233
+ * or less means no daily quota and records nothing.
234
+ */
235
+ export function checkDailyQuota(keyId, limitPerDay, now = Date.now()) {
236
+ return checkWindow(`${keyId}#day`, limitPerDay, DAY_MS, now);
237
+ }
238
+ /** Read the daily window without consuming a slot. */
239
+ export function peekDailyQuota(keyId, limitPerDay, now = Date.now()) {
240
+ return peekWindow(`${keyId}#day`, limitPerDay, DAY_MS, now);
241
+ }
242
+ function checkWindow(bucket, limit, windowMs, now) {
243
+ if (!limit || limit <= 0)
244
+ return { ...UNLIMITED };
245
+ const lock = acquireLock(now);
246
+ if (!lock) {
247
+ const { next, decision } = decide(memoryWindows.get(bucket), limit, windowMs, now);
248
+ memoryWindows.set(bucket, next);
249
+ return decision;
250
+ }
251
+ try {
252
+ const windows = readWindows();
253
+ const { next, decision } = decide(windows[bucket], limit, windowMs, now);
254
+ windows[bucket] = next;
255
+ memoryWindows.set(bucket, next);
256
+ writeWindows(windows, now);
257
+ return decision;
258
+ }
259
+ catch {
260
+ // Never fail a request because bookkeeping failed.
261
+ return { ...UNLIMITED, limit };
262
+ }
263
+ finally {
264
+ lock.release();
265
+ }
266
+ }
267
+ /** Read the current window without consuming a slot. */
268
+ export function peekRateLimit(keyId, limitPerMinute, now = Date.now()) {
269
+ return peekWindow(keyId, limitPerMinute, WINDOW_MS, now);
270
+ }
271
+ function peekWindow(bucket, limit, windowMs, now) {
272
+ if (!limit || limit <= 0)
273
+ return { ...UNLIMITED };
274
+ const record = readWindows()[bucket] ?? memoryWindows.get(bucket);
275
+ const fresh = !record || now - record.windowStart >= windowMs;
276
+ const windowStart = fresh ? now : record.windowStart;
277
+ const used = fresh ? 0 : record.count;
278
+ return {
279
+ allowed: used < limit,
280
+ limit,
281
+ remaining: Math.max(0, limit - used),
282
+ resetAt: Math.ceil((windowStart + windowMs) / 1000),
283
+ retryAfterSeconds: 0,
284
+ };
285
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Model routing and fallback ordering.
3
+ *
4
+ * Resolution order for the `model` field a third party sends:
5
+ * 1. an explicit route alias (`llms gateway route add`)
6
+ * 2. a qualified reference: `provider/model` or `provider:model`
7
+ * 3. a bare model id declared by one or more providers
8
+ * 4. providers that declare no model list (passthrough upstreams)
9
+ * 5. the configured default provider
10
+ *
11
+ * When several providers can serve the same model they are ordered by
12
+ * `priority` (ascending) and become each other's fallbacks, so ambiguity is
13
+ * deterministic rather than an error.
14
+ */
15
+ import { listGatewayProviders, listGatewayRoutes, readGatewayConfig, } from "./store.js";
16
+ export class ModelNotRoutableError extends Error {
17
+ requested;
18
+ availableModels;
19
+ constructor(requested, availableModels) {
20
+ const hint = availableModels.length
21
+ ? `可用模型:${availableModels.slice(0, 20).join(", ")}${availableModels.length > 20 ? " …" : ""}`
22
+ : "尚未配置任何 provider。请执行:llms gateway provider add";
23
+ super(`模型「${requested}」无法路由。${hint}`);
24
+ this.requested = requested;
25
+ this.availableModels = availableModels;
26
+ this.name = "ModelNotRoutableError";
27
+ }
28
+ }
29
+ function enabledProviders(providers) {
30
+ return providers
31
+ .filter((provider) => provider.enabled && provider.baseUrl)
32
+ .sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name));
33
+ }
34
+ function findProvider(providers, name) {
35
+ const wanted = name.trim().toLowerCase();
36
+ return providers.find((provider) => provider.name.toLowerCase() === wanted ||
37
+ provider.displayName.toLowerCase() === wanted);
38
+ }
39
+ function providerHasModel(provider, model) {
40
+ if (!provider.models.length)
41
+ return false;
42
+ const wanted = model.toLowerCase();
43
+ return provider.models.some((item) => item.toLowerCase() === wanted);
44
+ }
45
+ /** Split `provider/model` or `provider:model`; the model part may contain `/`. */
46
+ function splitQualified(requested) {
47
+ for (const separator of ["/", ":"]) {
48
+ const index = requested.indexOf(separator);
49
+ if (index <= 0)
50
+ continue;
51
+ const provider = requested.slice(0, index).trim();
52
+ const model = requested.slice(index + 1).trim();
53
+ if (provider && model)
54
+ return { provider, model };
55
+ }
56
+ return null;
57
+ }
58
+ function pushCandidate(out, seen, provider, model, source) {
59
+ const dedupe = `${provider.name}::${model}`;
60
+ if (seen.has(dedupe))
61
+ return;
62
+ seen.add(dedupe);
63
+ out.push({ provider, model, source });
64
+ }
65
+ export function resolveModelRoute(requestedRaw, input = {}) {
66
+ const requested = (requestedRaw || "").trim();
67
+ const allProviders = input.providers ?? listGatewayProviders();
68
+ const providers = enabledProviders(allProviders);
69
+ const routes = input.routes ?? listGatewayRoutes();
70
+ const config = input.config ?? readGatewayConfig();
71
+ const candidates = [];
72
+ const seen = new Set();
73
+ if (!requested) {
74
+ throw new ModelNotRoutableError("(empty)", listRoutableModelIds({ providers: allProviders, routes }));
75
+ }
76
+ // 1. Explicit alias.
77
+ const route = routes.find((item) => item.alias.toLowerCase() === requested.toLowerCase());
78
+ if (route) {
79
+ const primary = findProvider(providers, route.provider);
80
+ if (primary) {
81
+ pushCandidate(candidates, seen, primary, route.model || requested, "route");
82
+ }
83
+ for (const fallback of route.fallbacks || []) {
84
+ const provider = findProvider(providers, fallback.provider);
85
+ if (provider) {
86
+ pushCandidate(candidates, seen, provider, fallback.model || route.model || requested, "route");
87
+ }
88
+ }
89
+ }
90
+ // 2. Qualified provider reference.
91
+ const qualified = splitQualified(requested);
92
+ if (qualified) {
93
+ const provider = findProvider(providers, qualified.provider);
94
+ if (provider) {
95
+ pushCandidate(candidates, seen, provider, qualified.model, "qualified");
96
+ }
97
+ }
98
+ // 3. Bare model id declared by providers, ordered by priority.
99
+ const bare = qualified ? qualified.model : requested;
100
+ for (const provider of providers) {
101
+ if (providerHasModel(provider, bare)) {
102
+ pushCandidate(candidates, seen, provider, bare, "model-list");
103
+ }
104
+ }
105
+ // 4. Passthrough providers (no declared model list).
106
+ for (const provider of providers) {
107
+ if (!provider.models.length) {
108
+ pushCandidate(candidates, seen, provider, bare, "passthrough");
109
+ }
110
+ }
111
+ // 5. Configured default provider.
112
+ if (config.defaultProvider) {
113
+ const provider = findProvider(providers, config.defaultProvider);
114
+ if (provider) {
115
+ pushCandidate(candidates, seen, provider, bare, "default");
116
+ }
117
+ }
118
+ if (!candidates.length) {
119
+ throw new ModelNotRoutableError(requested, listRoutableModelIds({ providers: allProviders, routes }));
120
+ }
121
+ const maxAttempts = config.fallback.enabled
122
+ ? Math.max(1, config.fallback.maxAttempts)
123
+ : 1;
124
+ return { requested, candidates: candidates.slice(0, maxAttempts) };
125
+ }
126
+ /** Client-visible model catalogue: aliases, bare ids and qualified ids. */
127
+ export function listRoutableModels(input = {}) {
128
+ const providers = enabledProviders(input.providers ?? listGatewayProviders());
129
+ const routes = input.routes ?? listGatewayRoutes();
130
+ const out = [];
131
+ const seen = new Set();
132
+ const add = (id, provider, upstreamModel) => {
133
+ if (!id || seen.has(id))
134
+ return;
135
+ seen.add(id);
136
+ out.push({
137
+ id,
138
+ provider: provider.name,
139
+ upstreamModel,
140
+ format: provider.apiFormat,
141
+ });
142
+ };
143
+ for (const route of routes) {
144
+ const provider = providers.find((item) => item.name.toLowerCase() === route.provider.toLowerCase());
145
+ if (provider)
146
+ add(route.alias, provider, route.model || route.alias);
147
+ }
148
+ for (const provider of providers) {
149
+ for (const model of provider.models) {
150
+ add(model, provider, model);
151
+ }
152
+ }
153
+ // Qualified ids are always addressable, listed after the bare ids.
154
+ for (const provider of providers) {
155
+ for (const model of provider.models) {
156
+ add(`${provider.name}/${model}`, provider, model);
157
+ }
158
+ }
159
+ return out;
160
+ }
161
+ export function listRoutableModelIds(input = {}) {
162
+ return listRoutableModels(input).map((model) => model.id);
163
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Gateway listener resolution and exposure guards.
3
+ *
4
+ * The gateway is intended for third-party traffic, so binding it to a
5
+ * non-loopback address requires both an explicit opt-in and at least one active
6
+ * API key. Otherwise an unauthenticated model proxy would be reachable from the
7
+ * network.
8
+ */
9
+ import { advertiseHostForBind, isLoopbackHost, normalizeHost, parseBridgePort, } from "../bridge/runtime.js";
10
+ import { hasAnyActiveKey } from "./keys.js";
11
+ export function parseGatewayPort(value) {
12
+ return parseBridgePort(value);
13
+ }
14
+ export class GatewayExposureError extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = "GatewayExposureError";
18
+ }
19
+ }
20
+ export function assertGatewayListenerAllowed(host, allowRemote, options = {}) {
21
+ if (isLoopbackHost(host))
22
+ return;
23
+ if (!allowRemote) {
24
+ throw new GatewayExposureError(`非回环监听地址 ${host || "(empty)"} 必须显式传入 --allow-remote`);
25
+ }
26
+ const requireKey = options.requireKey !== false;
27
+ if (requireKey && !hasAnyActiveKey()) {
28
+ throw new GatewayExposureError("对外暴露前必须至少存在一个有效 API Key。请先执行:llms gateway key create");
29
+ }
30
+ }
31
+ export function resolveGatewayListener(options) {
32
+ const bindHost = normalizeHost(options.host);
33
+ assertGatewayListenerAllowed(bindHost, options.allowRemote, {
34
+ requireKey: options.requireKey,
35
+ });
36
+ return {
37
+ bindHost,
38
+ advertiseHost: options.advertiseHost
39
+ ? normalizeHost(options.advertiseHost)
40
+ : advertiseHostForBind(bindHost),
41
+ port: parseGatewayPort(options.port),
42
+ allowRemote: options.allowRemote,
43
+ };
44
+ }
45
+ export { isLoopbackHost };