llm-switcher 1.6.9 → 1.6.11
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 +16 -0
- package/README.md +2 -1
- package/README.vi.md +2 -1
- package/formats.mjs +136 -11
- package/package.json +1 -1
- package/proxy.mjs +51 -6
- package/tests/cdp.mjs +8 -1
- package/tests/dashboard-fixture.mjs +28 -16
- package/tests/formats.test.mjs +223 -2
- package/tests/gateway.e2e.test.mjs +99 -8
- package/tests/helpers.mjs +60 -0
- package/tests/teardown.test.mjs +84 -0
- package/tests/windows.test.mjs +31 -1
- package/windows.mjs +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog — LLM Switcher
|
|
2
2
|
|
|
3
|
+
## Release 1.6.11 — 2026-10-11
|
|
4
|
+
|
|
5
|
+
- **Output limit.** The proxy keeps the output limit of each model from the `/models` list of the profile (`max_output_tokens` or `top_provider.max_completion_tokens`) in `model-windows.json`. If the client asks for more tokens than this limit, the proxy sends the limit. This applies to `max_tokens`, `max_completion_tokens` and `maxOutputTokens`. If the limit is not known, the value does not change. The limit does not apply to the anthropic-to-anthropic passthrough (direct mode, outFormat `anthropic`, and the native goal-check request). These requests go to the upstream unchanged.
|
|
6
|
+
- **Images to a text-only chat model.** If a chat upstream refuses an image with HTTP 400, the proxy sends the request one more time. In that request, each image is the text `[image omitted: this model does not accept images]`. The retry happens in each request. The proxy remembers nothing between requests.
|
|
7
|
+
- **Test cleanup.** The browser tests and the gateway tests stop the gateway, the stand-in interceptor, an interceptor that the gateway started, and Chromium. They also delete their temp directories. This occurs also when a test fails or the test process gets `SIGTERM`, `SIGINT` or `SIGHUP`.
|
|
8
|
+
|
|
9
|
+
## Release 1.6.10 — 2026-10-11
|
|
10
|
+
|
|
11
|
+
- **`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.
|
|
12
|
+
- **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.
|
|
13
|
+
- **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.`.
|
|
14
|
+
- **Schema sanitizer.** Keywords with a `null` value and `pattern` values with lookaround are removed.
|
|
15
|
+
- **Long tool names from Codex.** Names longer than 64 characters use the hashed shortener, so two long names do not collide.
|
|
16
|
+
- **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.
|
|
17
|
+
- **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`.
|
|
18
|
+
|
|
3
19
|
## Release 1.6.9 — 2026-10-11
|
|
4
20
|
|
|
5
21
|
- **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);
|
|
@@ -1601,6 +1654,13 @@ function emitUpstreamBody(outFormat, ir, model, opts = {}) {
|
|
|
1601
1654
|
// thinkingMode 'off' applies to all upstreams: drop the client's thinking entirely.
|
|
1602
1655
|
let src = normThinkingMode(opts.thinkingMode) === 'off' ? { ...ir, thinking: { type: 'disabled' } } : ir;
|
|
1603
1656
|
if (isAntigravityModel(model) && src.system) src = { ...src, system: src.system.replace(BILLING_HEADER_RE, '') };
|
|
1657
|
+
// An upstream refuses a max tokens above the model's output limit. The Anthropic body always
|
|
1658
|
+
// carries one (4096 when the client sent none), so that default is clamped too.
|
|
1659
|
+
const limit = opts.maxOutputTokens;
|
|
1660
|
+
if (Number.isFinite(limit) && limit > 0) {
|
|
1661
|
+
const asked = typeof src.params?.maxTokens === 'number' ? src.params.maxTokens : (outFormat === 'anthropic' ? 4096 : null);
|
|
1662
|
+
if (asked != null && asked > limit) src = { ...src, params: { ...src.params, maxTokens: limit } };
|
|
1663
|
+
}
|
|
1604
1664
|
switch (outFormat) {
|
|
1605
1665
|
case 'anthropic': return irToAnthropicBody(src, model, opts);
|
|
1606
1666
|
case 'vertex': return irToVertexBody(src, model, opts);
|
|
@@ -1609,6 +1669,31 @@ function emitUpstreamBody(outFormat, ir, model, opts = {}) {
|
|
|
1609
1669
|
}
|
|
1610
1670
|
}
|
|
1611
1671
|
|
|
1672
|
+
// A text-only chat model refuses an image part with a 400. These are the refusals seen so far;
|
|
1673
|
+
// a looser match would turn an unrelated 400 into a silent retry without the image.
|
|
1674
|
+
export const IMAGE_OMITTED_TEXT = '[image omitted: this model does not accept images]';
|
|
1675
|
+
|
|
1676
|
+
export function refusesImageInput(text) {
|
|
1677
|
+
const t = String(text || '').toLowerCase();
|
|
1678
|
+
return t.includes('unknown variant `image_url`') || t.includes('image_url is only supported by certain models')
|
|
1679
|
+
|| t.includes('does not support image') || t.includes('image input is not supported')
|
|
1680
|
+
|| (t.includes('vision') && t.includes('not supported'));
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// Replaces every image part of a chat body with IMAGE_OMITTED_TEXT, in place. Returns how many it replaced.
|
|
1684
|
+
export function omitChatImages(body) {
|
|
1685
|
+
let replaced = 0;
|
|
1686
|
+
for (const m of Array.isArray(body?.messages) ? body.messages : []) {
|
|
1687
|
+
if (!Array.isArray(m?.content)) continue;
|
|
1688
|
+
m.content = m.content.map(p => {
|
|
1689
|
+
if (p?.type !== 'image_url') return p;
|
|
1690
|
+
replaced++;
|
|
1691
|
+
return { type: 'text', text: IMAGE_OMITTED_TEXT };
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
return replaced;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1612
1697
|
// ---------------- native Anthropic healer (direct passthrough) ----------------
|
|
1613
1698
|
// The anthropic -> anthropic branch skips IR (to preserve thinking signatures, cache_control, documents...),
|
|
1614
1699
|
// so it patches the Anthropic payload directly, touching only what the API will definitely reject:
|
|
@@ -1647,6 +1732,15 @@ function toolResultText(block) {
|
|
|
1647
1732
|
return `[Tool Result${block.tool_use_id ? ` (${block.tool_use_id})` : ''}]: ${text}`;
|
|
1648
1733
|
}
|
|
1649
1734
|
|
|
1735
|
+
// Anthropic: "a final assistant message must start with a thinking block" when thinking is on mid tool loop.
|
|
1736
|
+
function endsInUnsignedToolLoop(messages) {
|
|
1737
|
+
const lastUser = messages[messages.length - 1];
|
|
1738
|
+
const lastAssistant = messages[messages.length - 2];
|
|
1739
|
+
const inToolLoop = lastUser?.role === 'user' && toAnthropicBlocks(lastUser.content).some(b => b.type === 'tool_result');
|
|
1740
|
+
const first = lastAssistant?.role === 'assistant' ? toAnthropicBlocks(lastAssistant.content)[0] : null;
|
|
1741
|
+
return Boolean(inToolLoop && first && first.type !== 'thinking' && first.type !== 'redacted_thinking');
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1650
1744
|
function healAnthropicPayload(payload) {
|
|
1651
1745
|
const notes = [];
|
|
1652
1746
|
if (!payload || !Array.isArray(payload.messages)) return { payload, changed: false, notes };
|
|
@@ -1711,6 +1805,41 @@ function healAnthropicPayload(payload) {
|
|
|
1711
1805
|
}
|
|
1712
1806
|
if (emptyUser && msgs[msgs.length - 1]?.role !== 'user') msgs.push(emptyTurn());
|
|
1713
1807
|
|
|
1808
|
+
// Step 0b: an upstream that reuses an id (Kimi `functions.Bash:0`) leaves one id on two tool_use
|
|
1809
|
+
// blocks, which Anthropic refuses. A later use gets a suffix, and so does its result in the next turn.
|
|
1810
|
+
const allIds = new Set(msgs.flatMap(m => toAnthropicBlocks(m.content).filter(b => b.type === 'tool_use' && b.id).map(b => b.id)));
|
|
1811
|
+
const usedIds = new Set();
|
|
1812
|
+
for (let i = 0; i < msgs.length; i++) {
|
|
1813
|
+
if (msgs[i].role !== 'assistant' || !Array.isArray(msgs[i].content)) continue;
|
|
1814
|
+
const resultIds = new Map();
|
|
1815
|
+
let renamed = false;
|
|
1816
|
+
const content = msgs[i].content.map(b => {
|
|
1817
|
+
if (b?.type !== 'tool_use' || !b.id) return b;
|
|
1818
|
+
let id = b.id;
|
|
1819
|
+
if (usedIds.has(id)) {
|
|
1820
|
+
let n = 2;
|
|
1821
|
+
while (allIds.has(`${b.id}_${n}`)) n++;
|
|
1822
|
+
id = `${b.id}_${n}`;
|
|
1823
|
+
allIds.add(id);
|
|
1824
|
+
renamed = true;
|
|
1825
|
+
}
|
|
1826
|
+
usedIds.add(id);
|
|
1827
|
+
resultIds.set(b.id, [...(resultIds.get(b.id) || []), id]);
|
|
1828
|
+
return id === b.id ? b : { ...b, id };
|
|
1829
|
+
});
|
|
1830
|
+
if (!renamed) continue;
|
|
1831
|
+
msgs[i] = { ...msgs[i], content };
|
|
1832
|
+
const next = msgs[i + 1];
|
|
1833
|
+
if (next?.role === 'user' && Array.isArray(next.content)) {
|
|
1834
|
+
msgs[i + 1] = { ...next, content: next.content.map(b => {
|
|
1835
|
+
const id = b?.type === 'tool_result' ? resultIds.get(b.tool_use_id)?.shift() : undefined;
|
|
1836
|
+
return id && id !== b.tool_use_id ? { ...b, tool_use_id: id } : b;
|
|
1837
|
+
}) };
|
|
1838
|
+
}
|
|
1839
|
+
changed = true;
|
|
1840
|
+
notes.push('deduplicated repeated tool_use ids');
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1714
1843
|
// Steps 1-3: pair up tool_use / tool_result.
|
|
1715
1844
|
const out = [];
|
|
1716
1845
|
for (let i = 0; i < msgs.length; i++) {
|
|
@@ -1771,11 +1900,7 @@ function healAnthropicPayload(payload) {
|
|
|
1771
1900
|
// Step 5: thinking + an in-progress tool loop whose last assistant turn doesn't open with thinking.
|
|
1772
1901
|
const thinkingOn = payload.thinking && payload.thinking.type && payload.thinking.type !== 'disabled';
|
|
1773
1902
|
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') {
|
|
1903
|
+
if (endsInUnsignedToolLoop(out)) {
|
|
1779
1904
|
body = { ...body };
|
|
1780
1905
|
delete body.thinking;
|
|
1781
1906
|
changed = true;
|
package/package.json
CHANGED
package/proxy.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { createFrameReader } from './blindfold/wsframe.mjs';
|
|
|
11
11
|
import {
|
|
12
12
|
OUT_FORMATS, parseToIR, emitUpstreamBody, irToAnthropicBody, createUpstreamNormalizer, createCollector,
|
|
13
13
|
createThinkTagSplitter, splitThinkTags, healAnthropicPayload, estimateTokens, THINKING_MODES, VALID_EFFORTS,
|
|
14
|
-
toGeminiSchema, isAntigravityModel,
|
|
14
|
+
toGeminiSchema, isAntigravityModel, refusesImageInput, omitChatImages,
|
|
15
15
|
createAnthropicStream, createChatStream, createResponsesStream, createVertexStream,
|
|
16
16
|
buildAnthropicMessage, buildChatMessage, buildResponsesMessage, buildVertexMessage,
|
|
17
17
|
createCodeAssistStream, buildCodeAssistMessage
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
codexModelEntry,
|
|
27
27
|
contractLabSettings, STATE_DIR, stopRecordedBlindfold
|
|
28
28
|
} from './state.mjs';
|
|
29
|
-
import { parseModelWindows, resolveProfileWindows, readLocalCodexWindows } from './windows.mjs';
|
|
29
|
+
import { parseModelWindows, resolveProfileWindows, readLocalCodexWindows, readWindowsCache } from './windows.mjs';
|
|
30
30
|
import { createToolVocab, NULL_TOOL_VOCAB, toolNameMapForIR } from './toolvocab.mjs';
|
|
31
31
|
import { readUpstreamPayloads } from './upstream-stream.mjs';
|
|
32
32
|
import { compactionResponse, emitCompaction, expandGatewayCompactions, collectCompaction } from './compaction.mjs';
|
|
@@ -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
|
}
|
|
@@ -810,11 +828,24 @@ function resolveThinkingEffort(profile, slot, mappedModel) {
|
|
|
810
828
|
return profile?.thinkingEffort || 'auto';
|
|
811
829
|
}
|
|
812
830
|
|
|
831
|
+
// The output limit the profile's own model list names for this model, or 0 when no cached list
|
|
832
|
+
// names one. The cache is filled by /v1/models; this only reads it.
|
|
833
|
+
function modelOutputLimit(profile, mappedModel) {
|
|
834
|
+
if (!profile?.baseURL || !mappedModel) return 0;
|
|
835
|
+
for (const entry of Object.values(readWindowsCache(STATE_DIR))) {
|
|
836
|
+
const output = entry?.baseURL === profile.baseURL ? Number(entry.models?.[mappedModel]?.output) : 0;
|
|
837
|
+
if (output > 0) return output;
|
|
838
|
+
}
|
|
839
|
+
return 0;
|
|
840
|
+
}
|
|
841
|
+
|
|
813
842
|
function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot = null) {
|
|
814
843
|
const effort = resolveThinkingEffort(profile, slot, mappedModel);
|
|
815
844
|
const upBody = emitUpstreamBody(outFormat, ir, mappedModel, {
|
|
816
845
|
thinkingMode: profile.thinkingMode,
|
|
817
846
|
thinkingEffort: effort,
|
|
847
|
+
replayReasoning: profile.replayReasoning === true,
|
|
848
|
+
maxOutputTokens: modelOutputLimit(profile, mappedModel),
|
|
818
849
|
});
|
|
819
850
|
if (upBody?.tools && Array.isArray(upBody.tools)) {
|
|
820
851
|
upBody.tools = cleanSchemaDeep(upBody.tools);
|
|
@@ -826,6 +857,20 @@ function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot = n
|
|
|
826
857
|
return { url, headers, upBody };
|
|
827
858
|
}
|
|
828
859
|
|
|
860
|
+
// Sends the upstream request. A chat 400 that says the model takes no images is retried once with
|
|
861
|
+
// every image replaced by text. The status arrives before any byte goes to the client, so the client
|
|
862
|
+
// sees only the retry's answer. Nothing is remembered: a false refusal costs one request, not the model.
|
|
863
|
+
async function fetchUpstream(url, headers, upBody, signal, outFormat, profileKey, mappedModel) {
|
|
864
|
+
const send = () => fetch(url, { method: 'POST', headers, body: JSON.stringify(upBody), signal });
|
|
865
|
+
const res = await send();
|
|
866
|
+
const hasImage = outFormat === 'openai-chat' && upBody.messages?.some(m => Array.isArray(m?.content) && m.content.some(p => p?.type === 'image_url'));
|
|
867
|
+
if (res.status !== 400 || !hasImage) return res;
|
|
868
|
+
const errText = await res.text().catch(() => '');
|
|
869
|
+
if (!refusesImageInput(errText)) return new Response(errText, { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
870
|
+
console.warn(`[${profileKey}] ${mappedModel} refused ${omitChatImages(upBody)} image(s); retrying this request as text`);
|
|
871
|
+
return send();
|
|
872
|
+
}
|
|
873
|
+
|
|
829
874
|
// Feeds upstream stream events to a client renderer until the stream ends, fails or is aborted.
|
|
830
875
|
// Returns the stream error, or null. `sink` is the client stream whose backpressure it waits for.
|
|
831
876
|
async function pumpStream(upstreamRes, { normalize, col, renderer, splitter, signal, sink, tag }) {
|
|
@@ -1263,7 +1308,7 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
|
|
|
1263
1308
|
|
|
1264
1309
|
let upstreamRes;
|
|
1265
1310
|
try {
|
|
1266
|
-
upstreamRes = await
|
|
1311
|
+
upstreamRes = await fetchUpstream(url, headers, upBody, ac.signal, outFormat, profileKey, mappedModel);
|
|
1267
1312
|
} catch (fetchErr) {
|
|
1268
1313
|
if (ac.signal.aborted) return log({ status: 499, error: 'client disconnected' });
|
|
1269
1314
|
console.error(`[${profileKey}] Network error:`, fetchErr.message);
|
|
@@ -2534,7 +2579,7 @@ async function handleWsResponseCreate(socket, payload, req, ac, history = null)
|
|
|
2534
2579
|
|
|
2535
2580
|
let upstreamRes;
|
|
2536
2581
|
try {
|
|
2537
|
-
upstreamRes = await
|
|
2582
|
+
upstreamRes = await fetchUpstream(url, headers, upBody, ac.signal, outFormat, profileKey, mappedModel);
|
|
2538
2583
|
} catch (fetchErr) {
|
|
2539
2584
|
if (ac.signal.aborted) return log({ status: 499, error: 'client disconnected' });
|
|
2540
2585
|
console.error(`[${profileKey}:ws] Network error:`, fetchErr.message);
|
package/tests/cdp.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { spawn } from 'node:child_process';
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import os from 'node:os';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
+
import { reapDirSync, onAbnormalExit } from './helpers.mjs';
|
|
8
9
|
|
|
9
10
|
const CANDIDATES = [
|
|
10
11
|
process.env.LLM_SWITCHER_CHROMIUM,
|
|
@@ -48,7 +49,10 @@ export async function launchBrowser() {
|
|
|
48
49
|
'about:blank'
|
|
49
50
|
];
|
|
50
51
|
// POSIX: a process group of its own, so close() can stop the renderers and helpers too.
|
|
51
|
-
|
|
52
|
+
// TMPDIR too: Chromium makes its own org.chromium.* dirs there, and they go with the profile.
|
|
53
|
+
const child = spawn(findChromium(), args, { stdio: ['ignore', 'ignore', 'pipe'], env: { ...process.env, HOME: dir, TMPDIR: dir }, detached: process.platform !== 'win32' });
|
|
54
|
+
// The browser runs detached, so a test process that ends without close() would leave it running.
|
|
55
|
+
const cancelExitReap = onAbnormalExit(() => reapDirSync(dir, process.platform !== 'win32' ? [-child.pid] : [child.pid]));
|
|
52
56
|
const stop = (signal) => {
|
|
53
57
|
try {
|
|
54
58
|
if (process.platform !== 'win32') process.kill(-child.pid, signal);
|
|
@@ -71,6 +75,8 @@ export async function launchBrowser() {
|
|
|
71
75
|
stop('SIGKILL');
|
|
72
76
|
await new Promise(r => { child.once('exit', r); setTimeout(r, 3000); });
|
|
73
77
|
}
|
|
78
|
+
cancelExitReap();
|
|
79
|
+
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
|
74
80
|
throw new Error(`Chromium did not open a debugging port (exit: ${exited ?? 'killed'})\n${stderr}`);
|
|
75
81
|
}
|
|
76
82
|
|
|
@@ -84,6 +90,7 @@ export async function launchBrowser() {
|
|
|
84
90
|
return page;
|
|
85
91
|
},
|
|
86
92
|
async close() {
|
|
93
|
+
cancelExitReap();
|
|
87
94
|
for (const p of pages) p.close();
|
|
88
95
|
// The whole group: a helper that outlives the main process keeps writing into the profile.
|
|
89
96
|
stop('SIGTERM');
|
|
@@ -8,6 +8,7 @@ import os from 'node:os';
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { spawn } from 'node:child_process';
|
|
10
10
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
11
|
+
import { stopChild, reapDir, reapDirSync, onAbnormalExit } from './helpers.mjs';
|
|
11
12
|
|
|
12
13
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
13
14
|
|
|
@@ -39,6 +40,11 @@ export function baselineConfig(port, blindfoldPort, upstreamBase) {
|
|
|
39
40
|
export async function startDashboardFixture() {
|
|
40
41
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsw-dashboard-'));
|
|
41
42
|
const cfgPath = path.join(dir, 'config.json');
|
|
43
|
+
let interceptor = null;
|
|
44
|
+
let proxy = null;
|
|
45
|
+
// A run that ends before stop() (a signal, a failed start) still stops the children and an
|
|
46
|
+
// interceptor the gateway started detached, and removes the dir.
|
|
47
|
+
const cancelExitReap = onAbnormalExit(() => reapDirSync(dir, [proxy?.pid, interceptor?.pid]));
|
|
42
48
|
const received = [];
|
|
43
49
|
// 'ok' answers like a healthy provider; 'deny' answers 401; 'down' answers 503.
|
|
44
50
|
const upstream = { mode: 'ok', received, models: ['up-alpha', 'up-beta', 'up-gamma'], latest: '99.0.0' };
|
|
@@ -62,6 +68,23 @@ export async function startDashboardFixture() {
|
|
|
62
68
|
});
|
|
63
69
|
});
|
|
64
70
|
await new Promise(r => upstreamServer.listen(0, '127.0.0.1', r));
|
|
71
|
+
|
|
72
|
+
async function stop() {
|
|
73
|
+
cancelExitReap();
|
|
74
|
+
// The gateway first: once it is gone it can start no interceptor.
|
|
75
|
+
await stopChild(proxy);
|
|
76
|
+
await stopChild(interceptor);
|
|
77
|
+
upstreamServer.close();
|
|
78
|
+
upstreamServer.closeAllConnections?.();
|
|
79
|
+
// A tool set change can start a detached interceptor of its own; stop the one the gateway recorded.
|
|
80
|
+
try {
|
|
81
|
+
const { pid } = JSON.parse(fs.readFileSync(path.join(dir, 'blindfold.json'), 'utf8'));
|
|
82
|
+
if (Number.isInteger(pid) && pid !== interceptor?.pid) process.kill(pid);
|
|
83
|
+
} catch { /* none was started */ }
|
|
84
|
+
// One it had not recorded yet still names the dir on its command line.
|
|
85
|
+
await reapDir(dir);
|
|
86
|
+
}
|
|
87
|
+
|
|
65
88
|
const upstreamBase = `http://127.0.0.1:${upstreamServer.address().port}`;
|
|
66
89
|
|
|
67
90
|
const port = await freePort();
|
|
@@ -96,7 +119,7 @@ export async function startDashboardFixture() {
|
|
|
96
119
|
// The gateway starts an interceptor of its own when it finds none on the port. This one is up
|
|
97
120
|
// first and answers the identity proof with this gateway's port and tool set, so nothing real
|
|
98
121
|
// is spawned.
|
|
99
|
-
|
|
122
|
+
interceptor = spawn(process.execPath, ['--input-type=module', '-e', `
|
|
100
123
|
const s = await import(${JSON.stringify(pathToFileURL(path.join(ROOT, 'state.mjs')).href)});
|
|
101
124
|
const http = await import('node:http');
|
|
102
125
|
s.ensureAdminToken();
|
|
@@ -112,11 +135,11 @@ export async function startDashboardFixture() {
|
|
|
112
135
|
for (;;) {
|
|
113
136
|
const probe = await fetch(`http://127.0.0.1:${blindfoldPort}/?challenge=probe`).catch(() => null);
|
|
114
137
|
if (probe?.ok) break;
|
|
115
|
-
if (Date.now() > deadline) {
|
|
138
|
+
if (Date.now() > deadline) { await stop(); throw new Error('the stand-in interceptor never came up'); }
|
|
116
139
|
await new Promise(r => setTimeout(r, 100));
|
|
117
140
|
}
|
|
118
141
|
|
|
119
|
-
|
|
142
|
+
proxy = spawn(process.execPath, [path.join(ROOT, 'proxy.mjs'), '--port', String(port)], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
120
143
|
let log = '';
|
|
121
144
|
proxy.stdout.on('data', d => { log += d; });
|
|
122
145
|
proxy.stderr.on('data', d => { log += d; });
|
|
@@ -125,7 +148,7 @@ export async function startDashboardFixture() {
|
|
|
125
148
|
try { started = (await fetch(`http://127.0.0.1:${port}/health`)).ok; } catch { /* not listening yet */ }
|
|
126
149
|
if (!started) await new Promise(r => setTimeout(r, 100));
|
|
127
150
|
}
|
|
128
|
-
if (!started) {
|
|
151
|
+
if (!started) { await stop(); throw new Error(`gateway did not start:\n${log}`); }
|
|
129
152
|
|
|
130
153
|
const adminToken = () => fs.readFileSync(path.join(dir, 'admin.token'), 'utf8').trim();
|
|
131
154
|
return {
|
|
@@ -153,17 +176,6 @@ export async function startDashboardFixture() {
|
|
|
153
176
|
headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
|
|
154
177
|
body: JSON.stringify({ model: 'claude-opus-4-6', max_tokens: 64, messages: [{ role: 'user', content: 'hello from the test' }] })
|
|
155
178
|
}).then(async r => ({ status: r.status, text: await r.text() })),
|
|
156
|
-
|
|
157
|
-
interceptor.kill();
|
|
158
|
-
proxy.kill();
|
|
159
|
-
upstreamServer.close();
|
|
160
|
-
upstreamServer.closeAllConnections?.();
|
|
161
|
-
// A tool set change can start a detached interceptor of its own; stop the one the gateway recorded.
|
|
162
|
-
try {
|
|
163
|
-
const { pid } = JSON.parse(fs.readFileSync(path.join(dir, 'blindfold.json'), 'utf8'));
|
|
164
|
-
if (Number.isInteger(pid) && pid !== interceptor.pid) process.kill(pid);
|
|
165
|
-
} catch { /* none was started */ }
|
|
166
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
167
|
-
}
|
|
179
|
+
stop
|
|
168
180
|
};
|
|
169
181
|
}
|