ldrouter 1.11.17 → 1.12.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,21 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/) and the project adheres to
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [1.12.0] - 2026-09-05
8
+
9
+ ### Added
10
+
11
+ - **Request-lifecycle debug logging** (`docs/13-LOGGING.md`): full observability of every request as it passes through the gateway, emitted to stdout/stderr so `docker logs` collects it.
12
+ - Per-request ID (reuses `x-request-id` or generates one) tagged on every line — `docker logs ldrouter 2>&1 | grep req_xxx` reconstructs the whole lifecycle.
13
+ - `[INCOMING]` → `[BODY SUMMARY]` → `[MESSAGES]` → `[TOOLS]` → `[MODEL RESOLVE]` → `[CAPABILITIES REQUIRED]` → `[CAPABILITY REJECT]` → `[ATTEMPT]` → `[UPSTREAM REQUEST]` → `[UPSTREAM RESPONSE]` → `[STREAM START/END/ERROR]` → `[DONE]`.
14
+ - Per-candidate rejection reasons for the `No combo member satisfies...` error (why each member was filtered: `member_disabled`, `model_not_found`, `upstream_unavailable`, `circuit_open`, `tools`, `reasoning`, `streaming`, etc.).
15
+ - Nested `error.cause` / undici stack capture for `UPSTREAM FETCH ERROR` (no more bare `fetch failed`).
16
+ - Stream counters (chunks/bytes/first-chunk-ms) and client-disconnect detection.
17
+ - `[CONFIG]` line at startup printing body limit + active debug flags.
18
+ - Replaced `uncaughtException`/`unhandledRejection` no-op `{}` handlers with full message/stack/cause logging.
19
+ - **Debug env flags**: `DEBUG_HTTP`, `DEBUG_HTTP_BODY`, `DEBUG_UPSTREAM`, `DEBUG_STREAM`, plus `LOG_LEVEL` alias for `LATEDEV_LOG_LEVEL` (all default off; enable for one-shot reproduction).
20
+ - **Secret redaction**: all debug output routes through the existing `redact.ts` — provider keys are fingerprinted, never logged; `Authorization`/`x-api-key`/cookies always masked.
21
+
7
22
  ## [1.11.16] - 2026-09-04
8
23
 
9
24
  ### Fixed
@@ -21,6 +21,7 @@ import { registerGatewayRoutes } from './routes/gateway.js';
21
21
  import { registerHealthRoutes } from './routes/health.js';
22
22
  import { registerAdminIpGate } from './security/admin-ip-gate.js';
23
23
  import { metricsRegistry } from './metrics/registry.js';
24
+ import { fatal, lifecycle, formatError, getDebugFlags, errorLine } from './logging/debug.js';
24
25
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
26
  export async function buildApp(opts = {}) {
26
27
  const cfg = loadConfig();
@@ -63,11 +64,21 @@ export async function buildApp(opts = {}) {
63
64
  name: normalized instanceof Error ? normalized.name : undefined,
64
65
  message: errMsg,
65
66
  stack: normalized instanceof Error ? normalized.stack : undefined,
67
+ cause: normalized instanceof Error ? normalized.cause : undefined,
66
68
  };
67
69
  if (status >= 500)
68
70
  log.error({ requestId, url: req.url, err: errDetail }, 'request error');
69
71
  else
70
72
  log.warn({ requestId, url: req.url, err: { type: (g?.type ?? 'error'), message: errMsg } }, 'request rejected');
73
+ // docs/13 §19: server-side stack with request context (never to client).
74
+ if (status >= 500) {
75
+ errorLine(requestId, 'SERVER ERROR', [
76
+ `route=${req.routeOptions?.url ?? req.url}`,
77
+ `method=${req.method}`,
78
+ `statusCode=${status}`,
79
+ ...formatError(normalized),
80
+ ]);
81
+ }
71
82
  const accept = (req.headers['accept'] ?? '').toString();
72
83
  const isAnthropic = accept.includes('application/vnd.anthropic') || req.url.includes('/v1/messages');
73
84
  if (g) {
@@ -82,9 +93,21 @@ export async function buildApp(opts = {}) {
82
93
  registerAdminIpGate(app);
83
94
  // Operational routes (always available)
84
95
  await registerHealthRoutes(app);
85
- // Debug logging for request lifecycle (before routes)
86
- const { registerDebugHook } = await import('./logging/debug.js');
87
- registerDebugHook(app);
96
+ // docs/13 §20: print effective config so operators know body limits and
97
+ // which debug flags are active for the running process.
98
+ const dbg = getDebugFlags();
99
+ lifecycle('-', 'CONFIG', [
100
+ `host=${cfg.host}`,
101
+ `port=${cfg.port}`,
102
+ `dataDir=${cfg.dataDir}`,
103
+ `logLevel=${cfg.logLevel}`,
104
+ `trustProxyHops=${cfg.trustProxyHops}`,
105
+ `bodyLimit=${64 * 1024 * 1024} bytes`,
106
+ `debugHttp=${dbg.http}`,
107
+ `debugHttpBody=${dbg.httpBody}`,
108
+ `debugUpstream=${dbg.upstream}`,
109
+ `debugStream=${dbg.stream}`,
110
+ ]);
88
111
  // Admin + gateway routes MUST be registered BEFORE static files to avoid
89
112
  // 404s falling through to SPA index.html or static assets being served instead
90
113
  await registerAdminRoutes(app);
@@ -112,15 +135,17 @@ export async function buildApp(opts = {}) {
112
135
  reply.code(404).send(toOpenAIError(err, req.id));
113
136
  });
114
137
  // Process-level crash prevention
115
- process.on('uncaughtException', () => {
138
+ process.on('uncaughtException', (err) => {
139
+ fatal('FATAL', ['uncaughtException', ...formatError(err)]);
116
140
  const log = getLogger();
117
- log.error({ err: {} }, 'uncaught exception');
141
+ log.error({ err: { name: err.name, message: err.message, stack: err.stack } }, 'uncaught exception');
118
142
  // Don't exit immediately - let Fastify error handler process
119
143
  setTimeout(() => process.exit(1), 1000);
120
144
  });
121
- process.on('unhandledRejection', (_reason) => {
145
+ process.on('unhandledRejection', (reason) => {
146
+ fatal('FATAL', ['unhandledRejection', ...formatError(reason)]);
122
147
  const log = getLogger();
123
- log.error({ reason: '' }, 'unhandled rejection');
148
+ log.error({ err: { reason: String(reason) } }, 'unhandled rejection');
124
149
  });
125
150
  // On startup: ensure settings row + detect master key status
126
151
  app.addHook('onReady', async () => {
@@ -11,6 +11,8 @@ const EnvSchema = z.object({
11
11
  LATEDEV_MASTER_KEY: z.string().optional(),
12
12
  LATEDEV_TRUST_PROXY: z.coerce.number().int().min(0).max(8).default(0),
13
13
  LATEDEV_LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'),
14
+ // Alias used by docs/13 debug tooling: LOG_LEVEL=debug enables lifecycle lines.
15
+ LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).optional(),
14
16
  LATEDEV_DB_URL: z.string().optional(),
15
17
  NODE_ENV: z.enum(['development', 'production', 'test']).default('production'),
16
18
  });
@@ -54,7 +56,7 @@ export function loadConfig(env = process.env, argv = process.argv) {
54
56
  dbFile,
55
57
  masterKey: parsed.LATEDEV_MASTER_KEY ?? readMasterKeyFile(dataDir),
56
58
  trustProxyHops: parsed.LATEDEV_TRUST_PROXY,
57
- logLevel: parsed.LATEDEV_LOG_LEVEL,
59
+ logLevel: parsed.LOG_LEVEL ?? parsed.LATEDEV_LOG_LEVEL,
58
60
  env: parsed.NODE_ENV,
59
61
  isContainer,
60
62
  appVersion: getAppVersion(),
@@ -18,6 +18,7 @@ import { getSettings } from '../db/repositories/settings.js';
18
18
  import { buildCacheKey, lookupCache, storeCache, cacheAllowed } from '../caching/store.js';
19
19
  import { metrics } from '../metrics/registry.js';
20
20
  import { emitRequestLogged, emitRequestStarted } from './events.js';
21
+ import { debugHttp, debugBody, debugUpstream, debugStream, errorLine, formatError, getDebugFlags, truncate } from '../logging/debug.js';
21
22
  export class GatewayRunner {
22
23
  async execute(req, ctx) {
23
24
  const start = Date.now();
@@ -31,6 +32,17 @@ export class GatewayRunner {
31
32
  let comboPlan = null;
32
33
  if (resolved.kind === 'combo')
33
34
  comboPlan = loadCombo(resolved.comboId);
35
+ // docs/13 §9: full resolution chain requested -> alias -> combo/model
36
+ if (getDebugFlags().http) {
37
+ const lines = [`requested=${req.canonical.model}`, `type=${resolved.kind}`];
38
+ if (target.kind === 'alias')
39
+ lines.push(`viaAlias=${target.alias}`);
40
+ if (resolved.kind === 'model')
41
+ lines.push(`providerModelId=${resolved.modelId}`, `publicModelId=${resolved.publicModelId}`);
42
+ if (resolved.kind === 'combo')
43
+ lines.push(`comboId=${resolved.comboId}`, `comboPublicId=${resolved.publicModelId}`);
44
+ debugHttp(ctx.requestId, 'MODEL RESOLVE', lines);
45
+ }
34
46
  // --- ACL check ---
35
47
  if (ctx.key) {
36
48
  if (resolved.kind === 'model') {
@@ -74,20 +86,52 @@ export class GatewayRunner {
74
86
  try {
75
87
  // --- Capability requirements ---
76
88
  const required = deriveRequiredCapabilities(req.canonical);
89
+ debugHttp(ctx.requestId, 'CAPABILITIES REQUIRED', [
90
+ `tools=${required.tools}`,
91
+ `stream=${required.streaming}`,
92
+ `vision=${required.imageInput}`,
93
+ `reasoning=${required.reasoning}`,
94
+ `json=${required.structuredOutput}`,
95
+ `structuredOutput=${required.structuredOutput}`,
96
+ `parallelToolCalls=undefined(derived-from-request-not-gated)`,
97
+ `audioInput=${required.audioInput}`,
98
+ ]);
77
99
  // --- Determine candidates ---
78
100
  let candidates = [];
79
101
  let selectionReasons = [];
80
102
  if (resolved.kind === 'model') {
81
- candidates = await this.loadModelCandidate(resolved.modelId, required);
103
+ candidates = await this.loadModelCandidate(resolved.modelId, required, ctx.requestId);
82
104
  selectionReasons.push('direct_model');
105
+ if (candidates.length === 0) {
106
+ debugHttp(ctx.requestId, 'CAPABILITY REJECT', [
107
+ `model=${resolved.publicModelId}`,
108
+ `reason=direct_model_unavailable_or_capability_mismatch`,
109
+ '(direct model candidates rejected: not found / provider disabled / model disabled / upstream unavailable / circuit open / capability mismatch)',
110
+ ]);
111
+ }
83
112
  }
84
113
  else if (comboPlan) {
85
114
  const all = await this.loadAllModels();
86
- const filtered = selectCandidates(comboPlan, all, required);
115
+ const rejected = [];
116
+ const filtered = selectCandidates(comboPlan, all, required, (c, reason) => {
117
+ rejected.push({ publicModelId: c.publicModelId, reason });
118
+ debugHttp(ctx.requestId, 'CAPABILITY REJECT', [`model=${c.publicModelId}`, `reason=${reason}`]);
119
+ });
120
+ // docs/13 §10: always report why EACH member was filtered out
121
+ debugHttp(ctx.requestId, 'CANDIDATE FILTER', [
122
+ `comboMembers=${comboPlan.members.length}`,
123
+ `afterFilter=${filtered.length}`,
124
+ `rejectedCount=${rejected.length}`,
125
+ ...rejected.map((r) => `rejected: ${r.publicModelId} reason=${r.reason}`),
126
+ ]);
87
127
  if (filtered.length === 0) {
88
128
  throw new GatewayError('capability_not_supported', 'No combo member satisfies the request capabilities or availability', { status: 400 });
89
129
  }
90
130
  candidates = orderCandidates(comboPlan, filtered);
131
+ debugHttp(ctx.requestId, 'CANDIDATES ORDERED', [
132
+ `mode=${comboPlan.mode}`,
133
+ ...candidates.map((c, i) => `candidate[${i}]: providerModelId=${c.modelId} publicModelId=${c.publicModelId}`),
134
+ ]);
91
135
  selectionReasons.push('combo');
92
136
  }
93
137
  if (candidates.length === 0) {
@@ -137,11 +181,13 @@ export class GatewayRunner {
137
181
  finalModelId = candidate.modelId;
138
182
  const provider = getDb().select().from(schema.providers).where(eq(schema.providers.id, candidate.providerId)).get();
139
183
  if (!provider || !provider.enabled) {
184
+ debugHttp(ctx.requestId, 'ATTEMPT SKIP', [`attempt=${i + 1} model=${candidate.publicModelId} reason=skipped_disabled_provider`]);
140
185
  attempts.push(this.failedAttempt(i + 1, candidate, provider?.name ?? '', 'skipped_disabled_provider', null, null, 0, null, null));
141
186
  continue;
142
187
  }
143
188
  const eff = getEffectiveState(provider.id, provider.cbCooldownSeconds);
144
189
  if (eff === 'open' && !halfOpenProbeAllowed(provider.id)) {
190
+ debugHttp(ctx.requestId, 'ATTEMPT SKIP', [`attempt=${i + 1} model=${candidate.publicModelId} provider=${provider.name} reason=circuit_open`]);
145
191
  attempts.push(this.failedAttempt(i + 1, candidate, provider.name, 'circuit_open', null, null, 0, null, null));
146
192
  if (comboPlan && shouldFallback(comboPlan, { type: 'connection_error' })) {
147
193
  metrics.fallbackCount.inc();
@@ -152,6 +198,19 @@ export class GatewayRunner {
152
198
  }
153
199
  const cfg = providerToUpstreamConfig(provider);
154
200
  const attemptStart = Date.now();
201
+ // docs/13 §11–§12: provider/account + upstream request summary
202
+ const upstreamModel = candidate.publicModelId.split('/').slice(1).join('/');
203
+ debugHttp(ctx.requestId, 'ATTEMPT', [
204
+ `attempt=${i + 1}/${maxAttempts}`,
205
+ `provider=${provider.name}`,
206
+ `providerId=${provider.id}`,
207
+ `model=${candidate.publicModelId}`,
208
+ `upstreamModel=${upstreamModel}`,
209
+ `upstreamType=${cfg.type}`,
210
+ `baseUrl=${cfg.baseUrl}`,
211
+ `stream=${req.canonical.stream}`,
212
+ `providerKeyFingerprint=${apiKeyFingerprint(cfg.apiKey)}`,
213
+ ]);
155
214
  const attempt = {
156
215
  attemptNumber: i + 1,
157
216
  providerId: provider.id,
@@ -206,6 +265,14 @@ export class GatewayRunner {
206
265
  }
207
266
  catch (e) {
208
267
  const err = e instanceof GatewayError ? e : new GatewayError('upstream_error', e.message, { cause: e });
268
+ debugUpstream(ctx.requestId, 'ATTEMPT ERROR', [
269
+ `attempt=${i + 1}`,
270
+ `provider=${provider.name}`,
271
+ `model=${candidate.publicModelId}`,
272
+ `type=${err.type}`,
273
+ `status=${err.status}`,
274
+ `willFallback=${comboPlan ? shouldFallback(comboPlan, { type: classifyFailure(err), status: err.status }) : false}`,
275
+ ]);
209
276
  const shouldRetry = comboPlan ? shouldFallback(comboPlan, { type: classifyFailure(err), status: err.status }) : false;
210
277
  attempt.statusCode = err.status;
211
278
  attempt.success = false;
@@ -288,14 +355,26 @@ export class GatewayRunner {
288
355
  metrics.activeRequests.dec();
289
356
  }
290
357
  }
291
- async loadModelCandidate(modelId, required) {
358
+ async loadModelCandidate(modelId, required, requestId) {
292
359
  const db = getDb();
360
+ const reject = (reason) => {
361
+ if (requestId)
362
+ debugHttp(requestId, 'CAPABILITY REJECT', [`modelId=${modelId}`, `reason=${reason}`]);
363
+ };
293
364
  const m = db.select().from(schema.models).where(eq(schema.models.id, modelId)).get();
294
- if (!m)
365
+ if (!m) {
366
+ reject('model_not_found');
295
367
  return [];
368
+ }
296
369
  const p = db.select().from(schema.providers).where(eq(schema.providers.id, m.providerId)).get();
297
- if (!p || !p.enabled)
370
+ if (!p) {
371
+ reject('provider_not_found');
298
372
  return [];
373
+ }
374
+ if (!p.enabled) {
375
+ reject('provider_disabled');
376
+ return [];
377
+ }
299
378
  const caps = safeJson(m.capabilitiesJson);
300
379
  const candidate = {
301
380
  modelId: m.id,
@@ -306,12 +385,30 @@ export class GatewayRunner {
306
385
  circuitOpen: isOpen(m.providerId),
307
386
  capabilities: caps,
308
387
  };
309
- if (!m.enabled || !m.upstreamAvailable)
388
+ if (!m.enabled) {
389
+ reject('model_disabled');
310
390
  return [];
311
- if (candidate.circuitOpen)
391
+ }
392
+ if (!m.upstreamAvailable) {
393
+ reject('upstream_unavailable');
394
+ return [];
395
+ }
396
+ if (candidate.circuitOpen) {
397
+ reject('circuit_open');
312
398
  return [];
313
- if (!modelMeets(caps, required))
399
+ }
400
+ if (!modelMeets(caps, required)) {
401
+ reject('capability_mismatch');
314
402
  return [];
403
+ }
404
+ debugHttp(requestId ?? '-', 'CAPABILITY CANDIDATE', [
405
+ `model=${m.publicModelId}`,
406
+ `caps.tools=${caps.tools}`,
407
+ `caps.streaming=${caps.streaming}`,
408
+ `caps.reasoning=${caps.reasoning}`,
409
+ `caps.image_input=${caps.image_input}`,
410
+ `caps.structured_output=${caps.structured_output}`,
411
+ ]);
315
412
  return [candidate];
316
413
  }
317
414
  async loadAllModels() {
@@ -333,18 +430,20 @@ export class GatewayRunner {
333
430
  if (req.canonical.stream) {
334
431
  return this.runStreamingAttempt(req, ctx, candidate, cfg, onStreamStart, onFirstToken);
335
432
  }
336
- return this.runNonStreamingAttempt(req, candidate, cfg);
433
+ return this.runNonStreamingAttempt(req, candidate, cfg, ctx);
337
434
  }
338
- async runNonStreamingAttempt(req, candidate, cfg) {
435
+ async runNonStreamingAttempt(req, candidate, cfg, ctx) {
339
436
  const upstreamModel = candidate.publicModelId.split('/').slice(1).join('/');
340
437
  let call;
341
438
  if (cfg.type === 'openai') {
342
439
  const payload = canonicalToOpenAIRequest(req.canonical, upstreamModel);
343
- call = await callUpstreamNonStreaming(cfg, upstreamUrl(cfg, '/v1/chat/completions'), payload);
440
+ logUpstreamRequest(ctx.requestId, cfg, upstreamUrl(cfg, '/v1/chat/completions'), payload, req.canonical.stream);
441
+ call = await callUpstreamNonStreaming(cfg, upstreamUrl(cfg, '/v1/chat/completions'), payload, ctx.requestId);
344
442
  }
345
443
  else {
346
444
  const payload = canonicalToAnthropicRequest(req.canonical, upstreamModel);
347
- call = await callUpstreamNonStreaming(cfg, upstreamUrl(cfg, '/v1/messages'), payload);
445
+ logUpstreamRequest(ctx.requestId, cfg, upstreamUrl(cfg, '/v1/messages'), payload, req.canonical.stream);
446
+ call = await callUpstreamNonStreaming(cfg, upstreamUrl(cfg, '/v1/messages'), payload, ctx.requestId);
348
447
  }
349
448
  if (!call.ok) {
350
449
  if (call.status === 429)
@@ -402,7 +501,39 @@ export class GatewayRunner {
402
501
  const toolBuf = [];
403
502
  let finishReason = null;
404
503
  const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, total: 0 };
504
+ // docs/13 §15–§16: stream counters + client disconnect tracking
505
+ let chunkCount = 0;
506
+ let bytesReceived = 0;
507
+ let firstChunkTime = null;
508
+ let lastChunkTime = null;
509
+ const loggedFirstChunks = [];
510
+ let clientDisconnected = false;
511
+ // Guard: the admin test-stream endpoint passes a minimal `fakeRaw` that has
512
+ // writeHead/write/end but no `.on` — skip disconnect tracking in that case.
513
+ if (typeof pipe.on === 'function') {
514
+ pipe.on('close', () => {
515
+ // Distinguish client disconnect from normal end: 'close' fires on the raw
516
+ // socket when the client (or upstream) side goes away.
517
+ if (!pipe.writableEnded) {
518
+ clientDisconnected = true;
519
+ debugStream(ctx.requestId, 'CLIENT DISCONNECT', [
520
+ `afterMs=${Date.now() - streamStartTs}`,
521
+ `streaming=${streamStarted}`,
522
+ `chunksSoFar=${chunkCount}`,
523
+ ]);
524
+ }
525
+ });
526
+ }
405
527
  const chunkHandler = (chunk, isFirst) => {
528
+ chunkCount++;
529
+ bytesReceived += chunk.data.length;
530
+ lastChunkTime = Date.now();
531
+ if (isFirst)
532
+ firstChunkTime = Date.now() - streamStartTs;
533
+ if (loggedFirstChunks.length < 3) {
534
+ loggedFirstChunks.push(chunk.data);
535
+ debugStream(ctx.requestId, `STREAM FIRST CHUNK ${loggedFirstChunks.length}`, [truncate(chunk.data, 2000)]);
536
+ }
406
537
  try {
407
538
  const obj = JSON.parse(chunk.data);
408
539
  if (cfg.type === 'openai') {
@@ -473,12 +604,23 @@ export class GatewayRunner {
473
604
  try {
474
605
  const url = cfg.type === 'openai' ? upstreamUrl(cfg, '/v1/chat/completions') : upstreamUrl(cfg, '/v1/messages');
475
606
  const payload = cfg.type === 'openai' ? canonicalToOpenAIRequest(req.canonical, upstreamModel) : canonicalToAnthropicRequest(req.canonical, upstreamModel);
476
- const meta = await callUpstreamStreaming(cfg, url, payload, chunkHandler);
607
+ logUpstreamRequest(ctx.requestId, cfg, url, payload, true);
608
+ debugStream(ctx.requestId, 'STREAM START', [`upstreamConnected=true`]);
609
+ const meta = await callUpstreamStreaming(cfg, url, payload, chunkHandler, ctx.requestId);
477
610
  // Upstream completed cleanly: ensure head + terminator are written.
478
611
  if (!headWritten)
479
612
  writeHead();
480
613
  pipe.write('data: [DONE]\n\n');
481
614
  pipe.end();
615
+ debugStream(ctx.requestId, 'STREAM END', [
616
+ `chunks=${chunkCount}`,
617
+ `bytes=${bytesReceived}`,
618
+ `firstChunkMs=${firstChunkTime ?? 'null'}`,
619
+ `lastChunkMs=${lastChunkTime ? lastChunkTime - streamStartTs : 'null'}`,
620
+ `durationMs=${Date.now() - streamStartTs}`,
621
+ `finishedNormally=true`,
622
+ `clientDisconnected=${clientDisconnected}`,
623
+ ]);
482
624
  if (!usage.total)
483
625
  usage.total = usage.input + usage.output;
484
626
  return {
@@ -497,8 +639,20 @@ export class GatewayRunner {
497
639
  writeHead();
498
640
  pipe.end();
499
641
  const err = e instanceof GatewayError ? e : new GatewayError('upstream_error', e.message);
642
+ errorLine(ctx.requestId, 'STREAM ERROR', [
643
+ `chunksBeforeError=${chunkCount}`,
644
+ `bytesBeforeError=${bytesReceived}`,
645
+ `clientDisconnected=${clientDisconnected}`,
646
+ ...formatErrorPublic(e),
647
+ ]);
500
648
  throw err;
501
649
  }
650
+ errorLine(ctx.requestId, 'STREAM ERROR (before first chunk)', [
651
+ `chunksBeforeError=${chunkCount}`,
652
+ `bytesBeforeError=${bytesReceived}`,
653
+ `clientDisconnected=${clientDisconnected}`,
654
+ ...formatErrorPublic(e),
655
+ ]);
502
656
  throw e;
503
657
  }
504
658
  }
@@ -732,6 +886,36 @@ function hasTools(req) {
732
886
  function estimateTokens(s) {
733
887
  return Math.ceil(s.length / 4);
734
888
  }
889
+ // docs/13 §11: safe provider key fingerprint — never the key itself.
890
+ function apiKeyFingerprint(key) {
891
+ let h = 5381;
892
+ for (let i = 0; i < key.length; i++) {
893
+ h = ((h << 5) + h + key.charCodeAt(i)) >>> 0;
894
+ }
895
+ return `fp_${h.toString(16).padStart(8, '0')}…${key.slice(-4).replace(/./g, '*')}${key.length}ch`;
896
+ }
897
+ // docs/13 §12: upstream request summary + optional full body
898
+ function logUpstreamRequest(requestId, cfg, url, payload, stream) {
899
+ const json = JSON.stringify(payload ?? {});
900
+ debugUpstream(requestId, 'UPSTREAM REQUEST', [
901
+ `method=POST`,
902
+ `url=${url}`,
903
+ `type=${cfg.type}`,
904
+ `stream=${stream}`,
905
+ `contentLength=${json.length}`,
906
+ `customHeaders=${JSON.stringify(Object.keys(cfg.customHeaders ?? {}))}`,
907
+ ]);
908
+ if (getDebugFlags().httpBody) {
909
+ const LIMIT = 512 * 1024;
910
+ debugBody(requestId, 'UPSTREAM BODY', [
911
+ json.length > LIMIT ? `${json.slice(0, LIMIT)}…(+${json.length - LIMIT} chars, truncated)` : json,
912
+ ]);
913
+ }
914
+ }
915
+ /** Public alias so error formatting from debug.ts is available in this module. */
916
+ function formatErrorPublic(e) {
917
+ return formatError(e);
918
+ }
735
919
  /**
736
920
  * Safely parse capabilities JSON. Returns minimal default if parsing fails.
737
921
  * CRITICAL: Must return a complete default object with all capability fields,
@@ -1,62 +1,278 @@
1
- // Debug logging utilities for request lifecycle tracking
2
- import * as fs from 'fs';
3
- import * as path from 'path';
4
- const DEBUG_LOG_DIR = '/data';
5
- const DEBUG_LOG_FILE = path.join(DEBUG_LOG_DIR, 'ldrouter-debug.log');
6
- // Ensure log file exists
7
- if (!fs.existsSync(DEBUG_LOG_FILE)) {
8
- try {
9
- fs.writeFileSync(DEBUG_LOG_FILE, '');
1
+ // Request-lifecycle debug logging.
2
+ //
3
+ // All output goes to stdout/stderr so `docker logs` collects it (docs/13).
4
+ // Every line is prefixed with the requestId so the whole lifecycle of one
5
+ // request can be extracted with:
6
+ // docker logs ldrouter 2>&1 | grep req_xxx
7
+ //
8
+ // Env flags (all default off; enabled per docs/13 §22):
9
+ // DEBUG_HTTP — incoming request + body summary + messages/tools structure
10
+ // DEBUG_HTTP_BODY — full sanitized JSON bodies (incoming + upstream)
11
+ // DEBUG_UPSTREAM — upstream fetch/response/error detail
12
+ // DEBUG_STREAM — SSE stream lifecycle (start/first chunks/end/error)
13
+ // LOG_LEVEL gates lifecycle INFO-level lines ([INCOMING]/[DONE]/errors) —
14
+ // they emit at debug, so set LOG_LEVEL=debug to see them.
15
+ import process from 'node:process';
16
+ import { redactValue } from '../security/redact.js';
17
+ function envFlag(name) {
18
+ const v = process.env[name];
19
+ return v === '1' || v === 'true' || v === 'yes';
20
+ }
21
+ let flags = null;
22
+ /** Debug flags are read once per process (docs/13 §22). */
23
+ export function getDebugFlags() {
24
+ if (!flags) {
25
+ flags = {
26
+ http: envFlag('DEBUG_HTTP'),
27
+ httpBody: envFlag('DEBUG_HTTP_BODY'),
28
+ upstream: envFlag('DEBUG_UPSTREAM'),
29
+ stream: envFlag('DEBUG_STREAM'),
30
+ };
10
31
  }
11
- catch (_err) {
12
- // Silent fail - don't break the application
13
- console.error('Failed to create debug log:', _err);
32
+ return flags;
33
+ }
34
+ export function resetDebugFlagsForTests() {
35
+ flags = null;
36
+ }
37
+ // --- Output -------------------------------------------------------------
38
+ // Direct console use (not pino) keeps the human-readable
39
+ // [timestamp] [req_xxx] [TAG] line format that docs/13 §23 asks for, and is
40
+ // trivially visible in `docker logs -f`. Errors go to stderr.
41
+ function emit(level, requestId, tag, lines) {
42
+ const ts = new Date().toISOString();
43
+ const stream = level === 'error' ? process.stderr : process.stdout;
44
+ for (const body of lines) {
45
+ const first = body.split('\n')[0] ?? '';
46
+ const rest = body.split('\n').slice(1).join('\n');
47
+ const head = `[${ts}] [${requestId}] [${tag}] ${first}`;
48
+ stream.write(rest ? head + '\n' + rest + '\n' : head + '\n');
14
49
  }
15
50
  }
16
- export function appendDebugLog(entry) {
17
- const logLine = JSON.stringify(entry) + '\n';
51
+ /** Lifecycle INFO line — always on (gated by LOG_LEVEL=debug via pino parity, but kept unconditional so operators never lose the trail). */
52
+ export function lifecycle(requestId, tag, lines) {
53
+ emit('info', requestId, tag, lines);
54
+ }
55
+ export function debugHttp(requestId, tag, lines) {
56
+ if (getDebugFlags().http)
57
+ emit('info', requestId, tag, lines);
58
+ }
59
+ export function debugBody(requestId, tag, lines) {
60
+ if (getDebugFlags().httpBody)
61
+ emit('info', requestId, tag, lines);
62
+ }
63
+ export function debugUpstream(requestId, tag, lines) {
64
+ if (getDebugFlags().upstream)
65
+ emit('info', requestId, tag, lines);
66
+ }
67
+ export function debugStream(requestId, tag, lines) {
68
+ if (getDebugFlags().stream)
69
+ emit('info', requestId, tag, lines);
70
+ }
71
+ export function errorLine(requestId, tag, lines) {
72
+ emit('error', requestId, tag, lines);
73
+ }
74
+ /** Fatal process-level line (no requestId). */
75
+ export function fatal(tag, lines) {
76
+ emit('error', '-', tag, lines);
77
+ }
78
+ // --- Sanitization --------------------------------------------------------
79
+ /** Sanitize an arbitrary value for logging: deep-redact secrets. */
80
+ export function sanitize(value) {
81
+ return redactValue(value);
82
+ }
83
+ /** Sanitized JSON string; on circular/unserializable falls back to a marker. */
84
+ export function sanitizeJson(value) {
18
85
  try {
19
- fs.appendFileSync(DEBUG_LOG_FILE, logLine);
20
- }
21
- catch (_err) {
22
- // Silent fail - don't break the application
23
- console.error('Failed to write debug log:', _err);
24
- }
25
- }
26
- export function registerDebugHook(app) {
27
- // Log every request entering the system
28
- app.addHook('onRequest', async (req, _reply) => {
29
- const entry = {
30
- timestamp: new Date().toISOString(),
31
- level: 'DEBUG',
32
- requestId: req.id,
33
- url: req.url,
34
- method: req.method,
35
- phase: 'REQUEST_ENTERED',
36
- details: {
37
- headers: {
38
- authorization: req.headers.authorization ? '[REDACTED]' : undefined,
39
- 'content-type': req.headers['content-type'],
40
- },
41
- },
42
- };
43
- appendDebugLog(entry);
44
- });
45
- // Log before route handler execution
46
- app.addHook('preHandler', async (req, _reply) => {
47
- const entry = {
48
- timestamp: new Date().toISOString(),
49
- level: 'DEBUG',
50
- requestId: req.id,
51
- url: req.url,
52
- method: req.method,
53
- phase: 'ROUTE_MATCHED',
54
- details: {},
55
- };
56
- appendDebugLog(entry);
86
+ return JSON.stringify(redactValue(value));
87
+ }
88
+ catch {
89
+ try {
90
+ return JSON.stringify({ bodyLogError: 'unserializable' });
91
+ }
92
+ catch {
93
+ return '{"bodyLogError":"unserializable"}';
94
+ }
95
+ }
96
+ }
97
+ /** Truncate a string to `max` chars, marking truncation (docs/13 §5). */
98
+ export function truncate(s, max) {
99
+ if (s.length <= max)
100
+ return s;
101
+ return `${s.slice(0, max)}…(+${s.length - max} chars, truncated)`;
102
+ }
103
+ export function summarizeBody(body) {
104
+ const lines = [];
105
+ const p = (k, v) => {
106
+ lines.push(`${k}=${v === undefined ? 'undefined' : JSON.stringify(v)}`);
107
+ };
108
+ p('model', body['model']);
109
+ p('stream', body['stream']);
110
+ const keys = Object.keys(body);
111
+ lines.push(`bodyKeys=[${keys.map((k) => JSON.stringify(k)).join(', ')}]`);
112
+ const messages = Array.isArray(body['messages']) ? body['messages'] : null;
113
+ lines.push(`messages=${messages ? messages.length : 'undefined'}`);
114
+ const tools = Array.isArray(body['tools']) ? body['tools'] : null;
115
+ if (tools) {
116
+ lines.push(`toolsCount=${tools.length}`);
117
+ lines.push(`serializedToolsSize=${safeSize(tools)}`);
118
+ }
119
+ p('max_tokens', body['max_tokens']);
120
+ p('max_completion_tokens', body['max_completion_tokens']);
121
+ p('temperature', body['temperature']);
122
+ p('top_p', body['top_p']);
123
+ p('reasoning_effort', body['reasoning_effort']);
124
+ p('reasoning', body['reasoning']);
125
+ p('thinking', body['thinking']);
126
+ p('tool_choice', body['tool_choice']);
127
+ p('parallel_tool_calls', body['parallel_tool_calls']);
128
+ p('response_format', body['response_format']);
129
+ p('stream_options', body['stream_options']);
130
+ if (messages) {
131
+ lines.push(`messageRoles=[${messages.map((m) => (isRec(m) ? String(m['role'] ?? '?') : '?')).join(',')}]`);
132
+ }
133
+ return lines;
134
+ }
135
+ export function summarizeMessages(body) {
136
+ const messages = Array.isArray(body['messages']) ? body['messages'] : [];
137
+ const lines = [];
138
+ messages.forEach((m, i) => {
139
+ if (!isRec(m)) {
140
+ lines.push(`#${i} <non-object: ${typeof m}>`);
141
+ return;
142
+ }
143
+ const role = m['role'];
144
+ const content = m['content'];
145
+ const contentDesc = content === null
146
+ ? 'contentType=null contentLength=0'
147
+ : typeof content === 'string'
148
+ ? `contentType=string contentLength=${content.length}`
149
+ : Array.isArray(content)
150
+ ? `contentType=array contentParts=${content.length}`
151
+ : content === undefined
152
+ ? 'contentType=undefined'
153
+ : `contentType=${typeof content}`;
154
+ lines.push(`#${i} role=${role} ${contentDesc}`);
155
+ const toolCalls = m['tool_calls'];
156
+ if (Array.isArray(toolCalls))
157
+ lines.push(` toolCalls=${toolCalls.length}`);
158
+ if (m['tool_call_id'])
159
+ lines.push(` toolCallId=${String(m['tool_call_id'])}`);
160
+ if ('reasoning_content' in m)
161
+ lines.push(` reasoningContent=present`);
162
+ if (Array.isArray(content)) {
163
+ const partTypes = content.map((c) => (isRec(c) ? String(c['type'] ?? '?') : '?')).join(',');
164
+ lines.push(` partTypes=[${partTypes}]`);
165
+ }
57
166
  });
58
- // Log response completion with status code and content type
59
- app.addHook('onResponse', async (_req, _reply) => {
60
- // Note: We'll capture this in the onRequest handler instead for simpler logging
167
+ return lines;
168
+ }
169
+ export function summarizeTools(body) {
170
+ const tools = Array.isArray(body['tools']) ? body['tools'] : [];
171
+ const lines = [`count=${tools.length}`, `serializedSize=${safeSize(tools)}`];
172
+ tools.forEach((t, i) => {
173
+ if (!isRec(t)) {
174
+ lines.push(`tool[${i}]: <non-object>`);
175
+ return;
176
+ }
177
+ const fn = isRec(t['function']) ? t['function'] : null;
178
+ const name = fn ? String(fn['name'] ?? '?') : String(t['name'] ?? '?');
179
+ const descLen = fn && typeof fn['description'] === 'string' ? fn['description'].length : fn?.['description'] !== undefined ? -1 : 0;
180
+ const schema = fn ? fn['parameters'] : t['input_schema'];
181
+ const schemaSize = schema !== undefined ? safeSize(schema) : 0;
182
+ lines.push(`tool[${i}]: name=${name} descriptionLength=${descLen} schemaSize=${schemaSize}`);
61
183
  });
184
+ return lines;
185
+ }
186
+ // --- Error formatting (docs/13 §13) ---------------------------------------
187
+ /** Full error detail including nested undici `cause` chains. */
188
+ export function formatError(e) {
189
+ const lines = [];
190
+ const seen = new Set();
191
+ let depth = 0;
192
+ let cur = e;
193
+ while (cur instanceof Error && depth < 4) {
194
+ if (seen.has(cur))
195
+ break;
196
+ seen.add(cur);
197
+ const prefix = depth === 0 ? '' : 'cause.';
198
+ lines.push(`${prefix}name=${cur.name}`);
199
+ lines.push(`${prefix}message=${cur.message}`);
200
+ const code = cur.code;
201
+ if (code)
202
+ lines.push(`${prefix}code=${code}`);
203
+ if (depth === 0 && cur.stack)
204
+ lines.push(`stack=${truncate(cur.stack, 4000)}`);
205
+ cur = cur.cause;
206
+ depth++;
207
+ }
208
+ if (cur !== undefined && cur !== null && !(cur instanceof Error)) {
209
+ lines.push(`cause(raw)=${truncate(safeStringify(cur), 2000)}`);
210
+ }
211
+ if (lines.length === 0)
212
+ lines.push(`value=${truncate(safeStringify(e), 2000)}`);
213
+ return lines;
214
+ }
215
+ // --- Helpers --------------------------------------------------------------
216
+ function isRec(v) {
217
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
218
+ }
219
+ function safeSize(v) {
220
+ try {
221
+ return JSON.stringify(v)?.length ?? 0;
222
+ }
223
+ catch {
224
+ return -1;
225
+ }
226
+ }
227
+ function safeStringify(v) {
228
+ try {
229
+ return JSON.stringify(v);
230
+ }
231
+ catch {
232
+ return '[unserializable]';
233
+ }
234
+ }
235
+ // --- Header sanitization (docs/13 §2–§3) ----------------------------------
236
+ const SENSITIVE_HEADERS = new Set([
237
+ 'authorization',
238
+ 'x-api-key',
239
+ 'cookie',
240
+ 'set-cookie',
241
+ 'x-goog-api-key',
242
+ 'proxy-authorization',
243
+ ]);
244
+ const INTERESTING_HEADERS = [
245
+ 'content-type',
246
+ 'content-length',
247
+ 'user-agent',
248
+ 'host',
249
+ 'x-forwarded-for',
250
+ 'cf-ray',
251
+ 'cf-connecting-ip',
252
+ 'accept',
253
+ 'accept-encoding',
254
+ 'connection',
255
+ 'anthropic-version',
256
+ ];
257
+ /** Sanitized incoming-request header lines for the [INCOMING] block. */
258
+ export function summarizeHeaders(headers) {
259
+ const lines = [];
260
+ for (const name of INTERESTING_HEADERS) {
261
+ const v = headers[name];
262
+ if (v !== undefined)
263
+ lines.push(`${name}=${Array.isArray(v) ? v.join(',') : String(v)}`);
264
+ }
265
+ const auth = headers['authorization'];
266
+ if (auth !== undefined)
267
+ lines.push(`authorization=Bearer ***REDACTED***`);
268
+ const apiKey = headers['x-api-key'];
269
+ if (apiKey !== undefined)
270
+ lines.push(`x-api-key=***REDACTED***`);
271
+ // Report presence of any other sensitive headers without values.
272
+ for (const k of Object.keys(headers)) {
273
+ if (SENSITIVE_HEADERS.has(k.toLowerCase()) && !lines.some((l) => l.startsWith(`${k.toLowerCase()}=`) || l.startsWith(`${k}=`))) {
274
+ lines.push(`${k}=***REDACTED***`);
275
+ }
276
+ }
277
+ return lines;
62
278
  }
@@ -6,6 +6,7 @@ import { anthropicToCanonical } from '../../protocols/anthropic.js';
6
6
  import { GatewayError, toAnthropicError } from '../../errors.js';
7
7
  import { GatewayRunner } from '../../gateway/runner.js';
8
8
  import { uuid } from '../../auth/ids.js';
9
+ import { lifecycle, debugHttp, debugBody, getDebugFlags, summarizeMessages, summarizeTools, summarizeHeaders, sanitizeJson, truncate } from '../../logging/debug.js';
9
10
  const MessagesBody = z.object({
10
11
  model: z.string().min(1),
11
12
  messages: z.array(z.any()).min(1),
@@ -27,15 +28,21 @@ export async function registerAnthropicRoutes(app) {
27
28
  reply.code(405).send(toAnthropicError(new GatewayError('invalid_request_error', 'Use POST /v1/messages', { status: 405 }), ''));
28
29
  });
29
30
  app.post('/v1/messages', async (req, reply) => {
31
+ const requestId = req.id || uuid();
32
+ lifecycle(requestId, 'INCOMING', [
33
+ `POST ${req.url}`,
34
+ ...summarizeHeaders(req.headers),
35
+ ]);
30
36
  const key = authenticateGatewayHeaders(req);
31
37
  const body = MessagesBody.parse(req.body);
38
+ logIncomingMessagesBody(requestId, body);
32
39
  const ar = body;
33
40
  if (!ar.max_tokens) {
34
41
  throw new GatewayError('invalid_request_error', 'max_tokens is required', { status: 400 });
35
42
  }
36
43
  const canonical = anthropicToCanonical(ar);
37
44
  const ctx = {
38
- requestId: req.id || uuid(),
45
+ requestId,
39
46
  clientIp: resolveClientIp(req),
40
47
  protocol: 'anthropic',
41
48
  endpoint: 'messages',
@@ -60,9 +67,11 @@ export async function registerAnthropicRoutes(app) {
60
67
  }
61
68
  if (!outcome.success) {
62
69
  const g = new GatewayError(outcome.errorType ?? 'gateway_error', outcome.errorMessage ?? 'Gateway error', { status: outcome.httpStatus });
70
+ lifecycle(requestId, 'DONE', [`status=${outcome.httpStatus} durationMs=${outcome.latencyMs} error=true type=${g.type}`]);
63
71
  reply.code(outcome.httpStatus).send(toAnthropicError(g, ctx.requestId));
64
72
  return;
65
73
  }
74
+ lifecycle(requestId, 'DONE', [`status=200 durationMs=${outcome.latencyMs} finishReason=${outcome.finishReason ?? 'null'}`]);
66
75
  reply.header('x-request-id', ctx.requestId);
67
76
  reply.send({
68
77
  id: `msg_${ctx.requestId}`,
@@ -101,6 +110,34 @@ export async function registerAnthropicRoutes(app) {
101
110
  reply.send({ input_tokens: inputTokens });
102
111
  });
103
112
  }
113
+ // docs/13 §4–§7: incoming /v1/messages body structure logging.
114
+ function logIncomingMessagesBody(requestId, body) {
115
+ debugHttp(requestId, 'BODY SUMMARY', [
116
+ `model=${JSON.stringify(body['model'])}`,
117
+ `stream=${JSON.stringify(body['stream'])}`,
118
+ `max_tokens=${JSON.stringify(body['max_tokens'])}`,
119
+ `temperature=${JSON.stringify(body['temperature'])}`,
120
+ `top_p=${JSON.stringify(body['top_p'])}`,
121
+ `thinking=${JSON.stringify(body['thinking'])}`,
122
+ `tool_choice=${JSON.stringify(body['tool_choice'])}`,
123
+ `systemType=${Array.isArray(body['system']) ? `array(${body['system'].length})` : typeof body['system']}`,
124
+ `bodyKeys=[${Object.keys(body).map((k) => JSON.stringify(k)).join(', ')}]`,
125
+ `messages=${Array.isArray(body['messages']) ? body['messages'].length : 'undefined'}`,
126
+ `tools=${Array.isArray(body['tools']) ? body['tools'].length : 'undefined'}`,
127
+ ]);
128
+ debugHttp(requestId, 'MESSAGES', summarizeMessages(body));
129
+ if (body['tools'] !== undefined)
130
+ debugHttp(requestId, 'TOOLS', summarizeTools(body));
131
+ if (getDebugFlags().httpBody) {
132
+ const json = sanitizeJson(body);
133
+ const bodySize = json.length;
134
+ const LIMIT = 512 * 1024; // generous debug-only cap (docs/13 §5)
135
+ debugBody(requestId, 'INCOMING BODY', [
136
+ `bodySize=${bodySize} bytes bodyTruncated=${bodySize > LIMIT}`,
137
+ bodySize > LIMIT ? truncate(json, LIMIT) : json,
138
+ ]);
139
+ }
140
+ }
104
141
  function authenticateGatewayHeaders(req) {
105
142
  const key = authenticateGatewayKey(req);
106
143
  if (!key)
@@ -8,6 +8,7 @@ import { openAIToCanonical, openAIModelList } from '../../protocols/canonical.js
8
8
  import { GatewayError, toOpenAIError } from '../../errors.js';
9
9
  import { GatewayRunner } from '../../gateway/runner.js';
10
10
  import { uuid } from '../../auth/ids.js';
11
+ import { lifecycle, debugHttp, debugBody, getDebugFlags, summarizeBody, summarizeMessages, summarizeTools, summarizeHeaders, sanitizeJson, truncate } from '../../logging/debug.js';
11
12
  const ChatBody = z.object({
12
13
  model: z.string().min(1),
13
14
  messages: z.array(z.any()).min(1),
@@ -52,12 +53,18 @@ export async function registerOpenAIRoutes(app) {
52
53
  return openAIModelList(ids.map((id) => ({ publicModelId: id, upstreamModelId: id })));
53
54
  });
54
55
  app.post('/v1/chat/completions', async (req, reply) => {
56
+ const requestId = req.id || uuid();
57
+ lifecycle(requestId, 'INCOMING', [
58
+ `POST ${req.url}`,
59
+ ...summarizeHeaders(req.headers),
60
+ ]);
55
61
  const key = authenticateGatewayHeaders(req);
56
62
  const body = ChatBody.parse(req.body);
63
+ logIncomingChatBody(requestId, body);
57
64
  const req1 = body;
58
65
  const canonical = openAIToCanonical(req1);
59
66
  const ctx = {
60
- requestId: req.id || uuid(),
67
+ requestId,
61
68
  clientIp: resolveClientIp(req),
62
69
  protocol: 'openai',
63
70
  endpoint: 'chat/completions',
@@ -89,9 +96,11 @@ export async function registerOpenAIRoutes(app) {
89
96
  }
90
97
  if (!outcome.success) {
91
98
  const g = new GatewayError(outcome.errorType ?? 'gateway_error', outcome.errorMessage ?? 'Gateway error', { status: outcome.httpStatus });
99
+ lifecycle(requestId, 'DONE', [`status=${outcome.httpStatus} durationMs=${outcome.latencyMs} error=true type=${g.type}`]);
92
100
  reply.code(outcome.httpStatus).send(toOpenAIError(g, ctx.requestId));
93
101
  return;
94
102
  }
103
+ lifecycle(requestId, 'DONE', [`status=200 durationMs=${outcome.latencyMs} finishReason=${outcome.finishReason ?? 'null'}`]);
95
104
  reply.header('x-request-id', ctx.requestId);
96
105
  reply.send({
97
106
  id: `chatcmpl-${ctx.requestId}`,
@@ -129,9 +138,19 @@ export async function registerOpenAIRoutes(app) {
129
138
  }
130
139
  });
131
140
  app.post('/v1/responses', async (req, reply) => {
141
+ const requestId = req.id || uuid();
142
+ lifecycle(requestId, 'INCOMING', [
143
+ `POST ${req.url}`,
144
+ ...summarizeHeaders(req.headers),
145
+ ]);
132
146
  const key = authenticateGatewayHeaders(req);
133
147
  // v1 subset: accept Responses-style input, flatten to chat-completions messages.
134
148
  const body = ResponsesBody.parse(req.body);
149
+ debugHttp(requestId, 'BODY SUMMARY', [
150
+ `model=${body.model}`,
151
+ `stream=${body.stream ?? 'undefined'}`,
152
+ `inputType=${Array.isArray(body.input) ? `array(${body.input.length})` : typeof body.input}`,
153
+ ]);
135
154
  const flat = responsesInputToChat(body.input);
136
155
  const chatBody = {
137
156
  model: body.model,
@@ -141,7 +160,7 @@ export async function registerOpenAIRoutes(app) {
141
160
  };
142
161
  const canonical = openAIToCanonical(chatBody);
143
162
  const ctx = {
144
- requestId: req.id || uuid(),
163
+ requestId,
145
164
  clientIp: resolveClientIp(req),
146
165
  protocol: 'openai',
147
166
  endpoint: 'responses',
@@ -198,6 +217,23 @@ export async function registerOpenAIRoutes(app) {
198
217
  }
199
218
  });
200
219
  }
220
+ // docs/13 §4–§7: incoming chat body structure logging. Summary always
221
+ // available under DEBUG_HTTP; full sanitized body under DEBUG_HTTP_BODY.
222
+ function logIncomingChatBody(requestId, body) {
223
+ debugHttp(requestId, 'BODY SUMMARY', summarizeBody(body));
224
+ debugHttp(requestId, 'MESSAGES', summarizeMessages(body));
225
+ if (body['tools'] !== undefined)
226
+ debugHttp(requestId, 'TOOLS', summarizeTools(body));
227
+ if (getDebugFlags().httpBody) {
228
+ const json = sanitizeJson(body);
229
+ const bodySize = json.length;
230
+ const LIMIT = 512 * 1024; // generous debug-only cap (docs/13 §5)
231
+ debugBody(requestId, 'INCOMING BODY', [
232
+ `bodySize=${bodySize} bytes bodyTruncated=${bodySize > LIMIT}`,
233
+ bodySize > LIMIT ? truncate(json, LIMIT) : json,
234
+ ]);
235
+ }
236
+ }
201
237
  export function listRoutableModelIds(models, key, db) {
202
238
  const modelIds = new Set(models.map((m) => m.id));
203
239
  const modelById = new Map(models.map((m) => [m.id, m.publicModelId]));
@@ -23,28 +23,58 @@ export function loadCombo(comboId) {
23
23
  },
24
24
  };
25
25
  }
26
- export function selectCandidates(combo, allModels, req) {
26
+ export function selectCandidates(combo, allModels, req, onReject) {
27
27
  // Resolve each combo member to a candidate and apply filters
28
28
  const map = new Map(allModels.map((m) => [m.modelId, m]));
29
29
  const candidates = [];
30
30
  for (const m of combo.members) {
31
- if (!m.enabled)
31
+ if (!m.enabled) {
32
+ onReject?.({ modelId: m.modelId, publicModelId: map.get(m.modelId)?.publicModelId ?? m.modelId }, 'member_disabled');
32
33
  continue;
34
+ }
33
35
  const c = map.get(m.modelId);
34
- if (!c)
36
+ if (!c) {
37
+ onReject?.({ modelId: m.modelId, publicModelId: m.modelId }, 'model_not_found');
35
38
  continue;
36
- if (!c.enabled)
39
+ }
40
+ if (!c.enabled) {
41
+ onReject?.(c, 'model_disabled');
37
42
  continue;
38
- if (!c.upstreamAvailable)
43
+ }
44
+ if (!c.upstreamAvailable) {
45
+ onReject?.(c, 'upstream_unavailable');
39
46
  continue;
40
- if (c.circuitOpen)
47
+ }
48
+ if (c.circuitOpen) {
49
+ onReject?.(c, 'circuit_open');
41
50
  continue;
42
- if (!modelMeets(c.capabilities, req))
51
+ }
52
+ if (!modelMeets(c.capabilities, req)) {
53
+ onReject?.(c, capabilityRejection(c.capabilities, req));
43
54
  continue;
55
+ }
44
56
  candidates.push(c);
45
57
  }
46
58
  return candidates;
47
59
  }
60
+ /** First capability that explicitly failed (undefined = unknown caps never reject). */
61
+ function capabilityRejection(caps, req) {
62
+ if (req.streaming && caps.streaming === false)
63
+ return 'streaming';
64
+ if (req.tools && caps.tools === false)
65
+ return 'tools';
66
+ if (req.structuredOutput && caps.structured_output === false)
67
+ return 'structured_output';
68
+ if (req.imageInput && caps.image_input === false)
69
+ return 'image_input';
70
+ if (req.audioInput && caps.audio_input === false)
71
+ return 'audio_input';
72
+ if (req.reasoning && caps.reasoning === false)
73
+ return 'reasoning';
74
+ if (req.responses && caps.responses === false)
75
+ return 'responses';
76
+ return 'capability_mismatch';
77
+ }
48
78
  export function orderCandidates(combo, candidates) {
49
79
  if (combo.mode === 'fallback') {
50
80
  // Preserve declared position order
@@ -2,6 +2,7 @@
2
2
  import { decryptSecret, decryptCustomHeaders } from '../auth/crypto.js';
3
3
  import { GatewayError } from '../errors.js';
4
4
  import { buildHeaders, stripSlash } from '../providers/index.js';
5
+ import { debugUpstream, errorLine, formatError, truncate } from '../logging/debug.js';
5
6
  export function providerToUpstreamConfig(p) {
6
7
  let apiKey;
7
8
  let customHeaders;
@@ -34,7 +35,7 @@ export function providerToUpstreamConfig(p) {
34
35
  totalTimeoutMs: p.totalTimeoutMs,
35
36
  };
36
37
  }
37
- export async function callUpstreamNonStreaming(cfg, url, payload) {
38
+ export async function callUpstreamNonStreaming(cfg, url, payload, requestId = '-') {
38
39
  const ctl = new AbortController();
39
40
  const timer = setTimeout(() => ctl.abort(), cfg.totalTimeoutMs);
40
41
  const start = Date.now();
@@ -47,6 +48,7 @@ export async function callUpstreamNonStreaming(cfg, url, payload) {
47
48
  });
48
49
  const text = await res.text();
49
50
  const ttft = Date.now() - start;
51
+ logUpstreamResponse(requestId, res.status, res.statusText, res.headers, ttft, text);
50
52
  return {
51
53
  status: res.status,
52
54
  ok: res.ok,
@@ -58,6 +60,7 @@ export async function callUpstreamNonStreaming(cfg, url, payload) {
58
60
  }
59
61
  catch (e) {
60
62
  const err = e;
63
+ errorLine(requestId, 'UPSTREAM FETCH ERROR', [`url=${url}`, `afterMs=${Date.now() - start}`, ...formatError(e)]);
61
64
  if (err.name === 'AbortError') {
62
65
  throw new GatewayError('timeout_error', 'Upstream request timed out', { status: 504, cause: e });
63
66
  }
@@ -73,11 +76,28 @@ export function extractUpstreamRequestId(headers) {
73
76
  }
74
77
  return headers['x-request-id'] ?? headers['request-id'] ?? headers['x-amzn-requestid'] ?? null;
75
78
  }
79
+ /** docs/13 §14: upstream response summary (sanitized headers) + error body for non-2xx. */
80
+ function logUpstreamResponse(requestId, status, statusText, headers, durationMs, body) {
81
+ const h = (name) => headers.get(name) ?? undefined;
82
+ debugUpstream(requestId, 'UPSTREAM RESPONSE', [
83
+ `status=${status}`,
84
+ `statusText=${statusText}`,
85
+ `content-type=${h('content-type') ?? 'undefined'}`,
86
+ `content-length=${h('content-length') ?? 'undefined'}`,
87
+ `transfer-encoding=${h('transfer-encoding') ?? 'undefined'}`,
88
+ `server=${h('server') ?? 'undefined'}`,
89
+ `durationToHeadersMs=${durationMs}`,
90
+ `upstreamRequestId=${extractUpstreamRequestId(headers) ?? 'null'}`,
91
+ ]);
92
+ if (status < 200 || status >= 300) {
93
+ errorLine(requestId, 'UPSTREAM ERROR BODY', [truncate(body, 4000)]);
94
+ }
95
+ }
76
96
  /**
77
97
  * Call upstream with SSE streaming. Invokes onChunk for each SSE event.
78
98
  * Returns a promise resolving when the stream completes or rejects on failure.
79
99
  */
80
- export async function callUpstreamStreaming(cfg, url, payload, onChunk) {
100
+ export async function callUpstreamStreaming(cfg, url, payload, onChunk, requestId = '-') {
81
101
  const ctl = new AbortController();
82
102
  const totalTimer = setTimeout(() => ctl.abort(), cfg.totalTimeoutMs);
83
103
  const start = Date.now();
@@ -98,8 +118,10 @@ export async function callUpstreamStreaming(cfg, url, payload, onChunk) {
98
118
  });
99
119
  if (!res.ok || !res.body) {
100
120
  const text = await res.text();
121
+ logUpstreamResponse(requestId, res.status, res.statusText, res.headers, Date.now() - start, text);
101
122
  throw new UpstreamHttpError(res.status, text, extractUpstreamRequestId(res.headers));
102
123
  }
124
+ logUpstreamResponse(requestId, res.status, res.statusText, res.headers, Date.now() - start, '');
103
125
  // First-token watchdog
104
126
  firstTokenTimer = setTimeout(() => ctl.abort(), cfg.firstTokenTimeoutMs);
105
127
  resetIdle();
@@ -161,6 +183,12 @@ export async function callUpstreamStreaming(cfg, url, payload, onChunk) {
161
183
  throw new GatewayError('upstream_error', `Upstream HTTP ${e.status}: ${e.bodyExcerpt}`, { status: 502, cause: e });
162
184
  }
163
185
  const err = e;
186
+ errorLine(requestId, 'UPSTREAM FETCH ERROR', [
187
+ `url=${url}`,
188
+ `afterMs=${Date.now() - start}`,
189
+ `ttftKnown=${ttft !== null}`,
190
+ ...formatError(e),
191
+ ]);
164
192
  if (err.name === 'AbortError') {
165
193
  if (ttft === null && firstTokenTimer) {
166
194
  throw new GatewayError('timeout_error', 'Upstream first token timeout', { status: 504, cause: e });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ldrouter",
3
- "version": "1.11.17",
3
+ "version": "1.12.0",
4
4
  "description": "LateDev Router — lightweight self-hosted LLM gateway with admin UI",
5
5
  "type": "module",
6
6
  "license": "MIT",