llm-switcher 1.6.10 → 1.6.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +1 -1
- package/README.vi.md +1 -1
- package/formats.mjs +147 -12
- package/package.json +1 -1
- package/proxy.mjs +55 -19
- package/scripts/run-tests.mjs +12 -0
- package/tests/blindfold.task3.test.mjs +1 -0
- package/tests/cdp.mjs +8 -1
- package/tests/contract-check.test.mjs +7 -2
- package/tests/dashboard-fixture.mjs +28 -16
- package/tests/datadir.test.mjs +4 -2
- package/tests/formats.test.mjs +171 -1
- package/tests/gateway.e2e.test.mjs +209 -7
- package/tests/goalcheck.e2e.test.mjs +46 -2
- package/tests/helpers.mjs +60 -0
- package/tests/shim.test.mjs +8 -5
- package/tests/teardown.test.mjs +84 -0
- package/tests/version.test.mjs +5 -2
- package/tests/windows.test.mjs +94 -2
- package/windows.mjs +34 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog — LLM Switcher
|
|
2
2
|
|
|
3
|
+
## Release 1.6.12 — 2026-10-11
|
|
4
|
+
|
|
5
|
+
- **Reasoning from an aborted turn.** In a Codex request, reasoning that waits for an assistant turn is dropped at the next user message. Separate reasoning items are joined with a newline.
|
|
6
|
+
- **Token counts with separators.** A context overflow message with `131,072`, `131.072`, `131 072` or `131_072` gives the full number to Claude Code. A message without numbers keeps the plain form.
|
|
7
|
+
- **Context overflow over WebSocket.** A Codex turn on WebSocket fails with the code `context_length_exceeded`, the same as on HTTP.
|
|
8
|
+
- **Goal check and images.** The goal-check request to a chat upstream uses the same image retry as other requests.
|
|
9
|
+
- **Output limit cache.** The output limit comes only from the cache entry of the same profile, and only when the entry is less than one hour old. The cache key ignores the case of scheme and host and a trailing slash. A new model list is merged per model, so a list with only output limits keeps the cached windows. A cached output limit is kept only while the newest list still states it.
|
|
10
|
+
- **System messages on the direct Anthropic route.** A `system` message inside `messages` is no longer lost. Its text goes into the next user turn, or into the previous one if no user turn follows, with the prefix `[system] `.
|
|
11
|
+
- **Gemini `_placeholder` in tool calls.** If the model fills the `_placeholder` property that the proxy added, the proxy removes it from the arguments. This applies to stream and non-stream answers for every client format, Code Assist (agy) included, and only where the proxy added the property.
|
|
12
|
+
- **Test temp files.** The tests delete all their temp directories, also when a test fails. `npm test` deletes the `node-compile-cache` directory that npm made at the start of the run.
|
|
13
|
+
- **`replayReasoning` documentation.** The README files now say that `reasoning_content` goes out only on a request with thinking on.
|
|
14
|
+
|
|
15
|
+
## Release 1.6.11 — 2026-10-11
|
|
16
|
+
|
|
17
|
+
- **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.
|
|
18
|
+
- **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.
|
|
19
|
+
- **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`.
|
|
20
|
+
|
|
3
21
|
## Release 1.6.10 — 2026-10-11
|
|
4
22
|
|
|
5
23
|
- **`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.
|
package/README.md
CHANGED
|
@@ -869,7 +869,7 @@ Because of this split, a provider fingerprint is never a rule in this gateway. I
|
|
|
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
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`. `
|
|
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`. The field goes out only on a request with thinking on. In `native` mode, thinking is on when the client asks for it. In `auto` mode, the flag is not needed: thinking is on when the client asks for it, or when the model reasons natively and the client did not turn thinking off. `off` never sends the field. |
|
|
873
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. |
|
|
874
874
|
| `profile.endpoints.countTokens` | Override the Anthropic `count_tokens` URL. |
|
|
875
875
|
| `profile.endpoints` | Override upstream URLs per format: `{ "openai-chat": "...", "anthropic": "...", "vertex": "https://.../models/{model}:{action}" }`. |
|
package/README.vi.md
CHANGED
|
@@ -864,7 +864,7 @@ Nhờ cách chia này, fingerprint của provider không bao giờ là rule tron
|
|
|
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
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`.
|
|
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`. Field chỉ được gửi khi request bật thinking. Ở `native`, thinking bật khi client yêu cầu. Ở `auto`, không cần cờ này: thinking bật khi client yêu cầu, hoặc khi model có reasoning sẵn và client không tắt thinking. `off` không bao giờ gửi field này. |
|
|
868
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. |
|
|
869
869
|
| `profile.endpoints.countTokens` | Ghi đè URL `count_tokens` của Anthropic. |
|
|
870
870
|
| `profile.endpoints` | Ghi đè URL upstream theo từng format: `{ "openai-chat": "...", "anthropic": "...", "vertex": "https://.../models/{model}:{action}" }`. |
|
package/formats.mjs
CHANGED
|
@@ -693,10 +693,12 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
|
|
|
693
693
|
if (text) ir.system = ir.system ? `${ir.system}\n\n${text}` : text;
|
|
694
694
|
};
|
|
695
695
|
|
|
696
|
-
// A reasoning item precedes the assistant output it belongs to.
|
|
696
|
+
// A reasoning item precedes the assistant output it belongs to. A user message ends that turn,
|
|
697
|
+
// so reasoning still pending there came from an aborted turn and is dropped.
|
|
697
698
|
let reasoning = '';
|
|
699
|
+
const joinLines = (a, b) => (a ? `${a}\n${b}` : b);
|
|
698
700
|
const takeReasoning = (m) => {
|
|
699
|
-
if (reasoning && m?.role === 'assistant') { m.reasoning = (m.reasoning || ''
|
|
701
|
+
if (reasoning && m?.role === 'assistant') { m.reasoning = joinLines(m.reasoning || '', reasoning); reasoning = ''; }
|
|
700
702
|
};
|
|
701
703
|
|
|
702
704
|
// Parallel tool calls arrive as consecutive items -> merge into 1 assistant turn,
|
|
@@ -715,7 +717,7 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
|
|
|
715
717
|
ir.messages.push({ role: 'user', content: input });
|
|
716
718
|
} else if (Array.isArray(input)) {
|
|
717
719
|
for (const item of input) {
|
|
718
|
-
if (typeof item === 'string') { pushText('user', item); continue; }
|
|
720
|
+
if (typeof item === 'string') { reasoning = ''; pushText('user', item); continue; }
|
|
719
721
|
if (!item || typeof item !== 'object') continue;
|
|
720
722
|
// EasyInputMessage ({role, content}) doesn't require `type`.
|
|
721
723
|
if (item.type === 'message' || (!item.type && item.role)) {
|
|
@@ -739,6 +741,7 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
|
|
|
739
741
|
continue;
|
|
740
742
|
}
|
|
741
743
|
const role = item.role === 'assistant' ? 'assistant' : 'user';
|
|
744
|
+
if (role === 'user') reasoning = '';
|
|
742
745
|
if (parts.every(p => p.type === 'text')) {
|
|
743
746
|
pushText(role, parts.map(p => p.text).join('\n'));
|
|
744
747
|
} else {
|
|
@@ -747,7 +750,7 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
|
|
|
747
750
|
if (role === 'assistant') takeReasoning(ir.messages[ir.messages.length - 1]);
|
|
748
751
|
} else if (item.type === 'reasoning') {
|
|
749
752
|
const texts = (Array.isArray(item.summary) ? item.summary : []).map(x => x?.text).filter(t => typeof t === 'string' && t);
|
|
750
|
-
reasoning
|
|
753
|
+
if (texts.length) reasoning = joinLines(reasoning, texts.join('\n'));
|
|
751
754
|
} else if (item.type === 'function_call') {
|
|
752
755
|
pushCall({ id: item.call_id || item.id || null, name: responsesToolName(item.namespace, item.name), args: parseArgs(item.arguments) });
|
|
753
756
|
} else if (item.type === 'custom_tool_call') {
|
|
@@ -1560,6 +1563,83 @@ function collapseNullableUnion(branches) {
|
|
|
1560
1563
|
// key keeps the type an object; the root of a tool with no arguments may stay empty.
|
|
1561
1564
|
const GEMINI_PLACEHOLDER_PROPERTIES = { _placeholder: { type: 'boolean', description: 'Unused. Leave unset.' } };
|
|
1562
1565
|
|
|
1566
|
+
// A node the padding produced: its only property is the placeholder. Matched by shape, not by
|
|
1567
|
+
// reference, because the schema is deep-copied again before it is sent.
|
|
1568
|
+
function isGeminiPadded(schema) {
|
|
1569
|
+
const props = schema?.properties;
|
|
1570
|
+
const p = props?._placeholder;
|
|
1571
|
+
return Boolean(p && Object.keys(props).length === 1 && p.type === GEMINI_PLACEHOLDER_PROPERTIES._placeholder.type
|
|
1572
|
+
&& p.description === GEMINI_PLACEHOLDER_PROPERTIES._placeholder.description);
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// Removes `_placeholder` from the arguments wherever the schema sent upstream was padded, and only there.
|
|
1576
|
+
export function stripGeminiPlaceholders(value, schema) {
|
|
1577
|
+
if (!value || typeof value !== 'object' || !schema || typeof schema !== 'object') return value;
|
|
1578
|
+
if (Array.isArray(value)) {
|
|
1579
|
+
if (!schema.items) return value;
|
|
1580
|
+
const out = value.map(v => stripGeminiPlaceholders(v, schema.items));
|
|
1581
|
+
return out.some((v, i) => v !== value[i]) ? out : value;
|
|
1582
|
+
}
|
|
1583
|
+
if (isGeminiPadded(schema)) {
|
|
1584
|
+
if (!Object.hasOwn(value, '_placeholder')) return value;
|
|
1585
|
+
const { _placeholder, ...rest } = value;
|
|
1586
|
+
return rest;
|
|
1587
|
+
}
|
|
1588
|
+
let out = value;
|
|
1589
|
+
const props = schema.properties && typeof schema.properties === 'object' ? schema.properties : {};
|
|
1590
|
+
for (const [k, v] of Object.entries(value)) {
|
|
1591
|
+
if (!Object.hasOwn(props, k)) continue;
|
|
1592
|
+
const next = stripGeminiPlaceholders(v, props[k]);
|
|
1593
|
+
if (next !== v) out = { ...out, [k]: next };
|
|
1594
|
+
}
|
|
1595
|
+
for (const branch of Array.isArray(schema.anyOf) ? schema.anyOf : []) out = stripGeminiPlaceholders(out, branch);
|
|
1596
|
+
return out;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
function hasGeminiPadding(schema) {
|
|
1600
|
+
if (!schema || typeof schema !== 'object') return false;
|
|
1601
|
+
if (isGeminiPadded(schema)) return true;
|
|
1602
|
+
return Object.values(schema.properties || {}).some(hasGeminiPadding) || hasGeminiPadding(schema.items)
|
|
1603
|
+
|| (Array.isArray(schema.anyOf) && schema.anyOf.some(hasGeminiPadding));
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// The upstream tool name -> the schema sent, for each tool whose schema was padded. Reads both the
|
|
1607
|
+
// Vertex and the chat body shape; an empty map means no argument needs the strip.
|
|
1608
|
+
export function geminiPaddedTools(upBody) {
|
|
1609
|
+
const padded = new Map();
|
|
1610
|
+
for (const t of Array.isArray(upBody?.tools) ? upBody.tools : []) {
|
|
1611
|
+
const decls = Array.isArray(t?.functionDeclarations) ? t.functionDeclarations : t?.function ? [t.function] : [];
|
|
1612
|
+
for (const d of decls) if (d?.name && hasGeminiPadding(d.parameters)) padded.set(d.name, d.parameters);
|
|
1613
|
+
}
|
|
1614
|
+
return padded;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
// The tool vocabulary, plus the strip for padded tools. A renderer holds the arguments of a tool
|
|
1618
|
+
// whose needsArgsRewrite is true until the JSON is whole, so the strip also works on a stream.
|
|
1619
|
+
export function withGeminiPlaceholderStrip(vocab, padded) {
|
|
1620
|
+
if (!padded?.size) return vocab;
|
|
1621
|
+
const schemaOf = (name) => padded.get(name) || padded.get(vocab.name(name))
|
|
1622
|
+
|| [...padded].find(([upstream]) => vocab.name(upstream) === name)?.[1];
|
|
1623
|
+
const args = (name, argsJson) => {
|
|
1624
|
+
const out = vocab.args(name, argsJson);
|
|
1625
|
+
const schema = schemaOf(name);
|
|
1626
|
+
if (!schema) return out;
|
|
1627
|
+
let parsed;
|
|
1628
|
+
try { parsed = JSON.parse(out || '{}'); } catch { return out; }
|
|
1629
|
+
const stripped = stripGeminiPlaceholders(parsed, schema);
|
|
1630
|
+
return stripped === parsed ? out : JSON.stringify(stripped);
|
|
1631
|
+
};
|
|
1632
|
+
return {
|
|
1633
|
+
...vocab,
|
|
1634
|
+
args,
|
|
1635
|
+
needsArgsRewrite: (name) => Boolean(schemaOf(name)) || Boolean(vocab.needsArgsRewrite?.(name)),
|
|
1636
|
+
repair(upstream, argsJson) {
|
|
1637
|
+
const fixedName = vocab.name(upstream);
|
|
1638
|
+
return { name: fixedName, args: args(upstream, argsJson), renamed: fixedName !== upstream };
|
|
1639
|
+
}
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1563
1643
|
function toGeminiSchema(schema, root, seen) {
|
|
1564
1644
|
const nested = Boolean(root);
|
|
1565
1645
|
const emptyProperties = () => (nested ? { ...GEMINI_PLACEHOLDER_PROPERTIES } : {});
|
|
@@ -1654,6 +1734,13 @@ function emitUpstreamBody(outFormat, ir, model, opts = {}) {
|
|
|
1654
1734
|
// thinkingMode 'off' applies to all upstreams: drop the client's thinking entirely.
|
|
1655
1735
|
let src = normThinkingMode(opts.thinkingMode) === 'off' ? { ...ir, thinking: { type: 'disabled' } } : ir;
|
|
1656
1736
|
if (isAntigravityModel(model) && src.system) src = { ...src, system: src.system.replace(BILLING_HEADER_RE, '') };
|
|
1737
|
+
// An upstream refuses a max tokens above the model's output limit. The Anthropic body always
|
|
1738
|
+
// carries one (4096 when the client sent none), so that default is clamped too.
|
|
1739
|
+
const limit = opts.maxOutputTokens;
|
|
1740
|
+
if (Number.isFinite(limit) && limit > 0) {
|
|
1741
|
+
const asked = typeof src.params?.maxTokens === 'number' ? src.params.maxTokens : (outFormat === 'anthropic' ? 4096 : null);
|
|
1742
|
+
if (asked != null && asked > limit) src = { ...src, params: { ...src.params, maxTokens: limit } };
|
|
1743
|
+
}
|
|
1657
1744
|
switch (outFormat) {
|
|
1658
1745
|
case 'anthropic': return irToAnthropicBody(src, model, opts);
|
|
1659
1746
|
case 'vertex': return irToVertexBody(src, model, opts);
|
|
@@ -1662,6 +1749,31 @@ function emitUpstreamBody(outFormat, ir, model, opts = {}) {
|
|
|
1662
1749
|
}
|
|
1663
1750
|
}
|
|
1664
1751
|
|
|
1752
|
+
// A text-only chat model refuses an image part with a 400. These are the refusals seen so far;
|
|
1753
|
+
// a looser match would turn an unrelated 400 into a silent retry without the image.
|
|
1754
|
+
export const IMAGE_OMITTED_TEXT = '[image omitted: this model does not accept images]';
|
|
1755
|
+
|
|
1756
|
+
export function refusesImageInput(text) {
|
|
1757
|
+
const t = String(text || '').toLowerCase();
|
|
1758
|
+
return t.includes('unknown variant `image_url`') || t.includes('image_url is only supported by certain models')
|
|
1759
|
+
|| t.includes('does not support image') || t.includes('image input is not supported')
|
|
1760
|
+
|| (t.includes('vision') && t.includes('not supported'));
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
// Replaces every image part of a chat body with IMAGE_OMITTED_TEXT, in place. Returns how many it replaced.
|
|
1764
|
+
export function omitChatImages(body) {
|
|
1765
|
+
let replaced = 0;
|
|
1766
|
+
for (const m of Array.isArray(body?.messages) ? body.messages : []) {
|
|
1767
|
+
if (!Array.isArray(m?.content)) continue;
|
|
1768
|
+
m.content = m.content.map(p => {
|
|
1769
|
+
if (p?.type !== 'image_url') return p;
|
|
1770
|
+
replaced++;
|
|
1771
|
+
return { type: 'text', text: IMAGE_OMITTED_TEXT };
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1774
|
+
return replaced;
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1665
1777
|
// ---------------- native Anthropic healer (direct passthrough) ----------------
|
|
1666
1778
|
// The anthropic -> anthropic branch skips IR (to preserve thinking signatures, cache_control, documents...),
|
|
1667
1779
|
// so it patches the Anthropic payload directly, touching only what the API will definitely reject:
|
|
@@ -1712,8 +1824,29 @@ function endsInUnsignedToolLoop(messages) {
|
|
|
1712
1824
|
function healAnthropicPayload(payload) {
|
|
1713
1825
|
const notes = [];
|
|
1714
1826
|
if (!payload || !Array.isArray(payload.messages)) return { payload, changed: false, notes };
|
|
1715
|
-
|
|
1716
|
-
|
|
1827
|
+
// Anthropic has no system role inside messages. Its text joins the next user turn (else the previous
|
|
1828
|
+
// one) after the other blocks, because a tool_result must stay first in its turn.
|
|
1829
|
+
const src = [];
|
|
1830
|
+
let pendingSystem = [];
|
|
1831
|
+
const withSystem = (m) => ({ ...m, content: [...toAnthropicBlocks(m.content), ...pendingSystem] });
|
|
1832
|
+
for (const m of payload.messages) {
|
|
1833
|
+
if (m?.role === 'system') {
|
|
1834
|
+
const text = toAnthropicBlocks(m.content).filter(b => b.type === 'text' && !isBlankText(b)).map(b => b.text).join('\n');
|
|
1835
|
+
if (text) pendingSystem.push({ type: 'text', text: `[system] ${text}` });
|
|
1836
|
+
continue;
|
|
1837
|
+
}
|
|
1838
|
+
if (!m || (m.role !== 'user' && m.role !== 'assistant')) continue;
|
|
1839
|
+
if (m.role === 'user' && pendingSystem.length) { src.push(withSystem(m)); pendingSystem = []; continue; }
|
|
1840
|
+
src.push(m);
|
|
1841
|
+
}
|
|
1842
|
+
if (pendingSystem.length) {
|
|
1843
|
+
const i = src.findLastIndex(m => m.role === 'user');
|
|
1844
|
+
if (i >= 0) src[i] = withSystem(src[i]);
|
|
1845
|
+
else src.unshift({ role: 'user', content: pendingSystem });
|
|
1846
|
+
}
|
|
1847
|
+
const hadSystem = payload.messages.some(m => m?.role === 'system');
|
|
1848
|
+
if (hadSystem) notes.push('folded system messages into user turns');
|
|
1849
|
+
let changed = hadSystem || src.length !== payload.messages.length;
|
|
1717
1850
|
|
|
1718
1851
|
let system = payload.system;
|
|
1719
1852
|
if (Array.isArray(system) && system.some(isBlankText)) {
|
|
@@ -2721,7 +2854,7 @@ function vertexUsage(prompt, completion, cached, reasoning) {
|
|
|
2721
2854
|
return u;
|
|
2722
2855
|
}
|
|
2723
2856
|
|
|
2724
|
-
function buildVertexMessage({ model, think, text, tools, finish, prompt, completion, cached, reasoning, sig, ids = false }) {
|
|
2857
|
+
function buildVertexMessage({ model, think, text, tools, finish, prompt, completion, cached, reasoning, sig, ids = false, vocab = null }) {
|
|
2725
2858
|
const parts = [];
|
|
2726
2859
|
const thinking = (think || []).join('');
|
|
2727
2860
|
if (thinking) {
|
|
@@ -2732,7 +2865,8 @@ function buildVertexMessage({ model, think, text, tools, finish, prompt, complet
|
|
|
2732
2865
|
const body = (text || []).join('');
|
|
2733
2866
|
if (body) parts.push({ text: body });
|
|
2734
2867
|
for (const tc of (tools || [])) {
|
|
2735
|
-
const
|
|
2868
|
+
const args = vocab && typeof tc.args === 'string' ? vocab.args(tc.name, tc.args) : tc.args;
|
|
2869
|
+
const part = { functionCall: { ...(ids && tc.id ? { id: tc.id } : {}), name: tc.name || 'tool', args: parseArgs(args) } };
|
|
2736
2870
|
const tsig = tc.sig || lookupToolSignature(tc.id);
|
|
2737
2871
|
if (tsig) part.thoughtSignature = tsig;
|
|
2738
2872
|
parts.push(part);
|
|
@@ -2746,7 +2880,7 @@ function buildVertexMessage({ model, think, text, tools, finish, prompt, complet
|
|
|
2746
2880
|
}
|
|
2747
2881
|
|
|
2748
2882
|
// ids: name each functionCall, as Code Assist does. Plain Vertex has no call id.
|
|
2749
|
-
function createVertexStream(emit, model, { ids = false } = {}) {
|
|
2883
|
+
function createVertexStream(emit, model, { ids = false, vocab = null } = {}) {
|
|
2750
2884
|
const cand = (parts) => emit(null, { candidates: [{ content: { role: 'model', parts }, index: 0 }], modelVersion: model });
|
|
2751
2885
|
// Tool args arrive from OpenAI/Anthropic as deltas; Vertex needs complete functionCalls -> buffer them, emit at the end.
|
|
2752
2886
|
const tools = new Map();
|
|
@@ -2773,7 +2907,8 @@ function createVertexStream(emit, model, { ids = false } = {}) {
|
|
|
2773
2907
|
const parts = [...tools.values()]
|
|
2774
2908
|
.filter(t => t.name)
|
|
2775
2909
|
.map(t => {
|
|
2776
|
-
const
|
|
2910
|
+
const args = vocab ? vocab.args(t.name, t.args || '{}') : (t.args || '{}');
|
|
2911
|
+
const part = { functionCall: { ...(ids && t.id ? { id: t.id } : {}), name: t.name, args: parseArgs(args) } };
|
|
2777
2912
|
const tsig = t.sig || lookupToolSignature(t.id);
|
|
2778
2913
|
if (tsig) part.thoughtSignature = tsig;
|
|
2779
2914
|
return part;
|
|
@@ -2798,10 +2933,10 @@ function codeAssistTrace() {
|
|
|
2798
2933
|
return Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
|
|
2799
2934
|
}
|
|
2800
2935
|
|
|
2801
|
-
function createCodeAssistStream(emit, model) {
|
|
2936
|
+
function createCodeAssistStream(emit, model, { vocab = null } = {}) {
|
|
2802
2937
|
const traceId = codeAssistTrace();
|
|
2803
2938
|
const responseId = `agy-${traceId}`;
|
|
2804
|
-
return createVertexStream((e, d) => emit(e, d?.error ? d : { response: { ...d, responseId }, traceId }), model, { ids: true });
|
|
2939
|
+
return createVertexStream((e, d) => emit(e, d?.error ? d : { response: { ...d, responseId }, traceId }), model, { ids: true, vocab });
|
|
2805
2940
|
}
|
|
2806
2941
|
|
|
2807
2942
|
function buildCodeAssistMessage(args) {
|
package/package.json
CHANGED
package/proxy.mjs
CHANGED
|
@@ -11,7 +11,8 @@ 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
|
-
|
|
14
|
+
geminiPaddedTools, withGeminiPlaceholderStrip,
|
|
15
|
+
toGeminiSchema, isAntigravityModel, refusesImageInput, omitChatImages,
|
|
15
16
|
createAnthropicStream, createChatStream, createResponsesStream, createVertexStream,
|
|
16
17
|
buildAnthropicMessage, buildChatMessage, buildResponsesMessage, buildVertexMessage,
|
|
17
18
|
createCodeAssistStream, buildCodeAssistMessage
|
|
@@ -26,7 +27,7 @@ import {
|
|
|
26
27
|
codexModelEntry,
|
|
27
28
|
contractLabSettings, STATE_DIR, stopRecordedBlindfold
|
|
28
29
|
} from './state.mjs';
|
|
29
|
-
import { parseModelWindows, resolveProfileWindows, readLocalCodexWindows } from './windows.mjs';
|
|
30
|
+
import { parseModelWindows, resolveProfileWindows, readLocalCodexWindows, windowsCacheKey, cachedProfileWindows } from './windows.mjs';
|
|
30
31
|
import { createToolVocab, NULL_TOOL_VOCAB, toolNameMapForIR } from './toolvocab.mjs';
|
|
31
32
|
import { readUpstreamPayloads } from './upstream-stream.mjs';
|
|
32
33
|
import { compactionResponse, emitCompaction, expandGatewayCompactions, collectCompaction } from './compaction.mjs';
|
|
@@ -386,10 +387,15 @@ function isContextOverflow(status, message) {
|
|
|
386
387
|
}
|
|
387
388
|
|
|
388
389
|
// Claude Code compacts on "prompt is too long: N tokens > M maximum", so the numbers lead when the upstream names them.
|
|
390
|
+
// A count may group its digits ("131,072", "131.072", "131 072", "131_072"); the group form is tried first.
|
|
391
|
+
const TOKEN_COUNT = String.raw`(\d{1,3}(?:[,. _]\d{3})+(?!\d)|\d+)`;
|
|
392
|
+
const MAX_TOKENS_RE = new RegExp(String.raw`(?:maximum context length|context window|limit) (?:is |of )?` + TOKEN_COUNT, 'i');
|
|
393
|
+
const ASKED_TOKENS_RE = new RegExp(String.raw`(?:requested|resulted in|prompt (?:is|has|contains)|input (?:is|has)) (?:about )?` + TOKEN_COUNT, 'i');
|
|
394
|
+
|
|
389
395
|
function promptTooLongMessage(message) {
|
|
390
396
|
if (/^prompt is too long/i.test(message)) return message;
|
|
391
|
-
const max = message.match(
|
|
392
|
-
const asked = message.match(
|
|
397
|
+
const max = message.match(MAX_TOKENS_RE)?.[1]?.replace(/\D/g, '');
|
|
398
|
+
const asked = message.match(ASKED_TOKENS_RE)?.[1]?.replace(/\D/g, '');
|
|
393
399
|
return max && asked ? `prompt is too long: ${asked} tokens > ${max} maximum (${message})` : `prompt is too long: ${message}`;
|
|
394
400
|
}
|
|
395
401
|
|
|
@@ -494,15 +500,18 @@ function upstreamEndpoint(profile, outFormat, model, stream, req) {
|
|
|
494
500
|
// declared none pays nothing and a turn where the model agreed with the list changes nothing.
|
|
495
501
|
function toolVocabFor(ir, outFormat) {
|
|
496
502
|
if (!ir || typeof ir !== 'object') return NULL_TOOL_VOCAB;
|
|
497
|
-
if (!ir._vocab)
|
|
503
|
+
if (!ir._vocab) {
|
|
504
|
+
const vocab = createToolVocab(ir.tools, { allowHyphens: outFormat !== 'vertex', names: [...toolNameMapForIR(ir).forward.keys()] });
|
|
505
|
+
ir._vocab = withGeminiPlaceholderStrip(vocab, ir._geminiPadded);
|
|
506
|
+
}
|
|
498
507
|
return ir._vocab;
|
|
499
508
|
}
|
|
500
509
|
|
|
501
510
|
function clientRenderer(clientFormat, res, model, opts = {}) {
|
|
502
511
|
if (clientFormat === 'anthropic') return createAnthropicStream((e, d) => sendSSE(res, e, d), model, opts);
|
|
503
512
|
if (clientFormat === 'responses') return createResponsesStream((e, d) => sendSSE(res, e, d), model, opts);
|
|
504
|
-
if (clientFormat === 'vertex') return createVertexStream((e, d) => sendSSE(res, null, d), model);
|
|
505
|
-
if (clientFormat === 'codeassist') return createCodeAssistStream((e, d) => sendSSE(res, null, d), model);
|
|
513
|
+
if (clientFormat === 'vertex') return createVertexStream((e, d) => sendSSE(res, null, d), model, opts);
|
|
514
|
+
if (clientFormat === 'codeassist') return createCodeAssistStream((e, d) => sendSSE(res, null, d), model, opts);
|
|
506
515
|
return createChatStream((e, d) => sendSSE(res, null, d), model, opts);
|
|
507
516
|
}
|
|
508
517
|
|
|
@@ -781,7 +790,7 @@ async function maybeCompactIdle(req, clientFormat, payload, ir, profile, profile
|
|
|
781
790
|
const summaryModel = mapModel(policy.model, profile, clientFormat);
|
|
782
791
|
const summaryFormat = resolveOutFormat(profile, summaryModel);
|
|
783
792
|
askSummary({ ir: prefixIR, policy, profile, model: summaryModel,
|
|
784
|
-
build: (prof, model, summaryIR) => buildUpstreamRequest(prof, summaryFormat, summaryIR, model, req),
|
|
793
|
+
build: (prof, model, summaryIR) => buildUpstreamRequest(prof, summaryFormat, summaryIR, model, req, null, profileKey),
|
|
785
794
|
onDiagnostic: ({ code, status }) => console.warn(`[llm-switcher] idle compact ${key.slice(0, 12)}: ${code}${status ? ` (${status})` : ''}`),
|
|
786
795
|
}).then(summary => {
|
|
787
796
|
if (!summary) return;
|
|
@@ -828,23 +837,48 @@ function resolveThinkingEffort(profile, slot, mappedModel) {
|
|
|
828
837
|
return profile?.thinkingEffort || 'auto';
|
|
829
838
|
}
|
|
830
839
|
|
|
831
|
-
|
|
840
|
+
// The output limit this profile's own model list names for this model, or 0 when no fresh cached
|
|
841
|
+
// list names one. The cache is filled by /v1/models; this only reads it.
|
|
842
|
+
function modelOutputLimit(profileKey, profile, mappedModel) {
|
|
843
|
+
if (!profile?.baseURL || !mappedModel) return 0;
|
|
844
|
+
const output = Number(cachedProfileWindows(STATE_DIR, windowsCacheKey(profileKey, profile.baseURL))[mappedModel]?.output);
|
|
845
|
+
return output > 0 ? output : 0;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot = null, profileKey = null) {
|
|
832
849
|
const effort = resolveThinkingEffort(profile, slot, mappedModel);
|
|
833
850
|
const upBody = emitUpstreamBody(outFormat, ir, mappedModel, {
|
|
834
851
|
thinkingMode: profile.thinkingMode,
|
|
835
852
|
thinkingEffort: effort,
|
|
836
853
|
replayReasoning: profile.replayReasoning === true,
|
|
854
|
+
maxOutputTokens: modelOutputLimit(profileKey, profile, mappedModel),
|
|
837
855
|
});
|
|
838
856
|
if (upBody?.tools && Array.isArray(upBody.tools)) {
|
|
839
857
|
upBody.tools = cleanSchemaDeep(upBody.tools);
|
|
840
858
|
if (outFormat === 'openai-chat' && isAntigravityModel(mappedModel)) {
|
|
841
859
|
upBody.tools = geminiSafeTools(upBody.tools);
|
|
842
860
|
}
|
|
861
|
+
// Read by toolVocabFor when the answer is rendered: the arguments lose the keys padding added.
|
|
862
|
+
if (outFormat === 'vertex' || (outFormat === 'openai-chat' && isAntigravityModel(mappedModel))) ir._geminiPadded = geminiPaddedTools(upBody);
|
|
843
863
|
}
|
|
844
864
|
const { url, headers } = upstreamEndpoint(profile, outFormat, mappedModel, ir.stream, req);
|
|
845
865
|
return { url, headers, upBody };
|
|
846
866
|
}
|
|
847
867
|
|
|
868
|
+
// Sends the upstream request. A chat 400 that says the model takes no images is retried once with
|
|
869
|
+
// every image replaced by text. The status arrives before any byte goes to the client, so the client
|
|
870
|
+
// sees only the retry's answer. Nothing is remembered: a false refusal costs one request, not the model.
|
|
871
|
+
async function fetchUpstream(url, headers, upBody, signal, outFormat, profileKey, mappedModel) {
|
|
872
|
+
const send = () => fetch(url, { method: 'POST', headers, body: JSON.stringify(upBody), signal });
|
|
873
|
+
const res = await send();
|
|
874
|
+
const hasImage = outFormat === 'openai-chat' && upBody.messages?.some(m => Array.isArray(m?.content) && m.content.some(p => p?.type === 'image_url'));
|
|
875
|
+
if (res.status !== 400 || !hasImage) return res;
|
|
876
|
+
const errText = await res.text().catch(() => '');
|
|
877
|
+
if (!refusesImageInput(errText)) return new Response(errText, { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
878
|
+
console.warn(`[${profileKey}] ${mappedModel} refused ${omitChatImages(upBody)} image(s); retrying this request as text`);
|
|
879
|
+
return send();
|
|
880
|
+
}
|
|
881
|
+
|
|
848
882
|
// Feeds upstream stream events to a client renderer until the stream ends, fails or is aborted.
|
|
849
883
|
// Returns the stream error, or null. `sink` is the client stream whose backpressure it waits for.
|
|
850
884
|
async function pumpStream(upstreamRes, { normalize, col, renderer, splitter, signal, sink, tag }) {
|
|
@@ -975,7 +1009,7 @@ async function executeGoalCheckGate({ req, res, clientFormat, payload, ir, profi
|
|
|
975
1009
|
try {
|
|
976
1010
|
const nativePromise = (async () => {
|
|
977
1011
|
try {
|
|
978
|
-
let url, headers, body;
|
|
1012
|
+
let url, headers, body, upBody = null;
|
|
979
1013
|
if (bifrost) {
|
|
980
1014
|
url = bifrostURL(profile, clientFormat, req, opts);
|
|
981
1015
|
headers = bifrostHeaders(req, profile);
|
|
@@ -993,16 +1027,18 @@ async function executeGoalCheckGate({ req, res, clientFormat, payload, ir, profi
|
|
|
993
1027
|
body = Buffer.from(JSON.stringify(json), 'utf8');
|
|
994
1028
|
} else {
|
|
995
1029
|
const slot = resolveSlot(requestedModel, mappedModel, profile, clientFormat);
|
|
996
|
-
const reqData = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot);
|
|
1030
|
+
const reqData = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot, profileKey);
|
|
997
1031
|
url = reqData.url;
|
|
998
1032
|
headers = reqData.headers;
|
|
999
|
-
|
|
1033
|
+
upBody = reqData.upBody;
|
|
1000
1034
|
}
|
|
1001
1035
|
|
|
1002
1036
|
const traceId = clientFormat === 'codeassist' ? null : (probeTraceId(req) || contractLab.traceFor(mappedModel));
|
|
1003
1037
|
if (traceId) headers['x-intact-trace'] = traceId;
|
|
1004
1038
|
|
|
1005
|
-
const upRes =
|
|
1039
|
+
const upRes = upBody
|
|
1040
|
+
? await fetchUpstream(url, headers, upBody, gateAC.signal, outFormat, profileKey, mappedModel)
|
|
1041
|
+
: await fetch(url, { method: 'POST', headers, body, signal: gateAC.signal });
|
|
1006
1042
|
if (!upRes.ok) {
|
|
1007
1043
|
const errText = await upRes.text().catch(() => '');
|
|
1008
1044
|
return { malformed: true, error: `Upstream HTTP ${upRes.status}: ${errText.slice(0, 150)}` };
|
|
@@ -1276,13 +1312,13 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
|
|
|
1276
1312
|
return log({ status: 400, error: 'unsupported compaction state' });
|
|
1277
1313
|
}
|
|
1278
1314
|
const slot = resolveSlot(requestedModel, mappedModel, profile, clientFormat);
|
|
1279
|
-
const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot);
|
|
1315
|
+
const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot, profileKey);
|
|
1280
1316
|
if (traceId) headers['x-intact-trace'] = traceId;
|
|
1281
1317
|
debugLog(`[${profileKey}] ${clientFormat} -> ${outFormat} ${url} ::`, JSON.stringify(upBody).slice(0, 500));
|
|
1282
1318
|
|
|
1283
1319
|
let upstreamRes;
|
|
1284
1320
|
try {
|
|
1285
|
-
upstreamRes = await
|
|
1321
|
+
upstreamRes = await fetchUpstream(url, headers, upBody, ac.signal, outFormat, profileKey, mappedModel);
|
|
1286
1322
|
} catch (fetchErr) {
|
|
1287
1323
|
if (ac.signal.aborted) return log({ status: 499, error: 'client disconnected' });
|
|
1288
1324
|
console.error(`[${profileKey}] Network error:`, fetchErr.message);
|
|
@@ -1936,7 +1972,7 @@ async function servedModels(req) {
|
|
|
1936
1972
|
// still gets the cached numbers, because an entry with the template window is better than none.
|
|
1937
1973
|
async function modelWindowsFor(profileKey, profile) {
|
|
1938
1974
|
if (!profile?.baseURL) return {};
|
|
1939
|
-
const listed = await resolveProfileWindows(STATE_DIR,
|
|
1975
|
+
const listed = await resolveProfileWindows(STATE_DIR, windowsCacheKey(profileKey, profile.baseURL), profile.baseURL, profile.apiKey);
|
|
1940
1976
|
if (Object.keys(listed).length) return listed;
|
|
1941
1977
|
return readLocalCodexWindows();
|
|
1942
1978
|
}
|
|
@@ -2359,7 +2395,7 @@ function sendWsFailed(socket, model, message, status = 500, tap = null, code = n
|
|
|
2359
2395
|
if (socket.writable) socket.write(encodeWsFrame(JSON.stringify(d)));
|
|
2360
2396
|
}, model || 'main');
|
|
2361
2397
|
renderer.start();
|
|
2362
|
-
renderer.error(message, code || responsesErrorCode(status));
|
|
2398
|
+
renderer.error(message, code || (isContextOverflow(status, message) ? 'context_length_exceeded' : responsesErrorCode(status)));
|
|
2363
2399
|
}
|
|
2364
2400
|
|
|
2365
2401
|
// Codex on WebSocket sends only the new items of a turn and names the turn before in
|
|
@@ -2547,13 +2583,13 @@ async function handleWsResponseCreate(socket, payload, req, ac, history = null)
|
|
|
2547
2583
|
let answered = false; // set only after a complete answer; the half upload depends on it
|
|
2548
2584
|
try {
|
|
2549
2585
|
const slot = resolveSlot(requestedModel, mappedModel, profile, clientFormat);
|
|
2550
|
-
const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot);
|
|
2586
|
+
const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot, profileKey);
|
|
2551
2587
|
if (traceId) headers['x-intact-trace'] = traceId;
|
|
2552
2588
|
debugLog(`[${profileKey}:ws] ${clientFormat} -> ${outFormat} ${url} ::`, JSON.stringify(upBody).slice(0, 300));
|
|
2553
2589
|
|
|
2554
2590
|
let upstreamRes;
|
|
2555
2591
|
try {
|
|
2556
|
-
upstreamRes = await
|
|
2592
|
+
upstreamRes = await fetchUpstream(url, headers, upBody, ac.signal, outFormat, profileKey, mappedModel);
|
|
2557
2593
|
} catch (fetchErr) {
|
|
2558
2594
|
if (ac.signal.aborted) return log({ status: 499, error: 'client disconnected' });
|
|
2559
2595
|
console.error(`[${profileKey}:ws] Network error:`, fetchErr.message);
|
package/scripts/run-tests.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Runs only tests/**/*.test.mjs. A bare `node --test` also collects test files from git-ignored
|
|
2
2
|
// folders (an old copy in temp/), and Node 18 and 20 do not expand a glob argument.
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { spawnSync } from 'node:child_process';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
@@ -17,5 +18,16 @@ const files = [];
|
|
|
17
18
|
|
|
18
19
|
// A version check must never reach the real npm registry.
|
|
19
20
|
const env = { ...process.env, LLM_SWITCHER_REGISTRY_URL: 'http://127.0.0.1:9/' };
|
|
21
|
+
// `npm test` itself, not a test, makes node-compile-cache in TMPDIR as it starts. Only a cache born
|
|
22
|
+
// in the seconds before this run is removed afterwards; one that was there before stays.
|
|
23
|
+
const compileCache = path.join(os.tmpdir(), 'node-compile-cache');
|
|
24
|
+
const launchedAt = Date.now() - process.uptime() * 1000;
|
|
25
|
+
let npmMadeCache = false;
|
|
26
|
+
try {
|
|
27
|
+
const born = fs.statSync(compileCache).birthtimeMs;
|
|
28
|
+
npmMadeCache = Boolean(process.env.npm_lifecycle_event) && born > 0 && born >= launchedAt - 10_000;
|
|
29
|
+
} catch { /* no cache: nothing to remove */ }
|
|
30
|
+
|
|
20
31
|
const r = spawnSync(process.execPath, ['--test', ...process.argv.slice(2), ...files], { cwd: root, stdio: 'inherit', env });
|
|
32
|
+
if (npmMadeCache) fs.rmSync(compileCache, { recursive: true, force: true });
|
|
21
33
|
process.exit(r.status ?? 1);
|
|
@@ -24,6 +24,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
24
24
|
|
|
25
25
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
26
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-task3-'));
|
|
27
|
+
test.after(() => fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }));
|
|
27
28
|
process.env.LLM_SWITCHER_HOME = TMP;
|
|
28
29
|
process.env.LLM_SWITCHER_CONFIG = path.join(TMP, 'config.json');
|
|
29
30
|
delete process.env.LLM_SWITCHER_BLINDFOLD_CERTS;
|
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');
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// `switch contract-check`: it turns open findings of intact into JSON test data, and never into
|
|
2
2
|
// JavaScript. Every path, enum value and name in a finding or a fixture is untrusted text, so the
|
|
3
3
|
// hostile-finding test hashes every .mjs file of the repository before and after a run.
|
|
4
|
-
import { test } from 'node:test';
|
|
4
|
+
import { test, after } from 'node:test';
|
|
5
5
|
import assert from 'node:assert/strict';
|
|
6
6
|
import http from 'node:http';
|
|
7
7
|
import crypto from 'node:crypto';
|
|
@@ -15,8 +15,13 @@ import { runCheck, loadExclusions, matchExclusion, FINDING_ID_RE } from '../cont
|
|
|
15
15
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
16
16
|
const HOSTILE = "x');require('child_process')";
|
|
17
17
|
|
|
18
|
+
// Every directory is removed once more after the file, so a failed test leaves none behind.
|
|
19
|
+
const made = [];
|
|
20
|
+
after(() => { for (const d of made) fs.rmSync(d, { recursive: true, force: true }); });
|
|
18
21
|
function tmpDir(name) {
|
|
19
|
-
|
|
22
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `lsw-${name}-`));
|
|
23
|
+
made.push(dir);
|
|
24
|
+
return dir;
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
// One fake intact: it serves the findings list and one fixture per known trace id.
|