llm-switcher 1.6.8 → 1.6.10
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 +23 -2
- package/README.md +59 -4
- package/README.vi.md +60 -4
- package/formats.mjs +169 -20
- package/package.json +1 -1
- package/proxy.mjs +25 -3
- package/tests/compaction.e2e.test.mjs +17 -1
- package/tests/formats.test.mjs +292 -2
- package/tests/gateway.e2e.test.mjs +19 -1
- package/tests/ui.test.mjs +7 -1
- package/ui.html +761 -126
- package/upstream-stream.mjs +3 -1
package/formats.mjs
CHANGED
|
@@ -39,6 +39,8 @@ function canonFinish(raw) {
|
|
|
39
39
|
if (['length', 'max_tokens', 'max_output_tokens'].includes(r)) return 'length';
|
|
40
40
|
if (['tool_calls', 'tool_call', 'function_call', 'function_calls', 'tool_use'].includes(r)) return 'tool_calls';
|
|
41
41
|
if (['content_filter', 'safety', 'recitation', 'language', 'blocklist', 'prohibited_content', 'spii'].includes(r)) return 'content_filter';
|
|
42
|
+
// Gemini gave up on a tool call: as 'stop' the client would see a silent, empty turn.
|
|
43
|
+
if (r === 'malformed_function_call') return 'error';
|
|
42
44
|
return 'stop';
|
|
43
45
|
}
|
|
44
46
|
|
|
@@ -254,13 +256,18 @@ function smartDelta(choice) {
|
|
|
254
256
|
}
|
|
255
257
|
|
|
256
258
|
const SCHEMA_MAPS = new Set(['properties', 'patternProperties', '$defs', 'definitions']);
|
|
259
|
+
// Instance data, not schema: null is a real value here, and nothing inside may be rewritten.
|
|
260
|
+
const SCHEMA_VALUES = new Set(['default', 'const', 'enum', 'examples', 'example']);
|
|
261
|
+
// Lookahead and lookbehind; RE2 upstreams refuse the whole request ("invalid regex").
|
|
262
|
+
const LOOKAROUND_RE = /\(\?<?[=!]/;
|
|
257
263
|
|
|
258
264
|
function sanitizeJsonSchema(schema) {
|
|
259
265
|
if (schema === null || schema === undefined) return { type: 'object', properties: {} };
|
|
260
266
|
return sanitizeSchemaNode(schema);
|
|
261
267
|
}
|
|
262
268
|
|
|
263
|
-
// Inside a schema 0, false
|
|
269
|
+
// Inside a schema 0, false and "" are values (minimum: 0, additionalProperties: false), not a missing schema.
|
|
270
|
+
// A null keyword is dropped (strict upstreams answer "null is not of type array"), except as instance data.
|
|
264
271
|
function sanitizeSchemaNode(schema) {
|
|
265
272
|
if (schema === null || typeof schema !== 'object') return schema;
|
|
266
273
|
if (Array.isArray(schema)) return schema.map(sanitizeSchemaNode);
|
|
@@ -268,6 +275,9 @@ function sanitizeSchemaNode(schema) {
|
|
|
268
275
|
for (const [k, v] of Object.entries(schema)) {
|
|
269
276
|
if (k === '$schema' || k === 'cache_control' || k === 'encrypted') continue;
|
|
270
277
|
if (k === 'format' && ['uri', 'uri-reference'].includes(v)) continue;
|
|
278
|
+
if (SCHEMA_VALUES.has(k)) { clean[k] = v; continue; }
|
|
279
|
+
if (v === null) continue;
|
|
280
|
+
if (k === 'pattern' && typeof v === 'string' && LOOKAROUND_RE.test(v)) continue;
|
|
271
281
|
// A name map: each value is a schema, the map itself is not. A parameter may be named "properties".
|
|
272
282
|
if (SCHEMA_MAPS.has(k) && v && typeof v === 'object' && !Array.isArray(v)) {
|
|
273
283
|
clean[k] = Object.fromEntries(Object.entries(v).map(([name, sub]) => [name, sanitizeSchemaNode(sub)]));
|
|
@@ -394,6 +404,7 @@ function parseArgs(v) {
|
|
|
394
404
|
if (v === undefined || v === null) return {};
|
|
395
405
|
if (typeof v === 'object') return v;
|
|
396
406
|
if (typeof v === 'string') {
|
|
407
|
+
if (!v.trim()) return {};
|
|
397
408
|
try { return JSON.parse(v); } catch { return { raw: v }; }
|
|
398
409
|
}
|
|
399
410
|
return {};
|
|
@@ -454,10 +465,13 @@ function anthropicToIR(payload) {
|
|
|
454
465
|
if (Array.isArray(msg.content)) {
|
|
455
466
|
const parts = [];
|
|
456
467
|
const toolCalls = [];
|
|
468
|
+
let reasoning = '';
|
|
457
469
|
for (const part of msg.content) {
|
|
458
470
|
if (!part) continue;
|
|
459
471
|
if (part.type === 'text') {
|
|
460
472
|
if (part.text) parts.push({ type: 'text', text: part.text });
|
|
473
|
+
} else if (part.type === 'thinking' && typeof part.thinking === 'string') {
|
|
474
|
+
reasoning += part.thinking;
|
|
461
475
|
} else if (part.type === 'image' && part.source?.type === 'base64') {
|
|
462
476
|
const mimeType = part.source.media_type || 'image/jpeg';
|
|
463
477
|
parts.push({ type: 'image_url', image_url: { url: `data:${mimeType};base64,${part.source.data}` } });
|
|
@@ -498,6 +512,7 @@ function anthropicToIR(payload) {
|
|
|
498
512
|
out.content = parts.every(p => p.type === 'text') ? parts.map(p => p.text).join('\n') : parts;
|
|
499
513
|
}
|
|
500
514
|
if (toolCalls.length) out.toolCalls = toolCalls;
|
|
515
|
+
if (reasoning && msg.role === 'assistant') out.reasoning = reasoning;
|
|
501
516
|
if (out.content !== undefined || out.toolCalls) ir.messages.push(out);
|
|
502
517
|
} else if (msg.content) {
|
|
503
518
|
// Fallback for payloads that preprocessing/compression tools mangled into an object
|
|
@@ -628,9 +643,10 @@ const LOCAL_SHELL_PARAMS = {
|
|
|
628
643
|
required: ['command']
|
|
629
644
|
};
|
|
630
645
|
|
|
646
|
+
// A plain cut at 64 would give two long namespaced tools one name; the hashed form keeps them apart.
|
|
631
647
|
function responsesToolName(namespace, name) {
|
|
632
648
|
const raw = namespace ? `${namespace}__${name}` : String(name || '');
|
|
633
|
-
return raw
|
|
649
|
+
return raw ? sanitizeToolName(raw) : '';
|
|
634
650
|
}
|
|
635
651
|
|
|
636
652
|
function customToolDescription(t) {
|
|
@@ -677,12 +693,19 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
|
|
|
677
693
|
if (text) ir.system = ir.system ? `${ir.system}\n\n${text}` : text;
|
|
678
694
|
};
|
|
679
695
|
|
|
696
|
+
// A reasoning item precedes the assistant output it belongs to.
|
|
697
|
+
let reasoning = '';
|
|
698
|
+
const takeReasoning = (m) => {
|
|
699
|
+
if (reasoning && m?.role === 'assistant') { m.reasoning = (m.reasoning || '') + reasoning; reasoning = ''; }
|
|
700
|
+
};
|
|
701
|
+
|
|
680
702
|
// Parallel tool calls arrive as consecutive items -> merge into 1 assistant turn,
|
|
681
703
|
// otherwise OpenAI Chat/Anthropic will error that tool_calls aren't answered adjacently.
|
|
682
704
|
const pushCall = (call) => {
|
|
683
705
|
const last = ir.messages[ir.messages.length - 1];
|
|
684
706
|
if (last && last.role === 'assistant') last.toolCalls = [...(last.toolCalls || []), call];
|
|
685
707
|
else ir.messages.push({ role: 'assistant', toolCalls: [call] });
|
|
708
|
+
takeReasoning(ir.messages[ir.messages.length - 1]);
|
|
686
709
|
};
|
|
687
710
|
|
|
688
711
|
// Responses Lite sends the tools as an input item, not in payload.tools.
|
|
@@ -721,6 +744,10 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
|
|
|
721
744
|
} else {
|
|
722
745
|
ir.messages.push({ role, content: parts });
|
|
723
746
|
}
|
|
747
|
+
if (role === 'assistant') takeReasoning(ir.messages[ir.messages.length - 1]);
|
|
748
|
+
} else if (item.type === 'reasoning') {
|
|
749
|
+
const texts = (Array.isArray(item.summary) ? item.summary : []).map(x => x?.text).filter(t => typeof t === 'string' && t);
|
|
750
|
+
reasoning += texts.join('\n');
|
|
724
751
|
} else if (item.type === 'function_call') {
|
|
725
752
|
pushCall({ id: item.call_id || item.id || null, name: responsesToolName(item.namespace, item.name), args: parseArgs(item.arguments) });
|
|
726
753
|
} else if (item.type === 'custom_tool_call') {
|
|
@@ -788,7 +815,9 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
|
|
|
788
815
|
ir.thinking = thinkingFromReasoningParam(payload.reasoning);
|
|
789
816
|
}
|
|
790
817
|
ir.responseFormat = responseFormatIR(payload.text?.format);
|
|
791
|
-
|
|
818
|
+
// Tools stay declared: Anthropic refuses tool_use history without them, and OpenAI refuses
|
|
819
|
+
// tool_choice without tools. 'none' alone keeps the summary turn free of calls.
|
|
820
|
+
if (ir.compaction) { ir.toolChoice = ir.tools.length ? 'none' : null; ir.responseFormat = null; }
|
|
792
821
|
return ir;
|
|
793
822
|
}
|
|
794
823
|
|
|
@@ -1072,6 +1101,7 @@ function normThinkingEffort(effort) {
|
|
|
1072
1101
|
// - 'native' (genuine OpenAI / strict OpenAI-compatible servers): only send `reasoning_effort` when
|
|
1073
1102
|
// the client requests thinking, don't touch the system prompt, use `max_completion_tokens`.
|
|
1074
1103
|
// - 'off' : never send reasoning params, never inject prompts.
|
|
1104
|
+
// opts.replayReasoning: profile opt-in; native mode sends reasoning_content on tool-call turns only when it is true.
|
|
1075
1105
|
function irToChatBody(ir, model, opts = {}) {
|
|
1076
1106
|
const names = toolNameMapForIR(ir);
|
|
1077
1107
|
const toolName = names.name;
|
|
@@ -1085,6 +1115,8 @@ function irToChatBody(ir, model, opts = {}) {
|
|
|
1085
1115
|
const wantsThinking = mode === 'off' ? false
|
|
1086
1116
|
: mode === 'native' ? clientWantsThinking
|
|
1087
1117
|
: Boolean(clientWantsThinking || (modelIsThinking && (!ir.thinking || ir.thinking.type !== 'disabled')));
|
|
1118
|
+
// Strict OpenAI-compatible servers may refuse an unknown message key, so native mode needs the profile flag.
|
|
1119
|
+
const replayReasoning = wantsThinking && (mode === 'auto' || opts.replayReasoning === true);
|
|
1088
1120
|
|
|
1089
1121
|
let systemText = ir.system || '';
|
|
1090
1122
|
if (mode === 'auto' && wantsThinking && !modelIsThinking && !String(model).toLowerCase().includes('gemini')) {
|
|
@@ -1120,6 +1152,8 @@ function irToChatBody(ir, model, opts = {}) {
|
|
|
1120
1152
|
return call;
|
|
1121
1153
|
});
|
|
1122
1154
|
if (out.content === undefined || out.content === '') out.content = null;
|
|
1155
|
+
// DeepSeek/Kimi thinking mode refuse the next tool step without it; an empty string is accepted.
|
|
1156
|
+
if (replayReasoning) out.reasoning_content = m.reasoning ?? '';
|
|
1123
1157
|
}
|
|
1124
1158
|
if (out.content === undefined) out.content = '';
|
|
1125
1159
|
messages.push(out);
|
|
@@ -1232,7 +1266,7 @@ function irToAnthropicBody(ir, model, opts = {}) {
|
|
|
1232
1266
|
if (m.role === 'tool') {
|
|
1233
1267
|
messages.push({
|
|
1234
1268
|
role: 'user',
|
|
1235
|
-
content: [{ type: 'tool_result', tool_use_id: m.toolCallId,
|
|
1269
|
+
content: [{ type: 'tool_result', tool_use_id: safeToolId(m.toolCallId),
|
|
1236
1270
|
content: m.images?.length ? [...contentToAnthropicBlocks(m.content), ...contentToAnthropicBlocks(m.images)] : m.content ?? '' }]
|
|
1237
1271
|
});
|
|
1238
1272
|
continue;
|
|
@@ -1240,7 +1274,7 @@ function irToAnthropicBody(ir, model, opts = {}) {
|
|
|
1240
1274
|
if (m.role === 'assistant') {
|
|
1241
1275
|
const blocks = contentToAnthropicBlocks(m.content).filter(b => b.type === 'text');
|
|
1242
1276
|
for (const tc of (m.toolCalls || [])) {
|
|
1243
|
-
blocks.push({ type: 'tool_use', id: tc.id, name: toolName(tc.name), input: parseArgs(tc.args) });
|
|
1277
|
+
blocks.push({ type: 'tool_use', id: safeToolId(tc.id), name: toolName(tc.name), input: parseArgs(tc.args) });
|
|
1244
1278
|
}
|
|
1245
1279
|
if (blocks.length) messages.push({ role: 'assistant', content: blocks });
|
|
1246
1280
|
continue;
|
|
@@ -1327,6 +1361,8 @@ function irToAnthropicBody(ir, model, opts = {}) {
|
|
|
1327
1361
|
body.thinking = { type: 'enabled', budget_tokens: Math.min(budget, body.max_tokens - 1) };
|
|
1328
1362
|
}
|
|
1329
1363
|
}
|
|
1364
|
+
// This route never emits signed thinking blocks, so an open tool loop cannot keep thinking on.
|
|
1365
|
+
if (body.thinking && endsInUnsignedToolLoop(body.messages)) delete body.thinking;
|
|
1330
1366
|
if (body.thinking) {
|
|
1331
1367
|
// When thinking is on, Anthropic rejects temperature != 1, top_k, or top_p < 0.95.
|
|
1332
1368
|
if (body.temperature !== undefined && body.temperature !== 1) delete body.temperature;
|
|
@@ -1346,6 +1382,12 @@ function dataUrlToInlineData(url) {
|
|
|
1346
1382
|
return { inlineData: { mimeType: m[1], data: m[2] } };
|
|
1347
1383
|
}
|
|
1348
1384
|
|
|
1385
|
+
function hasDollarKey(v) {
|
|
1386
|
+
if (Array.isArray(v)) return v.some(hasDollarKey);
|
|
1387
|
+
if (!v || typeof v !== 'object') return false;
|
|
1388
|
+
return Object.entries(v).some(([k, x]) => k.startsWith('$') || hasDollarKey(x));
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1349
1391
|
// IR -> Vertex generateContent body.
|
|
1350
1392
|
function irToVertexBody(ir, model, opts = {}) {
|
|
1351
1393
|
const names = toolNameMapForIR(ir, { allowHyphens: false });
|
|
@@ -1395,6 +1437,8 @@ function irToVertexBody(ir, model, opts = {}) {
|
|
|
1395
1437
|
const parsed = typeof m.content === 'string' ? JSON.parse(m.content) : m.content;
|
|
1396
1438
|
// functionResponse.response must be an object (Struct), not an array/primitive.
|
|
1397
1439
|
resp = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : { result: parsed ?? '' };
|
|
1440
|
+
// Gemini refuses a Struct key that starts with `$` (`$ref` in a schema file), so send the text.
|
|
1441
|
+
if (hasDollarKey(resp)) resp = { result: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) };
|
|
1398
1442
|
} catch { resp = { result: m.content ?? '' }; }
|
|
1399
1443
|
// Gemini API & Vertex only accept roles 'user' | 'model' (functionResponse lives in the 'user' role).
|
|
1400
1444
|
push('user', [{ functionResponse: { name: toolName(m.name || 'tool'), response: resp } }]);
|
|
@@ -1418,6 +1462,9 @@ function irToVertexBody(ir, model, opts = {}) {
|
|
|
1418
1462
|
push('user', textPartsOf(m.content));
|
|
1419
1463
|
}
|
|
1420
1464
|
flushImages();
|
|
1465
|
+
// Gemini refuses a request that ends on a model turn (an assistant prefill). A user turn is
|
|
1466
|
+
// appended rather than the prefill dropped, so no text the client sent is lost.
|
|
1467
|
+
if (contents[contents.length - 1]?.role === 'model') contents.push({ role: 'user', parts: [{ text: 'Continue.' }] });
|
|
1421
1468
|
|
|
1422
1469
|
const body = { contents };
|
|
1423
1470
|
if (ir.system && ir.system.trim()) {
|
|
@@ -1509,15 +1556,21 @@ function collapseNullableUnion(branches) {
|
|
|
1509
1556
|
return { schema: rest[0], nullable: rest.length !== branches.length };
|
|
1510
1557
|
}
|
|
1511
1558
|
|
|
1559
|
+
// Gemini refuses a nested OBJECT with no properties (a free-form map such as `env`). One optional
|
|
1560
|
+
// key keeps the type an object; the root of a tool with no arguments may stay empty.
|
|
1561
|
+
const GEMINI_PLACEHOLDER_PROPERTIES = { _placeholder: { type: 'boolean', description: 'Unused. Leave unset.' } };
|
|
1562
|
+
|
|
1512
1563
|
function toGeminiSchema(schema, root, seen) {
|
|
1564
|
+
const nested = Boolean(root);
|
|
1565
|
+
const emptyProperties = () => (nested ? { ...GEMINI_PLACEHOLDER_PROPERTIES } : {});
|
|
1513
1566
|
// Shorthand left by some MCP servers: a bare "object"/"string" where a Schema belongs.
|
|
1514
1567
|
// Vertex rejects the raw string with INVALID_ARGUMENT, so expand it.
|
|
1515
1568
|
if (typeof schema === 'string') {
|
|
1516
|
-
return schema === 'object' ? { type: 'object', properties:
|
|
1569
|
+
return schema === 'object' ? { type: 'object', properties: emptyProperties() } : { type: schema };
|
|
1517
1570
|
}
|
|
1518
1571
|
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return schema;
|
|
1519
1572
|
// The same hole as the string shorthand above: an empty schema has no type Vertex can read.
|
|
1520
|
-
if (Object.keys(schema).length === 0) return { type: 'object', properties:
|
|
1573
|
+
if (Object.keys(schema).length === 0) return { type: 'object', properties: emptyProperties() };
|
|
1521
1574
|
root = root || schema;
|
|
1522
1575
|
seen = seen || new Set();
|
|
1523
1576
|
if (seen.has(schema)) return {};
|
|
@@ -1568,8 +1621,13 @@ function toGeminiSchema(schema, root, seen) {
|
|
|
1568
1621
|
}
|
|
1569
1622
|
}
|
|
1570
1623
|
if (!out.type && out.properties) out.type = 'object';
|
|
1571
|
-
if (out.type === 'object' && !out.properties) out.properties =
|
|
1572
|
-
if (out.enum && !out.type)
|
|
1624
|
+
if (out.type === 'object' && !(out.properties && Object.keys(out.properties).length)) out.properties = emptyProperties();
|
|
1625
|
+
if (out.enum && !out.type) {
|
|
1626
|
+
// Gemini enum values are strings only; an untyped `const: true` would otherwise stay boolean.
|
|
1627
|
+
out.type = 'string';
|
|
1628
|
+
out.enum = out.enum.filter(x => x !== null).map(String);
|
|
1629
|
+
if (!out.enum.length) delete out.enum;
|
|
1630
|
+
}
|
|
1573
1631
|
if (out.enum && out.type !== 'string') {
|
|
1574
1632
|
// Gemini supports enum for strings only. Keep the constraint as text for the model.
|
|
1575
1633
|
const values = (schema.enum || (Object.hasOwn(schema, 'const') ? [schema.const] : [])).filter(x => x !== null);
|
|
@@ -1629,6 +1687,11 @@ function toAnthropicBlocks(content) {
|
|
|
1629
1687
|
return Array.isArray(content) ? content.filter(Boolean) : [];
|
|
1630
1688
|
}
|
|
1631
1689
|
|
|
1690
|
+
// Anthropic refuses a text block with no visible text ("text content blocks must be non-empty").
|
|
1691
|
+
function isBlankText(b) {
|
|
1692
|
+
return b?.type === 'text' && !(typeof b.text === 'string' && b.text.trim());
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1632
1695
|
function toolResultText(block) {
|
|
1633
1696
|
const c = block.content;
|
|
1634
1697
|
const text = typeof c === 'string' ? c
|
|
@@ -1637,15 +1700,35 @@ function toolResultText(block) {
|
|
|
1637
1700
|
return `[Tool Result${block.tool_use_id ? ` (${block.tool_use_id})` : ''}]: ${text}`;
|
|
1638
1701
|
}
|
|
1639
1702
|
|
|
1703
|
+
// Anthropic: "a final assistant message must start with a thinking block" when thinking is on mid tool loop.
|
|
1704
|
+
function endsInUnsignedToolLoop(messages) {
|
|
1705
|
+
const lastUser = messages[messages.length - 1];
|
|
1706
|
+
const lastAssistant = messages[messages.length - 2];
|
|
1707
|
+
const inToolLoop = lastUser?.role === 'user' && toAnthropicBlocks(lastUser.content).some(b => b.type === 'tool_result');
|
|
1708
|
+
const first = lastAssistant?.role === 'assistant' ? toAnthropicBlocks(lastAssistant.content)[0] : null;
|
|
1709
|
+
return Boolean(inToolLoop && first && first.type !== 'thinking' && first.type !== 'redacted_thinking');
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1640
1712
|
function healAnthropicPayload(payload) {
|
|
1641
1713
|
const notes = [];
|
|
1642
1714
|
if (!payload || !Array.isArray(payload.messages)) return { payload, changed: false, notes };
|
|
1643
1715
|
const src = payload.messages.filter(m => m && (m.role === 'user' || m.role === 'assistant'));
|
|
1644
1716
|
let changed = src.length !== payload.messages.length;
|
|
1645
1717
|
|
|
1646
|
-
|
|
1647
|
-
|
|
1718
|
+
let system = payload.system;
|
|
1719
|
+
if (Array.isArray(system) && system.some(isBlankText)) {
|
|
1720
|
+
system = system.filter(b => !isBlankText(b));
|
|
1721
|
+
changed = true;
|
|
1722
|
+
notes.push('dropped empty text blocks');
|
|
1723
|
+
}
|
|
1724
|
+
const dropSystem = Array.isArray(system) ? !system.length : typeof system === 'string' && !system.trim();
|
|
1725
|
+
if (dropSystem) changed = true;
|
|
1726
|
+
|
|
1727
|
+
// Step 0: drop thinking blocks with fake signatures and empty text blocks; rewrite foreign tool ids;
|
|
1728
|
+
// merge adjacent user turns (so a tool_result split into a later turn can still be matched with its tool_use).
|
|
1648
1729
|
const msgs = [];
|
|
1730
|
+
const emptyTurn = () => ({ role: 'user', content: [{ type: 'text', text: '(empty)' }] });
|
|
1731
|
+
let emptyUser = false;
|
|
1649
1732
|
for (const m of src) {
|
|
1650
1733
|
let content = m.content;
|
|
1651
1734
|
if (m.role === 'assistant' && Array.isArray(content)) {
|
|
@@ -1655,11 +1738,31 @@ function healAnthropicPayload(payload) {
|
|
|
1655
1738
|
notes.push('stripped placeholder-signed thinking blocks');
|
|
1656
1739
|
content = kept;
|
|
1657
1740
|
}
|
|
1658
|
-
|
|
1741
|
+
}
|
|
1742
|
+
if (Array.isArray(content)) {
|
|
1743
|
+
const kept = [];
|
|
1744
|
+
let renamed = false;
|
|
1745
|
+
for (const b of content) {
|
|
1746
|
+
if (isBlankText(b)) continue;
|
|
1747
|
+
if (b?.type === 'tool_use' && b.id && safeToolId(b.id) !== b.id) { kept.push({ ...b, id: safeToolId(b.id) }); renamed = true; }
|
|
1748
|
+
else if (b?.type === 'tool_result' && b.tool_use_id && safeToolId(b.tool_use_id) !== b.tool_use_id) { kept.push({ ...b, tool_use_id: safeToolId(b.tool_use_id) }); renamed = true; }
|
|
1749
|
+
else kept.push(b);
|
|
1750
|
+
}
|
|
1751
|
+
if (kept.length !== content.length) notes.push('dropped empty text blocks');
|
|
1752
|
+
if (renamed) notes.push('sanitized foreign tool ids');
|
|
1753
|
+
if (renamed || kept.length !== content.length) {
|
|
1659
1754
|
changed = true;
|
|
1660
|
-
|
|
1755
|
+
content = kept;
|
|
1661
1756
|
}
|
|
1662
1757
|
}
|
|
1758
|
+
if (!toAnthropicBlocks(typeof content === 'string' ? content.trim() : content).length) {
|
|
1759
|
+
// An emptied user turn still holds its place between two assistant turns, or at the end.
|
|
1760
|
+
changed = true;
|
|
1761
|
+
if (m.role === 'user') emptyUser = true;
|
|
1762
|
+
continue;
|
|
1763
|
+
}
|
|
1764
|
+
if (emptyUser && m.role === 'assistant' && msgs[msgs.length - 1]?.role !== 'user') msgs.push(emptyTurn());
|
|
1765
|
+
emptyUser = false;
|
|
1663
1766
|
const last = msgs[msgs.length - 1];
|
|
1664
1767
|
if (m.role === 'user' && last && last.role === 'user') {
|
|
1665
1768
|
last.content = [...toAnthropicBlocks(last.content), ...toAnthropicBlocks(content)];
|
|
@@ -1668,6 +1771,42 @@ function healAnthropicPayload(payload) {
|
|
|
1668
1771
|
}
|
|
1669
1772
|
msgs.push({ ...m, content });
|
|
1670
1773
|
}
|
|
1774
|
+
if (emptyUser && msgs[msgs.length - 1]?.role !== 'user') msgs.push(emptyTurn());
|
|
1775
|
+
|
|
1776
|
+
// Step 0b: an upstream that reuses an id (Kimi `functions.Bash:0`) leaves one id on two tool_use
|
|
1777
|
+
// blocks, which Anthropic refuses. A later use gets a suffix, and so does its result in the next turn.
|
|
1778
|
+
const allIds = new Set(msgs.flatMap(m => toAnthropicBlocks(m.content).filter(b => b.type === 'tool_use' && b.id).map(b => b.id)));
|
|
1779
|
+
const usedIds = new Set();
|
|
1780
|
+
for (let i = 0; i < msgs.length; i++) {
|
|
1781
|
+
if (msgs[i].role !== 'assistant' || !Array.isArray(msgs[i].content)) continue;
|
|
1782
|
+
const resultIds = new Map();
|
|
1783
|
+
let renamed = false;
|
|
1784
|
+
const content = msgs[i].content.map(b => {
|
|
1785
|
+
if (b?.type !== 'tool_use' || !b.id) return b;
|
|
1786
|
+
let id = b.id;
|
|
1787
|
+
if (usedIds.has(id)) {
|
|
1788
|
+
let n = 2;
|
|
1789
|
+
while (allIds.has(`${b.id}_${n}`)) n++;
|
|
1790
|
+
id = `${b.id}_${n}`;
|
|
1791
|
+
allIds.add(id);
|
|
1792
|
+
renamed = true;
|
|
1793
|
+
}
|
|
1794
|
+
usedIds.add(id);
|
|
1795
|
+
resultIds.set(b.id, [...(resultIds.get(b.id) || []), id]);
|
|
1796
|
+
return id === b.id ? b : { ...b, id };
|
|
1797
|
+
});
|
|
1798
|
+
if (!renamed) continue;
|
|
1799
|
+
msgs[i] = { ...msgs[i], content };
|
|
1800
|
+
const next = msgs[i + 1];
|
|
1801
|
+
if (next?.role === 'user' && Array.isArray(next.content)) {
|
|
1802
|
+
msgs[i + 1] = { ...next, content: next.content.map(b => {
|
|
1803
|
+
const id = b?.type === 'tool_result' ? resultIds.get(b.tool_use_id)?.shift() : undefined;
|
|
1804
|
+
return id && id !== b.tool_use_id ? { ...b, tool_use_id: id } : b;
|
|
1805
|
+
}) };
|
|
1806
|
+
}
|
|
1807
|
+
changed = true;
|
|
1808
|
+
notes.push('deduplicated repeated tool_use ids');
|
|
1809
|
+
}
|
|
1671
1810
|
|
|
1672
1811
|
// Steps 1-3: pair up tool_use / tool_result.
|
|
1673
1812
|
const out = [];
|
|
@@ -1723,15 +1862,13 @@ function healAnthropicPayload(payload) {
|
|
|
1723
1862
|
}
|
|
1724
1863
|
|
|
1725
1864
|
let body = { ...payload, messages: out };
|
|
1865
|
+
if (dropSystem) delete body.system;
|
|
1866
|
+
else if (system !== payload.system) body.system = system;
|
|
1726
1867
|
|
|
1727
1868
|
// Step 5: thinking + an in-progress tool loop whose last assistant turn doesn't open with thinking.
|
|
1728
1869
|
const thinkingOn = payload.thinking && payload.thinking.type && payload.thinking.type !== 'disabled';
|
|
1729
1870
|
if (thinkingOn) {
|
|
1730
|
-
|
|
1731
|
-
const lastAssistant = out[out.length - 2];
|
|
1732
|
-
const inToolLoop = lastUser?.role === 'user' && toAnthropicBlocks(lastUser.content).some(b => b.type === 'tool_result');
|
|
1733
|
-
const first = lastAssistant?.role === 'assistant' ? toAnthropicBlocks(lastAssistant.content)[0] : null;
|
|
1734
|
-
if (inToolLoop && first && first.type !== 'thinking' && first.type !== 'redacted_thinking') {
|
|
1871
|
+
if (endsInUnsignedToolLoop(out)) {
|
|
1735
1872
|
body = { ...body };
|
|
1736
1873
|
delete body.thinking;
|
|
1737
1874
|
changed = true;
|
|
@@ -1887,6 +2024,7 @@ function normalizeUpstream(parsed, outFormat) {
|
|
|
1887
2024
|
for (const tc of smartToolCalls(node)) ev.tools.push(chunkToolFull(tc));
|
|
1888
2025
|
const f = smartFinish(parsed, choice);
|
|
1889
2026
|
if (f) ev.finish = canonFinish(f);
|
|
2027
|
+
if (ev.finish === 'error') { ev.finish = null; ev.error = `Upstream finished with ${f}`; }
|
|
1890
2028
|
return ev;
|
|
1891
2029
|
}
|
|
1892
2030
|
|
|
@@ -2054,6 +2192,17 @@ function rand(n = 6) {
|
|
|
2054
2192
|
return s.slice(0, n);
|
|
2055
2193
|
}
|
|
2056
2194
|
|
|
2195
|
+
// Anthropic takes tool ids matching ^[a-zA-Z0-9_-]+$ only (Kimi sends "functions.Read:0"). The map must
|
|
2196
|
+
// stay pure: a replayed history has to give the same id, and the hash keeps "a.b" and "a:b" apart.
|
|
2197
|
+
function safeToolId(id) {
|
|
2198
|
+
const s = String(id ?? '');
|
|
2199
|
+
if (!s) return `toolu_${rand(24)}`;
|
|
2200
|
+
if (/^[a-zA-Z0-9_-]+$/.test(s)) return s;
|
|
2201
|
+
let h = 0x811c9dc5;
|
|
2202
|
+
for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193);
|
|
2203
|
+
return `${s.replace(/[^a-zA-Z0-9_-]/g, '_')}_${(h >>> 0).toString(36)}`;
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2057
2206
|
function anthropicStopReason(canonical, hasTools) {
|
|
2058
2207
|
if (canonical === 'length') return 'max_tokens';
|
|
2059
2208
|
if (hasTools) return 'tool_use';
|
|
@@ -2147,7 +2296,7 @@ function createAnthropicStream(emit, model, opts = {}) {
|
|
|
2147
2296
|
const fixed = vocab ? vocab.name(tc.name) : tc.name;
|
|
2148
2297
|
const isShort = Boolean(vocab && typeof vocab.isShortened === 'function' && vocab.isShortened(tc.name));
|
|
2149
2298
|
st = {
|
|
2150
|
-
index: nextIndex++, id: tc.id
|
|
2299
|
+
index: nextIndex++, id: safeToolId(tc.id), name: fixed || 'tool', closed: false,
|
|
2151
2300
|
// Shortened names keep caller's schema; ToolSearch query aliases still need restoring.
|
|
2152
2301
|
buffered: '', held: Boolean(vocab && ((fixed && fixed !== tc.name && !isShort) || vocab.needsArgsRewrite?.(tc.name)))
|
|
2153
2302
|
};
|
|
@@ -2212,7 +2361,7 @@ function buildAnthropicMessage({ model, think, text, tools, finish, prompt, comp
|
|
|
2212
2361
|
// The name is written in the vocabulary the caller declared. A name it already uses is not
|
|
2213
2362
|
// touched, so the whole layer costs nothing on a turn where the model agreed with the list.
|
|
2214
2363
|
const fixed = repairToolCall(tc, vocab);
|
|
2215
|
-
content.push({ type: 'tool_use', id: tc.id
|
|
2364
|
+
content.push({ type: 'tool_use', id: safeToolId(tc.id), name: fixed.name, input: parseArgs(fixed.args) });
|
|
2216
2365
|
}
|
|
2217
2366
|
if (!content.length) content.push({ type: 'text', text: '' });
|
|
2218
2367
|
const p = prompt || 0;
|
package/package.json
CHANGED
package/proxy.mjs
CHANGED
|
@@ -378,15 +378,33 @@ function vertexStatus(status) {
|
|
|
378
378
|
}
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
+
// A chat upstream names an overflow its own way; a 429 about tokens per minute is a rate limit, not this.
|
|
382
|
+
const CONTEXT_OVERFLOW_RE = /maximum context length|context[_ ]length[_ ]exceeded|prompt is too long|exceeds? the (?:model'?s? )?context window/i;
|
|
383
|
+
|
|
384
|
+
function isContextOverflow(status, message) {
|
|
385
|
+
return status !== 429 && status < 500 && CONTEXT_OVERFLOW_RE.test(String(message || ''));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Claude Code compacts on "prompt is too long: N tokens > M maximum", so the numbers lead when the upstream names them.
|
|
389
|
+
function promptTooLongMessage(message) {
|
|
390
|
+
if (/^prompt is too long/i.test(message)) return message;
|
|
391
|
+
const max = message.match(/(?:maximum context length|context window|limit) (?:is |of )?(\d+)/i)?.[1];
|
|
392
|
+
const asked = message.match(/(?:requested|resulted in|prompt (?:is|has|contains)|input (?:is|has)) (?:about )?(\d+)/i)?.[1];
|
|
393
|
+
return max && asked ? `prompt is too long: ${asked} tokens > ${max} maximum (${message})` : `prompt is too long: ${message}`;
|
|
394
|
+
}
|
|
395
|
+
|
|
381
396
|
// Each SDK parses errors in its own shape; Claude Code relies on error.type + retry-after to decide retries.
|
|
382
397
|
function sendClientError(res, clientFormat, status, message, headers = {}) {
|
|
383
398
|
let body;
|
|
399
|
+
const overflow = isContextOverflow(status, message);
|
|
384
400
|
if (clientFormat === 'anthropic') {
|
|
385
|
-
body =
|
|
401
|
+
body = overflow
|
|
402
|
+
? { type: 'error', error: { type: 'invalid_request_error', message: promptTooLongMessage(String(message)) } }
|
|
403
|
+
: { type: 'error', error: { type: anthropicErrorType(status), message } };
|
|
386
404
|
} else if (clientFormat === 'vertex' || clientFormat === 'codeassist') {
|
|
387
405
|
body = { error: { code: status, message, status: vertexStatus(status) } };
|
|
388
406
|
} else {
|
|
389
|
-
body = { error: { message, type: status >= 500 ? 'server_error' : 'invalid_request_error', code: String(status) } };
|
|
407
|
+
body = { error: { message, type: status >= 500 ? 'server_error' : 'invalid_request_error', code: overflow ? 'context_length_exceeded' : String(status) } };
|
|
390
408
|
}
|
|
391
409
|
sendJson(res, status, body, headers);
|
|
392
410
|
}
|
|
@@ -815,6 +833,7 @@ function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot = n
|
|
|
815
833
|
const upBody = emitUpstreamBody(outFormat, ir, mappedModel, {
|
|
816
834
|
thinkingMode: profile.thinkingMode,
|
|
817
835
|
thinkingEffort: effort,
|
|
836
|
+
replayReasoning: profile.replayReasoning === true,
|
|
818
837
|
});
|
|
819
838
|
if (upBody?.tools && Array.isArray(upBody.tools)) {
|
|
820
839
|
upBody.tools = cleanSchemaDeep(upBody.tools);
|
|
@@ -831,8 +850,9 @@ function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot = n
|
|
|
831
850
|
async function pumpStream(upstreamRes, { normalize, col, renderer, splitter, signal, sink, tag }) {
|
|
832
851
|
let streamError = null;
|
|
833
852
|
let events = 0;
|
|
853
|
+
const framing = {};
|
|
834
854
|
try {
|
|
835
|
-
for await (const parsed of readUpstreamPayloads(upstreamRes)) {
|
|
855
|
+
for await (const parsed of readUpstreamPayloads(upstreamRes, framing)) {
|
|
836
856
|
events++;
|
|
837
857
|
const ev = normalize(parsed);
|
|
838
858
|
col.add(ev);
|
|
@@ -850,6 +870,8 @@ async function pumpStream(upstreamRes, { normalize, col, renderer, splitter, sig
|
|
|
850
870
|
await drained(sink, signal);
|
|
851
871
|
}
|
|
852
872
|
if (!streamError && events === 0) streamError = 'Upstream returned an empty stream';
|
|
873
|
+
// A clean close with no finish reason is a cut: rendered as done, it hands the client half a tool call.
|
|
874
|
+
else if (!streamError && col.finish == null && !framing.done) streamError = 'Upstream stream ended before a finish reason';
|
|
853
875
|
} catch (streamErr) {
|
|
854
876
|
if (!signal.aborted) {
|
|
855
877
|
console.error(`[${tag}] Stream error:`, streamErr.message);
|
|
@@ -21,7 +21,7 @@ async function fixture(t) {
|
|
|
21
21
|
if (body.stream) {
|
|
22
22
|
res.setHeader('content-type', 'text/event-stream');
|
|
23
23
|
res.write(`data: {"choices":\ndata: [{"index":0,"delta":{"content":"${summary}"}}]}\n\n`);
|
|
24
|
-
if (!truncate) res.write(`data: {"choices":[{"index":0,"delta":{},"finish_reason"
|
|
24
|
+
if (!truncate) res.write(`data: {"choices":[{"index":0,"delta":{},"finish_reason":${JSON.stringify(finish)}}]}\n\ndata: [DONE]\n\n`);
|
|
25
25
|
res.end();
|
|
26
26
|
} else { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ choices: [{ message: { content: summary }, finish_reason: finish }] })); }
|
|
27
27
|
});
|
|
@@ -77,3 +77,19 @@ test('WS compaction replays the compacted state through previous_response_id', {
|
|
|
77
77
|
assert.match(JSON.stringify(f.received.at(-1).messages), /KEEP_DECISION_7391.*NEXT_TURN/);
|
|
78
78
|
assert.doesNotMatch(JSON.stringify(f.received.at(-1).messages), /ORIGINAL_HISTORY/);
|
|
79
79
|
});
|
|
80
|
+
test('stream cut without finish_reason renders error, not completed', { timeout: 20000 }, async t => {
|
|
81
|
+
const f = await fixture(t); f.failure('stop', true);
|
|
82
|
+
const codex = events(await (await f.send({ model: 'm', stream: true, input: [{ role: 'user', content: 'hi' }] })).text());
|
|
83
|
+
assert.ok(codex.some(e => e.type === 'response.failed'), JSON.stringify(codex));
|
|
84
|
+
assert.ok(!codex.some(e => e.type === 'response.completed'), JSON.stringify(codex));
|
|
85
|
+
const claude = await fetch(`http://127.0.0.1:${f.port}/v1/messages`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-profile': 'p1' },
|
|
86
|
+
body: JSON.stringify({ model: 'm', max_tokens: 64, stream: true, messages: [{ role: 'user', content: 'hi' }] }) });
|
|
87
|
+
const claudeEvents = events(await claude.text());
|
|
88
|
+
assert.ok(claudeEvents.some(e => e.type === 'error'), JSON.stringify(claudeEvents));
|
|
89
|
+
assert.ok(!claudeEvents.some(e => e.type === 'message_stop'), JSON.stringify(claudeEvents));
|
|
90
|
+
});
|
|
91
|
+
test('stream closed by [DONE] without finish_reason still completes', { timeout: 20000 }, async t => {
|
|
92
|
+
const f = await fixture(t); f.failure(null);
|
|
93
|
+
const codex = events(await (await f.send({ model: 'm', stream: true, input: [{ role: 'user', content: 'hi' }] })).text());
|
|
94
|
+
assert.ok(codex.some(e => e.type === 'response.completed'), JSON.stringify(codex));
|
|
95
|
+
});
|