ldrouter 1.11.15 → 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
@@ -111,6 +111,17 @@ export async function buildApp(opts = {}) {
111
111
  const err = new GatewayError('invalid_request_error', 'Not found', { status: 404 });
112
112
  reply.code(404).send(toOpenAIError(err, req.id));
113
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
+ });
114
125
  // On startup: ensure settings row + detect master key status
115
126
  app.addHook('onReady', async () => {
116
127
  const s = getSettings();
@@ -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) {
@@ -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
  }
@@ -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.15",
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",