ldrouter 1.6.0 → 1.6.2

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 (63) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/cli.js +20 -0
  3. package/dist/server/app.js +138 -0
  4. package/dist/server/auth/api-key.js +75 -0
  5. package/dist/server/auth/crypto.js +94 -0
  6. package/dist/server/auth/ids.js +40 -0
  7. package/dist/server/auth/middleware.js +36 -0
  8. package/dist/server/auth/recovery.js +11 -0
  9. package/dist/server/caching/store.js +119 -0
  10. package/dist/server/cli/tui/ansi.js +58 -0
  11. package/dist/server/cli/tui/noise.js +397 -0
  12. package/dist/server/config/index.js +97 -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 +208 -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/server/version.js +35 -0
  57. package/dist/shared/types.js +2 -0
  58. package/dist/web/assets/index-B3mCvc2W.js +251 -0
  59. package/dist/web/assets/index-DSddXVaT.css +1 -0
  60. package/dist/web/favicon.png +0 -0
  61. package/dist/web/index.html +15 -0
  62. package/dist/web/logo.png +0 -0
  63. package/package.json +2 -2
@@ -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,208 @@
1
+ // Self-update: check the npm registry for newer versions and apply them.
2
+ //
3
+ // Two deployment modes:
4
+ // - npm global install: `run()` installs <pkg>@latest globally with the
5
+ // detected package manager, then signals SIGTERM to itself so the regular
6
+ // graceful-shutdown path (app.close + closeDb) runs and the supervisor
7
+ // (systemd/pm2/…) restarts the new version.
8
+ // - Docker: `run()` asks a Watchtower sidecar (HTTP API, label-enable mode)
9
+ // to pull the new image and recreate this container. The registry is the
10
+ // source of truth for "latest" in both modes — npm and the GHCR image are
11
+ // published together from the same release.
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import process from 'node:process';
15
+ import { execa } from 'execa';
16
+ import semver from 'semver';
17
+ import { getLogger } from '../logging/logger.js';
18
+ import { getAppVersion } from '../version.js';
19
+ // Pure: compare two semver versions. Returns null when either side is not a
20
+ // valid semver string (e.g. dev builds), which callers treat as "no update".
21
+ export function compareUpdate(currentVersion, latestVersion) {
22
+ const cur = semver.valid(semver.coerce(currentVersion));
23
+ const latest = semver.valid(semver.coerce(latestVersion));
24
+ if (!cur || !latest)
25
+ return null;
26
+ return semver.gt(latest, cur);
27
+ }
28
+ // Pure: derive the global-install command from where npm resolves its own
29
+ // executable. When the gateway was installed globally with pnpm/yarn/bun,
30
+ // those tools still run `npm exec`/npx under the hood, so
31
+ // process.env.npm_execpath carries the invoking package manager's script.
32
+ export function resolvePackageManager(npmExecPath) {
33
+ const pm = npmExecPath ? path.basename(npmExecPath).toLowerCase() : '';
34
+ if (pm.startsWith('pnpm'))
35
+ return { pm: 'pnpm', installArgs: (pkg) => ['add', '-g', pkg] };
36
+ if (pm.startsWith('yarn'))
37
+ return { pm: 'yarn', installArgs: (pkg) => ['global', 'add', pkg] };
38
+ if (pm.startsWith('bun'))
39
+ return { pm: 'bun', installArgs: (pkg) => ['add', '-g', pkg] };
40
+ return { pm: 'npm', installArgs: (pkg) => ['install', '-g', pkg] };
41
+ }
42
+ export class SelfUpdater {
43
+ packageName;
44
+ currentVersion;
45
+ inDocker;
46
+ cached = null;
47
+ updating = false;
48
+ constructor(packageName, currentVersion, inDocker = fs.existsSync('/.dockerenv')) {
49
+ this.packageName = packageName;
50
+ this.currentVersion = currentVersion;
51
+ this.inDocker = inDocker;
52
+ }
53
+ watchtowerEnabled() {
54
+ return Boolean(process.env.LATEDEV_WATCHTOWER_URL);
55
+ }
56
+ status() {
57
+ const docker = this.inDocker;
58
+ const watchtower = docker && this.watchtowerEnabled();
59
+ if (this.updating)
60
+ return { available: false, reason: 'Update already running', updating: true, docker, watchtower };
61
+ if (docker && !watchtower) {
62
+ return {
63
+ available: false,
64
+ reason: 'Docker deployment without a Watchtower updater — enable the watchtower compose service, or run `docker compose pull && docker compose up -d` on the host',
65
+ updating: false,
66
+ docker: true,
67
+ watchtower: false,
68
+ };
69
+ }
70
+ return { available: true, reason: null, updating: false, docker, watchtower };
71
+ }
72
+ // Checks the npm registry (10s timeout). Cached for 15 minutes; a failed
73
+ // check never raises — it just reports no update.
74
+ async check(force = false) {
75
+ const fresh = !force &&
76
+ this.cached &&
77
+ Date.now() - new Date(this.cached.checkedAt).getTime() < 15 * 60 * 1000;
78
+ if (fresh)
79
+ return this.cached;
80
+ try {
81
+ const controller = new AbortController();
82
+ const timer = setTimeout(() => controller.abort(), 10_000);
83
+ const res = await fetch(`https://registry.npmjs.org/${this.packageName}/latest`, {
84
+ signal: controller.signal,
85
+ headers: { accept: 'application/json' },
86
+ });
87
+ clearTimeout(timer);
88
+ const meta = (res.ok ? (await res.json()) : {});
89
+ const coerced = typeof meta.version === 'string' ? semver.coerce(meta.version) : null;
90
+ const latest = coerced && semver.valid(coerced) ? coerced.version : null;
91
+ const tarball = meta.dist?.tarball ?? null;
92
+ const result = {
93
+ currentVersion: this.currentVersion,
94
+ latestVersion: latest,
95
+ hasUpdate: latest ? compareUpdate(this.currentVersion, latest) === true : false,
96
+ // registry.npmjs.org serves package files (including CHANGELOG.md)
97
+ // straight from the tarball URL.
98
+ changelogUrl: tarball ? tarball.replace(/\/-\/.+$/, '/-/CHANGELOG.md') : null,
99
+ checkedAt: new Date().toISOString(),
100
+ watchtowerReachable: null,
101
+ };
102
+ result.watchtowerReachable = this.inDocker && this.watchtowerEnabled() ? await this.probeWatchtower() : null;
103
+ this.cached = result;
104
+ return result;
105
+ }
106
+ catch (e) {
107
+ getLogger().warn({ err: e.message }, 'update check failed');
108
+ return {
109
+ currentVersion: this.currentVersion,
110
+ latestVersion: null,
111
+ hasUpdate: false,
112
+ changelogUrl: null,
113
+ checkedAt: new Date().toISOString(),
114
+ watchtowerReachable: null,
115
+ };
116
+ }
117
+ }
118
+ // Cheap reachability probe (3s): any HTTP answer means the Watchtower API
119
+ // is up; a connection error means the sidecar service is not running.
120
+ async probeWatchtower() {
121
+ const url = process.env.LATEDEV_WATCHTOWER_URL;
122
+ if (!url)
123
+ return false;
124
+ const headers = {};
125
+ if (process.env.LATEDEV_WATCHTOWER_TOKEN)
126
+ headers.authorization = `Bearer ${process.env.LATEDEV_WATCHTOWER_TOKEN}`;
127
+ const controller = new AbortController();
128
+ const timer = setTimeout(() => controller.abort(), 3_000);
129
+ try {
130
+ // GET on the update endpoint: 404/405 is fine — it proves the API listens.
131
+ await fetch(`${url.replace(/\/$/, '')}/v1/update`, { headers, signal: controller.signal });
132
+ return true;
133
+ }
134
+ catch {
135
+ return false;
136
+ }
137
+ finally {
138
+ clearTimeout(timer);
139
+ }
140
+ }
141
+ async run() {
142
+ const st = this.status();
143
+ if (!st.available)
144
+ throw new Error(st.reason ?? 'Update not available');
145
+ const check = await this.check(true);
146
+ if (!check.latestVersion || !check.hasUpdate)
147
+ throw new Error('No update available');
148
+ this.updating = true;
149
+ const log = getLogger();
150
+ try {
151
+ if (this.inDocker) {
152
+ await this.runWatchtower(check.latestVersion);
153
+ return { ok: true, message: `Watchtower is pulling version ${check.latestVersion} — the container will restart automatically.` };
154
+ }
155
+ const { pm, installArgs } = resolvePackageManager(process.env.npm_execpath);
156
+ log.info({ pm, pkg: this.packageName, to: check.latestVersion }, 'self-update: installing new version');
157
+ await execa(pm, installArgs(`${this.packageName}@${check.latestVersion}`), {
158
+ stdio: 'inherit',
159
+ timeout: 5 * 60 * 1000,
160
+ });
161
+ log.info({ from: this.currentVersion, to: check.latestVersion }, 'self-update: installed, restarting');
162
+ // Signal SIGTERM to ourselves: startApp's handler runs the graceful
163
+ // shutdown (app.close + closeDb), then the supervisor restarts the new
164
+ // version. With no supervisor the process simply stops — the install
165
+ // already succeeded.
166
+ setTimeout(() => process.kill(process.pid, 'SIGTERM'), 500).unref();
167
+ return { ok: true, message: `Updated ${this.currentVersion} → ${check.latestVersion}. Restarting…` };
168
+ }
169
+ finally {
170
+ // If the update failed, allow retries.
171
+ setTimeout(() => { this.updating = false; }, 2000).unref();
172
+ }
173
+ }
174
+ // Ask the Watchtower sidecar (HTTP API, label-enable mode) to update the
175
+ // containers that carry the com.centurylinklabs.watchtower.enable=true
176
+ // label — which includes this one. Watchtower pulls the new image and
177
+ // recreates the container; /data survives via the named volume.
178
+ async runWatchtower(latestVersion) {
179
+ const url = process.env.LATEDEV_WATCHTOWER_URL;
180
+ if (!url)
181
+ throw new Error('LATEDEV_WATCHTOWER_URL is not configured');
182
+ const headers = {};
183
+ if (process.env.LATEDEV_WATCHTOWER_TOKEN)
184
+ headers.authorization = `Bearer ${process.env.LATEDEV_WATCHTOWER_TOKEN}`;
185
+ getLogger().info({ to: latestVersion }, 'self-update: requesting Watchtower update');
186
+ const controller = new AbortController();
187
+ const timer = setTimeout(() => controller.abort(), 60_000);
188
+ let res;
189
+ try {
190
+ res = await fetch(`${url.replace(/\/$/, '')}/v1/update`, { method: 'POST', headers, signal: controller.signal });
191
+ }
192
+ catch (e) {
193
+ clearTimeout(timer);
194
+ throw new Error(`Watchtower is unreachable at ${url}: ${e.message}. Start the watchtower compose service first.`, { cause: e });
195
+ }
196
+ clearTimeout(timer);
197
+ if (!res.ok) {
198
+ const body = await res.text().catch(() => '');
199
+ throw new Error(`Watchtower rejected the update (HTTP ${res.status})${body ? `: ${body.slice(0, 200)}` : ''}`);
200
+ }
201
+ }
202
+ }
203
+ let updater = null;
204
+ export function getSelfUpdater() {
205
+ if (!updater)
206
+ updater = new SelfUpdater('ldrouter', getAppVersion());
207
+ return updater;
208
+ }
@@ -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
+ }