llm-switcher 1.6.9 → 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 +10 -0
- package/README.md +2 -1
- package/README.vi.md +2 -1
- package/formats.mjs +104 -11
- package/package.json +1 -1
- package/proxy.mjs +21 -2
- package/tests/formats.test.mjs +177 -1
- package/tests/gateway.e2e.test.mjs +19 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog — LLM Switcher
|
|
2
2
|
|
|
3
|
+
## Release 1.6.10 — 2026-10-11
|
|
4
|
+
|
|
5
|
+
- **`reasoning_content` replay.** On assistant tool-call turns, `auto` mode sends the earlier thinking as `reasoning_content`. `native` mode sends it only when the profile sets `replayReasoning: true`, because a strict OpenAI-compatible server can refuse the field. `off` mode never sends it.
|
|
6
|
+
- **Codex to Anthropic with thinking.** If an open tool loop has no signed thinking, the request drops `thinking`. Anthropic refused this request with HTTP 400.
|
|
7
|
+
- **Gemini schema and turn shape.** A nested object with no properties gets an optional `_placeholder` property. An untyped `const` becomes a string. Tool output with keys that start with `$` is wrapped as `{result: ...}`. A request never ends on a model turn: the proxy adds a user turn `Continue.`.
|
|
8
|
+
- **Schema sanitizer.** Keywords with a `null` value and `pattern` values with lookaround are removed.
|
|
9
|
+
- **Long tool names from Codex.** Names longer than 64 characters use the hashed shortener, so two long names do not collide.
|
|
10
|
+
- **Repeated tool ids.** On the direct Anthropic route, a repeated `tool_use` id gets a suffix (`_2`, `_3`), and the matching `tool_result` changes with it.
|
|
11
|
+
- **Context overflow errors.** Anthropic clients receive `invalid_request_error` with a message that starts with `prompt is too long`. Responses clients receive the code `context_length_exceeded`.
|
|
12
|
+
|
|
3
13
|
## Release 1.6.9 — 2026-10-11
|
|
4
14
|
|
|
5
15
|
- **Compaction keeps the tools.** A Codex compaction turn with tool history now keeps the `tools` list and sets `tool_choice` to `none`. Before, Anthropic and OpenAI chat upstreams refused this turn with HTTP 400.
|
package/README.md
CHANGED
|
@@ -868,7 +868,8 @@ Because of this split, a provider fingerprint is never a rule in this gateway. I
|
|
|
868
868
|
| `LLM_SWITCHER_CODE_ASSIST_TIMEOUT_MS` | Idle time of such a call, from 1000 to 2147483647 ms (default 300000). Before the answer starts, agy gets a 504. After it starts, the connection is cut. |
|
|
869
869
|
| `LLM_SWITCHER_BIFROST_RETRY_MS` | Time before the gateway asks intact again after a failed Bifrost lookup (default 30000 ms). |
|
|
870
870
|
| `x-llm-profile: <key>` header (alias `x-profile`) or `?profile=<key>` | Route a single request through a specific profile. An unknown key returns HTTP 400 instead of silently falling back. |
|
|
871
|
-
| `profile.thinkingMode` | `auto` (default, for gateways like intact or 9Router): restore stripped thinking, inject a `<think>` guide for non-reasoning models, send `thinking` + `reasoning_effort
|
|
871
|
+
| `profile.thinkingMode` | `auto` (default, for gateways like intact or 9Router): restore stripped thinking, inject a `<think>` guide for non-reasoning models, send `thinking` + `reasoning_effort`, and send the earlier thinking as `reasoning_content` on assistant tool-call turns. `native` (strict OpenAI APIs): send only `reasoning_effort` when the client asks, never touch the prompt, use `max_completion_tokens`. Native mode never sends `reasoning_content` unless the profile sets `replayReasoning: true`. `off`: never send reasoning parameters. |
|
|
872
|
+
| `profile.replayReasoning` | `true` sends the earlier thinking as `reasoning_content` on assistant tool-call turns in `native` mode. DeepSeek and Kimi thinking mode need it. The default is `false`. `auto` mode always sends the field, and `off` never sends it. The model name and the client request do not change this rule. |
|
|
872
873
|
| `profile.thinkingEffort` | `auto` (default, caller/client controls effort), or an explicit override level: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `ultra`. Overrides client effort before routing upstream. Automatically maps to the nearest equivalent level accepted by the target upstream (e.g. OpenAI ceiling `high`, Anthropic `max`, Gemini budget bounds). In the Web UI, this selector dynamically hides with a guidance hint when Bifrost passthrough is active. |
|
|
873
874
|
| `profile.endpoints.countTokens` | Override the Anthropic `count_tokens` URL. |
|
|
874
875
|
| `profile.endpoints` | Override upstream URLs per format: `{ "openai-chat": "...", "anthropic": "...", "vertex": "https://.../models/{model}:{action}" }`. |
|
package/README.vi.md
CHANGED
|
@@ -863,7 +863,8 @@ Nhờ cách chia này, fingerprint của provider không bao giờ là rule tron
|
|
|
863
863
|
| `LLM_SWITCHER_CODE_ASSIST_TIMEOUT_MS` | Thời gian chờ không có dữ liệu của lệnh gọi đó, từ 1000 tới 2147483647 ms (mặc định 300000). Trước khi câu trả lời bắt đầu, agy nhận 504. Sau khi đã bắt đầu, kết nối bị cắt. |
|
|
864
864
|
| `LLM_SWITCHER_BIFROST_RETRY_MS` | Thời gian trước khi gateway hỏi lại intact sau một lần tra cứu Bifrost thất bại (mặc định 30000 ms). |
|
|
865
865
|
| Header `x-llm-profile: <key>` (tên khác `x-profile`) hoặc `?profile=<key>` | Định tuyến riêng 1 request qua profile chỉ định. Key không tồn tại trả HTTP 400 thay vì âm thầm dùng profile khác. |
|
|
866
|
-
| `profile.thinkingMode` | `auto` (mặc định, cho gateway như intact hoặc 9Router): phục hồi thinking bị xoá, inject hướng dẫn `<think>` cho model không có reasoning, gửi `thinking` + `reasoning_effort
|
|
866
|
+
| `profile.thinkingMode` | `auto` (mặc định, cho gateway như intact hoặc 9Router): phục hồi thinking bị xoá, inject hướng dẫn `<think>` cho model không có reasoning, gửi `thinking` + `reasoning_effort`, và gửi lại thinking trước đó thành `reasoning_content` ở lượt assistant có tool call. `native` (API OpenAI nghiêm ngặt): chỉ gửi `reasoning_effort` khi client yêu cầu, không sửa prompt, dùng `max_completion_tokens`; không gửi `reasoning_content` trừ khi profile đặt `replayReasoning: true`. `off`: không bao giờ gửi tham số reasoning. |
|
|
867
|
+
| `profile.replayReasoning` | `true`: ở chế độ `native`, gửi lại thinking trước đó thành `reasoning_content` ở lượt assistant có tool call. Thinking mode của DeepSeek và Kimi cần field này. Mặc định là `false`. `auto` luôn gửi, `off` không bao giờ gửi. Tên model và request của client không đổi quy tắc này. |
|
|
867
868
|
| `profile.thinkingEffort` | `auto` (mặc định, client tự quyết định), hoặc đặt cố định mức override: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `ultra`. Ghi đè mức reasoning effort từ công cụ trước khi gửi lên upstream. Tự động ánh xạ về mức tương đương gần nhất mà upstream đích hỗ trợ (OpenAI trần `high`, Anthropic trần `max`, Gemini điều chỉnh `thinkingBudget`). Trên Web UI, tùy chọn này tự động ẩn cùng thông báo gợi ý khi profile bật chế độ Bifrost passthrough. |
|
|
868
869
|
| `profile.endpoints.countTokens` | Ghi đè URL `count_tokens` của Anthropic. |
|
|
869
870
|
| `profile.endpoints` | Ghi đè URL upstream theo từng format: `{ "openai-chat": "...", "anthropic": "...", "vertex": "https://.../models/{model}:{action}" }`. |
|
package/formats.mjs
CHANGED
|
@@ -256,13 +256,18 @@ function smartDelta(choice) {
|
|
|
256
256
|
}
|
|
257
257
|
|
|
258
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 = /\(\?<?[=!]/;
|
|
259
263
|
|
|
260
264
|
function sanitizeJsonSchema(schema) {
|
|
261
265
|
if (schema === null || schema === undefined) return { type: 'object', properties: {} };
|
|
262
266
|
return sanitizeSchemaNode(schema);
|
|
263
267
|
}
|
|
264
268
|
|
|
265
|
-
// 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.
|
|
266
271
|
function sanitizeSchemaNode(schema) {
|
|
267
272
|
if (schema === null || typeof schema !== 'object') return schema;
|
|
268
273
|
if (Array.isArray(schema)) return schema.map(sanitizeSchemaNode);
|
|
@@ -270,6 +275,9 @@ function sanitizeSchemaNode(schema) {
|
|
|
270
275
|
for (const [k, v] of Object.entries(schema)) {
|
|
271
276
|
if (k === '$schema' || k === 'cache_control' || k === 'encrypted') continue;
|
|
272
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;
|
|
273
281
|
// A name map: each value is a schema, the map itself is not. A parameter may be named "properties".
|
|
274
282
|
if (SCHEMA_MAPS.has(k) && v && typeof v === 'object' && !Array.isArray(v)) {
|
|
275
283
|
clean[k] = Object.fromEntries(Object.entries(v).map(([name, sub]) => [name, sanitizeSchemaNode(sub)]));
|
|
@@ -457,10 +465,13 @@ function anthropicToIR(payload) {
|
|
|
457
465
|
if (Array.isArray(msg.content)) {
|
|
458
466
|
const parts = [];
|
|
459
467
|
const toolCalls = [];
|
|
468
|
+
let reasoning = '';
|
|
460
469
|
for (const part of msg.content) {
|
|
461
470
|
if (!part) continue;
|
|
462
471
|
if (part.type === 'text') {
|
|
463
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;
|
|
464
475
|
} else if (part.type === 'image' && part.source?.type === 'base64') {
|
|
465
476
|
const mimeType = part.source.media_type || 'image/jpeg';
|
|
466
477
|
parts.push({ type: 'image_url', image_url: { url: `data:${mimeType};base64,${part.source.data}` } });
|
|
@@ -501,6 +512,7 @@ function anthropicToIR(payload) {
|
|
|
501
512
|
out.content = parts.every(p => p.type === 'text') ? parts.map(p => p.text).join('\n') : parts;
|
|
502
513
|
}
|
|
503
514
|
if (toolCalls.length) out.toolCalls = toolCalls;
|
|
515
|
+
if (reasoning && msg.role === 'assistant') out.reasoning = reasoning;
|
|
504
516
|
if (out.content !== undefined || out.toolCalls) ir.messages.push(out);
|
|
505
517
|
} else if (msg.content) {
|
|
506
518
|
// Fallback for payloads that preprocessing/compression tools mangled into an object
|
|
@@ -631,9 +643,10 @@ const LOCAL_SHELL_PARAMS = {
|
|
|
631
643
|
required: ['command']
|
|
632
644
|
};
|
|
633
645
|
|
|
646
|
+
// A plain cut at 64 would give two long namespaced tools one name; the hashed form keeps them apart.
|
|
634
647
|
function responsesToolName(namespace, name) {
|
|
635
648
|
const raw = namespace ? `${namespace}__${name}` : String(name || '');
|
|
636
|
-
return raw
|
|
649
|
+
return raw ? sanitizeToolName(raw) : '';
|
|
637
650
|
}
|
|
638
651
|
|
|
639
652
|
function customToolDescription(t) {
|
|
@@ -680,12 +693,19 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
|
|
|
680
693
|
if (text) ir.system = ir.system ? `${ir.system}\n\n${text}` : text;
|
|
681
694
|
};
|
|
682
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
|
+
|
|
683
702
|
// Parallel tool calls arrive as consecutive items -> merge into 1 assistant turn,
|
|
684
703
|
// otherwise OpenAI Chat/Anthropic will error that tool_calls aren't answered adjacently.
|
|
685
704
|
const pushCall = (call) => {
|
|
686
705
|
const last = ir.messages[ir.messages.length - 1];
|
|
687
706
|
if (last && last.role === 'assistant') last.toolCalls = [...(last.toolCalls || []), call];
|
|
688
707
|
else ir.messages.push({ role: 'assistant', toolCalls: [call] });
|
|
708
|
+
takeReasoning(ir.messages[ir.messages.length - 1]);
|
|
689
709
|
};
|
|
690
710
|
|
|
691
711
|
// Responses Lite sends the tools as an input item, not in payload.tools.
|
|
@@ -724,6 +744,10 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
|
|
|
724
744
|
} else {
|
|
725
745
|
ir.messages.push({ role, content: parts });
|
|
726
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');
|
|
727
751
|
} else if (item.type === 'function_call') {
|
|
728
752
|
pushCall({ id: item.call_id || item.id || null, name: responsesToolName(item.namespace, item.name), args: parseArgs(item.arguments) });
|
|
729
753
|
} else if (item.type === 'custom_tool_call') {
|
|
@@ -1077,6 +1101,7 @@ function normThinkingEffort(effort) {
|
|
|
1077
1101
|
// - 'native' (genuine OpenAI / strict OpenAI-compatible servers): only send `reasoning_effort` when
|
|
1078
1102
|
// the client requests thinking, don't touch the system prompt, use `max_completion_tokens`.
|
|
1079
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.
|
|
1080
1105
|
function irToChatBody(ir, model, opts = {}) {
|
|
1081
1106
|
const names = toolNameMapForIR(ir);
|
|
1082
1107
|
const toolName = names.name;
|
|
@@ -1090,6 +1115,8 @@ function irToChatBody(ir, model, opts = {}) {
|
|
|
1090
1115
|
const wantsThinking = mode === 'off' ? false
|
|
1091
1116
|
: mode === 'native' ? clientWantsThinking
|
|
1092
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);
|
|
1093
1120
|
|
|
1094
1121
|
let systemText = ir.system || '';
|
|
1095
1122
|
if (mode === 'auto' && wantsThinking && !modelIsThinking && !String(model).toLowerCase().includes('gemini')) {
|
|
@@ -1125,6 +1152,8 @@ function irToChatBody(ir, model, opts = {}) {
|
|
|
1125
1152
|
return call;
|
|
1126
1153
|
});
|
|
1127
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 ?? '';
|
|
1128
1157
|
}
|
|
1129
1158
|
if (out.content === undefined) out.content = '';
|
|
1130
1159
|
messages.push(out);
|
|
@@ -1332,6 +1361,8 @@ function irToAnthropicBody(ir, model, opts = {}) {
|
|
|
1332
1361
|
body.thinking = { type: 'enabled', budget_tokens: Math.min(budget, body.max_tokens - 1) };
|
|
1333
1362
|
}
|
|
1334
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;
|
|
1335
1366
|
if (body.thinking) {
|
|
1336
1367
|
// When thinking is on, Anthropic rejects temperature != 1, top_k, or top_p < 0.95.
|
|
1337
1368
|
if (body.temperature !== undefined && body.temperature !== 1) delete body.temperature;
|
|
@@ -1351,6 +1382,12 @@ function dataUrlToInlineData(url) {
|
|
|
1351
1382
|
return { inlineData: { mimeType: m[1], data: m[2] } };
|
|
1352
1383
|
}
|
|
1353
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
|
+
|
|
1354
1391
|
// IR -> Vertex generateContent body.
|
|
1355
1392
|
function irToVertexBody(ir, model, opts = {}) {
|
|
1356
1393
|
const names = toolNameMapForIR(ir, { allowHyphens: false });
|
|
@@ -1400,6 +1437,8 @@ function irToVertexBody(ir, model, opts = {}) {
|
|
|
1400
1437
|
const parsed = typeof m.content === 'string' ? JSON.parse(m.content) : m.content;
|
|
1401
1438
|
// functionResponse.response must be an object (Struct), not an array/primitive.
|
|
1402
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) };
|
|
1403
1442
|
} catch { resp = { result: m.content ?? '' }; }
|
|
1404
1443
|
// Gemini API & Vertex only accept roles 'user' | 'model' (functionResponse lives in the 'user' role).
|
|
1405
1444
|
push('user', [{ functionResponse: { name: toolName(m.name || 'tool'), response: resp } }]);
|
|
@@ -1423,6 +1462,9 @@ function irToVertexBody(ir, model, opts = {}) {
|
|
|
1423
1462
|
push('user', textPartsOf(m.content));
|
|
1424
1463
|
}
|
|
1425
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.' }] });
|
|
1426
1468
|
|
|
1427
1469
|
const body = { contents };
|
|
1428
1470
|
if (ir.system && ir.system.trim()) {
|
|
@@ -1514,15 +1556,21 @@ function collapseNullableUnion(branches) {
|
|
|
1514
1556
|
return { schema: rest[0], nullable: rest.length !== branches.length };
|
|
1515
1557
|
}
|
|
1516
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
|
+
|
|
1517
1563
|
function toGeminiSchema(schema, root, seen) {
|
|
1564
|
+
const nested = Boolean(root);
|
|
1565
|
+
const emptyProperties = () => (nested ? { ...GEMINI_PLACEHOLDER_PROPERTIES } : {});
|
|
1518
1566
|
// Shorthand left by some MCP servers: a bare "object"/"string" where a Schema belongs.
|
|
1519
1567
|
// Vertex rejects the raw string with INVALID_ARGUMENT, so expand it.
|
|
1520
1568
|
if (typeof schema === 'string') {
|
|
1521
|
-
return schema === 'object' ? { type: 'object', properties:
|
|
1569
|
+
return schema === 'object' ? { type: 'object', properties: emptyProperties() } : { type: schema };
|
|
1522
1570
|
}
|
|
1523
1571
|
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return schema;
|
|
1524
1572
|
// The same hole as the string shorthand above: an empty schema has no type Vertex can read.
|
|
1525
|
-
if (Object.keys(schema).length === 0) return { type: 'object', properties:
|
|
1573
|
+
if (Object.keys(schema).length === 0) return { type: 'object', properties: emptyProperties() };
|
|
1526
1574
|
root = root || schema;
|
|
1527
1575
|
seen = seen || new Set();
|
|
1528
1576
|
if (seen.has(schema)) return {};
|
|
@@ -1573,8 +1621,13 @@ function toGeminiSchema(schema, root, seen) {
|
|
|
1573
1621
|
}
|
|
1574
1622
|
}
|
|
1575
1623
|
if (!out.type && out.properties) out.type = 'object';
|
|
1576
|
-
if (out.type === 'object' && !out.properties) out.properties =
|
|
1577
|
-
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
|
+
}
|
|
1578
1631
|
if (out.enum && out.type !== 'string') {
|
|
1579
1632
|
// Gemini supports enum for strings only. Keep the constraint as text for the model.
|
|
1580
1633
|
const values = (schema.enum || (Object.hasOwn(schema, 'const') ? [schema.const] : [])).filter(x => x !== null);
|
|
@@ -1647,6 +1700,15 @@ function toolResultText(block) {
|
|
|
1647
1700
|
return `[Tool Result${block.tool_use_id ? ` (${block.tool_use_id})` : ''}]: ${text}`;
|
|
1648
1701
|
}
|
|
1649
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
|
+
|
|
1650
1712
|
function healAnthropicPayload(payload) {
|
|
1651
1713
|
const notes = [];
|
|
1652
1714
|
if (!payload || !Array.isArray(payload.messages)) return { payload, changed: false, notes };
|
|
@@ -1711,6 +1773,41 @@ function healAnthropicPayload(payload) {
|
|
|
1711
1773
|
}
|
|
1712
1774
|
if (emptyUser && msgs[msgs.length - 1]?.role !== 'user') msgs.push(emptyTurn());
|
|
1713
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
|
+
}
|
|
1810
|
+
|
|
1714
1811
|
// Steps 1-3: pair up tool_use / tool_result.
|
|
1715
1812
|
const out = [];
|
|
1716
1813
|
for (let i = 0; i < msgs.length; i++) {
|
|
@@ -1771,11 +1868,7 @@ function healAnthropicPayload(payload) {
|
|
|
1771
1868
|
// Step 5: thinking + an in-progress tool loop whose last assistant turn doesn't open with thinking.
|
|
1772
1869
|
const thinkingOn = payload.thinking && payload.thinking.type && payload.thinking.type !== 'disabled';
|
|
1773
1870
|
if (thinkingOn) {
|
|
1774
|
-
|
|
1775
|
-
const lastAssistant = out[out.length - 2];
|
|
1776
|
-
const inToolLoop = lastUser?.role === 'user' && toAnthropicBlocks(lastUser.content).some(b => b.type === 'tool_result');
|
|
1777
|
-
const first = lastAssistant?.role === 'assistant' ? toAnthropicBlocks(lastAssistant.content)[0] : null;
|
|
1778
|
-
if (inToolLoop && first && first.type !== 'thinking' && first.type !== 'redacted_thinking') {
|
|
1871
|
+
if (endsInUnsignedToolLoop(out)) {
|
|
1779
1872
|
body = { ...body };
|
|
1780
1873
|
delete body.thinking;
|
|
1781
1874
|
changed = true;
|
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);
|
package/tests/formats.test.mjs
CHANGED
|
@@ -420,7 +420,7 @@ test('toGeminiSchema: bare string shorthands become Schema objects (Vertex 400 f
|
|
|
420
420
|
assert.deepEqual(toGeminiSchema('string'), { type: 'string' });
|
|
421
421
|
assert.deepEqual(
|
|
422
422
|
toGeminiSchema({ type: 'object', properties: { tags: { type: 'array', items: 'object' }, n: 'string' } }),
|
|
423
|
-
{ type: 'object', properties: { tags: { type: 'array', items: { type: 'object', properties: {} } }, n: { type: 'string' } } }
|
|
423
|
+
{ type: 'object', properties: { tags: { type: 'array', items: { type: 'object', properties: { _placeholder: { type: 'boolean', description: 'Unused. Leave unset.' } } } }, n: { type: 'string' } } }
|
|
424
424
|
);
|
|
425
425
|
});
|
|
426
426
|
|
|
@@ -1167,3 +1167,179 @@ test('Gemini MALFORMED_FUNCTION_CALL finish is an error, not stop', () => {
|
|
|
1167
1167
|
const chat = normalizeUpstream({ choices: [{ delta: {}, finish_reason: 'malformed_function_call' }] }, 'openai-chat');
|
|
1168
1168
|
assert.match(String(chat.error), /malformed_function_call/i);
|
|
1169
1169
|
});
|
|
1170
|
+
|
|
1171
|
+
// DeepSeek/Kimi thinking mode refuse a tool step whose earlier tool-call turn lost its reasoning_content.
|
|
1172
|
+
test('irToChatBody replays thinking as reasoning_content on tool-call turns', () => {
|
|
1173
|
+
const ir = anthropicToIR({ model: 'x', max_tokens: 8000, thinking: { type: 'enabled', budget_tokens: 4000 }, messages: [
|
|
1174
|
+
{ role: 'user', content: 'list files' },
|
|
1175
|
+
{ role: 'assistant', content: [{ type: 'thinking', thinking: 'I should run ls', signature: 'sig' }, { type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'ls' } }] },
|
|
1176
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'a.txt' }] },
|
|
1177
|
+
{ role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_2', name: 'Bash', input: { command: 'pwd' } }] },
|
|
1178
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_2', content: '/' }] }
|
|
1179
|
+
] });
|
|
1180
|
+
for (const opts of [{ thinkingMode: 'auto' }, { thinkingMode: 'native', replayReasoning: true }]) {
|
|
1181
|
+
const calls = irToChatBody(ir, 'deepseek-reasoner', opts).messages.filter(m => m.tool_calls);
|
|
1182
|
+
assert.equal(calls[0].reasoning_content, 'I should run ls', opts.thinkingMode);
|
|
1183
|
+
assert.equal(calls[1].reasoning_content, '', opts.thinkingMode);
|
|
1184
|
+
}
|
|
1185
|
+
const off = emitUpstreamBody('openai-chat', ir, 'deepseek-reasoner', { thinkingMode: 'off' });
|
|
1186
|
+
assert.ok(off.messages.every(m => !('reasoning_content' in m)));
|
|
1187
|
+
|
|
1188
|
+
const rir = responsesToIR({ model: 'x', reasoning: { effort: 'high' }, input: [
|
|
1189
|
+
{ type: 'message', role: 'user', content: 'go' },
|
|
1190
|
+
{ type: 'reasoning', summary: [{ type: 'summary_text', text: 'Plan: run ls' }], encrypted_content: 'opaque' },
|
|
1191
|
+
{ type: 'function_call', call_id: 'c1', name: 'shell', arguments: '{}' },
|
|
1192
|
+
{ type: 'function_call_output', call_id: 'c1', output: 'ok' }
|
|
1193
|
+
] });
|
|
1194
|
+
const rb = irToChatBody(rir, 'kimi-k2-thinking', { thinkingMode: 'native', replayReasoning: true });
|
|
1195
|
+
assert.equal(rb.messages.find(m => m.tool_calls).reasoning_content, 'Plan: run ls');
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
// Strict OpenAI-compatible servers may refuse the extra key, so native mode sends it only on the profile opt-in.
|
|
1199
|
+
test('irToChatBody native mode sends no reasoning_content without the profile opt-in', () => {
|
|
1200
|
+
const ir = anthropicToIR({ model: 'x', max_tokens: 8000, thinking: { type: 'enabled', budget_tokens: 4000 }, messages: [
|
|
1201
|
+
{ role: 'user', content: 'list files' },
|
|
1202
|
+
{ role: 'assistant', content: [{ type: 'thinking', thinking: 'I should run ls', signature: 'sig' }, { type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'ls' } }] },
|
|
1203
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'a.txt' }] },
|
|
1204
|
+
{ role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_2', name: 'Bash', input: { command: 'pwd' } }] },
|
|
1205
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_2', content: '/' }] }
|
|
1206
|
+
] });
|
|
1207
|
+
const cases = [
|
|
1208
|
+
['gpt-5', ir, { thinkingMode: 'native' }],
|
|
1209
|
+
['deepseek-reasoner', ir, { thinkingMode: 'native' }],
|
|
1210
|
+
['gpt-5', ir, { thinkingMode: 'native', replayReasoning: 'yes' }],
|
|
1211
|
+
['gpt-5', { ...ir, replayReasoning: true }, { thinkingMode: 'native' }]
|
|
1212
|
+
];
|
|
1213
|
+
for (const [model, input, opts] of cases) {
|
|
1214
|
+
const body = irToChatBody(input, model, opts);
|
|
1215
|
+
assert.ok(body.messages.some(m => m.tool_calls), 'fixture has tool-call turns');
|
|
1216
|
+
assert.ok(body.messages.every(m => !('reasoning_content' in m)), `${model} ${JSON.stringify(opts)}`);
|
|
1217
|
+
}
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
test('irToAnthropicBody drops thinking for an unsigned tool loop', () => {
|
|
1221
|
+
const ir = responsesToIR({ model: 'x', reasoning: { effort: 'high' }, input: [
|
|
1222
|
+
{ type: 'message', role: 'user', content: 'run ls' },
|
|
1223
|
+
{ type: 'function_call', call_id: 'c1', name: 'shell', arguments: '{"command":["ls"]}' },
|
|
1224
|
+
{ type: 'function_call_output', call_id: 'c1', output: 'a.txt' }
|
|
1225
|
+
] });
|
|
1226
|
+
const body = irToAnthropicBody(ir, 'claude-opus-4-6');
|
|
1227
|
+
assert.equal(body.thinking, undefined);
|
|
1228
|
+
assert.equal(body.messages.at(-1).content[0].type, 'tool_result');
|
|
1229
|
+
const forced = irToAnthropicBody(ir, 'claude-opus-4-6', { thinkingEffort: 'high' });
|
|
1230
|
+
assert.equal(forced.thinking, undefined);
|
|
1231
|
+
const fresh = irToAnthropicBody(responsesToIR({ model: 'x', reasoning: { effort: 'high' }, input: 'hi' }), 'claude-opus-4-6');
|
|
1232
|
+
assert.equal(fresh.thinking?.type, 'enabled');
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
test('toGeminiSchema never emits an empty nested OBJECT', () => {
|
|
1236
|
+
const out = toGeminiSchema({ type: 'object', properties: {
|
|
1237
|
+
env: { type: 'object', additionalProperties: { type: 'string' } },
|
|
1238
|
+
list: { type: 'array', items: { type: 'object' } },
|
|
1239
|
+
either: { anyOf: [{ type: 'object' }, { type: 'string' }] },
|
|
1240
|
+
short: 'object'
|
|
1241
|
+
} });
|
|
1242
|
+
const walk = (s, nested) => {
|
|
1243
|
+
if (!s || typeof s !== 'object') return;
|
|
1244
|
+
if (nested && s.type === 'object') assert.ok(Object.keys(s.properties || {}).length, JSON.stringify(s));
|
|
1245
|
+
for (const p of Object.values(s.properties || {})) walk(p, true);
|
|
1246
|
+
if (s.items) walk(s.items, true);
|
|
1247
|
+
for (const b of s.anyOf || []) walk(b, true);
|
|
1248
|
+
};
|
|
1249
|
+
walk(out, false);
|
|
1250
|
+
assert.deepEqual(toGeminiSchema({ type: 'object' }), { type: 'object', properties: {} });
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
test('irToVertexBody wraps tool output with $ keys', () => {
|
|
1254
|
+
const output = JSON.stringify({ $ref: '#/definitions/Pet', definitions: { Pet: { $id: 'p' } } });
|
|
1255
|
+
const nested = JSON.stringify({ a: { b: [{ $schema: 'x' }] } });
|
|
1256
|
+
const ir = { ...anthropicToIR({ model: 'x', messages: [
|
|
1257
|
+
{ role: 'user', content: 'go' },
|
|
1258
|
+
{ role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: {} }, { type: 'tool_use', id: 't2', name: 'Bash', input: {} }, { type: 'tool_use', id: 't3', name: 'Bash', input: {} }] },
|
|
1259
|
+
{ role: 'user', content: [
|
|
1260
|
+
{ type: 'tool_result', tool_use_id: 't1', content: output },
|
|
1261
|
+
{ type: 'tool_result', tool_use_id: 't2', content: nested },
|
|
1262
|
+
{ type: 'tool_result', tool_use_id: 't3', content: '{"ok":true}' }
|
|
1263
|
+
] }
|
|
1264
|
+
] }) };
|
|
1265
|
+
const responses = irToVertexBody(ir, 'gemini-2.5-pro').contents.flatMap(c => c.parts).filter(p => p.functionResponse).map(p => p.functionResponse.response);
|
|
1266
|
+
assert.deepEqual(responses[0], { result: output });
|
|
1267
|
+
assert.deepEqual(responses[1], { result: nested });
|
|
1268
|
+
assert.deepEqual(responses[2], { ok: true });
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
test('toGeminiSchema stringifies an untyped const', () => {
|
|
1272
|
+
const out = toGeminiSchema({ type: 'object', properties: { flag: { const: true }, n: { const: 3 }, s: { const: 'x' } } });
|
|
1273
|
+
assert.deepEqual(out.properties.flag, { enum: ['true'], type: 'string' });
|
|
1274
|
+
assert.deepEqual(out.properties.n, { enum: ['3'], type: 'string' });
|
|
1275
|
+
assert.deepEqual(out.properties.s, { enum: ['x'], type: 'string' });
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
test('responsesToIR keeps long colliding tool names distinct', () => {
|
|
1279
|
+
const ns = `mcp__${'server_with_a_long_name_'.repeat(3)}`;
|
|
1280
|
+
const ir = responsesToIR({ model: 'x', input: [
|
|
1281
|
+
{ type: 'message', role: 'user', content: 'go' },
|
|
1282
|
+
{ type: 'function_call', call_id: 'c1', namespace: ns, name: 'list_items_with_filter_b', arguments: '{}' },
|
|
1283
|
+
{ type: 'function_call_output', call_id: 'c1', output: 'ok' }
|
|
1284
|
+
], tools: [{ type: 'namespace', name: ns, tools: [
|
|
1285
|
+
{ type: 'function', name: 'list_items_with_filter_a', parameters: { type: 'object', properties: {} } },
|
|
1286
|
+
{ type: 'function', name: 'list_items_with_filter_b', parameters: { type: 'object', properties: {} } }
|
|
1287
|
+
] }] });
|
|
1288
|
+
const names = ir.tools.map(t => t.name);
|
|
1289
|
+
assert.equal(names.length, 2);
|
|
1290
|
+
assert.notEqual(names[0], names[1]);
|
|
1291
|
+
assert.ok(names.every(n => n.length <= 64 && /^[A-Za-z0-9_-]+$/.test(n)), names.join());
|
|
1292
|
+
assert.equal(ir.toolMeta[names[1]].name, 'list_items_with_filter_b');
|
|
1293
|
+
assert.equal(ir.messages[1].toolCalls[0].name, names[1]);
|
|
1294
|
+
assert.equal(responsesToIR({ model: 'x', input: 'hi', tools: [{ type: 'function', name: 'read_file', parameters: {} }] }).tools[0].name, 'read_file');
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
test('sanitizeJsonSchema drops null keywords and lookaround patterns', () => {
|
|
1298
|
+
const out = sanitizeJsonSchema({ type: 'object', required: null, description: null, properties: {
|
|
1299
|
+
slug: { type: 'string', pattern: '^(?!-)[a-z-]+$' },
|
|
1300
|
+
tag: { type: 'string', pattern: '(?<=#)\\w+' },
|
|
1301
|
+
plain: { type: 'string', pattern: '^[a-z]+$', default: null },
|
|
1302
|
+
named: { type: 'string', pattern: '^(?<year>\\d{4})$' },
|
|
1303
|
+
pattern: { type: 'string', enum: ['a', null], const: null }
|
|
1304
|
+
} });
|
|
1305
|
+
assert.equal('required' in out, false);
|
|
1306
|
+
assert.equal('description' in out, false);
|
|
1307
|
+
assert.equal('pattern' in out.properties.slug, false);
|
|
1308
|
+
assert.equal('pattern' in out.properties.tag, false);
|
|
1309
|
+
assert.equal(out.properties.plain.pattern, '^[a-z]+$');
|
|
1310
|
+
assert.equal(out.properties.plain.default, null);
|
|
1311
|
+
assert.equal(out.properties.named.pattern, '^(?<year>\\d{4})$');
|
|
1312
|
+
assert.deepEqual(out.properties.pattern, { type: 'string', enum: ['a', null], const: null });
|
|
1313
|
+
assert.deepEqual(sanitizeJsonSchema({ type: 'object', properties: {}, default: { a: null, b: {} } }).default, { a: null, b: {} });
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
test('irToVertexBody never ends on a model turn', () => {
|
|
1317
|
+
const ir = anthropicToIR({ model: 'x', messages: [
|
|
1318
|
+
{ role: 'user', content: 'Write a haiku' },
|
|
1319
|
+
{ role: 'assistant', content: 'Autumn moon' }
|
|
1320
|
+
] });
|
|
1321
|
+
const body = irToVertexBody(ir, 'gemini-2.5-pro');
|
|
1322
|
+
assert.equal(body.contents.at(-1).role, 'user');
|
|
1323
|
+
assert.ok(body.contents.some(c => c.role === 'model' && c.parts.some(p => p.text === 'Autumn moon')), 'the prefill text is kept');
|
|
1324
|
+
});
|
|
1325
|
+
|
|
1326
|
+
test('healAnthropicPayload dedupes repeated tool_use ids', () => {
|
|
1327
|
+
const use = (id, cmd) => ({ type: 'tool_use', id, name: 'Bash', input: { command: cmd } });
|
|
1328
|
+
const result = (id, text) => ({ type: 'tool_result', tool_use_id: id, content: text });
|
|
1329
|
+
const r = healAnthropicPayload({ model: 'claude', messages: [
|
|
1330
|
+
{ role: 'user', content: 'go' },
|
|
1331
|
+
{ role: 'assistant', content: [use('functions_Bash_0', 'ls')] },
|
|
1332
|
+
{ role: 'user', content: [result('functions_Bash_0', 'a.txt')] },
|
|
1333
|
+
{ role: 'assistant', content: [use('functions_Bash_0', 'pwd'), use('functions_Bash_1', 'id')] },
|
|
1334
|
+
{ role: 'user', content: [result('functions_Bash_0', '/'), result('functions_Bash_1', 'uid')] }
|
|
1335
|
+
] });
|
|
1336
|
+
assert.equal(r.changed, true);
|
|
1337
|
+
const msgs = r.payload.messages;
|
|
1338
|
+
const ids = msgs.filter(m => m.role === 'assistant').flatMap(m => m.content.filter(b => b.type === 'tool_use').map(b => b.id));
|
|
1339
|
+
assert.equal(new Set(ids).size, ids.length, ids.join());
|
|
1340
|
+
assert.ok(ids.every(id => /^[a-zA-Z0-9_-]+$/.test(id)));
|
|
1341
|
+
assert.equal(ids[0], 'functions_Bash_0');
|
|
1342
|
+
// Each result still answers its own call.
|
|
1343
|
+
assert.deepEqual(msgs[2].content.map(b => [b.tool_use_id, b.content]), [[ids[0], 'a.txt']]);
|
|
1344
|
+
assert.deepEqual(msgs[4].content.map(b => [b.tool_use_id, b.content]), [[ids[1], '/'], [ids[2], 'uid']]);
|
|
1345
|
+
});
|
|
@@ -112,6 +112,10 @@ function startUpstream() {
|
|
|
112
112
|
res.writeHead(429, { 'Content-Type': 'application/json', 'retry-after': '7' });
|
|
113
113
|
return res.end(JSON.stringify({ error: { message: 'slow down' } }));
|
|
114
114
|
}
|
|
115
|
+
if (lastUser.includes('CONTEXT_OVERFLOW')) {
|
|
116
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
117
|
+
return res.end(JSON.stringify({ error: { message: "This model's maximum context length is 131072 tokens. However, you requested 140000 tokens (132000 in the messages, 8000 in the completion). Please reduce the length of the messages or completion.", type: 'invalid_request_error', code: 'invalid_request_error' } }));
|
|
118
|
+
}
|
|
115
119
|
if (lastUser.includes('APPLY_PATCH')) {
|
|
116
120
|
return sse(res, [
|
|
117
121
|
{ choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: 'call_patch', type: 'function', function: { name: 'apply_patch', arguments: '{"input":"*** Begin Patch\\n' } }] } }] },
|
|
@@ -407,7 +411,7 @@ test('Codex via ag/* target: Gemini-hostile tool schemas are rewritten before up
|
|
|
407
411
|
const params = up.body.tools[0].function.parameters;
|
|
408
412
|
assert.deepEqual(params.properties.ids, { type: 'array', items: { type: 'string' }, nullable: true });
|
|
409
413
|
assert.deepEqual(params.properties.part, { type: 'object', properties: { t: { type: 'string' } } });
|
|
410
|
-
assert.deepEqual(params.properties.raw, { type: 'object', properties: {} });
|
|
414
|
+
assert.deepEqual(params.properties.raw, { type: 'object', properties: { _placeholder: { type: 'boolean', description: 'Unused. Leave unset.' } } });
|
|
411
415
|
assert.deepEqual(params.required, ['ids']);
|
|
412
416
|
assert.ok(!JSON.stringify(params).includes('$ref'), 'no $ref survives');
|
|
413
417
|
assert.equal(params.$defs, undefined);
|
|
@@ -594,6 +598,20 @@ test('Upstream 429 is returned in Anthropic error shape with retry-after', async
|
|
|
594
598
|
assert.deepEqual(json, { type: 'error', error: { type: 'rate_limit_error', message: 'slow down' } });
|
|
595
599
|
});
|
|
596
600
|
|
|
601
|
+
// Claude Code compacts only on "prompt is too long", and Codex only on context_length_exceeded.
|
|
602
|
+
test('sendClientError maps context overflow per client', async () => {
|
|
603
|
+
const cc = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 10, messages: [{ role: 'user', content: 'CONTEXT_OVERFLOW' }] });
|
|
604
|
+
assert.equal(cc.status, 400);
|
|
605
|
+
const ccJson = await cc.json();
|
|
606
|
+
assert.equal(ccJson.error.type, 'invalid_request_error');
|
|
607
|
+
assert.match(ccJson.error.message, /^prompt is too long: 140000 tokens > 131072 maximum/);
|
|
608
|
+
const codex = await post('/v1/responses', { model: 'gpt-5-codex', input: 'CONTEXT_OVERFLOW' });
|
|
609
|
+
assert.equal(codex.status, 400);
|
|
610
|
+
const codexJson = await codex.json();
|
|
611
|
+
assert.equal(codexJson.error.code, 'context_length_exceeded');
|
|
612
|
+
assert.match(codexJson.error.message, /maximum context length is 131072/);
|
|
613
|
+
});
|
|
614
|
+
|
|
597
615
|
test('Mid-stream upstream error surfaces as an error event instead of a fake end_turn', async () => {
|
|
598
616
|
const res = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 10, stream: true, messages: [{ role: 'user', content: 'MID_STREAM_ERROR' }] });
|
|
599
617
|
const events = parseSSE(await res.text());
|