ldrouter 1.11.15 → 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,39 @@ 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
+
22
+ ## [1.11.16] - 2026-09-04
23
+
24
+ ### Fixed
25
+
26
+ - **Claude Code compatibility**: Added Zod `.passthrough()` to gateway routes to accept extra fields (`parallel_tool_calls`, `max_completion_tokens`, `stream_options`, `metadata`, etc.)
27
+ - **Combo model capability rejection**: Changed capability comparison from `!caps.field` to `caps.field === false` so models with undefined capabilities are treated as "potentially supported" instead of rejected
28
+ - **Cloudflare 502 errors**: Added robust error handling in streaming chunk handler, safe JSON defaults for capabilities parsing, process-level uncaught exception handlers
29
+ - **Response parser safety**: Added null/undefined checks in OpenAI response canonical conversion to prevent crashes on malformed upstream responses
30
+
31
+ ### Testing
32
+
33
+ - Added unit tests for Claude Code compatibility (`tests/unit/claude-code-compatibility.test.ts`) - 9 tests covering Zod passthrough, capability handling, and safe JSON parsing
34
+
35
+ ### Documentation
36
+
37
+ - Full root cause analysis documented in `DEBUG-COMPATIBILITY-ROOT-CAUSE.md`
38
+ - Deployment guide in `DEPLOYMENT-READY.md`
39
+
7
40
  ## [1.11.9] - 2026-09-04
8
41
 
9
42
  ### 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);
@@ -111,6 +134,19 @@ export async function buildApp(opts = {}) {
111
134
  const err = new GatewayError('invalid_request_error', 'Not found', { status: 404 });
112
135
  reply.code(404).send(toOpenAIError(err, req.id));
113
136
  });
137
+ // Process-level crash prevention
138
+ process.on('uncaughtException', (err) => {
139
+ fatal('FATAL', ['uncaughtException', ...formatError(err)]);
140
+ const log = getLogger();
141
+ log.error({ err: { name: err.name, message: err.message, stack: err.stack } }, 'uncaught exception');
142
+ // Don't exit immediately - let Fastify error handler process
143
+ setTimeout(() => process.exit(1), 1000);
144
+ });
145
+ process.on('unhandledRejection', (reason) => {
146
+ fatal('FATAL', ['unhandledRejection', ...formatError(reason)]);
147
+ const log = getLogger();
148
+ log.error({ err: { reason: String(reason) } }, 'unhandled rejection');
149
+ });
114
150
  // On startup: ensure settings row + detect master key status
115
151
  app.addHook('onReady', async () => {
116
152
  const s = getSettings();
@@ -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');
372
+ return [];
373
+ }
374
+ if (!p.enabled) {
375
+ reject('provider_disabled');
298
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');
390
+ return [];
391
+ }
392
+ if (!m.upstreamAvailable) {
393
+ reject('upstream_unavailable');
310
394
  return [];
311
- if (candidate.circuitOpen)
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,8 +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) => {
406
- // Track usage/finish (protocol-agnostic; runs even on the first chunk).
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
+ }
407
537
  try {
408
538
  const obj = JSON.parse(chunk.data);
409
539
  if (cfg.type === 'openai') {
@@ -412,8 +542,19 @@ export class GatewayRunner {
412
542
  textBuf += choice.delta.content;
413
543
  if (choice?.delta?.tool_calls) {
414
544
  for (const tc of choice.delta.tool_calls) {
415
- if (tc.function?.name)
416
- toolBuf.push({ id: tc.id ?? '', name: tc.function.name, input: {} });
545
+ const id = typeof tc.id === 'string' ? tc.id : `toolu-${Math.random().toString(36).slice(2)}`;
546
+ const name = typeof tc.function?.name === 'string' ? tc.function.name : 'unknown';
547
+ let input = {};
548
+ if (typeof tc.function?.arguments === 'string') {
549
+ try {
550
+ input = JSON.parse(tc.function.arguments);
551
+ }
552
+ catch { /* ignore */ }
553
+ }
554
+ else if (typeof tc.function?.arguments === 'object') {
555
+ input = tc.function.arguments;
556
+ }
557
+ toolBuf.push({ id, name, input });
417
558
  }
418
559
  }
419
560
  if (choice?.finish_reason)
@@ -463,12 +604,23 @@ export class GatewayRunner {
463
604
  try {
464
605
  const url = cfg.type === 'openai' ? upstreamUrl(cfg, '/v1/chat/completions') : upstreamUrl(cfg, '/v1/messages');
465
606
  const payload = cfg.type === 'openai' ? canonicalToOpenAIRequest(req.canonical, upstreamModel) : canonicalToAnthropicRequest(req.canonical, upstreamModel);
466
- 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);
467
610
  // Upstream completed cleanly: ensure head + terminator are written.
468
611
  if (!headWritten)
469
612
  writeHead();
470
613
  pipe.write('data: [DONE]\n\n');
471
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
+ ]);
472
624
  if (!usage.total)
473
625
  usage.total = usage.input + usage.output;
474
626
  return {
@@ -487,8 +639,20 @@ export class GatewayRunner {
487
639
  writeHead();
488
640
  pipe.end();
489
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
+ ]);
490
648
  throw err;
491
649
  }
650
+ errorLine(ctx.requestId, 'STREAM ERROR (before first chunk)', [
651
+ `chunksBeforeError=${chunkCount}`,
652
+ `bytesBeforeError=${bytesReceived}`,
653
+ `clientDisconnected=${clientDisconnected}`,
654
+ ...formatErrorPublic(e),
655
+ ]);
492
656
  throw e;
493
657
  }
494
658
  }
@@ -722,12 +886,70 @@ function hasTools(req) {
722
886
  function estimateTokens(s) {
723
887
  return Math.ceil(s.length / 4);
724
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
+ }
919
+ /**
920
+ * Safely parse capabilities JSON. Returns minimal default if parsing fails.
921
+ * CRITICAL: Must return a complete default object with all capability fields,
922
+ * otherwise undefined values will cause modelMeets() to fail incorrectly.
923
+ */
725
924
  function safeJson(s) {
726
925
  try {
727
- return JSON.parse(s);
926
+ const parsed = JSON.parse(s);
927
+ // Ensure all required fields exist, using true as default for "unknown"
928
+ const result = {
929
+ chat: true,
930
+ streaming: true,
931
+ tools: true,
932
+ structured_output: true,
933
+ image_input: true,
934
+ audio_input: true,
935
+ reasoning: true,
936
+ responses: true,
937
+ ...parsed,
938
+ };
939
+ return result;
728
940
  }
729
941
  catch {
730
- return {};
942
+ // Fallback to defaults if completely unparseable
943
+ return {
944
+ chat: true,
945
+ streaming: true,
946
+ tools: true,
947
+ structured_output: true,
948
+ image_input: true,
949
+ audio_input: true,
950
+ reasoning: true,
951
+ responses: true,
952
+ };
731
953
  }
732
954
  }
733
955
  function safeJsonParse(s) {
@@ -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
  }
@@ -149,18 +149,32 @@ export function canonicalToOpenAIRequest(req, targetModel) {
149
149
  }
150
150
  export function openAIResponseToCanonical(res, requestedModel) {
151
151
  const choice = res.choices[0];
152
+ if (!choice || !choice.message) {
153
+ // Handle empty or malformed response
154
+ return {
155
+ model: requestedModel,
156
+ text: '',
157
+ toolCalls: [],
158
+ finishReason: null,
159
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, total: 0 },
160
+ };
161
+ }
152
162
  return {
153
163
  model: requestedModel,
154
- text: choice?.message?.content ?? '',
155
- toolCalls: (choice?.message?.tool_calls ?? []).map((tc) => ({ id: tc.id, name: tc.function.name, input: safeJson(tc.function.arguments) })),
164
+ text: typeof choice.message.content === 'string' ? choice.message.content : '',
165
+ toolCalls: (choice.message.tool_calls ?? []).map((tc) => ({
166
+ id: typeof tc.id === 'string' ? tc.id : `toolu-${Math.random().toString(36).slice(2)}`,
167
+ name: typeof tc.function?.name === 'string' ? tc.function.name : 'unknown',
168
+ input: safeJson(typeof tc.function?.arguments === 'string' ? tc.function.arguments : '{}'),
169
+ })),
156
170
  finishReason: choice?.finish_reason ?? null,
157
171
  usage: {
158
- input: res.usage?.prompt_tokens ?? 0,
159
- output: res.usage?.completion_tokens ?? 0,
160
- cacheRead: res.usage?.prompt_tokens_details?.cached_tokens ?? 0,
172
+ input: typeof res.usage?.prompt_tokens === 'number' ? res.usage.prompt_tokens : 0,
173
+ output: typeof res.usage?.completion_tokens === 'number' ? res.usage.completion_tokens : 0,
174
+ cacheRead: typeof res.usage?.prompt_tokens_details?.cached_tokens === 'number' ? res.usage.prompt_tokens_details.cached_tokens : 0,
161
175
  cacheWrite: 0,
162
- reasoning: res.usage?.completion_tokens_details?.reasoning_tokens ?? 0,
163
- total: res.usage?.total_tokens ?? 0,
176
+ reasoning: typeof res.usage?.completion_tokens_details?.reasoning_tokens === 'number' ? res.usage.completion_tokens_details.reasoning_tokens : 0,
177
+ total: typeof res.usage?.total_tokens === 'number' ? res.usage.total_tokens : 0,
164
178
  },
165
179
  };
166
180
  }
@@ -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),
@@ -18,7 +19,8 @@ const MessagesBody = z.object({
18
19
  tools: z.array(z.any()).optional(),
19
20
  tool_choice: z.any().optional(),
20
21
  thinking: z.object({ type: z.literal('enabled'), budget_tokens: z.number().int().min(1) }).optional(),
21
- });
22
+ // Accept additional fields
23
+ }).passthrough();
22
24
  const CountTokensBody = MessagesBody.omit({ stream: true });
23
25
  export async function registerAnthropicRoutes(app) {
24
26
  const runner = new GatewayRunner();
@@ -26,15 +28,21 @@ export async function registerAnthropicRoutes(app) {
26
28
  reply.code(405).send(toAnthropicError(new GatewayError('invalid_request_error', 'Use POST /v1/messages', { status: 405 }), ''));
27
29
  });
28
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
+ ]);
29
36
  const key = authenticateGatewayHeaders(req);
30
37
  const body = MessagesBody.parse(req.body);
38
+ logIncomingMessagesBody(requestId, body);
31
39
  const ar = body;
32
40
  if (!ar.max_tokens) {
33
41
  throw new GatewayError('invalid_request_error', 'max_tokens is required', { status: 400 });
34
42
  }
35
43
  const canonical = anthropicToCanonical(ar);
36
44
  const ctx = {
37
- requestId: req.id || uuid(),
45
+ requestId,
38
46
  clientIp: resolveClientIp(req),
39
47
  protocol: 'anthropic',
40
48
  endpoint: 'messages',
@@ -59,9 +67,11 @@ export async function registerAnthropicRoutes(app) {
59
67
  }
60
68
  if (!outcome.success) {
61
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}`]);
62
71
  reply.code(outcome.httpStatus).send(toAnthropicError(g, ctx.requestId));
63
72
  return;
64
73
  }
74
+ lifecycle(requestId, 'DONE', [`status=200 durationMs=${outcome.latencyMs} finishReason=${outcome.finishReason ?? 'null'}`]);
65
75
  reply.header('x-request-id', ctx.requestId);
66
76
  reply.send({
67
77
  id: `msg_${ctx.requestId}`,
@@ -100,6 +110,34 @@ export async function registerAnthropicRoutes(app) {
100
110
  reply.send({ input_tokens: inputTokens });
101
111
  });
102
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
+ }
103
141
  function authenticateGatewayHeaders(req) {
104
142
  const key = authenticateGatewayKey(req);
105
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),
@@ -17,10 +18,17 @@ const ChatBody = z.object({
17
18
  temperature: z.number().optional(),
18
19
  top_p: z.number().optional(),
19
20
  max_tokens: z.number().int().min(1).optional(),
21
+ max_completion_tokens: z.number().int().min(1).optional(),
20
22
  stop: z.union([z.array(z.string()), z.string()]).optional(),
21
23
  response_format: z.any().optional(),
22
24
  reasoning_effort: z.enum(['low', 'medium', 'high']).optional(),
23
- });
25
+ // Accept additional Claude Code fields without failing
26
+ parallel_tool_calls: z.any().optional(),
27
+ stream_options: z.any().optional(),
28
+ metadata: z.any().optional(),
29
+ seed: z.number().int().optional(),
30
+ service_tier: z.any().optional(),
31
+ }).passthrough(); // Allow unknown fields to pass through (forward to upstream)
24
32
  const ResponsesBody = z.object({
25
33
  model: z.string().min(1),
26
34
  input: z.any(),
@@ -45,12 +53,18 @@ export async function registerOpenAIRoutes(app) {
45
53
  return openAIModelList(ids.map((id) => ({ publicModelId: id, upstreamModelId: id })));
46
54
  });
47
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
+ ]);
48
61
  const key = authenticateGatewayHeaders(req);
49
62
  const body = ChatBody.parse(req.body);
63
+ logIncomingChatBody(requestId, body);
50
64
  const req1 = body;
51
65
  const canonical = openAIToCanonical(req1);
52
66
  const ctx = {
53
- requestId: req.id || uuid(),
67
+ requestId,
54
68
  clientIp: resolveClientIp(req),
55
69
  protocol: 'openai',
56
70
  endpoint: 'chat/completions',
@@ -82,9 +96,11 @@ export async function registerOpenAIRoutes(app) {
82
96
  }
83
97
  if (!outcome.success) {
84
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}`]);
85
100
  reply.code(outcome.httpStatus).send(toOpenAIError(g, ctx.requestId));
86
101
  return;
87
102
  }
103
+ lifecycle(requestId, 'DONE', [`status=200 durationMs=${outcome.latencyMs} finishReason=${outcome.finishReason ?? 'null'}`]);
88
104
  reply.header('x-request-id', ctx.requestId);
89
105
  reply.send({
90
106
  id: `chatcmpl-${ctx.requestId}`,
@@ -122,9 +138,19 @@ export async function registerOpenAIRoutes(app) {
122
138
  }
123
139
  });
124
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
+ ]);
125
146
  const key = authenticateGatewayHeaders(req);
126
147
  // v1 subset: accept Responses-style input, flatten to chat-completions messages.
127
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
+ ]);
128
154
  const flat = responsesInputToChat(body.input);
129
155
  const chatBody = {
130
156
  model: body.model,
@@ -134,7 +160,7 @@ export async function registerOpenAIRoutes(app) {
134
160
  };
135
161
  const canonical = openAIToCanonical(chatBody);
136
162
  const ctx = {
137
- requestId: req.id || uuid(),
163
+ requestId,
138
164
  clientIp: resolveClientIp(req),
139
165
  protocol: 'openai',
140
166
  endpoint: 'responses',
@@ -191,6 +217,23 @@ export async function registerOpenAIRoutes(app) {
191
217
  }
192
218
  });
193
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
+ }
194
237
  export function listRoutableModelIds(models, key, db) {
195
238
  const modelIds = new Set(models.map((m) => m.id));
196
239
  const modelById = new Map(models.map((m) => [m.id, m.publicModelId]));
@@ -33,20 +33,28 @@ export function deriveRequiredCapabilities(req) {
33
33
  responses: false,
34
34
  };
35
35
  }
36
+ /**
37
+ * Check if a model meets required capabilities.
38
+ * IMPORTANT: Treat undefined as "unknown" rather than "unsupported".
39
+ * For generic OpenAI-compatible providers where capabilities weren't explicitly imported,
40
+ * undefined means we don't know, so we should assume it's potentially supported.
41
+ * Explicit false means "known unsupported".
42
+ */
36
43
  export function modelMeets(caps, req) {
37
- if (req.streaming && !caps.streaming)
44
+ // Only reject if capability is explicitly false, not if unknown (undefined)
45
+ if (req.streaming && caps.streaming === false)
38
46
  return false;
39
- if (req.tools && !caps.tools)
47
+ if (req.tools && caps.tools === false)
40
48
  return false;
41
- if (req.structuredOutput && !caps.structured_output)
49
+ if (req.structuredOutput && caps.structured_output === false)
42
50
  return false;
43
- if (req.imageInput && !caps.image_input)
51
+ if (req.imageInput && caps.image_input === false)
44
52
  return false;
45
- if (req.audioInput && !caps.audio_input)
53
+ if (req.audioInput && caps.audio_input === false)
46
54
  return false;
47
- if (req.reasoning && !caps.reasoning)
55
+ if (req.reasoning && caps.reasoning === false)
48
56
  return false;
49
- if (req.responses && !caps.responses)
57
+ if (req.responses && caps.responses === false)
50
58
  return false;
51
59
  return true;
52
60
  }
@@ -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.15",
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",