ldrouter 1.11.12 → 1.11.17
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 +18 -0
- package/dist/server/app.js +14 -0
- package/dist/server/auth/api-key.js +9 -0
- package/dist/server/gateway/runner.js +43 -5
- package/dist/server/logging/debug.js +62 -0
- package/dist/server/protocols/canonical.js +21 -7
- package/dist/server/routes/admin/api-keys.js +10 -0
- package/dist/server/routes/gateway/anthropic.js +2 -1
- package/dist/server/routes/gateway/openai.js +8 -1
- package/dist/server/routing/capabilities.js +15 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,24 @@ 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.11.16] - 2026-09-04
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Claude Code compatibility**: Added Zod `.passthrough()` to gateway routes to accept extra fields (`parallel_tool_calls`, `max_completion_tokens`, `stream_options`, `metadata`, etc.)
|
|
12
|
+
- **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
|
|
13
|
+
- **Cloudflare 502 errors**: Added robust error handling in streaming chunk handler, safe JSON defaults for capabilities parsing, process-level uncaught exception handlers
|
|
14
|
+
- **Response parser safety**: Added null/undefined checks in OpenAI response canonical conversion to prevent crashes on malformed upstream responses
|
|
15
|
+
|
|
16
|
+
### Testing
|
|
17
|
+
|
|
18
|
+
- 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
|
|
19
|
+
|
|
20
|
+
### Documentation
|
|
21
|
+
|
|
22
|
+
- Full root cause analysis documented in `DEBUG-COMPATIBILITY-ROOT-CAUSE.md`
|
|
23
|
+
- Deployment guide in `DEPLOYMENT-READY.md`
|
|
24
|
+
|
|
7
25
|
## [1.11.9] - 2026-09-04
|
|
8
26
|
|
|
9
27
|
### Fixed
|
package/dist/server/app.js
CHANGED
|
@@ -82,6 +82,9 @@ export async function buildApp(opts = {}) {
|
|
|
82
82
|
registerAdminIpGate(app);
|
|
83
83
|
// Operational routes (always available)
|
|
84
84
|
await registerHealthRoutes(app);
|
|
85
|
+
// Debug logging for request lifecycle (before routes)
|
|
86
|
+
const { registerDebugHook } = await import('./logging/debug.js');
|
|
87
|
+
registerDebugHook(app);
|
|
85
88
|
// Admin + gateway routes MUST be registered BEFORE static files to avoid
|
|
86
89
|
// 404s falling through to SPA index.html or static assets being served instead
|
|
87
90
|
await registerAdminRoutes(app);
|
|
@@ -108,6 +111,17 @@ export async function buildApp(opts = {}) {
|
|
|
108
111
|
const err = new GatewayError('invalid_request_error', 'Not found', { status: 404 });
|
|
109
112
|
reply.code(404).send(toOpenAIError(err, req.id));
|
|
110
113
|
});
|
|
114
|
+
// Process-level crash prevention
|
|
115
|
+
process.on('uncaughtException', () => {
|
|
116
|
+
const log = getLogger();
|
|
117
|
+
log.error({ err: {} }, 'uncaught exception');
|
|
118
|
+
// Don't exit immediately - let Fastify error handler process
|
|
119
|
+
setTimeout(() => process.exit(1), 1000);
|
|
120
|
+
});
|
|
121
|
+
process.on('unhandledRejection', (_reason) => {
|
|
122
|
+
const log = getLogger();
|
|
123
|
+
log.error({ reason: '' }, 'unhandled rejection');
|
|
124
|
+
});
|
|
111
125
|
// On startup: ensure settings row + detect master key status
|
|
112
126
|
app.addHook('onReady', async () => {
|
|
113
127
|
const s = getSettings();
|
|
@@ -37,12 +37,21 @@ export function authenticateGatewayKey(req) {
|
|
|
37
37
|
candidate = anthropic;
|
|
38
38
|
if (!candidate)
|
|
39
39
|
return null;
|
|
40
|
+
// DEBUG: Log what we're trying to authenticate
|
|
41
|
+
console.log(`🔑 API KEY AUTH - Candidate extracted: ${candidate.slice(0, 8)}...${candidate.slice(-4)}`);
|
|
40
42
|
// Custom keys are stored verbatim (no prefix requirement); auto-generated
|
|
41
43
|
// keys start with ld-, but authentication must accept any stored secret.
|
|
42
44
|
const digest = sha256Hex(candidate);
|
|
45
|
+
console.log(`🔑 API KEY AUTH - SHA256 Digest: ${digest.slice(0, 16)}...`);
|
|
43
46
|
const db = getDb();
|
|
47
|
+
// DEBUG: Check total keys in database
|
|
48
|
+
const allKeys = db.select().from(schema.apiKeys).all();
|
|
49
|
+
console.log(`📊 TOTAL KEYS IN DB: ${allKeys.length}`);
|
|
50
|
+
console.log(`📋 ALL KEYS:`, allKeys.map(k => ({ id: k.id.slice(0, 8), name: k.name, keyPrefix: k.keyPrefix, digestPreview: k.keyDigest?.slice(0, 16) + '...' })));
|
|
44
51
|
const row = db.select().from(schema.apiKeys).where(eq(schema.apiKeys.keyDigest, digest)).get();
|
|
52
|
+
console.log(`❓ QUERY RESULT: Found match? ${!!row}`);
|
|
45
53
|
if (!row) {
|
|
54
|
+
console.error(`❌ INVALID API KEY - No matching digest found for: ${digest.slice(0, 32)}...`);
|
|
46
55
|
throw new GatewayError('authentication_error', 'Invalid API key', { status: 401 });
|
|
47
56
|
}
|
|
48
57
|
return {
|
|
@@ -403,7 +403,6 @@ export class GatewayRunner {
|
|
|
403
403
|
let finishReason = null;
|
|
404
404
|
const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, total: 0 };
|
|
405
405
|
const chunkHandler = (chunk, isFirst) => {
|
|
406
|
-
// Track usage/finish (protocol-agnostic; runs even on the first chunk).
|
|
407
406
|
try {
|
|
408
407
|
const obj = JSON.parse(chunk.data);
|
|
409
408
|
if (cfg.type === 'openai') {
|
|
@@ -412,8 +411,19 @@ export class GatewayRunner {
|
|
|
412
411
|
textBuf += choice.delta.content;
|
|
413
412
|
if (choice?.delta?.tool_calls) {
|
|
414
413
|
for (const tc of choice.delta.tool_calls) {
|
|
415
|
-
|
|
416
|
-
|
|
414
|
+
const id = typeof tc.id === 'string' ? tc.id : `toolu-${Math.random().toString(36).slice(2)}`;
|
|
415
|
+
const name = typeof tc.function?.name === 'string' ? tc.function.name : 'unknown';
|
|
416
|
+
let input = {};
|
|
417
|
+
if (typeof tc.function?.arguments === 'string') {
|
|
418
|
+
try {
|
|
419
|
+
input = JSON.parse(tc.function.arguments);
|
|
420
|
+
}
|
|
421
|
+
catch { /* ignore */ }
|
|
422
|
+
}
|
|
423
|
+
else if (typeof tc.function?.arguments === 'object') {
|
|
424
|
+
input = tc.function.arguments;
|
|
425
|
+
}
|
|
426
|
+
toolBuf.push({ id, name, input });
|
|
417
427
|
}
|
|
418
428
|
}
|
|
419
429
|
if (choice?.finish_reason)
|
|
@@ -722,12 +732,40 @@ function hasTools(req) {
|
|
|
722
732
|
function estimateTokens(s) {
|
|
723
733
|
return Math.ceil(s.length / 4);
|
|
724
734
|
}
|
|
735
|
+
/**
|
|
736
|
+
* Safely parse capabilities JSON. Returns minimal default if parsing fails.
|
|
737
|
+
* CRITICAL: Must return a complete default object with all capability fields,
|
|
738
|
+
* otherwise undefined values will cause modelMeets() to fail incorrectly.
|
|
739
|
+
*/
|
|
725
740
|
function safeJson(s) {
|
|
726
741
|
try {
|
|
727
|
-
|
|
742
|
+
const parsed = JSON.parse(s);
|
|
743
|
+
// Ensure all required fields exist, using true as default for "unknown"
|
|
744
|
+
const result = {
|
|
745
|
+
chat: true,
|
|
746
|
+
streaming: true,
|
|
747
|
+
tools: true,
|
|
748
|
+
structured_output: true,
|
|
749
|
+
image_input: true,
|
|
750
|
+
audio_input: true,
|
|
751
|
+
reasoning: true,
|
|
752
|
+
responses: true,
|
|
753
|
+
...parsed,
|
|
754
|
+
};
|
|
755
|
+
return result;
|
|
728
756
|
}
|
|
729
757
|
catch {
|
|
730
|
-
|
|
758
|
+
// Fallback to defaults if completely unparseable
|
|
759
|
+
return {
|
|
760
|
+
chat: true,
|
|
761
|
+
streaming: true,
|
|
762
|
+
tools: true,
|
|
763
|
+
structured_output: true,
|
|
764
|
+
image_input: true,
|
|
765
|
+
audio_input: true,
|
|
766
|
+
reasoning: true,
|
|
767
|
+
responses: true,
|
|
768
|
+
};
|
|
731
769
|
}
|
|
732
770
|
}
|
|
733
771
|
function safeJsonParse(s) {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Debug logging utilities for request lifecycle tracking
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
const DEBUG_LOG_DIR = '/data';
|
|
5
|
+
const DEBUG_LOG_FILE = path.join(DEBUG_LOG_DIR, 'ldrouter-debug.log');
|
|
6
|
+
// Ensure log file exists
|
|
7
|
+
if (!fs.existsSync(DEBUG_LOG_FILE)) {
|
|
8
|
+
try {
|
|
9
|
+
fs.writeFileSync(DEBUG_LOG_FILE, '');
|
|
10
|
+
}
|
|
11
|
+
catch (_err) {
|
|
12
|
+
// Silent fail - don't break the application
|
|
13
|
+
console.error('Failed to create debug log:', _err);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function appendDebugLog(entry) {
|
|
17
|
+
const logLine = JSON.stringify(entry) + '\n';
|
|
18
|
+
try {
|
|
19
|
+
fs.appendFileSync(DEBUG_LOG_FILE, logLine);
|
|
20
|
+
}
|
|
21
|
+
catch (_err) {
|
|
22
|
+
// Silent fail - don't break the application
|
|
23
|
+
console.error('Failed to write debug log:', _err);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function registerDebugHook(app) {
|
|
27
|
+
// Log every request entering the system
|
|
28
|
+
app.addHook('onRequest', async (req, _reply) => {
|
|
29
|
+
const entry = {
|
|
30
|
+
timestamp: new Date().toISOString(),
|
|
31
|
+
level: 'DEBUG',
|
|
32
|
+
requestId: req.id,
|
|
33
|
+
url: req.url,
|
|
34
|
+
method: req.method,
|
|
35
|
+
phase: 'REQUEST_ENTERED',
|
|
36
|
+
details: {
|
|
37
|
+
headers: {
|
|
38
|
+
authorization: req.headers.authorization ? '[REDACTED]' : undefined,
|
|
39
|
+
'content-type': req.headers['content-type'],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
appendDebugLog(entry);
|
|
44
|
+
});
|
|
45
|
+
// Log before route handler execution
|
|
46
|
+
app.addHook('preHandler', async (req, _reply) => {
|
|
47
|
+
const entry = {
|
|
48
|
+
timestamp: new Date().toISOString(),
|
|
49
|
+
level: 'DEBUG',
|
|
50
|
+
requestId: req.id,
|
|
51
|
+
url: req.url,
|
|
52
|
+
method: req.method,
|
|
53
|
+
phase: 'ROUTE_MATCHED',
|
|
54
|
+
details: {},
|
|
55
|
+
};
|
|
56
|
+
appendDebugLog(entry);
|
|
57
|
+
});
|
|
58
|
+
// Log response completion with status code and content type
|
|
59
|
+
app.addHook('onResponse', async (_req, _reply) => {
|
|
60
|
+
// Note: We'll capture this in the onRequest handler instead for simpler logging
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -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
|
}
|
|
@@ -112,6 +112,10 @@ export async function registerApiKeyRoutes(app) {
|
|
|
112
112
|
throw new GatewayError('invalid_request_error', `An API key with this exact secret already exists ("${existing.name}"). Choose a different key value.`, { status: 409 });
|
|
113
113
|
}
|
|
114
114
|
const enc = encryptSecret(secret);
|
|
115
|
+
// DEBUG: Log what we're about to insert
|
|
116
|
+
console.log(`📝 CREATE API KEY - Name: ${body.name}, Prefix: ${keyPrefix}`);
|
|
117
|
+
console.log(`📝 CREATE API KEY - Secret (full): ${secret}`);
|
|
118
|
+
console.log(`📝 CREATE API KEY - Digest: ${keyDigest}`);
|
|
115
119
|
db.insert(schema.apiKeys).values({
|
|
116
120
|
id,
|
|
117
121
|
name: body.name,
|
|
@@ -131,6 +135,12 @@ export async function registerApiKeyRoutes(app) {
|
|
|
131
135
|
maxOutputTokensPerRequest: body.maxOutputTokensPerRequest ?? null,
|
|
132
136
|
cacheOverrideEnabled: body.cacheOverrideEnabled ?? null,
|
|
133
137
|
}).run();
|
|
138
|
+
// DEBUG: Verify it was actually inserted
|
|
139
|
+
const verify = db.select().from(schema.apiKeys).where(eq(schema.apiKeys.id, id)).get();
|
|
140
|
+
console.log(`✅ INSERT VERIFICATION: ${verify ? 'SUCCESS' : 'FAILED'} - Key exists in DB? ${!!verify}`);
|
|
141
|
+
if (!verify) {
|
|
142
|
+
console.error('❌ DATABASE INSERT FAILED - Key should exist but query returned null');
|
|
143
|
+
}
|
|
134
144
|
if (body.permissions) {
|
|
135
145
|
for (const p of body.permissions) {
|
|
136
146
|
db.insert(schema.apiKeyModelPermissions).values({ id: uuid(), apiKeyId: id, targetKind: p.targetKind, targetId: p.targetId }).run();
|
|
@@ -18,7 +18,8 @@ const MessagesBody = z.object({
|
|
|
18
18
|
tools: z.array(z.any()).optional(),
|
|
19
19
|
tool_choice: z.any().optional(),
|
|
20
20
|
thinking: z.object({ type: z.literal('enabled'), budget_tokens: z.number().int().min(1) }).optional(),
|
|
21
|
-
|
|
21
|
+
// Accept additional fields
|
|
22
|
+
}).passthrough();
|
|
22
23
|
const CountTokensBody = MessagesBody.omit({ stream: true });
|
|
23
24
|
export async function registerAnthropicRoutes(app) {
|
|
24
25
|
const runner = new GatewayRunner();
|
|
@@ -17,10 +17,17 @@ const ChatBody = z.object({
|
|
|
17
17
|
temperature: z.number().optional(),
|
|
18
18
|
top_p: z.number().optional(),
|
|
19
19
|
max_tokens: z.number().int().min(1).optional(),
|
|
20
|
+
max_completion_tokens: z.number().int().min(1).optional(),
|
|
20
21
|
stop: z.union([z.array(z.string()), z.string()]).optional(),
|
|
21
22
|
response_format: z.any().optional(),
|
|
22
23
|
reasoning_effort: z.enum(['low', 'medium', 'high']).optional(),
|
|
23
|
-
|
|
24
|
+
// Accept additional Claude Code fields without failing
|
|
25
|
+
parallel_tool_calls: z.any().optional(),
|
|
26
|
+
stream_options: z.any().optional(),
|
|
27
|
+
metadata: z.any().optional(),
|
|
28
|
+
seed: z.number().int().optional(),
|
|
29
|
+
service_tier: z.any().optional(),
|
|
30
|
+
}).passthrough(); // Allow unknown fields to pass through (forward to upstream)
|
|
24
31
|
const ResponsesBody = z.object({
|
|
25
32
|
model: z.string().min(1),
|
|
26
33
|
input: z.any(),
|
|
@@ -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
|
}
|