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,745 @@
1
+ // Gateway execution pipeline: auth -> resolve -> ACL -> limits -> route -> attempt -> persist.
2
+ import { getDb, schema } from '../db/index.js';
3
+ import { eq } from 'drizzle-orm';
4
+ import { GatewayError } from '../errors.js';
5
+ import { resolveRequestedModel, unwrapAlias } from '../routing/resolver.js';
6
+ import { deriveRequiredCapabilities, modelMeets } from '../routing/capabilities.js';
7
+ import { loadCombo, selectCandidates, orderCandidates, shouldFallback } from '../routing/combo.js';
8
+ import { getEffectiveState, isOpen, recordSuccess, recordFailure, halfOpenProbeAllowed } from '../routing/circuit.js';
9
+ import { checkRpm, checkTpm, acquireConcurrent, releaseConcurrent } from '../routing/ratelimit.js';
10
+ import { checkDailyMonthly, consumeUsage } from '../routing/quota.js';
11
+ import { keyAllowedFor } from '../auth/api-key.js';
12
+ import { providerToUpstreamConfig, callUpstreamNonStreaming, callUpstreamStreaming, upstreamUrl } from '../upstream/client.js';
13
+ import { canonicalToOpenAIRequest, openAIResponseToCanonical } from '../protocols/canonical.js';
14
+ import { canonicalToAnthropicRequest, anthropicResponseToCanonical } from '../protocols/anthropic.js';
15
+ import { uuid } from '../auth/ids.js';
16
+ import { redactString, redactValue } from '../security/redact.js';
17
+ import { getSettings } from '../db/repositories/settings.js';
18
+ import { buildCacheKey, lookupCache, storeCache, cacheAllowed } from '../caching/store.js';
19
+ import { metrics } from '../metrics/registry.js';
20
+ export class GatewayRunner {
21
+ async execute(req, ctx) {
22
+ const start = Date.now();
23
+ metrics.activeRequests.inc();
24
+ let concurrencyAcquired = false;
25
+ const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, total: 0 };
26
+ const attempts = [];
27
+ // --- Resolve model ---
28
+ const target = resolveRequestedModel(req.canonical.model);
29
+ const resolved = unwrapAlias(target);
30
+ let comboPlan = null;
31
+ if (resolved.kind === 'combo')
32
+ comboPlan = loadCombo(resolved.comboId);
33
+ // --- ACL check ---
34
+ if (ctx.key) {
35
+ if (resolved.kind === 'model') {
36
+ if (!keyAllowedFor(ctx.key, 'model', resolved.modelId))
37
+ throw new GatewayError('permission_error', 'Model not permitted for this API key', { status: 403 });
38
+ }
39
+ else if (resolved.kind === 'combo') {
40
+ if (!keyAllowedFor(ctx.key, 'combo', resolved.comboId))
41
+ throw new GatewayError('permission_error', 'Combo not permitted for this API key', { status: 403 });
42
+ }
43
+ }
44
+ // --- Limits: RPM / TPM / concurrency / quota ---
45
+ if (ctx.key) {
46
+ const rpm = checkRpm(ctx.key.id, ctx.key.rpmLimit);
47
+ if (!rpm.allowed) {
48
+ metrics.rateLimited.inc({ reason: 'rpm' });
49
+ const err = new GatewayError('rate_limit_error', 'Rate limit exceeded (RPM)', { status: 429, code: 'rpm_limit' });
50
+ err.retryAfter = rpm.retryAfterSeconds;
51
+ throw err;
52
+ }
53
+ // Estimate input tokens for TPM: conservative approximation (4 chars/token)
54
+ const estimatedInput = estimateTokens(JSON.stringify(req.canonical.messages));
55
+ const tpm = checkTpm(ctx.key.id, ctx.key.tpmLimit, estimatedInput + (req.canonical.maxOutputTokens ?? 1024));
56
+ if (!tpm.allowed) {
57
+ metrics.rateLimited.inc({ reason: 'tpm' });
58
+ const err = new GatewayError('rate_limit_error', 'Rate limit exceeded (TPM)', { status: 429, code: 'tpm_limit' });
59
+ err.retryAfter = tpm.retryAfterSeconds;
60
+ throw err;
61
+ }
62
+ const quota = checkDailyMonthly(ctx.key.id, ctx.key.dailyTokenLimit, ctx.key.monthlyTokenLimit, estimatedInput + (req.canonical.maxOutputTokens ?? 1024));
63
+ if (!quota.allowed) {
64
+ metrics.rateLimited.inc({ reason: quota.reason ?? 'quota' });
65
+ throw new GatewayError('rate_limit_error', `Quota exceeded (${quota.reason})`, { status: 429, code: 'quota_limit' });
66
+ }
67
+ if (!acquireConcurrent(ctx.key.id, ctx.key.maxConcurrent)) {
68
+ metrics.rateLimited.inc({ reason: 'concurrency' });
69
+ throw new GatewayError('rate_limit_error', 'Too many concurrent requests', { status: 429, code: 'concurrency_limit' });
70
+ }
71
+ concurrencyAcquired = true;
72
+ }
73
+ try {
74
+ // --- Capability requirements ---
75
+ const required = deriveRequiredCapabilities(req.canonical);
76
+ // --- Determine candidates ---
77
+ let candidates = [];
78
+ let selectionReasons = [];
79
+ if (resolved.kind === 'model') {
80
+ candidates = await this.loadModelCandidate(resolved.modelId, required);
81
+ selectionReasons.push('direct_model');
82
+ }
83
+ else if (comboPlan) {
84
+ const all = await this.loadAllModels();
85
+ const filtered = selectCandidates(comboPlan, all, required);
86
+ if (filtered.length === 0) {
87
+ throw new GatewayError('capability_not_supported', 'No combo member satisfies the request capabilities or availability', { status: 400 });
88
+ }
89
+ candidates = orderCandidates(comboPlan, filtered);
90
+ selectionReasons.push('combo');
91
+ }
92
+ if (candidates.length === 0) {
93
+ throw new GatewayError('upstream_unavailable', 'No available model candidates', { status: 502 });
94
+ }
95
+ // --- Gateway response cache check ---
96
+ const settings = getSettings();
97
+ const keyCacheOverride = ctx.key?.cacheOverrideEnabled ?? null;
98
+ const targetCacheOverride = this.getTargetCacheOverride(resolved);
99
+ const allowed = cacheAllowed({
100
+ globalEnabled: settings.gatewayCacheEnabled,
101
+ keyAllowed: keyCacheOverride,
102
+ targetAllowed: targetCacheOverride,
103
+ streaming: req.canonical.stream,
104
+ });
105
+ if (allowed && !req.canonical.stream && !hasTools(req.canonical)) {
106
+ const configVersion = this.getTargetConfigVersion(resolved);
107
+ const cacheKey = buildCacheKey({
108
+ protocol: req.protocol,
109
+ resolvedTargetKind: resolved.kind,
110
+ resolvedTargetId: this.resolvedId(resolved),
111
+ configVersion,
112
+ canonicalRequest: req.canonical,
113
+ });
114
+ const cached = lookupCache(cacheKey);
115
+ if (cached.hit && cached.payload) {
116
+ metrics.cacheHits.inc();
117
+ const g = this.cachedToOutcome(cached.payload, cached.usage, ctx, resolved, start);
118
+ await this.persistRequest(req, ctx, resolved, g, usageFromCache(cached.usage), attempts, true);
119
+ metrics.activeRequests.dec();
120
+ return g;
121
+ }
122
+ // remember cacheKey for storing after success
123
+ req._cacheKey = cacheKey;
124
+ req._cacheConfigVersion = configVersion;
125
+ }
126
+ // --- Attempt loop with fallback ---
127
+ const maxAttempts = comboPlan?.maxTotalAttempts ?? (candidates.length > 1 ? 2 : 1);
128
+ let lastError = null;
129
+ let sentToClient = false; // semantically committed output sent
130
+ let resultText = '';
131
+ let resultToolCalls = [];
132
+ let resultFinishReason = null;
133
+ let finalModelId = null;
134
+ for (let i = 0; i < maxAttempts && i < candidates.length; i++) {
135
+ const candidate = candidates[i];
136
+ finalModelId = candidate.modelId;
137
+ const provider = getDb().select().from(schema.providers).where(eq(schema.providers.id, candidate.providerId)).get();
138
+ if (!provider || !provider.enabled) {
139
+ attempts.push(this.failedAttempt(i + 1, candidate, provider?.name ?? '', 'skipped_disabled_provider', null, null, 0, null, null));
140
+ continue;
141
+ }
142
+ const eff = getEffectiveState(provider.id, provider.cbCooldownSeconds);
143
+ if (eff === 'open' && !halfOpenProbeAllowed(provider.id)) {
144
+ attempts.push(this.failedAttempt(i + 1, candidate, provider.name, 'circuit_open', null, null, 0, null, null));
145
+ if (comboPlan && shouldFallback(comboPlan, { type: 'connection_error' })) {
146
+ metrics.fallbackCount.inc();
147
+ continue;
148
+ }
149
+ lastError = new GatewayError('upstream_unavailable', 'Provider circuit is open', { status: 502 });
150
+ break;
151
+ }
152
+ const cfg = providerToUpstreamConfig(provider);
153
+ const attemptStart = Date.now();
154
+ const attempt = {
155
+ attemptNumber: i + 1,
156
+ providerId: provider.id,
157
+ modelId: candidate.modelId,
158
+ providerName: provider.name,
159
+ startedAt: new Date(attemptStart).toISOString(),
160
+ completedAt: '',
161
+ statusCode: null,
162
+ success: false,
163
+ latencyMs: 0,
164
+ ttftMs: null,
165
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, total: 0 },
166
+ streamStarted: false,
167
+ partialResponse: false,
168
+ selectionReason: selectionReasons[0] ?? 'direct',
169
+ failureReason: null,
170
+ sanitizedError: null,
171
+ upstreamRequestId: null,
172
+ };
173
+ try {
174
+ const out = await this.runOneAttempt(req, ctx, candidate, provider.name, cfg, required, (isStreamStarted) => {
175
+ attempt.streamStarted = isStreamStarted;
176
+ attempt.ttftMs = Date.now() - attemptStart;
177
+ });
178
+ attempt.statusCode = out.statusCode ?? null;
179
+ attempt.success = true;
180
+ attempt.latencyMs = Date.now() - attemptStart;
181
+ attempt.ttftMs = out.ttftMs;
182
+ attempt.usage = out.usage;
183
+ attempt.upstreamRequestId = out.upstreamRequestId;
184
+ attempt.completedAt = new Date().toISOString();
185
+ attempt.result = out.result;
186
+ usage.input += out.usage.input;
187
+ usage.output += out.usage.output;
188
+ usage.cacheRead += out.usage.cacheRead;
189
+ usage.cacheWrite += out.usage.cacheWrite;
190
+ usage.reasoning += out.usage.reasoning;
191
+ usage.total += out.usage.total;
192
+ resultText = out.result.text;
193
+ resultToolCalls = out.result.toolCalls;
194
+ resultFinishReason = out.result.finishReason;
195
+ recordSuccess(provider.id);
196
+ getDb().update(schema.providers).set({ healthState: 'healthy', updatedAt: new Date().toISOString() }).where(eq(schema.providers.id, provider.id)).run();
197
+ attempts.push(attempt);
198
+ sentToClient = out.streamStarted ?? false;
199
+ break;
200
+ }
201
+ catch (e) {
202
+ const err = e instanceof GatewayError ? e : new GatewayError('upstream_error', e.message, { cause: e });
203
+ const shouldRetry = comboPlan ? shouldFallback(comboPlan, { type: classifyFailure(err), status: err.status }) : false;
204
+ attempt.statusCode = err.status;
205
+ attempt.success = false;
206
+ attempt.latencyMs = Date.now() - attemptStart;
207
+ attempt.completedAt = new Date().toISOString();
208
+ attempt.failureReason = classifyFailure(err);
209
+ attempt.sanitizedError = redactString(err.message);
210
+ // If stream already started sending content, do NOT fallback
211
+ if (attempt.streamStarted) {
212
+ attempt.partialResponse = true;
213
+ attempts.push(attempt);
214
+ sentToClient = true;
215
+ void sentToClient;
216
+ lastError = err;
217
+ break;
218
+ }
219
+ attempts.push(attempt);
220
+ lastError = err;
221
+ recordFailure(provider.id, provider.cbFailureThreshold, provider.cbCooldownSeconds);
222
+ getDb().update(schema.providers).set({ healthState: 'down', updatedAt: new Date().toISOString() }).where(eq(schema.providers.id, provider.id)).run();
223
+ if (shouldRetry && i + 1 < maxAttempts) {
224
+ metrics.fallbackCount.inc();
225
+ continue;
226
+ }
227
+ break;
228
+ }
229
+ }
230
+ // Build outcome
231
+ const latencyMs = Date.now() - start;
232
+ const outcome = {
233
+ success: !lastError,
234
+ httpStatus: lastError ? lastError.status : 200,
235
+ errorType: lastError?.type ?? null,
236
+ errorMessage: lastError ? redactString(lastError.message) : null,
237
+ text: lastError ? null : resultText,
238
+ toolCalls: lastError ? null : resultToolCalls,
239
+ finishReason: lastError ? null : resultFinishReason,
240
+ usage,
241
+ ttftMs: attempts.find((a) => a.ttftMs != null)?.ttftMs ?? null,
242
+ latencyMs,
243
+ attempts,
244
+ finalModelId,
245
+ resolvedTargetKind: resolved.kind,
246
+ resolvedTargetId: resolved.kind === 'model' ? resolved.modelId : resolved.kind === 'combo' ? resolved.comboId : null,
247
+ gatewayCacheHit: false,
248
+ streamEvents: null,
249
+ };
250
+ // Store in gateway cache if eligible
251
+ const cacheMeta = req;
252
+ if (outcome.success && cacheMeta._cacheKey && !req.canonical.stream && !hasTools(req.canonical)) {
253
+ const payload = this.buildCachedPayload(req, ctx, outcome);
254
+ storeCache({
255
+ cacheKey: cacheMeta._cacheKey,
256
+ targetKind: resolved.kind,
257
+ targetId: outcome.resolvedTargetId ?? '',
258
+ configVersion: cacheMeta._cacheConfigVersion ?? 1,
259
+ protocol: req.protocol,
260
+ payload,
261
+ usage: usage,
262
+ ttlSeconds: settings.gatewayCacheDefaultTtlSeconds,
263
+ });
264
+ }
265
+ await this.persistRequest(req, ctx, resolved, outcome, usage, attempts, false);
266
+ metrics.requestsTotal.inc({ protocol: req.protocol, status: String(outcome.httpStatus) });
267
+ metrics.requestDuration.observe(outcome.latencyMs, { protocol: req.protocol });
268
+ if (outcome.ttftMs != null)
269
+ metrics.requestTtft.observe(outcome.ttftMs, { protocol: req.protocol });
270
+ metrics.tokensInput.inc({ protocol: req.protocol }, usage.input);
271
+ metrics.tokensOutput.inc({ protocol: req.protocol }, usage.output);
272
+ metrics.tokensCacheRead.inc({ protocol: req.protocol }, usage.cacheRead);
273
+ metrics.tokensCacheWrite.inc({ protocol: req.protocol }, usage.cacheWrite);
274
+ metrics.tokensReasoning.inc({ protocol: req.protocol }, usage.reasoning);
275
+ if (ctx.key)
276
+ consumeUsage(ctx.key.id, usage.input, usage.output);
277
+ return outcome;
278
+ }
279
+ finally {
280
+ if (concurrencyAcquired && ctx.key)
281
+ releaseConcurrent(ctx.key.id);
282
+ metrics.activeRequests.dec();
283
+ }
284
+ }
285
+ async loadModelCandidate(modelId, required) {
286
+ const db = getDb();
287
+ const m = db.select().from(schema.models).where(eq(schema.models.id, modelId)).get();
288
+ if (!m)
289
+ return [];
290
+ const p = db.select().from(schema.providers).where(eq(schema.providers.id, m.providerId)).get();
291
+ if (!p || !p.enabled)
292
+ return [];
293
+ const caps = safeJson(m.capabilitiesJson);
294
+ const candidate = {
295
+ modelId: m.id,
296
+ publicModelId: m.publicModelId,
297
+ providerId: m.providerId,
298
+ enabled: m.enabled,
299
+ upstreamAvailable: m.upstreamAvailable,
300
+ circuitOpen: isOpen(m.providerId),
301
+ capabilities: caps,
302
+ };
303
+ if (!m.enabled || !m.upstreamAvailable)
304
+ return [];
305
+ if (candidate.circuitOpen)
306
+ return [];
307
+ if (!modelMeets(caps, required))
308
+ return [];
309
+ return [candidate];
310
+ }
311
+ async loadAllModels() {
312
+ const db = getDb();
313
+ const models = db.select().from(schema.models).all();
314
+ const providers = db.select().from(schema.providers).all();
315
+ const providerEnabled = new Map(providers.map((p) => [p.id, p.enabled]));
316
+ return models.map((m) => ({
317
+ modelId: m.id,
318
+ publicModelId: m.publicModelId,
319
+ providerId: m.providerId,
320
+ enabled: m.enabled,
321
+ upstreamAvailable: m.upstreamAvailable,
322
+ circuitOpen: isOpen(m.providerId),
323
+ capabilities: safeJson(m.capabilitiesJson),
324
+ })).filter((m) => m.enabled && m.upstreamAvailable && providerEnabled.get(m.providerId));
325
+ }
326
+ async runOneAttempt(req, ctx, candidate, providerName, cfg, required, onStreamStart) {
327
+ if (req.canonical.stream) {
328
+ return this.runStreamingAttempt(req, ctx, candidate, cfg, onStreamStart);
329
+ }
330
+ return this.runNonStreamingAttempt(req, candidate, cfg);
331
+ }
332
+ async runNonStreamingAttempt(req, candidate, cfg) {
333
+ const upstreamModel = candidate.publicModelId.split('/').slice(1).join('/');
334
+ let call;
335
+ if (cfg.type === 'openai') {
336
+ const payload = canonicalToOpenAIRequest(req.canonical, upstreamModel);
337
+ call = await callUpstreamNonStreaming(cfg, upstreamUrl(cfg, '/v1/chat/completions'), payload);
338
+ }
339
+ else {
340
+ const payload = canonicalToAnthropicRequest(req.canonical, upstreamModel);
341
+ call = await callUpstreamNonStreaming(cfg, upstreamUrl(cfg, '/v1/messages'), payload);
342
+ }
343
+ if (!call.ok) {
344
+ if (call.status === 429)
345
+ throw new GatewayError('upstream_rate_limit', `Upstream rate limited (HTTP ${call.status})`, { status: 429, code: 'upstream_http_429' });
346
+ if (call.status === 401 || call.status === 403)
347
+ throw new GatewayError('upstream_auth_error', 'Upstream authentication failed', { status: 502 });
348
+ if (call.status >= 500)
349
+ throw new GatewayError('upstream_error', `Upstream HTTP ${call.status}`, { status: 502, code: `upstream_http_${call.status}` });
350
+ throw new GatewayError('upstream_error', `Upstream HTTP ${call.status}: ${redactString(call.text.slice(0, 300))}`, { status: 502 });
351
+ }
352
+ let parsed;
353
+ try {
354
+ parsed = JSON.parse(call.text);
355
+ }
356
+ catch {
357
+ throw new GatewayError('upstream_error', 'Upstream returned invalid JSON', { status: 502 });
358
+ }
359
+ let result;
360
+ let usage;
361
+ if (cfg.type === 'openai') {
362
+ const conv = openAIResponseToCanonical(parsed, req.canonical.model);
363
+ result = { text: conv.text, toolCalls: conv.toolCalls, finishReason: conv.finishReason };
364
+ usage = conv.usage;
365
+ }
366
+ else {
367
+ const conv = anthropicResponseToCanonical(parsed, req.canonical.model);
368
+ result = { text: conv.text, toolCalls: conv.toolCalls, finishReason: conv.finishReason };
369
+ usage = conv.usage;
370
+ }
371
+ return { statusCode: call.status, ttftMs: call.ttftMs, upstreamRequestId: call.upstreamRequestId, usage, result };
372
+ }
373
+ async runStreamingAttempt(req, ctx, candidate, cfg, onStreamStart) {
374
+ const upstreamModel = candidate.publicModelId.split('/').slice(1).join('/');
375
+ const encoder = req.protocol === 'openai' ? openaiStreamEncoder : anthropicStreamEncoder;
376
+ // Hard streaming invariant: the client SSE head is NOT written until the
377
+ // upstream confirms by delivering its first chunk (or completes). Before
378
+ // that first chunk, a pre-stream failure may fall back to the next model.
379
+ // Once content hits the client, fallback is forbidden (see docs/04).
380
+ const pipe = ctx.reply.raw;
381
+ let headWritten = false;
382
+ const writeHead = () => {
383
+ if (headWritten)
384
+ return;
385
+ headWritten = true;
386
+ pipe.writeHead(200, {
387
+ 'content-type': 'text/event-stream',
388
+ 'cache-control': 'no-cache',
389
+ connection: 'keep-alive',
390
+ 'x-request-id': ctx.requestId,
391
+ });
392
+ };
393
+ let streamStarted = false;
394
+ let textBuf = '';
395
+ const toolBuf = [];
396
+ let finishReason = null;
397
+ const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, total: 0 };
398
+ const chunkHandler = (chunk, isFirst) => {
399
+ // Track usage/finish (protocol-agnostic; runs even on the first chunk).
400
+ try {
401
+ const obj = JSON.parse(chunk.data);
402
+ if (cfg.type === 'openai') {
403
+ const choice = obj.choices?.[0];
404
+ if (choice?.delta?.content)
405
+ textBuf += choice.delta.content;
406
+ if (choice?.delta?.tool_calls) {
407
+ for (const tc of choice.delta.tool_calls) {
408
+ if (tc.function?.name)
409
+ toolBuf.push({ id: tc.id ?? '', name: tc.function.name, input: {} });
410
+ }
411
+ }
412
+ if (choice?.finish_reason)
413
+ finishReason = choice.finish_reason;
414
+ if (obj.usage) {
415
+ usage.input = obj.usage.prompt_tokens ?? usage.input;
416
+ usage.output = obj.usage.completion_tokens ?? usage.output;
417
+ usage.cacheRead = obj.usage.prompt_tokens_details?.cached_tokens ?? usage.cacheRead;
418
+ usage.reasoning = obj.usage.completion_tokens_details?.reasoning_tokens ?? usage.reasoning;
419
+ usage.total = obj.usage.total_tokens ?? usage.total;
420
+ }
421
+ }
422
+ else {
423
+ // anthropic stream events
424
+ if (obj.type === 'content_block_delta' && obj.delta?.text)
425
+ textBuf += obj.delta.text;
426
+ if (obj.type === 'content_block_start' && obj.content_block?.type === 'tool_use')
427
+ toolBuf.push({ id: obj.content_block.id, name: obj.content_block.name, input: obj.content_block.input ?? {} });
428
+ if (obj.type === 'message_delta' && obj.delta?.stop_reason)
429
+ finishReason = obj.delta.stop_reason;
430
+ if (obj.type === 'message_start' && obj.message?.usage) {
431
+ usage.input = obj.message.usage.input_tokens ?? 0;
432
+ usage.cacheRead = obj.message.usage.cache_read_input_tokens ?? 0;
433
+ usage.cacheWrite = obj.message.usage.cache_creation_input_tokens ?? 0;
434
+ }
435
+ if (obj.type === 'message_delta' && obj.usage) {
436
+ usage.output = obj.usage.output_tokens ?? usage.output;
437
+ }
438
+ }
439
+ }
440
+ catch {
441
+ // ignore parse errors in stream
442
+ }
443
+ // First chunk: commit the downstream SSE response and flush any buffered.
444
+ if (isFirst) {
445
+ streamStarted = true;
446
+ onStreamStart(true);
447
+ }
448
+ const encoded = encoder(chunk.data, chunk.event);
449
+ if (!headWritten)
450
+ writeHead();
451
+ if (encoded)
452
+ pipe.write(encoded);
453
+ return;
454
+ };
455
+ try {
456
+ const url = cfg.type === 'openai' ? upstreamUrl(cfg, '/v1/chat/completions') : upstreamUrl(cfg, '/v1/messages');
457
+ const payload = cfg.type === 'openai' ? canonicalToOpenAIRequest(req.canonical, upstreamModel) : canonicalToAnthropicRequest(req.canonical, upstreamModel);
458
+ const meta = await callUpstreamStreaming(cfg, url, payload, chunkHandler);
459
+ // Upstream completed cleanly: ensure head + terminator are written.
460
+ if (!headWritten)
461
+ writeHead();
462
+ pipe.write('data: [DONE]\n\n');
463
+ pipe.end();
464
+ if (!usage.total)
465
+ usage.total = usage.input + usage.output;
466
+ return {
467
+ statusCode: 200,
468
+ ttftMs: meta.ttftMs,
469
+ upstreamRequestId: meta.upstreamRequestId,
470
+ usage,
471
+ result: { text: textBuf, toolCalls: toolBuf, finishReason },
472
+ };
473
+ }
474
+ catch (e) {
475
+ // Stream ended with failure. If we already sent content, mark partial and terminate.
476
+ if (streamStarted) {
477
+ // Ensure the (already-committed) response is closed.
478
+ if (!headWritten)
479
+ writeHead();
480
+ pipe.end();
481
+ const err = e instanceof GatewayError ? e : new GatewayError('upstream_error', e.message);
482
+ throw err;
483
+ }
484
+ throw e;
485
+ }
486
+ }
487
+ failedAttempt(n, candidate, providerName, failureReason, statusCode, sanitizedError, latencyMs, ttftMs, upstreamRequestId) {
488
+ const now = new Date().toISOString();
489
+ return {
490
+ attemptNumber: n,
491
+ providerId: candidate.providerId,
492
+ modelId: candidate.modelId,
493
+ providerName,
494
+ startedAt: now,
495
+ completedAt: now,
496
+ statusCode,
497
+ success: false,
498
+ latencyMs,
499
+ ttftMs,
500
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, total: 0 },
501
+ streamStarted: false,
502
+ partialResponse: false,
503
+ selectionReason: 'candidate',
504
+ failureReason,
505
+ sanitizedError,
506
+ upstreamRequestId,
507
+ };
508
+ }
509
+ getTargetCacheOverride(resolved) {
510
+ const db = getDb();
511
+ if (resolved.kind === 'model' && 'modelId' in resolved && resolved.modelId) {
512
+ const m = db.select().from(schema.models).where(eq(schema.models.id, resolved.modelId)).get();
513
+ return m?.cacheOverrideEnabled ?? null;
514
+ }
515
+ if (resolved.kind === 'combo' && 'comboId' in resolved && resolved.comboId) {
516
+ const c = db.select().from(schema.combos).where(eq(schema.combos.id, resolved.comboId)).get();
517
+ return c?.cacheOverrideEnabled ?? null;
518
+ }
519
+ return null;
520
+ }
521
+ getTargetConfigVersion(resolved) {
522
+ if (resolved.kind === 'combo' && 'comboId' in resolved && resolved.comboId) {
523
+ const db = getDb();
524
+ const c = db.select().from(schema.combos).where(eq(schema.combos.id, resolved.comboId)).get();
525
+ return c?.configVersion ?? 1;
526
+ }
527
+ return 1;
528
+ }
529
+ resolvedId(resolved) {
530
+ if (resolved.kind === 'model' && resolved.modelId)
531
+ return resolved.modelId;
532
+ if (resolved.kind === 'combo' && resolved.comboId)
533
+ return resolved.comboId;
534
+ return '';
535
+ }
536
+ buildCachedPayload(req, ctx, outcome) {
537
+ // Encode canonical outcome into the originating protocol's JSON response shape.
538
+ if (req.protocol === 'openai') {
539
+ return {
540
+ id: `chatcmpl-${ctx.requestId}`,
541
+ object: 'chat.completion',
542
+ created: Math.floor(Date.now() / 1000),
543
+ model: req.canonical.model,
544
+ choices: [
545
+ {
546
+ index: 0,
547
+ message: {
548
+ role: 'assistant',
549
+ content: outcome.text,
550
+ ...(outcome.toolCalls && outcome.toolCalls.length ? { tool_calls: outcome.toolCalls.map((tc) => ({ id: tc.id, type: 'function', function: { name: tc.name, arguments: JSON.stringify(tc.input) } })) } : {}),
551
+ },
552
+ finish_reason: outcome.finishReason,
553
+ },
554
+ ],
555
+ usage: {
556
+ prompt_tokens: outcome.usage.input,
557
+ completion_tokens: outcome.usage.output,
558
+ total_tokens: outcome.usage.total,
559
+ },
560
+ };
561
+ }
562
+ return {
563
+ id: `msg_${ctx.requestId}`,
564
+ type: 'message',
565
+ role: 'assistant',
566
+ model: req.canonical.model,
567
+ content: [
568
+ ...(outcome.text ? [{ type: 'text', text: outcome.text }] : []),
569
+ ...(outcome.toolCalls ?? []).map((tc) => ({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.input })),
570
+ ],
571
+ stop_reason: outcome.finishReason,
572
+ usage: {
573
+ input_tokens: outcome.usage.input,
574
+ output_tokens: outcome.usage.output,
575
+ cache_read_input_tokens: outcome.usage.cacheRead,
576
+ cache_creation_input_tokens: outcome.usage.cacheWrite,
577
+ },
578
+ };
579
+ }
580
+ cachedToOutcome(payload, usage, ctx, resolved, start) {
581
+ const p = payload;
582
+ let text = '';
583
+ let toolCalls = [];
584
+ let finishReason = null;
585
+ if (p.choices) {
586
+ const c = p.choices[0];
587
+ text = c?.message?.content ?? '';
588
+ toolCalls = (c?.message?.tool_calls ?? []).map((tc) => ({ id: tc.id, name: tc.function.name, input: safeJsonParse(tc.function.arguments) }));
589
+ finishReason = c?.finish_reason ?? null;
590
+ }
591
+ else if (p.content) {
592
+ text = p.content.filter((b) => b.type === 'text').map((b) => b.text ?? '').join('');
593
+ toolCalls = p.content.filter((b) => b.type === 'tool_use').map((b) => ({ id: b.id ?? '', name: b.name ?? '', input: b.input ?? {} }));
594
+ finishReason = p.stop_reason ?? null;
595
+ }
596
+ const u = usage ? { ...usage, total: usage.total ?? usage.input + usage.output } : { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, total: 0 };
597
+ return {
598
+ success: true,
599
+ httpStatus: 200,
600
+ errorType: null,
601
+ errorMessage: null,
602
+ text,
603
+ toolCalls,
604
+ finishReason,
605
+ usage: u,
606
+ ttftMs: 0,
607
+ latencyMs: Date.now() - start,
608
+ attempts: [],
609
+ finalModelId: resolved.kind === 'model' && 'modelId' in resolved ? resolved.modelId ?? null : null,
610
+ resolvedTargetKind: resolved.kind,
611
+ resolvedTargetId: null,
612
+ gatewayCacheHit: true,
613
+ streamEvents: null,
614
+ };
615
+ }
616
+ async persistRequest(req, ctx, resolved, outcome, usage, attempts, cacheHit) {
617
+ const db = getDb();
618
+ const settings = getSettings();
619
+ const requestId = ctx.requestId;
620
+ const now = new Date().toISOString();
621
+ let requestPayload = null;
622
+ let responsePayload = null;
623
+ if (settings.contentLogMode === 'prompt' || settings.contentLogMode === 'prompt_and_response') {
624
+ requestPayload = JSON.stringify(redactValue(req.canonical));
625
+ }
626
+ if (settings.contentLogMode === 'prompt_and_response') {
627
+ responsePayload = JSON.stringify(redactValue({ text: outcome.text, toolCalls: outcome.toolCalls, finishReason: outcome.finishReason }));
628
+ }
629
+ db.insert(schema.requests).values({
630
+ id: requestId,
631
+ createdAt: now,
632
+ completedAt: now,
633
+ apiKeyId: ctx.key?.id ?? null,
634
+ keyPrefixSnapshot: ctx.key?.keyPrefix ?? null,
635
+ clientIp: ctx.clientIp,
636
+ protocol: ctx.protocol,
637
+ endpoint: req.endpoint,
638
+ requestedModel: req.canonical.model,
639
+ resolvedTargetKind: outcome.resolvedTargetKind,
640
+ resolvedTargetId: outcome.resolvedTargetId,
641
+ finalModelId: outcome.finalModelId,
642
+ streaming: req.canonical.stream,
643
+ httpStatus: outcome.httpStatus,
644
+ success: outcome.success ? 1 : 0,
645
+ totalLatencyMs: outcome.latencyMs,
646
+ ttftMs: outcome.ttftMs,
647
+ inputTokens: usage.input,
648
+ outputTokens: usage.output,
649
+ cacheReadTokens: usage.cacheRead,
650
+ cacheWriteTokens: usage.cacheWrite,
651
+ reasoningTokens: usage.reasoning,
652
+ totalTokens: usage.total,
653
+ attemptsCount: attempts.length,
654
+ errorType: outcome.errorType,
655
+ errorMessage: outcome.errorMessage ? redactString(outcome.errorMessage) : null,
656
+ requestPayloadJson: requestPayload,
657
+ responsePayloadJson: responsePayload,
658
+ gatewayCacheHit: cacheHit ? true : false,
659
+ }).run();
660
+ for (const a of attempts) {
661
+ db.insert(schema.requestAttempts).values({
662
+ id: uuid(),
663
+ requestId,
664
+ attemptNumber: a.attemptNumber,
665
+ providerId: a.providerId,
666
+ modelId: a.modelId,
667
+ startedAt: a.startedAt,
668
+ completedAt: a.completedAt,
669
+ statusCode: a.statusCode,
670
+ success: a.success,
671
+ latencyMs: a.latencyMs,
672
+ ttftMs: a.ttftMs,
673
+ inputTokens: a.usage.input,
674
+ outputTokens: a.usage.output,
675
+ cacheReadTokens: a.usage.cacheRead,
676
+ cacheWriteTokens: a.usage.cacheWrite,
677
+ reasoningTokens: a.usage.reasoning,
678
+ streamStarted: a.streamStarted ? 1 : 0,
679
+ partialResponse: a.partialResponse ? 1 : 0,
680
+ selectionReason: a.selectionReason,
681
+ failureReason: a.failureReason,
682
+ errorMessage: a.sanitizedError ? redactString(a.sanitizedError) : null,
683
+ upstreamRequestId: a.upstreamRequestId,
684
+ }).run();
685
+ }
686
+ }
687
+ }
688
+ function classifyFailure(err) {
689
+ switch (err.type) {
690
+ case 'timeout_error':
691
+ return err.message.includes('first token') ? 'first_token_timeout' : 'connect_timeout';
692
+ case 'upstream_unavailable':
693
+ return 'connection_error';
694
+ case 'upstream_rate_limit':
695
+ return 'http_status';
696
+ case 'upstream_error':
697
+ return err.status >= 500 ? 'http_status' : 'connection_error';
698
+ default:
699
+ return 'unknown';
700
+ }
701
+ }
702
+ function hasTools(req) {
703
+ if (req.tools && req.tools.length > 0)
704
+ return true;
705
+ for (const m of req.messages) {
706
+ for (const b of m.content) {
707
+ if (b.type === 'tool_use' || b.type === 'tool_result')
708
+ return true;
709
+ }
710
+ }
711
+ return false;
712
+ }
713
+ function estimateTokens(s) {
714
+ return Math.ceil(s.length / 4);
715
+ }
716
+ function safeJson(s) {
717
+ try {
718
+ return JSON.parse(s);
719
+ }
720
+ catch {
721
+ return {};
722
+ }
723
+ }
724
+ function safeJsonParse(s) {
725
+ try {
726
+ return JSON.parse(s);
727
+ }
728
+ catch {
729
+ return s;
730
+ }
731
+ }
732
+ function usageFromCache(u) {
733
+ return u ? { ...u, total: u.input + u.output } : { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, total: 0 };
734
+ }
735
+ // SSE encoders: canonical stream chunk -> client protocol event
736
+ function openaiStreamEncoder(data, _event) {
737
+ if (data === '[DONE]')
738
+ return 'data: [DONE]\n\n';
739
+ return `data: ${data}\n\n`;
740
+ }
741
+ function anthropicStreamEncoder(data, _event) {
742
+ if (data === '[DONE]')
743
+ return 'data: [DONE]\n\n';
744
+ return `data: ${data}\n\n`;
745
+ }