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 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
@@ -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
- if (tc.function?.name)
416
- toolBuf.push({ id: tc.id ?? '', name: tc.function.name, input: {} });
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
- return JSON.parse(s);
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
- return {};
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?.message?.content ?? '',
155
- toolCalls: (choice?.message?.tool_calls ?? []).map((tc) => ({ id: tc.id, name: tc.function.name, input: safeJson(tc.function.arguments) })),
164
+ text: typeof choice.message.content === 'string' ? choice.message.content : '',
165
+ toolCalls: (choice.message.tool_calls ?? []).map((tc) => ({
166
+ id: typeof tc.id === 'string' ? tc.id : `toolu-${Math.random().toString(36).slice(2)}`,
167
+ name: typeof tc.function?.name === 'string' ? tc.function.name : 'unknown',
168
+ input: safeJson(typeof tc.function?.arguments === 'string' ? tc.function.arguments : '{}'),
169
+ })),
156
170
  finishReason: choice?.finish_reason ?? null,
157
171
  usage: {
158
- input: res.usage?.prompt_tokens ?? 0,
159
- output: res.usage?.completion_tokens ?? 0,
160
- cacheRead: res.usage?.prompt_tokens_details?.cached_tokens ?? 0,
172
+ input: typeof res.usage?.prompt_tokens === 'number' ? res.usage.prompt_tokens : 0,
173
+ output: typeof res.usage?.completion_tokens === 'number' ? res.usage.completion_tokens : 0,
174
+ cacheRead: typeof res.usage?.prompt_tokens_details?.cached_tokens === 'number' ? res.usage.prompt_tokens_details.cached_tokens : 0,
161
175
  cacheWrite: 0,
162
- reasoning: res.usage?.completion_tokens_details?.reasoning_tokens ?? 0,
163
- total: res.usage?.total_tokens ?? 0,
176
+ reasoning: typeof res.usage?.completion_tokens_details?.reasoning_tokens === 'number' ? res.usage.completion_tokens_details.reasoning_tokens : 0,
177
+ total: typeof res.usage?.total_tokens === 'number' ? res.usage.total_tokens : 0,
164
178
  },
165
179
  };
166
180
  }
@@ -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 (req.streaming && !caps.streaming)
44
+ // Only reject if capability is explicitly false, not if unknown (undefined)
45
+ if (req.streaming && caps.streaming === false)
38
46
  return false;
39
- if (req.tools && !caps.tools)
47
+ if (req.tools && caps.tools === false)
40
48
  return false;
41
- if (req.structuredOutput && !caps.structured_output)
49
+ if (req.structuredOutput && caps.structured_output === false)
42
50
  return false;
43
- if (req.imageInput && !caps.image_input)
51
+ if (req.imageInput && caps.image_input === false)
44
52
  return false;
45
- if (req.audioInput && !caps.audio_input)
53
+ if (req.audioInput && caps.audio_input === false)
46
54
  return false;
47
- if (req.reasoning && !caps.reasoning)
55
+ if (req.reasoning && caps.reasoning === false)
48
56
  return false;
49
- if (req.responses && !caps.responses)
57
+ if (req.responses && caps.responses === false)
50
58
  return false;
51
59
  return true;
52
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ldrouter",
3
- "version": "1.11.12",
3
+ "version": "1.11.17",
4
4
  "description": "LateDev Router — lightweight self-hosted LLM gateway with admin UI",
5
5
  "type": "module",
6
6
  "license": "MIT",