ldrouter 1.5.1

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.
Files changed (64) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +101 -0
  4. package/dist/cli.js +13 -0
  5. package/dist/server/app.js +138 -0
  6. package/dist/server/auth/api-key.js +75 -0
  7. package/dist/server/auth/crypto.js +94 -0
  8. package/dist/server/auth/ids.js +40 -0
  9. package/dist/server/auth/middleware.js +36 -0
  10. package/dist/server/auth/recovery.js +11 -0
  11. package/dist/server/caching/store.js +119 -0
  12. package/dist/server/config/index.js +96 -0
  13. package/dist/server/db/index.js +64 -0
  14. package/dist/server/db/migrate.js +408 -0
  15. package/dist/server/db/repositories/audit.js +75 -0
  16. package/dist/server/db/repositories/settings.js +63 -0
  17. package/dist/server/db/schema.js +396 -0
  18. package/dist/server/errors.js +65 -0
  19. package/dist/server/gateway/runner.js +745 -0
  20. package/dist/server/logging/logger.js +35 -0
  21. package/dist/server/maintenance/retention.js +48 -0
  22. package/dist/server/metrics/registry.js +169 -0
  23. package/dist/server/protocols/anthropic.js +154 -0
  24. package/dist/server/protocols/canonical.js +201 -0
  25. package/dist/server/providers/index.js +89 -0
  26. package/dist/server/routes/admin/aliases.js +98 -0
  27. package/dist/server/routes/admin/api-keys.js +194 -0
  28. package/dist/server/routes/admin/audit.js +19 -0
  29. package/dist/server/routes/admin/auth.js +124 -0
  30. package/dist/server/routes/admin/backup.js +113 -0
  31. package/dist/server/routes/admin/combos.js +198 -0
  32. package/dist/server/routes/admin/dashboard.js +55 -0
  33. package/dist/server/routes/admin/models.js +178 -0
  34. package/dist/server/routes/admin/providers.js +212 -0
  35. package/dist/server/routes/admin/requests.js +156 -0
  36. package/dist/server/routes/admin/settings.js +197 -0
  37. package/dist/server/routes/admin/setup.js +80 -0
  38. package/dist/server/routes/admin/stats.js +180 -0
  39. package/dist/server/routes/admin.js +39 -0
  40. package/dist/server/routes/gateway/anthropic.js +112 -0
  41. package/dist/server/routes/gateway/openai.js +257 -0
  42. package/dist/server/routes/gateway.js +7 -0
  43. package/dist/server/routes/health.js +27 -0
  44. package/dist/server/routing/capabilities.js +52 -0
  45. package/dist/server/routing/circuit.js +37 -0
  46. package/dist/server/routing/combo.js +100 -0
  47. package/dist/server/routing/quota.js +51 -0
  48. package/dist/server/routing/ratelimit.js +58 -0
  49. package/dist/server/routing/resolver.js +43 -0
  50. package/dist/server/security/redact.js +111 -0
  51. package/dist/server/selfupdate/index.js +154 -0
  52. package/dist/server/upstream/client.js +179 -0
  53. package/dist/server/util/cidr.js +91 -0
  54. package/dist/server/util/client-ip.js +15 -0
  55. package/dist/server/util/stable-json.js +19 -0
  56. package/dist/shared/types.js +2 -0
  57. package/dist/web/assets/index-COSbvF8Z.css +1 -0
  58. package/dist/web/assets/index-DbnEzuxq.js +251 -0
  59. package/dist/web/favicon.png +0 -0
  60. package/dist/web/index.html +15 -0
  61. package/dist/web/logo.png +0 -0
  62. package/migrations/0001_initial_schema.sql +323 -0
  63. package/migrations/0002_source_api_key_secrets.sql +7 -0
  64. package/package.json +117 -0
@@ -0,0 +1,58 @@
1
+ // In-memory rate limiter: token bucket per key for RPM, TPM, concurrency.
2
+ const rpmBuckets = new Map();
3
+ const tpmBuckets = new Map();
4
+ const concurrentCounters = new Map();
5
+ export function checkRpm(keyId, rpmLimit) {
6
+ if (rpmLimit == null || rpmLimit <= 0)
7
+ return { allowed: true };
8
+ const now = Date.now();
9
+ const bucket = rpmBuckets.get(keyId) ?? { tokens: rpmLimit, lastRefill: now };
10
+ // Refill 1 token per (60_000 / rpm) ms
11
+ const elapsed = now - bucket.lastRefill;
12
+ const refillPerMs = rpmLimit / 60_000;
13
+ const newTokens = Math.min(rpmLimit, bucket.tokens + elapsed * refillPerMs);
14
+ if (newTokens < 1) {
15
+ rpmBuckets.set(keyId, { tokens: newTokens, lastRefill: now });
16
+ const wait = (1 - newTokens) / refillPerMs / 1000;
17
+ return { allowed: false, reason: 'rpm', retryAfterSeconds: Math.ceil(wait) };
18
+ }
19
+ rpmBuckets.set(keyId, { tokens: newTokens - 1, lastRefill: now });
20
+ return { allowed: true };
21
+ }
22
+ export function checkTpm(keyId, tpmLimit, tokensToConsume) {
23
+ if (tpmLimit == null || tpmLimit <= 0)
24
+ return { allowed: true };
25
+ const now = Date.now();
26
+ const bucket = tpmBuckets.get(keyId) ?? { tokens: tpmLimit, lastRefill: now };
27
+ const elapsed = now - bucket.lastRefill;
28
+ const refillPerMs = tpmLimit / 60_000;
29
+ const newTokens = Math.min(tpmLimit, bucket.tokens + elapsed * refillPerMs);
30
+ if (newTokens < tokensToConsume) {
31
+ tpmBuckets.set(keyId, { tokens: newTokens, lastRefill: now });
32
+ const wait = (tokensToConsume - newTokens) / refillPerMs / 1000;
33
+ return { allowed: false, reason: 'tpm', retryAfterSeconds: Math.ceil(wait) };
34
+ }
35
+ tpmBuckets.set(keyId, { tokens: newTokens - tokensToConsume, lastRefill: now });
36
+ return { allowed: true };
37
+ }
38
+ export function acquireConcurrent(keyId, max) {
39
+ if (max == null || max <= 0) {
40
+ concurrentCounters.set(keyId, (concurrentCounters.get(keyId) ?? 0) + 1);
41
+ return true;
42
+ }
43
+ const cur = concurrentCounters.get(keyId) ?? 0;
44
+ if (cur >= max)
45
+ return false;
46
+ concurrentCounters.set(keyId, cur + 1);
47
+ return true;
48
+ }
49
+ export function releaseConcurrent(keyId) {
50
+ const cur = concurrentCounters.get(keyId) ?? 0;
51
+ concurrentCounters.set(keyId, Math.max(0, cur - 1));
52
+ }
53
+ export function activeRequests() {
54
+ let total = 0;
55
+ for (const v of concurrentCounters.values())
56
+ total += v;
57
+ return total;
58
+ }
@@ -0,0 +1,43 @@
1
+ // Model resolution: physical model, combo, or alias (one hop only).
2
+ import { eq, and } from 'drizzle-orm';
3
+ import { getDb, schema } from '../db/index.js';
4
+ import { GatewayError } from '../errors.js';
5
+ export function resolveRequestedModel(requested) {
6
+ const db = getDb();
7
+ // Physical model
8
+ const model = db.select().from(schema.models).where(eq(schema.models.publicModelId, requested)).get();
9
+ if (model && model.enabled) {
10
+ return { kind: 'model', modelId: model.id, publicModelId: model.publicModelId };
11
+ }
12
+ // Combo by exact public ID — with-prefix ("combo/<slug>") or the
13
+ // prefix-less default (<slug>).
14
+ const combo = db.select().from(schema.combos).where(eq(schema.combos.publicModelId, requested)).get();
15
+ if (combo && combo.enabled)
16
+ return { kind: 'combo', comboId: combo.id, publicModelId: combo.publicModelId };
17
+ // Alias (one hop only)
18
+ const alias = db.select().from(schema.modelAliases).where(and(eq(schema.modelAliases.alias, requested), eq(schema.modelAliases.enabled, true))).get();
19
+ if (alias) {
20
+ if (alias.targetKind === 'model') {
21
+ const m = db.select().from(schema.models).where(eq(schema.models.id, alias.targetId)).get();
22
+ if (m && m.enabled)
23
+ return { kind: 'alias', aliasId: alias.id, alias: alias.alias, resolved: { kind: 'model', modelId: m.id, publicModelId: m.publicModelId } };
24
+ }
25
+ else {
26
+ const c = db.select().from(schema.combos).where(eq(schema.combos.id, alias.targetId)).get();
27
+ if (c && c.enabled)
28
+ return { kind: 'alias', aliasId: alias.id, alias: alias.alias, resolved: { kind: 'combo', comboId: c.id, publicModelId: c.publicModelId } };
29
+ }
30
+ }
31
+ // Maybe the user typed the combo slug without the prefix
32
+ if (!requested.includes('/')) {
33
+ const c = db.select().from(schema.combos).where(eq(schema.combos.slug, requested)).get();
34
+ if (c && c.enabled)
35
+ return { kind: 'combo', comboId: c.id, publicModelId: c.publicModelId };
36
+ }
37
+ throw new GatewayError('model_not_found', `Unknown model: ${requested}`, { status: 404 });
38
+ }
39
+ export function unwrapAlias(t) {
40
+ if (t.kind === 'alias')
41
+ return t.resolved;
42
+ return t;
43
+ }
@@ -0,0 +1,111 @@
1
+ // Recursive structured redaction + defensive string redaction.
2
+ // Never let secrets reach logs, audit metadata, or error responses.
3
+ const DEFAULT_SECRET_KEYS = [
4
+ 'authorization',
5
+ 'authorizationheader',
6
+ 'x-api-key',
7
+ 'xapikey',
8
+ 'api-key',
9
+ 'apikey',
10
+ 'api_key',
11
+ 'apiKey',
12
+ 'api_key_plain',
13
+ 'apiKeyPlain',
14
+ 'cookie',
15
+ 'set-cookie',
16
+ 'setcookie',
17
+ 'session',
18
+ 'sessiontoken',
19
+ 'token',
20
+ 'accesstoken',
21
+ 'refresh_token',
22
+ 'password',
23
+ 'passwd',
24
+ 'currentpassword',
25
+ 'newpassword',
26
+ 'masterkey',
27
+ 'master_key',
28
+ 'encrypted_api_key',
29
+ 'apiKeyNonce',
30
+ 'api_key_nonce',
31
+ 'totpsecret',
32
+ 'totp_secret',
33
+ 'recoverycode',
34
+ 'recoverycodes',
35
+ 'secret',
36
+ 'client_secret',
37
+ 'privatekey',
38
+ 'x-goog-api-key',
39
+ ];
40
+ const SECRET_KEY_REGEX = /(authorization|api[-_]?key|api[-_]?secret|password|passwd|secret|token|cookie|master[-_]?key|totp|recovery|private[-_]?key|session)/i;
41
+ const PLAINTEXT_PATTERNS = [
42
+ /\bld-[A-Za-z0-9_-]{20,}\b/g, // gateway api keys
43
+ /(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi,
44
+ /sk-[A-Za-z0-9_-]{12,}/g, // OpenAI-style
45
+ /anthropic[_-]?[A-Za-z0-9_-]{20,}/gi,
46
+ /sk-ant-[A-Za-z0-9_-]{10,}/g,
47
+ /x-api-key[":= ]+[A-Za-z0-9._~+/=-]{8,}/gi,
48
+ /Authorization[":= ]+[A-Za-z0-9._~+/=-]{8,}/gi,
49
+ ];
50
+ const REDACTED = '[REDACTED]';
51
+ export function redactValue(value, opts = {}) {
52
+ const { secrets = [], keyMarker = REDACTED, secretKeys = DEFAULT_SECRET_KEYS } = opts;
53
+ const allSecrets = new Set(secrets.filter((s) => s && s.length >= 6));
54
+ return redactInternal(value, allSecrets, keyMarker, secretKeys);
55
+ }
56
+ function redactInternal(value, secrets, marker, secretKeys) {
57
+ if (value === null || value === undefined)
58
+ return value;
59
+ if (typeof value === 'string') {
60
+ let out = value;
61
+ for (const s of secrets) {
62
+ if (s && out.includes(s))
63
+ out = out.split(s).join(marker);
64
+ }
65
+ for (const re of PLAINTEXT_PATTERNS) {
66
+ out = out.replace(re, marker);
67
+ }
68
+ return out;
69
+ }
70
+ if (typeof value === 'number' || typeof value === 'boolean')
71
+ return value;
72
+ if (Array.isArray(value))
73
+ return value.map((v) => redactInternal(v, secrets, marker, secretKeys));
74
+ if (typeof value === 'object') {
75
+ const out = {};
76
+ for (const [k, v] of Object.entries(value)) {
77
+ if (secretKeys.includes(k) || SECRET_KEY_REGEX.test(k)) {
78
+ out[k] = v === null || v === undefined ? v : marker;
79
+ }
80
+ else {
81
+ out[k] = redactInternal(v, secrets, marker, secretKeys);
82
+ }
83
+ }
84
+ return out;
85
+ }
86
+ return value;
87
+ }
88
+ /** Redact any known secrets from a string (for error messages). */
89
+ export function redactString(input, opts = {}) {
90
+ const { secrets = [] } = opts;
91
+ let out = input;
92
+ for (const s of secrets) {
93
+ if (s && out.includes(s))
94
+ out = out.split(s).join(REDACTED);
95
+ }
96
+ for (const re of PLAINTEXT_PATTERNS) {
97
+ out = out.replace(re, REDACTED);
98
+ }
99
+ return out;
100
+ }
101
+ export function redactJsonString(json, opts) {
102
+ if (!json)
103
+ return json ?? null;
104
+ try {
105
+ const parsed = JSON.parse(json);
106
+ return JSON.stringify(redactValue(parsed, opts));
107
+ }
108
+ catch {
109
+ return redactString(json, opts);
110
+ }
111
+ }
@@ -0,0 +1,154 @@
1
+ // Self-update: check the npm registry for newer versions and update the
2
+ // globally installed package in place. Docker deployments are excluded —
3
+ // their version comes from the image tag and updates happen by pulling a new
4
+ // image, never by installing over the running container.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import process from 'node:process';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { execa } from 'execa';
10
+ import semver from 'semver';
11
+ import { getLogger } from '../logging/logger.js';
12
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+ // Pure: compare two semver versions. Returns null when either side is not a
14
+ // valid semver string (e.g. dev builds), which callers treat as "no update".
15
+ export function compareUpdate(currentVersion, latestVersion) {
16
+ const cur = semver.valid(semver.coerce(currentVersion));
17
+ const latest = semver.valid(semver.coerce(latestVersion));
18
+ if (!cur || !latest)
19
+ return null;
20
+ return semver.gt(latest, cur);
21
+ }
22
+ // Pure: derive the global-install command from where npm resolves its own
23
+ // executable. When the gateway was installed globally with pnpm/yarn/bun,
24
+ // those tools still run `npm exec`/npx under the hood, so
25
+ // process.env.npm_execpath carries the invoking package manager's script.
26
+ export function resolvePackageManager(npmExecPath) {
27
+ const pm = npmExecPath ? path.basename(npmExecPath).toLowerCase() : '';
28
+ if (pm.startsWith('pnpm'))
29
+ return { pm: 'pnpm', installArgs: (pkg) => ['add', '-g', pkg] };
30
+ if (pm.startsWith('yarn'))
31
+ return { pm: 'yarn', installArgs: (pkg) => ['global', 'add', pkg] };
32
+ if (pm.startsWith('bun'))
33
+ return { pm: 'bun', installArgs: (pkg) => ['add', '-g', pkg] };
34
+ return { pm: 'npm', installArgs: (pkg) => ['install', '-g', pkg] };
35
+ }
36
+ export class SelfUpdater {
37
+ packageName;
38
+ currentVersion;
39
+ inDocker = fs.existsSync('/.dockerenv');
40
+ cached = null;
41
+ updating = false;
42
+ constructor(packageName, currentVersion) {
43
+ this.packageName = packageName;
44
+ this.currentVersion = currentVersion;
45
+ }
46
+ status() {
47
+ if (this.inDocker) {
48
+ return { available: false, reason: 'Docker deployment — update by pulling the new image tag', updating: this.updating, docker: true };
49
+ }
50
+ if (this.updating)
51
+ return { available: false, reason: 'Update already running', updating: true, docker: false };
52
+ return { available: true, reason: null, updating: false, docker: false };
53
+ }
54
+ // Checks the npm registry (10s timeout). Cached for 15 minutes; a failed
55
+ // check never raises — it just reports no update.
56
+ async check(force = false) {
57
+ const fresh = !force &&
58
+ this.cached &&
59
+ Date.now() - new Date(this.cached.checkedAt).getTime() < 15 * 60 * 1000;
60
+ if (fresh)
61
+ return this.cached;
62
+ try {
63
+ const controller = new AbortController();
64
+ const timer = setTimeout(() => controller.abort(), 10_000);
65
+ const res = await fetch(`https://registry.npmjs.org/${this.packageName}/latest`, {
66
+ signal: controller.signal,
67
+ headers: { accept: 'application/json' },
68
+ });
69
+ clearTimeout(timer);
70
+ const meta = (res.ok ? (await res.json()) : {});
71
+ const coerced = typeof meta.version === 'string' ? semver.coerce(meta.version) : null;
72
+ const latest = coerced && semver.valid(coerced) ? coerced.version : null;
73
+ const tarball = meta.dist?.tarball ?? null;
74
+ const result = {
75
+ currentVersion: this.currentVersion,
76
+ latestVersion: latest,
77
+ hasUpdate: latest ? compareUpdate(this.currentVersion, latest) === true : false,
78
+ // registry.npmjs.org serves package files (including CHANGELOG.md)
79
+ // straight from the tarball URL.
80
+ changelogUrl: tarball ? tarball.replace(/\/-\/.+$/, '/-/CHANGELOG.md') : null,
81
+ checkedAt: new Date().toISOString(),
82
+ };
83
+ this.cached = result;
84
+ return result;
85
+ }
86
+ catch (e) {
87
+ getLogger().warn({ err: e.message }, 'update check failed');
88
+ return {
89
+ currentVersion: this.currentVersion,
90
+ latestVersion: null,
91
+ hasUpdate: false,
92
+ changelogUrl: null,
93
+ checkedAt: new Date().toISOString(),
94
+ };
95
+ }
96
+ }
97
+ // Installs the latest version globally with the detected package manager,
98
+ // then terminates the process so the supervisor (systemd, pm2, Docker
99
+ // restart policy…) restarts us on the new version.
100
+ async run() {
101
+ const st = this.status();
102
+ if (!st.available)
103
+ throw new Error(st.reason ?? 'Update not available');
104
+ const check = await this.check(true);
105
+ if (!check.latestVersion || !check.hasUpdate)
106
+ throw new Error('No update available');
107
+ this.updating = true;
108
+ const log = getLogger();
109
+ try {
110
+ const { pm, installArgs } = resolvePackageManager(process.env.npm_execpath);
111
+ log.info({ pm, pkg: this.packageName, to: check.latestVersion }, 'self-update: installing new version');
112
+ await execa(pm, installArgs(`${this.packageName}@${check.latestVersion}`), {
113
+ stdio: 'inherit',
114
+ timeout: 5 * 60 * 1000,
115
+ });
116
+ log.info({ from: this.currentVersion, to: check.latestVersion }, 'self-update: installed, restarting');
117
+ // Graceful exit: Fastify/supervisor restart policies bring the new
118
+ // version up. (When there is no supervisor the process simply stops —
119
+ // the install already succeeded.)
120
+ setTimeout(() => process.exit(0), 500).unref();
121
+ return { ok: true, message: `Updated ${this.currentVersion} → ${check.latestVersion}. Restarting…` };
122
+ }
123
+ finally {
124
+ // If the install failed, allow retries.
125
+ setTimeout(() => { this.updating = false; }, 2000).unref();
126
+ }
127
+ }
128
+ }
129
+ // The running version: walk up from this file to the first package.json
130
+ // (works from dist/server/selfupdate AND the source tree, and in Docker
131
+ // where npm_package_version is unset) and fall back to the npm-injected
132
+ // env var.
133
+ function readInstalledVersion() {
134
+ let dir = __dirname;
135
+ for (let i = 0; i < 6; i++) {
136
+ try {
137
+ const v = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).version;
138
+ if (v)
139
+ return v;
140
+ }
141
+ catch { /* keep climbing */ }
142
+ const parent = path.dirname(dir);
143
+ if (parent === dir)
144
+ break;
145
+ dir = parent;
146
+ }
147
+ return process.env.npm_package_version ?? '0.0.0';
148
+ }
149
+ let updater = null;
150
+ export function getSelfUpdater() {
151
+ if (!updater)
152
+ updater = new SelfUpdater('ldrouter', readInstalledVersion());
153
+ return updater;
154
+ }
@@ -0,0 +1,179 @@
1
+ // Upstream HTTP client: streaming + non-streaming with timeouts and error normalization.
2
+ import { decryptSecret, decryptCustomHeaders } from '../auth/crypto.js';
3
+ import { GatewayError } from '../errors.js';
4
+ import { buildHeaders, stripSlash } from '../providers/index.js';
5
+ export function providerToUpstreamConfig(p) {
6
+ return {
7
+ type: p.type,
8
+ baseUrl: p.baseUrl,
9
+ apiKey: decryptSecret({ ciphertext: p.encryptedApiKey, nonce: p.apiKeyNonce, version: p.apiKeyVersion }),
10
+ customHeaders: decryptCustomHeaders(p.customHeadersEncrypted && p.customHeadersNonce
11
+ ? { ciphertext: p.customHeadersEncrypted, nonce: p.customHeadersNonce, version: 1 }
12
+ : null),
13
+ connectTimeoutMs: p.connectTimeoutMs,
14
+ firstTokenTimeoutMs: p.firstTokenTimeoutMs,
15
+ streamIdleTimeoutMs: p.streamIdleTimeoutMs,
16
+ totalTimeoutMs: p.totalTimeoutMs,
17
+ };
18
+ }
19
+ export async function callUpstreamNonStreaming(cfg, url, payload) {
20
+ const ctl = new AbortController();
21
+ const timer = setTimeout(() => ctl.abort(), cfg.totalTimeoutMs);
22
+ const start = Date.now();
23
+ try {
24
+ const res = await fetch(url, {
25
+ method: 'POST',
26
+ headers: { ...buildHeaders(cfg), 'content-type': 'application/json', accept: 'application/json' },
27
+ body: JSON.stringify(payload),
28
+ signal: ctl.signal,
29
+ });
30
+ const text = await res.text();
31
+ const ttft = Date.now() - start;
32
+ return {
33
+ status: res.status,
34
+ ok: res.ok,
35
+ text,
36
+ headers: Object.fromEntries(res.headers.entries()),
37
+ upstreamRequestId: extractUpstreamRequestId(res.headers),
38
+ ttftMs: ttft,
39
+ };
40
+ }
41
+ catch (e) {
42
+ const err = e;
43
+ if (err.name === 'AbortError') {
44
+ throw new GatewayError('timeout_error', 'Upstream request timed out', { status: 504, cause: e });
45
+ }
46
+ throw new GatewayError('upstream_unavailable', `Upstream connection failed: ${err.message}`, { status: 502, cause: e });
47
+ }
48
+ finally {
49
+ clearTimeout(timer);
50
+ }
51
+ }
52
+ export function extractUpstreamRequestId(headers) {
53
+ if (headers instanceof Headers) {
54
+ return headers.get('x-request-id') ?? headers.get('request-id') ?? headers.get('x-amzn-requestid') ?? null;
55
+ }
56
+ return headers['x-request-id'] ?? headers['request-id'] ?? headers['x-amzn-requestid'] ?? null;
57
+ }
58
+ /**
59
+ * Call upstream with SSE streaming. Invokes onChunk for each SSE event.
60
+ * Returns a promise resolving when the stream completes or rejects on failure.
61
+ */
62
+ export async function callUpstreamStreaming(cfg, url, payload, onChunk) {
63
+ const ctl = new AbortController();
64
+ const totalTimer = setTimeout(() => ctl.abort(), cfg.totalTimeoutMs);
65
+ const start = Date.now();
66
+ let ttft = null;
67
+ let firstTokenTimer = null;
68
+ let idleTimer = null;
69
+ const resetIdle = () => {
70
+ if (idleTimer)
71
+ clearTimeout(idleTimer);
72
+ idleTimer = setTimeout(() => ctl.abort(), cfg.streamIdleTimeoutMs);
73
+ };
74
+ try {
75
+ const res = await fetch(url, {
76
+ method: 'POST',
77
+ headers: { ...buildHeaders(cfg), 'content-type': 'application/json', accept: 'text/event-stream' },
78
+ body: JSON.stringify(payload),
79
+ signal: ctl.signal,
80
+ });
81
+ if (!res.ok || !res.body) {
82
+ const text = await res.text();
83
+ throw new UpstreamHttpError(res.status, text, extractUpstreamRequestId(res.headers));
84
+ }
85
+ // First-token watchdog
86
+ firstTokenTimer = setTimeout(() => ctl.abort(), cfg.firstTokenTimeoutMs);
87
+ resetIdle();
88
+ const reader = res.body.getReader();
89
+ const decoder = new TextDecoder();
90
+ let buffer = '';
91
+ let isFirst = true;
92
+ let done = false;
93
+ while (!done) {
94
+ const { value, done: streamDone } = await reader.read();
95
+ done = streamDone;
96
+ if (done)
97
+ break;
98
+ buffer += decoder.decode(value, { stream: true });
99
+ // Process complete SSE events (split on blank line)
100
+ let idx;
101
+ while ((idx = buffer.indexOf('\n\n')) >= 0) {
102
+ const rawEvent = buffer.slice(0, idx);
103
+ buffer = buffer.slice(idx + 2);
104
+ const lines = rawEvent.split('\n');
105
+ let data = '';
106
+ let event = 'message';
107
+ for (const line of lines) {
108
+ if (line.startsWith('data:'))
109
+ data = line.slice(5).trimStart();
110
+ else if (line.startsWith('event:'))
111
+ event = line.slice(6).trimStart();
112
+ }
113
+ if (data === '[DONE]') {
114
+ done = true;
115
+ break;
116
+ }
117
+ if (data) {
118
+ if (ttft === null) {
119
+ ttft = Date.now() - start;
120
+ if (firstTokenTimer)
121
+ clearTimeout(firstTokenTimer);
122
+ }
123
+ resetIdle();
124
+ onChunk({ data, event }, isFirst);
125
+ isFirst = false;
126
+ }
127
+ }
128
+ }
129
+ return {
130
+ headers: Object.fromEntries(res.headers.entries()),
131
+ upstreamRequestId: extractUpstreamRequestId(res.headers),
132
+ ttftMs: ttft ?? Date.now() - start,
133
+ };
134
+ }
135
+ catch (e) {
136
+ if (e instanceof UpstreamHttpError) {
137
+ if (e.status >= 500)
138
+ throw new GatewayError('upstream_error', `Upstream HTTP ${e.status}`, { status: 502, cause: e, code: 'upstream_http_' + e.status });
139
+ if (e.status === 429)
140
+ throw new GatewayError('upstream_rate_limit', 'Upstream rate limited', { status: 529, cause: e });
141
+ if (e.status === 401 || e.status === 403)
142
+ throw new GatewayError('upstream_auth_error', 'Upstream authentication failed', { status: 502, cause: e });
143
+ throw new GatewayError('upstream_error', `Upstream HTTP ${e.status}: ${e.bodyExcerpt}`, { status: 502, cause: e });
144
+ }
145
+ const err = e;
146
+ if (err.name === 'AbortError') {
147
+ if (ttft === null && firstTokenTimer) {
148
+ throw new GatewayError('timeout_error', 'Upstream first token timeout', { status: 504, cause: e });
149
+ }
150
+ throw new GatewayError('timeout_error', 'Upstream stream idle timeout', { status: 504, cause: e });
151
+ }
152
+ throw new GatewayError('upstream_unavailable', `Upstream connection failed: ${err.message}`, { status: 502, cause: e });
153
+ }
154
+ finally {
155
+ clearTimeout(totalTimer);
156
+ if (firstTokenTimer)
157
+ clearTimeout(firstTokenTimer);
158
+ if (idleTimer)
159
+ clearTimeout(idleTimer);
160
+ }
161
+ }
162
+ export class UpstreamHttpError extends Error {
163
+ status;
164
+ body;
165
+ requestId;
166
+ constructor(status, body, requestId) {
167
+ super(`Upstream HTTP ${status}`);
168
+ this.status = status;
169
+ this.body = body;
170
+ this.requestId = requestId;
171
+ this.name = 'UpstreamHttpError';
172
+ }
173
+ get bodyExcerpt() {
174
+ return this.body.slice(0, 500);
175
+ }
176
+ }
177
+ export function upstreamUrl(cfg, path) {
178
+ return `${stripSlash(cfg.baseUrl)}${path.startsWith('/') ? path : '/' + path}`;
179
+ }
@@ -0,0 +1,91 @@
1
+ // CIDR matching for IPv4 + IPv6.
2
+ import ipaddr from 'ipaddr.js';
3
+ export function parseCidr(input) {
4
+ const cidr = input.trim();
5
+ if (!cidr)
6
+ throw new Error('empty cidr');
7
+ let base;
8
+ let prefix;
9
+ if (cidr.includes('/')) {
10
+ const parts = cidr.split('/');
11
+ base = parts[0];
12
+ prefix = Number(parts[1]);
13
+ }
14
+ else {
15
+ base = cidr;
16
+ prefix = -1; // -1 = single host, normalized
17
+ }
18
+ const parsed = ipaddr.parse(base);
19
+ const family = parsed.kind() === 'ipv4' ? 'ipv4' : 'ipv6';
20
+ if (prefix === -1) {
21
+ prefix = family === 'ipv4' ? 32 : 128;
22
+ }
23
+ if (family === 'ipv4' && (prefix < 0 || prefix > 32))
24
+ throw new Error('invalid ipv4 prefix');
25
+ if (family === 'ipv6' && (prefix < 0 || prefix > 128))
26
+ throw new Error('invalid ipv6 prefix');
27
+ const normalized = `${parsed.toString()}/${prefix}`;
28
+ return {
29
+ cidr: normalized,
30
+ family,
31
+ prefix,
32
+ matches(ip) {
33
+ try {
34
+ const addr = ipaddr.parse(ip);
35
+ const addrKind = addr.kind();
36
+ if (addrKind === family) {
37
+ return samePrefix(parsed, addr, prefix);
38
+ }
39
+ // IPv4-mapped IPv6 (::ffff:a.b.c.d) satisfies IPv4 CIDR rules.
40
+ if (family === 'ipv4' && addrKind === 'ipv6') {
41
+ const v4 = addr.toIPv4Address();
42
+ if (!v4)
43
+ return false;
44
+ return samePrefix(parsed, v4, prefix);
45
+ }
46
+ return false;
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ },
52
+ };
53
+ }
54
+ function samePrefix(a, b, prefix) {
55
+ const aBytes = octets(a);
56
+ const bBytes = octets(b);
57
+ const full = aBytes.length * 8;
58
+ if (prefix === 0)
59
+ return true;
60
+ if (prefix >= full)
61
+ return a.toString() === b.toString();
62
+ const fullBytes = Math.floor(prefix / 8);
63
+ const remBits = prefix % 8;
64
+ for (let i = 0; i < fullBytes; i++) {
65
+ if (aBytes[i] !== bBytes[i])
66
+ return false;
67
+ }
68
+ if (remBits === 0)
69
+ return true;
70
+ const mask = (0xff << (8 - remBits)) & 0xff;
71
+ return (aBytes[fullBytes] & mask) === (bBytes[fullBytes] & mask);
72
+ }
73
+ function octets(a) {
74
+ if (a.kind() === 'ipv4') {
75
+ return a.toByteArray();
76
+ }
77
+ return a.toByteArray();
78
+ }
79
+ export function ipMatchesAny(ip, cidrs) {
80
+ for (const c of cidrs) {
81
+ try {
82
+ const parsed = parseCidr(c);
83
+ if (parsed.matches(ip))
84
+ return true;
85
+ }
86
+ catch {
87
+ // ignore invalid cidr
88
+ }
89
+ }
90
+ return false;
91
+ }
@@ -0,0 +1,15 @@
1
+ // Client-IP resolution: trust X-Forwarded-For only when trustProxyHops > 0.
2
+ import { loadConfig } from '../config/index.js';
3
+ export function resolveClientIp(req) {
4
+ const cfg = loadConfig();
5
+ if (cfg.trustProxyHops > 0) {
6
+ const xff = req.headers['x-forwarded-for'];
7
+ if (typeof xff === 'string') {
8
+ const chain = xff.split(',').map((s) => s.trim()).filter(Boolean);
9
+ const idx = Math.max(0, chain.length - cfg.trustProxyHops);
10
+ if (chain[idx])
11
+ return chain[idx];
12
+ }
13
+ }
14
+ return req.ip || (req.socket?.remoteAddress ?? '0.0.0.0');
15
+ }
@@ -0,0 +1,19 @@
1
+ // Stable deterministic JSON serialization for cache key derivation.
2
+ export function stableStringify(value) {
3
+ return JSON.stringify(sortKeys(value));
4
+ }
5
+ function sortKeys(value) {
6
+ if (value === null || value === undefined)
7
+ return value;
8
+ if (Array.isArray(value))
9
+ return value.map(sortKeys);
10
+ if (typeof value === 'object') {
11
+ const obj = value;
12
+ const sorted = {};
13
+ for (const k of Object.keys(obj).sort()) {
14
+ sorted[k] = sortKeys(obj[k]);
15
+ }
16
+ return sorted;
17
+ }
18
+ return value;
19
+ }
@@ -0,0 +1,2 @@
1
+ // Shared public types between server and web admin UI.
2
+ export {};