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 +15 -0
- package/dist/server/app.js +32 -7
- package/dist/server/config/index.js +3 -1
- package/dist/server/gateway/runner.js +197 -13
- package/dist/server/logging/debug.js +271 -55
- package/dist/server/routes/gateway/anthropic.js +38 -1
- package/dist/server/routes/gateway/openai.js +38 -2
- package/dist/server/routing/combo.js +37 -7
- package/dist/server/upstream/client.js +30 -2
- package/package.json +1 -1
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
|
package/dist/server/app.js
CHANGED
|
@@ -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
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
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', (
|
|
145
|
+
process.on('unhandledRejection', (reason) => {
|
|
146
|
+
fatal('FATAL', ['unhandledRejection', ...formatError(reason)]);
|
|
122
147
|
const log = getLogger();
|
|
123
|
-
log.error({ reason:
|
|
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
|
|
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
|
|
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
|
|
388
|
+
if (!m.enabled) {
|
|
389
|
+
reject('model_disabled');
|
|
310
390
|
return [];
|
|
311
|
-
|
|
391
|
+
}
|
|
392
|
+
if (!m.upstreamAvailable) {
|
|
393
|
+
reject('upstream_unavailable');
|
|
394
|
+
return [];
|
|
395
|
+
}
|
|
396
|
+
if (candidate.circuitOpen) {
|
|
397
|
+
reject('circuit_open');
|
|
312
398
|
return [];
|
|
313
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
20
|
-
}
|
|
21
|
-
catch
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
39
|
+
}
|
|
40
|
+
if (!c.enabled) {
|
|
41
|
+
onReject?.(c, 'model_disabled');
|
|
37
42
|
continue;
|
|
38
|
-
|
|
43
|
+
}
|
|
44
|
+
if (!c.upstreamAvailable) {
|
|
45
|
+
onReject?.(c, 'upstream_unavailable');
|
|
39
46
|
continue;
|
|
40
|
-
|
|
47
|
+
}
|
|
48
|
+
if (c.circuitOpen) {
|
|
49
|
+
onReject?.(c, 'circuit_open');
|
|
41
50
|
continue;
|
|
42
|
-
|
|
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 });
|