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 +18 -0
- package/dist/server/app.js +11 -0
- package/dist/server/gateway/runner.js +43 -5
- package/dist/server/protocols/canonical.js +21 -7
- 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
|
@@ -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
|
-
|
|
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) {
|
|
@@ -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
|
}
|
|
@@ -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
|
}
|