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 +33 -0
- package/dist/server/app.js +39 -3
- package/dist/server/config/index.js +3 -1
- package/dist/server/gateway/runner.js +240 -18
- package/dist/server/logging/debug.js +271 -55
- package/dist/server/protocols/canonical.js +21 -7
- package/dist/server/routes/gateway/anthropic.js +40 -2
- package/dist/server/routes/gateway/openai.js +46 -3
- package/dist/server/routing/capabilities.js +15 -7
- 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,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
|
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);
|
|
@@ -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
|
|
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');
|
|
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
|
|
388
|
+
if (!m.enabled) {
|
|
389
|
+
reject('model_disabled');
|
|
390
|
+
return [];
|
|
391
|
+
}
|
|
392
|
+
if (!m.upstreamAvailable) {
|
|
393
|
+
reject('upstream_unavailable');
|
|
310
394
|
return [];
|
|
311
|
-
|
|
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,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
|
-
|
|
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
|
-
|
|
416
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
}
|
|
@@ -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
|
|
155
|
-
toolCalls: (choice
|
|
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
|
|
159
|
-
output: res.usage?.completion_tokens
|
|
160
|
-
cacheRead: res.usage?.prompt_tokens_details?.cached_tokens
|
|
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
|
|
163
|
-
total: res.usage?.total_tokens
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 &&
|
|
47
|
+
if (req.tools && caps.tools === false)
|
|
40
48
|
return false;
|
|
41
|
-
if (req.structuredOutput &&
|
|
49
|
+
if (req.structuredOutput && caps.structured_output === false)
|
|
42
50
|
return false;
|
|
43
|
-
if (req.imageInput &&
|
|
51
|
+
if (req.imageInput && caps.image_input === false)
|
|
44
52
|
return false;
|
|
45
|
-
if (req.audioInput &&
|
|
53
|
+
if (req.audioInput && caps.audio_input === false)
|
|
46
54
|
return false;
|
|
47
|
-
if (req.reasoning &&
|
|
55
|
+
if (req.reasoning && caps.reasoning === false)
|
|
48
56
|
return false;
|
|
49
|
-
if (req.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
|
-
|
|
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 });
|