llm-switcher 1.6.11 → 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 +12 -0
- package/README.md +1 -1
- package/README.vi.md +1 -1
- package/formats.mjs +115 -12
- package/package.json +1 -1
- package/proxy.mjs +35 -25
- package/scripts/run-tests.mjs +12 -0
- package/tests/blindfold.task3.test.mjs +1 -0
- package/tests/contract-check.test.mjs +7 -2
- package/tests/datadir.test.mjs +4 -2
- package/tests/formats.test.mjs +126 -1
- package/tests/gateway.e2e.test.mjs +129 -0
- package/tests/goalcheck.e2e.test.mjs +46 -2
- package/tests/shim.test.mjs +8 -5
- package/tests/version.test.mjs +5 -2
- package/tests/windows.test.mjs +63 -1
- package/windows.mjs +29 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
3
15
|
## Release 1.6.11 — 2026-10-11
|
|
4
16
|
|
|
5
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.
|
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 } : {});
|
|
@@ -1744,8 +1824,29 @@ function endsInUnsignedToolLoop(messages) {
|
|
|
1744
1824
|
function healAnthropicPayload(payload) {
|
|
1745
1825
|
const notes = [];
|
|
1746
1826
|
if (!payload || !Array.isArray(payload.messages)) return { payload, changed: false, notes };
|
|
1747
|
-
|
|
1748
|
-
|
|
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;
|
|
1749
1850
|
|
|
1750
1851
|
let system = payload.system;
|
|
1751
1852
|
if (Array.isArray(system) && system.some(isBlankText)) {
|
|
@@ -2753,7 +2854,7 @@ function vertexUsage(prompt, completion, cached, reasoning) {
|
|
|
2753
2854
|
return u;
|
|
2754
2855
|
}
|
|
2755
2856
|
|
|
2756
|
-
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 }) {
|
|
2757
2858
|
const parts = [];
|
|
2758
2859
|
const thinking = (think || []).join('');
|
|
2759
2860
|
if (thinking) {
|
|
@@ -2764,7 +2865,8 @@ function buildVertexMessage({ model, think, text, tools, finish, prompt, complet
|
|
|
2764
2865
|
const body = (text || []).join('');
|
|
2765
2866
|
if (body) parts.push({ text: body });
|
|
2766
2867
|
for (const tc of (tools || [])) {
|
|
2767
|
-
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) } };
|
|
2768
2870
|
const tsig = tc.sig || lookupToolSignature(tc.id);
|
|
2769
2871
|
if (tsig) part.thoughtSignature = tsig;
|
|
2770
2872
|
parts.push(part);
|
|
@@ -2778,7 +2880,7 @@ function buildVertexMessage({ model, think, text, tools, finish, prompt, complet
|
|
|
2778
2880
|
}
|
|
2779
2881
|
|
|
2780
2882
|
// ids: name each functionCall, as Code Assist does. Plain Vertex has no call id.
|
|
2781
|
-
function createVertexStream(emit, model, { ids = false } = {}) {
|
|
2883
|
+
function createVertexStream(emit, model, { ids = false, vocab = null } = {}) {
|
|
2782
2884
|
const cand = (parts) => emit(null, { candidates: [{ content: { role: 'model', parts }, index: 0 }], modelVersion: model });
|
|
2783
2885
|
// Tool args arrive from OpenAI/Anthropic as deltas; Vertex needs complete functionCalls -> buffer them, emit at the end.
|
|
2784
2886
|
const tools = new Map();
|
|
@@ -2805,7 +2907,8 @@ function createVertexStream(emit, model, { ids = false } = {}) {
|
|
|
2805
2907
|
const parts = [...tools.values()]
|
|
2806
2908
|
.filter(t => t.name)
|
|
2807
2909
|
.map(t => {
|
|
2808
|
-
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) } };
|
|
2809
2912
|
const tsig = t.sig || lookupToolSignature(t.id);
|
|
2810
2913
|
if (tsig) part.thoughtSignature = tsig;
|
|
2811
2914
|
return part;
|
|
@@ -2830,10 +2933,10 @@ function codeAssistTrace() {
|
|
|
2830
2933
|
return Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
|
|
2831
2934
|
}
|
|
2832
2935
|
|
|
2833
|
-
function createCodeAssistStream(emit, model) {
|
|
2936
|
+
function createCodeAssistStream(emit, model, { vocab = null } = {}) {
|
|
2834
2937
|
const traceId = codeAssistTrace();
|
|
2835
2938
|
const responseId = `agy-${traceId}`;
|
|
2836
|
-
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 });
|
|
2837
2940
|
}
|
|
2838
2941
|
|
|
2839
2942
|
function buildCodeAssistMessage(args) {
|
package/package.json
CHANGED
package/proxy.mjs
CHANGED
|
@@ -11,6 +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
|
+
geminiPaddedTools, withGeminiPlaceholderStrip,
|
|
14
15
|
toGeminiSchema, isAntigravityModel, refusesImageInput, omitChatImages,
|
|
15
16
|
createAnthropicStream, createChatStream, createResponsesStream, createVertexStream,
|
|
16
17
|
buildAnthropicMessage, buildChatMessage, buildResponsesMessage, buildVertexMessage,
|
|
@@ -26,7 +27,7 @@ import {
|
|
|
26
27
|
codexModelEntry,
|
|
27
28
|
contractLabSettings, STATE_DIR, stopRecordedBlindfold
|
|
28
29
|
} from './state.mjs';
|
|
29
|
-
import { parseModelWindows, resolveProfileWindows, readLocalCodexWindows,
|
|
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,30 +837,29 @@ function resolveThinkingEffort(profile, slot, mappedModel) {
|
|
|
828
837
|
return profile?.thinkingEffort || 'auto';
|
|
829
838
|
}
|
|
830
839
|
|
|
831
|
-
// The output limit
|
|
832
|
-
// names one. The cache is filled by /v1/models; this only reads it.
|
|
833
|
-
function modelOutputLimit(profile, mappedModel) {
|
|
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) {
|
|
834
843
|
if (!profile?.baseURL || !mappedModel) return 0;
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
if (output > 0) return output;
|
|
838
|
-
}
|
|
839
|
-
return 0;
|
|
844
|
+
const output = Number(cachedProfileWindows(STATE_DIR, windowsCacheKey(profileKey, profile.baseURL))[mappedModel]?.output);
|
|
845
|
+
return output > 0 ? output : 0;
|
|
840
846
|
}
|
|
841
847
|
|
|
842
|
-
function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot = null) {
|
|
848
|
+
function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot = null, profileKey = null) {
|
|
843
849
|
const effort = resolveThinkingEffort(profile, slot, mappedModel);
|
|
844
850
|
const upBody = emitUpstreamBody(outFormat, ir, mappedModel, {
|
|
845
851
|
thinkingMode: profile.thinkingMode,
|
|
846
852
|
thinkingEffort: effort,
|
|
847
853
|
replayReasoning: profile.replayReasoning === true,
|
|
848
|
-
maxOutputTokens: modelOutputLimit(profile, mappedModel),
|
|
854
|
+
maxOutputTokens: modelOutputLimit(profileKey, profile, mappedModel),
|
|
849
855
|
});
|
|
850
856
|
if (upBody?.tools && Array.isArray(upBody.tools)) {
|
|
851
857
|
upBody.tools = cleanSchemaDeep(upBody.tools);
|
|
852
858
|
if (outFormat === 'openai-chat' && isAntigravityModel(mappedModel)) {
|
|
853
859
|
upBody.tools = geminiSafeTools(upBody.tools);
|
|
854
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);
|
|
855
863
|
}
|
|
856
864
|
const { url, headers } = upstreamEndpoint(profile, outFormat, mappedModel, ir.stream, req);
|
|
857
865
|
return { url, headers, upBody };
|
|
@@ -1001,7 +1009,7 @@ async function executeGoalCheckGate({ req, res, clientFormat, payload, ir, profi
|
|
|
1001
1009
|
try {
|
|
1002
1010
|
const nativePromise = (async () => {
|
|
1003
1011
|
try {
|
|
1004
|
-
let url, headers, body;
|
|
1012
|
+
let url, headers, body, upBody = null;
|
|
1005
1013
|
if (bifrost) {
|
|
1006
1014
|
url = bifrostURL(profile, clientFormat, req, opts);
|
|
1007
1015
|
headers = bifrostHeaders(req, profile);
|
|
@@ -1019,16 +1027,18 @@ async function executeGoalCheckGate({ req, res, clientFormat, payload, ir, profi
|
|
|
1019
1027
|
body = Buffer.from(JSON.stringify(json), 'utf8');
|
|
1020
1028
|
} else {
|
|
1021
1029
|
const slot = resolveSlot(requestedModel, mappedModel, profile, clientFormat);
|
|
1022
|
-
const reqData = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot);
|
|
1030
|
+
const reqData = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot, profileKey);
|
|
1023
1031
|
url = reqData.url;
|
|
1024
1032
|
headers = reqData.headers;
|
|
1025
|
-
|
|
1033
|
+
upBody = reqData.upBody;
|
|
1026
1034
|
}
|
|
1027
1035
|
|
|
1028
1036
|
const traceId = clientFormat === 'codeassist' ? null : (probeTraceId(req) || contractLab.traceFor(mappedModel));
|
|
1029
1037
|
if (traceId) headers['x-intact-trace'] = traceId;
|
|
1030
1038
|
|
|
1031
|
-
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 });
|
|
1032
1042
|
if (!upRes.ok) {
|
|
1033
1043
|
const errText = await upRes.text().catch(() => '');
|
|
1034
1044
|
return { malformed: true, error: `Upstream HTTP ${upRes.status}: ${errText.slice(0, 150)}` };
|
|
@@ -1302,7 +1312,7 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
|
|
|
1302
1312
|
return log({ status: 400, error: 'unsupported compaction state' });
|
|
1303
1313
|
}
|
|
1304
1314
|
const slot = resolveSlot(requestedModel, mappedModel, profile, clientFormat);
|
|
1305
|
-
const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot);
|
|
1315
|
+
const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot, profileKey);
|
|
1306
1316
|
if (traceId) headers['x-intact-trace'] = traceId;
|
|
1307
1317
|
debugLog(`[${profileKey}] ${clientFormat} -> ${outFormat} ${url} ::`, JSON.stringify(upBody).slice(0, 500));
|
|
1308
1318
|
|
|
@@ -1962,7 +1972,7 @@ async function servedModels(req) {
|
|
|
1962
1972
|
// still gets the cached numbers, because an entry with the template window is better than none.
|
|
1963
1973
|
async function modelWindowsFor(profileKey, profile) {
|
|
1964
1974
|
if (!profile?.baseURL) return {};
|
|
1965
|
-
const listed = await resolveProfileWindows(STATE_DIR,
|
|
1975
|
+
const listed = await resolveProfileWindows(STATE_DIR, windowsCacheKey(profileKey, profile.baseURL), profile.baseURL, profile.apiKey);
|
|
1966
1976
|
if (Object.keys(listed).length) return listed;
|
|
1967
1977
|
return readLocalCodexWindows();
|
|
1968
1978
|
}
|
|
@@ -2385,7 +2395,7 @@ function sendWsFailed(socket, model, message, status = 500, tap = null, code = n
|
|
|
2385
2395
|
if (socket.writable) socket.write(encodeWsFrame(JSON.stringify(d)));
|
|
2386
2396
|
}, model || 'main');
|
|
2387
2397
|
renderer.start();
|
|
2388
|
-
renderer.error(message, code || responsesErrorCode(status));
|
|
2398
|
+
renderer.error(message, code || (isContextOverflow(status, message) ? 'context_length_exceeded' : responsesErrorCode(status)));
|
|
2389
2399
|
}
|
|
2390
2400
|
|
|
2391
2401
|
// Codex on WebSocket sends only the new items of a turn and names the turn before in
|
|
@@ -2573,7 +2583,7 @@ async function handleWsResponseCreate(socket, payload, req, ac, history = null)
|
|
|
2573
2583
|
let answered = false; // set only after a complete answer; the half upload depends on it
|
|
2574
2584
|
try {
|
|
2575
2585
|
const slot = resolveSlot(requestedModel, mappedModel, profile, clientFormat);
|
|
2576
|
-
const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot);
|
|
2586
|
+
const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot, profileKey);
|
|
2577
2587
|
if (traceId) headers['x-intact-trace'] = traceId;
|
|
2578
2588
|
debugLog(`[${profileKey}:ws] ${clientFormat} -> ${outFormat} ${url} ::`, JSON.stringify(upBody).slice(0, 300));
|
|
2579
2589
|
|
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;
|
|
@@ -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.
|
package/tests/datadir.test.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { test } from 'node:test';
|
|
1
|
+
import { test, after } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import os from 'node:os';
|
|
@@ -6,7 +6,9 @@ import path from 'node:path';
|
|
|
6
6
|
import { execFileSync } from 'node:child_process';
|
|
7
7
|
import { resolveDataDir } from '../state.mjs';
|
|
8
8
|
|
|
9
|
-
const
|
|
9
|
+
const made = [];
|
|
10
|
+
after(() => { for (const d of made) fs.rmSync(d, { recursive: true, force: true }); });
|
|
11
|
+
const tmp = () => { const d = fs.mkdtempSync(path.join(os.tmpdir(), 'llm-sw-datadir-')); made.push(d); return d; };
|
|
10
12
|
|
|
11
13
|
test('a git checkout keeps its data next to the code', () => {
|
|
12
14
|
const root = tmp();
|
package/tests/formats.test.mjs
CHANGED
|
@@ -13,8 +13,10 @@ import {
|
|
|
13
13
|
smartText, smartReasoning, sanitizeJsonSchema, normalizeUpstream, buildVertexMessage,
|
|
14
14
|
createCodeAssistStream,
|
|
15
15
|
budgetToEffort, effortToBudget, normalizeEffort, capEffort, mapEquivalentEffort, EFFORT_LEVELS, parseArgs, buildAnthropicMessage,
|
|
16
|
-
refusesImageInput, omitChatImages, IMAGE_OMITTED_TEXT
|
|
16
|
+
refusesImageInput, omitChatImages, IMAGE_OMITTED_TEXT,
|
|
17
|
+
stripGeminiPlaceholders, geminiPaddedTools, withGeminiPlaceholderStrip
|
|
17
18
|
} from '../formats.mjs';
|
|
19
|
+
import { NULL_TOOL_VOCAB, createToolVocab } from '../toolvocab.mjs';
|
|
18
20
|
import { assertValidAnthropicEvents } from './helpers.mjs';
|
|
19
21
|
|
|
20
22
|
test('anthropicToIR: stream defaults to false and thinking budget_tokens is preserved', () => {
|
|
@@ -1388,3 +1390,126 @@ test('healAnthropicPayload dedupes repeated tool_use ids', () => {
|
|
|
1388
1390
|
assert.deepEqual(msgs[2].content.map(b => [b.tool_use_id, b.content]), [[ids[0], 'a.txt']]);
|
|
1389
1391
|
assert.deepEqual(msgs[4].content.map(b => [b.tool_use_id, b.content]), [[ids[1], '/'], [ids[2], 'uid']]);
|
|
1390
1392
|
});
|
|
1393
|
+
|
|
1394
|
+
// Reasoning from an aborted turn must not attach to the assistant turn after the next user message.
|
|
1395
|
+
test('responsesToIR drops pending reasoning at a user message and joins reasoning pieces with a newline', () => {
|
|
1396
|
+
const ir = responsesToIR({ model: 'x', input: [
|
|
1397
|
+
{ type: 'message', role: 'user', content: 'plan it' },
|
|
1398
|
+
{ type: 'reasoning', summary: [{ type: 'summary_text', text: 'stale plan' }] },
|
|
1399
|
+
{ type: 'message', role: 'user', content: 'start over' },
|
|
1400
|
+
{ type: 'reasoning', summary: [{ type: 'summary_text', text: 'fresh plan' }] },
|
|
1401
|
+
{ type: 'reasoning', summary: [{ type: 'summary_text', text: 'second piece' }] },
|
|
1402
|
+
{ type: 'message', role: 'assistant', content: 'done' }
|
|
1403
|
+
] });
|
|
1404
|
+
const assistant = ir.messages.find(m => m.role === 'assistant');
|
|
1405
|
+
assert.equal(assistant.reasoning, 'fresh plan\nsecond piece');
|
|
1406
|
+
const viaString = responsesToIR({ model: 'x', input: [
|
|
1407
|
+
{ type: 'reasoning', summary: [{ type: 'summary_text', text: 'stale plan' }] },
|
|
1408
|
+
'start over',
|
|
1409
|
+
{ type: 'message', role: 'assistant', content: 'done' }
|
|
1410
|
+
] });
|
|
1411
|
+
assert.equal(viaString.messages.find(m => m.role === 'assistant').reasoning, undefined);
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
// The direct Anthropic route has no system role inside messages; dropping the message loses its instruction.
|
|
1415
|
+
test('healAnthropicPayload folds a mid-conversation system message into a user turn', () => {
|
|
1416
|
+
const roles = (msgs) => msgs.map(m => m.role);
|
|
1417
|
+
const texts = (msgs) => JSON.stringify(msgs);
|
|
1418
|
+
const alternates = (msgs) => msgs.every((m, i) => i === 0 || m.role !== msgs[i - 1].role);
|
|
1419
|
+
|
|
1420
|
+
const r = healAnthropicPayload({ model: 'x', max_tokens: 10, messages: [
|
|
1421
|
+
{ role: 'user', content: 'list files' },
|
|
1422
|
+
{ role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'ls' } }] },
|
|
1423
|
+
{ role: 'system', content: 'Use British spelling from now on.' },
|
|
1424
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'a.txt' }] },
|
|
1425
|
+
{ role: 'assistant', content: 'one file' },
|
|
1426
|
+
{ role: 'system', content: [{ type: 'text', text: 'Be brief.' }] }
|
|
1427
|
+
] });
|
|
1428
|
+
assert.equal(r.changed, true);
|
|
1429
|
+
const msgs = r.payload.messages;
|
|
1430
|
+
assert.ok(!msgs.some(m => m.role === 'system'), 'no system role remains');
|
|
1431
|
+
assert.ok(alternates(msgs), `roles alternate: ${roles(msgs)}`);
|
|
1432
|
+
assert.deepEqual(roles(msgs), ['user', 'assistant', 'user', 'assistant']);
|
|
1433
|
+
assert.deepEqual(msgs[2].content, [
|
|
1434
|
+
{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'a.txt' },
|
|
1435
|
+
{ type: 'text', text: '[system] Use British spelling from now on.' },
|
|
1436
|
+
{ type: 'text', text: '[system] Be brief.' }
|
|
1437
|
+
], 'a tool_result stays first; with no user turn after it, the last text joins the previous user turn');
|
|
1438
|
+
assert.ok(!texts(msgs[3]).includes('[system]'));
|
|
1439
|
+
|
|
1440
|
+
const only = healAnthropicPayload({ model: 'x', max_tokens: 10, messages: [
|
|
1441
|
+
{ role: 'system', content: 'Answer in French.' },
|
|
1442
|
+
{ role: 'assistant', content: 'Bonjour' }
|
|
1443
|
+
] }).payload.messages;
|
|
1444
|
+
assert.deepEqual(only, [{ role: 'user', content: [{ type: 'text', text: '[system] Answer in French.' }] }, { role: 'assistant', content: 'Bonjour' }]);
|
|
1445
|
+
});
|
|
1446
|
+
|
|
1447
|
+
// Vertex path: the schema is deep-copied before it is sent, and an Anthropic client streams the call.
|
|
1448
|
+
test('a Vertex tool padded for Gemini loses _placeholder in streamed arguments, and only where padded', () => {
|
|
1449
|
+
const ir = anthropicToIR({ model: 'x', max_tokens: 10, messages: [{ role: 'user', content: 'go' }], tools: [{ name: 'run', input_schema: {
|
|
1450
|
+
type: 'object', properties: { env: { type: 'object' }, list: { type: 'array', items: { type: 'object' } }, opts: { type: 'object', properties: { _placeholder: { type: 'string' } } } }
|
|
1451
|
+
} }, { name: 'plain', input_schema: { type: 'object', properties: { a: { type: 'string' } } } }] });
|
|
1452
|
+
const body = JSON.parse(JSON.stringify(irToVertexBody(ir, 'gemini-3-pro')));
|
|
1453
|
+
const padded = geminiPaddedTools(body);
|
|
1454
|
+
assert.deepEqual([...padded.keys()], ['run'], 'only the tool with a padded node');
|
|
1455
|
+
const args = { env: { A: '1', _placeholder: true }, list: [{ _placeholder: false, b: 2 }], opts: { _placeholder: 'keep' }, _placeholder: 1 };
|
|
1456
|
+
assert.deepEqual(stripGeminiPlaceholders(args, padded.get('run')), { env: { A: '1' }, list: [{ b: 2 }], opts: { _placeholder: 'keep' }, _placeholder: 1 });
|
|
1457
|
+
|
|
1458
|
+
const events = [];
|
|
1459
|
+
const r = createAnthropicStream((e, d) => events.push(d), 'm', { vocab: withGeminiPlaceholderStrip(NULL_TOOL_VOCAB, padded) });
|
|
1460
|
+
r.start();
|
|
1461
|
+
const json = JSON.stringify(args);
|
|
1462
|
+
r.tool({ index: 0, id: 'c1', name: 'run', args: json.slice(0, 12) });
|
|
1463
|
+
r.tool({ index: 0, args: json.slice(12) });
|
|
1464
|
+
r.finish('tool_calls');
|
|
1465
|
+
const streamed = events.filter(d => d.delta?.type === 'input_json_delta').map(d => d.delta.partial_json).join('');
|
|
1466
|
+
assert.deepEqual(JSON.parse(streamed), { env: { A: '1' }, list: [{ b: 2 }], opts: { _placeholder: 'keep' }, _placeholder: 1 });
|
|
1467
|
+
assert.equal(withGeminiPlaceholderStrip(NULL_TOOL_VOCAB, padded).args('plain', '{"_placeholder":true}'), '{"_placeholder":true}');
|
|
1468
|
+
assert.equal(withGeminiPlaceholderStrip(NULL_TOOL_VOCAB, new Map()), NULL_TOOL_VOCAB, 'no padding, no wrapper');
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
// A vertex client gets complete functionCalls, so the strip applies when the call is written out.
|
|
1472
|
+
test('a vertex client gets tool-call args without the padded _placeholder, stream and non-stream', () => {
|
|
1473
|
+
const declared = [{ name: 'run', parameters: { type: 'object', properties: { env: { type: 'object' }, opts: { type: 'object', properties: { _placeholder: { type: 'string' } } } } } }];
|
|
1474
|
+
const ir = anthropicToIR({ model: 'x', max_tokens: 10, messages: [{ role: 'user', content: 'go' }], tools: declared.map(t => ({ name: t.name, input_schema: t.parameters })) });
|
|
1475
|
+
const padded = geminiPaddedTools(JSON.parse(JSON.stringify(irToVertexBody(ir, 'gemini-3-pro'))));
|
|
1476
|
+
const vocab = withGeminiPlaceholderStrip(createToolVocab(ir.tools, { allowHyphens: false }), padded);
|
|
1477
|
+
const args = JSON.stringify({ env: { A: '1', _placeholder: true }, opts: { _placeholder: 'keep' } });
|
|
1478
|
+
const want = { env: { A: '1' }, opts: { _placeholder: 'keep' } };
|
|
1479
|
+
|
|
1480
|
+
const chunks = [];
|
|
1481
|
+
const s = createVertexStream((e, d) => chunks.push(d), 'm', { vocab });
|
|
1482
|
+
s.tool({ index: 0, id: 'c1', name: 'run', args: args.slice(0, 9) });
|
|
1483
|
+
s.tool({ index: 0, args: args.slice(9) });
|
|
1484
|
+
s.finish('tool_calls');
|
|
1485
|
+
const streamed = chunks.flatMap(c => c.candidates?.[0]?.content?.parts || []).find(p => p.functionCall);
|
|
1486
|
+
assert.deepEqual(streamed.functionCall.args, want);
|
|
1487
|
+
|
|
1488
|
+
const whole = buildVertexMessage({ model: 'm', tools: [{ index: 0, id: 'c1', name: 'run', args }], finish: 'tool_calls', vocab });
|
|
1489
|
+
assert.deepEqual(whole.candidates[0].content.parts.find(p => p.functionCall).functionCall.args, want);
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
// The Vertex body names a tool without hyphens, so the strip must find the schema by either name.
|
|
1493
|
+
test('the placeholder strip finds a hyphenated tool by its upstream name and by its client name', () => {
|
|
1494
|
+
const ir = anthropicToIR({ model: 'x', max_tokens: 10, messages: [{ role: 'user', content: 'go' }], tools: [{ name: 'my-tool', input_schema: { type: 'object', properties: { env: { type: 'object' } } } }] });
|
|
1495
|
+
const vocab = createToolVocab(ir.tools, { allowHyphens: false, names: ['my-tool'] });
|
|
1496
|
+
assert.equal(vocab.name('my_tool'), 'my-tool', 'fixture: the upstream name is the sanitized one');
|
|
1497
|
+
const padded = geminiPaddedTools(JSON.parse(JSON.stringify(irToVertexBody(ir, 'gemini-3-pro'))));
|
|
1498
|
+
assert.deepEqual([...padded.keys()], ['my_tool']);
|
|
1499
|
+
const strip = withGeminiPlaceholderStrip(vocab, padded);
|
|
1500
|
+
const args = '{"env":{"A":"1","_placeholder":true}}';
|
|
1501
|
+
assert.deepEqual(JSON.parse(strip.args('my_tool', args)), { env: { A: '1' } }, 'upstream name');
|
|
1502
|
+
assert.deepEqual(JSON.parse(strip.args('my-tool', args)), { env: { A: '1' } }, 'client name');
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
// A union with an object branch becomes anyOf for Gemini, and that object branch is padded too.
|
|
1506
|
+
test('the placeholder strip reaches a padded object inside an anyOf branch', () => {
|
|
1507
|
+
const ir = anthropicToIR({ model: 'x', max_tokens: 10, messages: [{ role: 'user', content: 'go' }], tools: [{ name: 'run', input_schema: {
|
|
1508
|
+
type: 'object', properties: { env: { oneOf: [{ type: 'object' }, { type: 'string' }] } }
|
|
1509
|
+
} }] });
|
|
1510
|
+
const padded = geminiPaddedTools(JSON.parse(JSON.stringify(irToVertexBody(ir, 'gemini-3-pro'))));
|
|
1511
|
+
const schema = padded.get('run');
|
|
1512
|
+
assert.ok(Array.isArray(schema?.properties?.env?.anyOf), 'fixture: env is an anyOf with a padded branch');
|
|
1513
|
+
assert.deepEqual(stripGeminiPlaceholders({ env: { A: '1', _placeholder: true } }, schema), { env: { A: '1' } });
|
|
1514
|
+
assert.deepEqual(stripGeminiPlaceholders({ env: 'plain' }, schema), { env: 'plain' });
|
|
1515
|
+
});
|
|
@@ -62,6 +62,18 @@ const sse = (res, objs, { raw = [] } = {}) => {
|
|
|
62
62
|
res.end();
|
|
63
63
|
};
|
|
64
64
|
|
|
65
|
+
// Upstream errors keyed by a MOCK_ERR:<key> marker in the last user message: [status, message].
|
|
66
|
+
const MOCK_ERRORS = {
|
|
67
|
+
comma: [400, "This model's maximum context length is 131,072 tokens. However, your messages resulted in 140,000 tokens."],
|
|
68
|
+
dot: [400, 'Input exceeds the context window of 131.072 tokens: requested 140.000 tokens'],
|
|
69
|
+
space: [400, 'maximum context length is 131 072 tokens, you requested 140 000 tokens'],
|
|
70
|
+
underscore: [400, 'maximum context length is 131_072 tokens, you requested 140_000 tokens'],
|
|
71
|
+
prefixed: [400, 'prompt is too long: 200000 tokens > 199999 maximum'],
|
|
72
|
+
nonumber: [400, 'The input exceeds the context window of this model.'],
|
|
73
|
+
rate: [429, 'Rate limit reached on context tokens per minute: maximum context length is 131,072'],
|
|
74
|
+
server: [500, "Internal error: this model's maximum context length is 131,072 tokens"]
|
|
75
|
+
};
|
|
76
|
+
|
|
65
77
|
function startUpstream() {
|
|
66
78
|
upstream = http.createServer((req, res) => {
|
|
67
79
|
let body = '';
|
|
@@ -123,10 +135,28 @@ function startUpstream() {
|
|
|
123
135
|
{ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 10, completion_tokens: 15 } }
|
|
124
136
|
], { raw: ['data: [DONE]\n\n'] });
|
|
125
137
|
}
|
|
138
|
+
if (lastUser.includes('PLACEHOLDER_ECHO')) {
|
|
139
|
+
const args = '{"cmd":"ls","env":{"A":"1","_placeholder":true},"opts":{"_placeholder":"keep"},"_placeholder":true}';
|
|
140
|
+
if (!json.stream) {
|
|
141
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
142
|
+
return res.end(JSON.stringify({ choices: [{ index: 0, message: { role: 'assistant', content: null, tool_calls: [{ id: 'call_ph', type: 'function', function: { name: 'run', arguments: args } }] }, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 5, completion_tokens: 5 } }));
|
|
143
|
+
}
|
|
144
|
+
return sse(res, [
|
|
145
|
+
{ choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: 'call_ph', type: 'function', function: { name: 'run', arguments: args.slice(0, 30) } }] } }] },
|
|
146
|
+
{ choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: args.slice(30) } }] } }] },
|
|
147
|
+
{ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 5, completion_tokens: 5 } }
|
|
148
|
+
], { raw: ['data: [DONE]\n\n'] });
|
|
149
|
+
}
|
|
126
150
|
if (lastUser.includes('RATE_LIMIT')) {
|
|
127
151
|
res.writeHead(429, { 'Content-Type': 'application/json', 'retry-after': '7' });
|
|
128
152
|
return res.end(JSON.stringify({ error: { message: 'slow down' } }));
|
|
129
153
|
}
|
|
154
|
+
const mockErr = lastUser.match(/MOCK_ERR:(\w+)/)?.[1];
|
|
155
|
+
if (mockErr && MOCK_ERRORS[mockErr]) {
|
|
156
|
+
const [status, message] = MOCK_ERRORS[mockErr];
|
|
157
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
158
|
+
return res.end(JSON.stringify({ error: { message, type: 'invalid_request_error' } }));
|
|
159
|
+
}
|
|
130
160
|
if (lastUser.includes('CONTEXT_OVERFLOW')) {
|
|
131
161
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
132
162
|
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' } }));
|
|
@@ -262,6 +292,9 @@ before(async () => {
|
|
|
262
292
|
intactcc: { name: 'Mock intact', mode: 'convert', tool: 'claude', outFormat: 'openai-chat', thinkingMode: 'off', baseURL: `${base}/intact/v1`, apiKey: 'sk-intact-user', defaultModels: { opus: 'claude/claude-opus-5', sonnet: 'gem/flash', haiku: 'claude/claude-haiku-4-5', fable: 'gem/flash' } },
|
|
263
293
|
textonly: { name: 'Mock Text Only', mode: 'convert', inFormat: 'auto', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-textonly', defaultModels: { opus: 'up-textonly', sonnet: 'up-badreq', haiku: 'up-haiku', fable: 'up-fable' } },
|
|
264
294
|
capped: { name: 'Mock Capped Output', mode: 'convert', inFormat: 'auto', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-capped', defaultModels: { opus: 'up-capped', sonnet: 'up-sonnet', haiku: 'up-haiku', fable: 'up-fable' } },
|
|
295
|
+
agyph: { name: 'Mock agy to AG via chat', mode: 'convert', tool: 'agy', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-agy', defaultModels: { main: 'ag/mock-flash' } },
|
|
296
|
+
cappedtwin: { name: 'Mock Capped Twin', mode: 'convert', inFormat: 'auto', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-capped', defaultModels: { opus: 'up-capped', sonnet: 'up-sonnet', haiku: 'up-haiku', fable: 'up-fable' } },
|
|
297
|
+
cappedold: { name: 'Mock Capped Old', mode: 'convert', inFormat: 'auto', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-capped', defaultModels: { opus: 'up-capped', sonnet: 'up-sonnet', haiku: 'up-haiku', fable: 'up-fable' } },
|
|
265
298
|
ant: { name: 'Mock Anthropic', mode: 'direct', inFormat: 'auto', outFormat: 'anthropic', baseURL: `${base}/ant`, apiKey: 'sk-secret-ant', defaultModels: models }
|
|
266
299
|
}
|
|
267
300
|
};
|
|
@@ -628,6 +661,72 @@ test('max_tokens above the listed output limit is clamped; a model with no liste
|
|
|
628
661
|
assert.equal(received.at(-1).body.max_tokens, 64000);
|
|
629
662
|
});
|
|
630
663
|
|
|
664
|
+
// The clamp trusts only the list this profile read within the hour, whatever other profile shares its baseURL.
|
|
665
|
+
test('max_tokens is clamped only from a fresh cache entry of the same profile', async () => {
|
|
666
|
+
const listed = await fetch(url('/v1/models'), { headers: { 'x-llm-profile': 'capped' } });
|
|
667
|
+
await listed.text();
|
|
668
|
+
const ask = async (profile) => {
|
|
669
|
+
const res = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 64000, messages: [{ role: 'user', content: 'hi' }] }, { 'x-llm-profile': profile });
|
|
670
|
+
assert.equal(res.status, 200, profile);
|
|
671
|
+
await res.text();
|
|
672
|
+
assert.equal(received.at(-1).body.model, 'up-capped', profile);
|
|
673
|
+
return received.at(-1).body.max_tokens;
|
|
674
|
+
};
|
|
675
|
+
assert.equal(await ask('cappedtwin'), 64000, 'another profile on the same baseURL has not read its list');
|
|
676
|
+
const cacheFile = path.join(tmpDir, 'model-windows.json');
|
|
677
|
+
const cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
|
678
|
+
const base = `http://127.0.0.1:${upstreamPort}/chat/v1`;
|
|
679
|
+
cache[`cappedold@${base}`] = { at: Date.now() - 2 * 3_600_000, baseURL: base, models: { 'up-capped': { context: 200000, compact: 0, output: 4096 } } };
|
|
680
|
+
fs.writeFileSync(cacheFile, JSON.stringify(cache));
|
|
681
|
+
assert.equal(await ask('cappedold'), 64000, 'an entry two hours old is not trusted');
|
|
682
|
+
assert.equal(await ask('capped'), 8192);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
// Gemini gets `_placeholder` on an empty nested object; the client never declared it, so it must not come back.
|
|
686
|
+
test('A _placeholder the gateway added for Gemini is stripped from tool-call arguments, stream and non-stream', async () => {
|
|
687
|
+
const tools = [{ type: 'function', name: 'run', parameters: { type: 'object', properties: {
|
|
688
|
+
cmd: { type: 'string' },
|
|
689
|
+
env: { type: 'object', additionalProperties: { type: 'string' } },
|
|
690
|
+
opts: { type: 'object', properties: { _placeholder: { type: 'string' } } }
|
|
691
|
+
} } }];
|
|
692
|
+
const want = { cmd: 'ls', env: { A: '1' }, opts: { _placeholder: 'keep' }, _placeholder: true };
|
|
693
|
+
|
|
694
|
+
const plain = await post('/v1/responses', { model: 'main', stream: false, tools, input: 'PLACEHOLDER_ECHO' }, { 'x-llm-profile': 'agmock' });
|
|
695
|
+
assert.equal(plain.status, 200);
|
|
696
|
+
const sent = received.at(-1).body.tools.find(t => t.function.name === 'run').function.parameters;
|
|
697
|
+
assert.deepEqual(Object.keys(sent.properties.env.properties), ['_placeholder'], 'the schema sent upstream was padded');
|
|
698
|
+
const call = (await plain.json()).output.find(o => o.type === 'function_call');
|
|
699
|
+
assert.deepEqual(JSON.parse(call.arguments), want);
|
|
700
|
+
|
|
701
|
+
const streamed = await post('/v1/responses', { model: 'main', stream: true, tools, input: 'PLACEHOLDER_ECHO' }, { 'x-llm-profile': 'agmock' });
|
|
702
|
+
const done = parseSSE(await streamed.text()).find(e => e.data?.type === 'response.completed');
|
|
703
|
+
assert.ok(done, 'the stream completes');
|
|
704
|
+
assert.deepEqual(JSON.parse(done.data.response.output.find(o => o.type === 'function_call').arguments), want);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test('A Code Assist (agy) client gets tool-call args without the padded _placeholder, stream and non-stream', async () => {
|
|
708
|
+
const envelope = { project: 'p', requestId: 'agent/s/1/t/1', model: 'gemini-3.8-flash-high', userAgent: 'antigravity', requestType: 'agent', request: {
|
|
709
|
+
contents: [{ role: 'user', parts: [{ text: 'PLACEHOLDER_ECHO' }] }],
|
|
710
|
+
tools: [{ functionDeclarations: [{ name: 'run', description: 'Run.', parameters: { type: 'OBJECT', properties: {
|
|
711
|
+
cmd: { type: 'STRING' }, env: { type: 'OBJECT' }, opts: { type: 'OBJECT', properties: { _placeholder: { type: 'STRING' } } }
|
|
712
|
+
} } }] }]
|
|
713
|
+
} };
|
|
714
|
+
const want = { cmd: 'ls', env: { A: '1' }, opts: { _placeholder: 'keep' }, _placeholder: true };
|
|
715
|
+
const headers = { 'content-type': 'application/json', 'x-llm-profile': 'agyph' };
|
|
716
|
+
const callOf = (chunks) => chunks.flatMap(c => c.response?.candidates?.[0]?.content?.parts || []).find(p => p.functionCall)?.functionCall;
|
|
717
|
+
|
|
718
|
+
const whole = await fetch(url('/v1internal:generateContent'), { method: 'POST', headers, body: JSON.stringify(envelope) });
|
|
719
|
+
assert.equal(whole.status, 200);
|
|
720
|
+
const sent = received.at(-1).body.tools.find(t => t.function.name === 'run').function.parameters;
|
|
721
|
+
assert.deepEqual(Object.keys(sent.properties.env.properties), ['_placeholder'], 'the schema sent upstream was padded');
|
|
722
|
+
assert.deepEqual(callOf([await whole.json()]).args, want);
|
|
723
|
+
|
|
724
|
+
const streamed = await fetch(url('/v1internal:streamGenerateContent?alt=sse'), { method: 'POST', headers, body: JSON.stringify(envelope) });
|
|
725
|
+
assert.equal(streamed.status, 200);
|
|
726
|
+
const chunks = parseSSE(await streamed.text()).map(e => e.data).filter(Boolean);
|
|
727
|
+
assert.deepEqual(callOf(chunks).args, want);
|
|
728
|
+
});
|
|
729
|
+
|
|
631
730
|
// A text-only model refuses an image that stays in the history, so every later turn would fail too.
|
|
632
731
|
test('An image refused by a text-only chat model is retried once as text in each request', async () => {
|
|
633
732
|
const image = { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgo=' } };
|
|
@@ -685,6 +784,36 @@ test('sendClientError maps context overflow per client', async () => {
|
|
|
685
784
|
assert.match(codexJson.error.message, /maximum context length is 131072/);
|
|
686
785
|
});
|
|
687
786
|
|
|
787
|
+
test('Context overflow numbers with thousands separators reach Claude Code whole', async () => {
|
|
788
|
+
const ask = async (key) => {
|
|
789
|
+
const res = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 10, messages: [{ role: 'user', content: `MOCK_ERR:${key}` }] });
|
|
790
|
+
return { status: res.status, error: (await res.json()).error };
|
|
791
|
+
};
|
|
792
|
+
for (const key of ['comma', 'dot', 'space', 'underscore']) {
|
|
793
|
+
const { status, error } = await ask(key);
|
|
794
|
+
assert.equal(status, 400, key);
|
|
795
|
+
assert.equal(error.type, 'invalid_request_error', key);
|
|
796
|
+
assert.equal(error.message, `prompt is too long: 140000 tokens > 131072 maximum (${MOCK_ERRORS[key][1]})`, key);
|
|
797
|
+
}
|
|
798
|
+
assert.deepEqual(await ask('prefixed'), { status: 400, error: { type: 'invalid_request_error', message: MOCK_ERRORS.prefixed[1] } });
|
|
799
|
+
assert.deepEqual(await ask('nonumber'), { status: 400, error: { type: 'invalid_request_error', message: `prompt is too long: ${MOCK_ERRORS.nonumber[1]}` } });
|
|
800
|
+
assert.deepEqual(await ask('rate'), { status: 429, error: { type: 'rate_limit_error', message: MOCK_ERRORS.rate[1] } });
|
|
801
|
+
assert.deepEqual(await ask('server'), { status: 500, error: { type: 'api_error', message: MOCK_ERRORS.server[1] } });
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
test('Codex WS: an upstream context overflow fails the turn with context_length_exceeded', async () => {
|
|
805
|
+
const ws = await rawWs();
|
|
806
|
+
ws.socket.write(clientFrame(1, JSON.stringify({ type: 'response.create', model: 'main', input: 'MOCK_ERR:comma over ws' })));
|
|
807
|
+
assert.ok(await until(() => ws.messages.some(m => m.type === 'response.failed')), 'no response.failed');
|
|
808
|
+
const failed = ws.messages.find(m => m.type === 'response.failed').response.error;
|
|
809
|
+
assert.equal(failed.code, 'context_length_exceeded');
|
|
810
|
+
assert.equal(failed.message, MOCK_ERRORS.comma[1]);
|
|
811
|
+
ws.socket.write(clientFrame(1, JSON.stringify({ type: 'response.create', model: 'main', input: 'MOCK_ERR:rate over ws' })));
|
|
812
|
+
assert.ok(await until(() => ws.messages.filter(m => m.type === 'response.failed').length === 2), 'no second response.failed');
|
|
813
|
+
assert.equal(ws.messages.filter(m => m.type === 'response.failed')[1].response.error.code, 'rate_limit_exceeded');
|
|
814
|
+
ws.socket.destroy();
|
|
815
|
+
});
|
|
816
|
+
|
|
688
817
|
test('Mid-stream upstream error surfaces as an error event instead of a fake end_turn', async () => {
|
|
689
818
|
const res = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 10, stream: true, messages: [{ role: 'user', content: 'MID_STREAM_ERROR' }] });
|
|
690
819
|
const events = parseSSE(await res.text());
|
|
@@ -27,6 +27,7 @@ async function fixture(t) {
|
|
|
27
27
|
let nativeVerdict = { ok: true, reason: 'looks complete' };
|
|
28
28
|
let jevScores = { complete: 0.14, evidence: 0.84, unfinished: 0.63 };
|
|
29
29
|
let jevStatus = 200;
|
|
30
|
+
const chatRequests = [];
|
|
30
31
|
|
|
31
32
|
// Mock native upstream (Anthropic format)
|
|
32
33
|
const nativeUp = http.createServer((req, res) => {
|
|
@@ -40,6 +41,16 @@ async function fixture(t) {
|
|
|
40
41
|
const body = JSON.parse(raw);
|
|
41
42
|
const isStream = Boolean(body.stream);
|
|
42
43
|
const respText = JSON.stringify(nativeVerdict);
|
|
44
|
+
// A text-only chat model: it refuses any image part.
|
|
45
|
+
if (req.url.endsWith('/chat/completions')) {
|
|
46
|
+
chatRequests.push(body);
|
|
47
|
+
res.setHeader('content-type', 'application/json');
|
|
48
|
+
if (body.messages.some(m => Array.isArray(m.content) && m.content.some(c => c?.type === 'image_url'))) {
|
|
49
|
+
res.statusCode = 400;
|
|
50
|
+
return res.end(JSON.stringify({ error: { message: 'Failed to deserialize the JSON body: messages[1]: unknown variant `image_url`, expected `text`' } }));
|
|
51
|
+
}
|
|
52
|
+
return res.end(JSON.stringify({ id: 'c1', object: 'chat.completion', model: body.model, choices: [{ index: 0, message: { role: 'assistant', content: respText }, finish_reason: 'stop' }] }));
|
|
53
|
+
}
|
|
43
54
|
|
|
44
55
|
if (isStream) {
|
|
45
56
|
res.setHeader('content-type', 'text/event-stream');
|
|
@@ -110,6 +121,16 @@ async function fixture(t) {
|
|
|
110
121
|
outFormat: 'anthropic',
|
|
111
122
|
baseURL: `http://127.0.0.1:${nativeUp.address().port}`,
|
|
112
123
|
apiKey: 'sk-native-key'
|
|
124
|
+
},
|
|
125
|
+
chat: {
|
|
126
|
+
name: 'ChatMock',
|
|
127
|
+
mode: 'convert',
|
|
128
|
+
tool: 'claude',
|
|
129
|
+
inFormat: 'auto',
|
|
130
|
+
outFormat: 'openai-chat',
|
|
131
|
+
baseURL: `http://127.0.0.1:${nativeUp.address().port}/v1`,
|
|
132
|
+
apiKey: 'sk-chat-key',
|
|
133
|
+
defaultModels: { opus: 'up-textonly', sonnet: 'up-textonly', haiku: 'up-textonly', fable: 'up-textonly' }
|
|
113
134
|
}
|
|
114
135
|
},
|
|
115
136
|
goalCheck: {
|
|
@@ -166,11 +187,12 @@ async function fixture(t) {
|
|
|
166
187
|
body: body ? JSON.stringify(body) : undefined
|
|
167
188
|
});
|
|
168
189
|
|
|
169
|
-
const sendClaude = (body, stream = false) => fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
190
|
+
const sendClaude = (body, stream = false, headers = {}) => fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
170
191
|
method: 'POST',
|
|
171
192
|
headers: {
|
|
172
193
|
'content-type': 'application/json',
|
|
173
|
-
'user-agent': 'claude-cli/2.1.295'
|
|
194
|
+
'user-agent': 'claude-cli/2.1.295',
|
|
195
|
+
...headers
|
|
174
196
|
},
|
|
175
197
|
body: JSON.stringify({ ...body, stream })
|
|
176
198
|
});
|
|
@@ -183,6 +205,7 @@ async function fixture(t) {
|
|
|
183
205
|
setJevScores: s => { jevScores = s; },
|
|
184
206
|
setJevStatus: st => { jevStatus = st; },
|
|
185
207
|
jevRequests,
|
|
208
|
+
chatRequests,
|
|
186
209
|
logs: () => logs
|
|
187
210
|
};
|
|
188
211
|
}
|
|
@@ -314,3 +337,24 @@ test('goalcheck e2e: live interception, veto, and allow behaviors', async t => {
|
|
|
314
337
|
assert.ok(logsData.logs.length >= 4);
|
|
315
338
|
assert.equal(logsData.logs[0].verdictType, 'Unavailable');
|
|
316
339
|
});
|
|
340
|
+
|
|
341
|
+
test('goalcheck e2e: an openai-chat evaluator call that a text-only model refuses for an image is retried as text', async t => {
|
|
342
|
+
const f = await fixture(t);
|
|
343
|
+
await f.sendAdmin('/api/goal-check', 'POST', { enabled: true });
|
|
344
|
+
f.setJevScores({ complete: 0.95, evidence: 0.90, unfinished: 0.05 });
|
|
345
|
+
f.setNativeVerdict({ ok: true, reason: 'screenshot shows green tests' });
|
|
346
|
+
const image = { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgo=' } };
|
|
347
|
+
const request = {
|
|
348
|
+
...evaluatorRequest,
|
|
349
|
+
messages: [
|
|
350
|
+
{ role: 'user', content: [image, { type: 'text', text: 'Please fix the failing tests in repo.' }] },
|
|
351
|
+
...evaluatorRequest.messages.slice(1)
|
|
352
|
+
]
|
|
353
|
+
};
|
|
354
|
+
const res = await f.sendClaude(request, false, { 'x-llm-profile': 'chat' });
|
|
355
|
+
assert.equal(res.status, 200);
|
|
356
|
+
const verdict = JSON.parse((await res.json()).content[0].text);
|
|
357
|
+
assert.deepEqual(verdict, { ok: true, reason: 'screenshot shows green tests' });
|
|
358
|
+
assert.equal(f.chatRequests.length, 2, 'one refusal, one retry');
|
|
359
|
+
assert.ok(JSON.stringify(f.chatRequests[1]).includes('[image omitted'), 'the retry carries text in place of the image');
|
|
360
|
+
});
|
package/tests/shim.test.mjs
CHANGED
|
@@ -34,7 +34,10 @@ if (process.platform !== 'win32') {
|
|
|
34
34
|
// Only node, never the directory it lives in: an npm install puts the real claude and codex next to it.
|
|
35
35
|
const NODE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'shimnode-'));
|
|
36
36
|
if (process.platform !== 'win32') fs.symlinkSync(process.execPath, path.join(NODE_DIR, 'node'));
|
|
37
|
+
// Per-test fake binaries live here, so one teardown removes them even when a test fails.
|
|
38
|
+
const SCRATCH = fs.mkdtempSync(path.join(os.tmpdir(), 'shimscratch-'));
|
|
37
39
|
test.after(() => {
|
|
40
|
+
fs.rmSync(SCRATCH, { recursive: true, force: true });
|
|
38
41
|
fs.rmSync(NODE_DIR, { recursive: true, force: true });
|
|
39
42
|
fs.rmSync(RENDER_DIR, { recursive: true, force: true });
|
|
40
43
|
fs.rmSync(STATE, { recursive: true, force: true });
|
|
@@ -61,7 +64,7 @@ function runShim(name, args, { active, fakeDir, extraPath = '', env = {}, files
|
|
|
61
64
|
// Prints every value the switcher could have touched, so a test can see both what was scrubbed
|
|
62
65
|
// and what must have been kept.
|
|
63
66
|
function makeEnvBin(name) {
|
|
64
|
-
const dir = fs.mkdtempSync(path.join(
|
|
67
|
+
const dir = fs.mkdtempSync(path.join(SCRATCH, 'shimenv-'));
|
|
65
68
|
fs.writeFileSync(path.join(dir, name),
|
|
66
69
|
'#!/usr/bin/env bash\n'
|
|
67
70
|
+ 'echo "HTTPS_PROXY=${HTTPS_PROXY:-NONE} BASE=${ANTHROPIC_BASE_URL:-NONE} OPENAI=${OPENAI_BASE_URL:-NONE} MODEL=${ANTHROPIC_MODEL:-NONE} DEFOPUS=${ANTHROPIC_DEFAULT_OPUS_MODEL:-NONE} CA=${NODE_EXTRA_CA_CERTS:-NONE}"\n'
|
|
@@ -73,14 +76,14 @@ function makeEnvBin(name) {
|
|
|
73
76
|
|
|
74
77
|
// A fake binary that prints every argument on its own line, so the test sees argument boundaries.
|
|
75
78
|
function makeArgvBin(name) {
|
|
76
|
-
const dir = fs.mkdtempSync(path.join(
|
|
79
|
+
const dir = fs.mkdtempSync(path.join(SCRATCH, 'shimargv-'));
|
|
77
80
|
fs.writeFileSync(path.join(dir, name), '#!/usr/bin/env bash\necho "HTTPS_PROXY=${HTTPS_PROXY:-NONE}"\nprintf \'%s\\n\' "$@"\n');
|
|
78
81
|
fs.chmodSync(path.join(dir, name), 0o755);
|
|
79
82
|
return dir;
|
|
80
83
|
}
|
|
81
84
|
|
|
82
85
|
function makeFakeBin(name) {
|
|
83
|
-
const dir = fs.mkdtempSync(path.join(
|
|
86
|
+
const dir = fs.mkdtempSync(path.join(SCRATCH, 'shimtest-'));
|
|
84
87
|
fs.writeFileSync(path.join(dir, name),
|
|
85
88
|
'#!/usr/bin/env bash\necho "BASE=${ANTHROPIC_BASE_URL:-NONE} ARGS=$*"\n');
|
|
86
89
|
fs.chmodSync(path.join(dir, name), 0o755);
|
|
@@ -118,7 +121,7 @@ test('shim never recurses into itself', (t) => {
|
|
|
118
121
|
|
|
119
122
|
test('shim fails loudly (127) when the real binary is missing', (t) => {
|
|
120
123
|
if (process.platform === 'win32') return t.skip('posix only');
|
|
121
|
-
const empty = fs.mkdtempSync(path.join(
|
|
124
|
+
const empty = fs.mkdtempSync(path.join(SCRATCH, 'shimempty-'));
|
|
122
125
|
assert.throws(
|
|
123
126
|
() => runShim('claude', [], { active: true, fakeDir: empty }),
|
|
124
127
|
(err) => err.status === 127,
|
|
@@ -583,7 +586,7 @@ for (const [kind, routed] of [['forged', false], ['silent', false], ['real', tru
|
|
|
583
586
|
const server = await agyListener(kind);
|
|
584
587
|
t.after(() => server.close());
|
|
585
588
|
const port = server.address().port;
|
|
586
|
-
const dir = fs.mkdtempSync(path.join(
|
|
589
|
+
const dir = fs.mkdtempSync(path.join(SCRATCH, 'agyposix-'));
|
|
587
590
|
fs.writeFileSync(path.join(dir, 'agy'), `#!/usr/bin/env bash\necho "CCU=[\${CLOUD_CODE_URL:-}]"\nnode "${AGY_REQUEST}"\n`);
|
|
588
591
|
fs.chmodSync(path.join(dir, 'agy'), 0o755);
|
|
589
592
|
for (const f of fs.readdirSync(STATE)) if (fs.statSync(path.join(STATE, f)).isFile()) fs.rmSync(path.join(STATE, f), { force: true });
|
package/tests/version.test.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The running version and the update notice. The registry is a local server, never npm.
|
|
2
|
-
import { test } from 'node:test';
|
|
2
|
+
import { test, after } from 'node:test';
|
|
3
3
|
import assert from 'node:assert/strict';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
@@ -15,7 +15,10 @@ async function registry(handler) {
|
|
|
15
15
|
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
|
16
16
|
return { url: `http://127.0.0.1:${server.address().port}/llm-switcher/latest`, hits: () => hits, close: () => server.close() };
|
|
17
17
|
}
|
|
18
|
-
|
|
18
|
+
// Every state dir is removed after the file, also the ones a failed test did not reach.
|
|
19
|
+
const made = [];
|
|
20
|
+
after(() => { for (const d of made) fs.rmSync(d, { recursive: true, force: true }); });
|
|
21
|
+
const tmpState = () => { const d = fs.mkdtempSync(path.join(os.tmpdir(), 'lsw-version-')); made.push(d); return d; };
|
|
19
22
|
|
|
20
23
|
test('the current version is the one in package.json', () => {
|
|
21
24
|
assert.equal(CURRENT_VERSION, pkg.version);
|
package/tests/windows.test.mjs
CHANGED
|
@@ -6,7 +6,8 @@ import path from 'node:path';
|
|
|
6
6
|
import http from 'node:http';
|
|
7
7
|
import {
|
|
8
8
|
parseModelWindows, parseModelList, fetchProfileWindows,
|
|
9
|
-
resolveProfileWindows, readWindowsCache, readLocalCodexWindows
|
|
9
|
+
resolveProfileWindows, readWindowsCache, readLocalCodexWindows,
|
|
10
|
+
windowsCacheKey, cachedProfileWindows
|
|
10
11
|
} from '../windows.mjs';
|
|
11
12
|
|
|
12
13
|
const tmpDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'lsw-windows-'));
|
|
@@ -152,3 +153,64 @@ test('the Codex CLI own cache is a fallback, not a source of truth', () => {
|
|
|
152
153
|
fs.rmSync(home, { recursive: true, force: true });
|
|
153
154
|
}
|
|
154
155
|
});
|
|
156
|
+
|
|
157
|
+
// A profile whose baseURL is spelled with another case or a trailing slash is the same address.
|
|
158
|
+
test('the cache key of a profile ignores the case of scheme and host and a trailing slash', () => {
|
|
159
|
+
assert.equal(windowsCacheKey('p', 'HTTP://Api.Example.COM:8080/v1/'), 'p@http://api.example.com:8080/v1');
|
|
160
|
+
assert.equal(windowsCacheKey('p', 'http://api.example.com:8080/v1'), 'p@http://api.example.com:8080/v1');
|
|
161
|
+
assert.equal(windowsCacheKey('p', 'https://api.example.com/V1//'), 'p@https://api.example.com/V1', 'the path keeps its case');
|
|
162
|
+
assert.equal(windowsCacheKey(null, 'https://h/v1'), 'profile@https://h/v1');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// The output clamp reads the cache without a refresh, so it trusts only what this profile read within the hour.
|
|
166
|
+
test('cachedProfileWindows answers only for its own key and only while the entry is fresh', () => {
|
|
167
|
+
const dir = tmpDir();
|
|
168
|
+
try {
|
|
169
|
+
const models = { m: { context: 200000, compact: 0, output: 8192 } };
|
|
170
|
+
fs.writeFileSync(path.join(dir, 'model-windows.json'), JSON.stringify({ 'a@http://h/v1': { at: 1_000, baseURL: 'http://h/v1', models } }));
|
|
171
|
+
assert.deepEqual(cachedProfileWindows(dir, 'a@http://h/v1', { now: 1_000 + 60_000 }), models);
|
|
172
|
+
assert.deepEqual(cachedProfileWindows(dir, 'b@http://h/v1', { now: 1_000 + 60_000 }), {}, 'another profile on the same baseURL');
|
|
173
|
+
assert.deepEqual(cachedProfileWindows(dir, 'a@http://h/v1', { now: 1_000 + 3_600_000 }), {}, 'an entry an hour old');
|
|
174
|
+
assert.deepEqual(cachedProfileWindows(dir, 'a@http://h/v1', { now: 2_000, ttlMs: 500 }), {});
|
|
175
|
+
} finally {
|
|
176
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// A list that names only an output limit must not erase the windows an earlier read found, and an
|
|
181
|
+
// output limit lives only as long as the newest list still states it.
|
|
182
|
+
test('a refresh merges each model into the cached windows, but never carries an output limit forward', async () => {
|
|
183
|
+
const dir = tmpDir();
|
|
184
|
+
const lists = [
|
|
185
|
+
[{ id: 'm', context_length: 200000, compact_window: 150000, max_output_tokens: 4096 }, { id: 'n', context_length: 100000, max_output_tokens: 2048 }],
|
|
186
|
+
[{ id: 'm', context_length: 300000 }, { id: 'o', context_length: 50000 }],
|
|
187
|
+
[{ id: 'm', max_output_tokens: 8192 }]
|
|
188
|
+
];
|
|
189
|
+
let hits = 0;
|
|
190
|
+
const sink = http.createServer((req, res) => {
|
|
191
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
192
|
+
res.end(JSON.stringify({ data: lists[hits++] }));
|
|
193
|
+
});
|
|
194
|
+
await new Promise(r => sink.listen(0, '127.0.0.1', r));
|
|
195
|
+
const base = `http://127.0.0.1:${sink.address().port}/v1`;
|
|
196
|
+
try {
|
|
197
|
+
await resolveProfileWindows(dir, 'p', base, 'k', { now: 1_000 });
|
|
198
|
+
const second = await resolveProfileWindows(dir, 'p', base, 'k', { now: 1_000 + 3_600_000 });
|
|
199
|
+
const expected = {
|
|
200
|
+
m: { context: 300000, compact: 150000 },
|
|
201
|
+
n: { context: 100000, compact: 0 },
|
|
202
|
+
o: { context: 50000, compact: 0 }
|
|
203
|
+
};
|
|
204
|
+
assert.deepEqual(second, expected, 'a changed window replaces the cached one; no output limit the list did not state');
|
|
205
|
+
assert.deepEqual(readWindowsCache(dir).p.models, expected);
|
|
206
|
+
const fresh = cachedProfileWindows(dir, 'p', { now: 1_000 + 3_600_000 + 60_000 });
|
|
207
|
+
assert.equal(fresh.m.output, undefined);
|
|
208
|
+
assert.equal(fresh.m.context, 300000);
|
|
209
|
+
const third = await resolveProfileWindows(dir, 'p', base, 'k', { now: 1_000 + 7_200_000 });
|
|
210
|
+
assert.equal(hits, 3);
|
|
211
|
+
assert.deepEqual(third.m, { context: 300000, compact: 150000, output: 8192 }, 'an output-only list keeps the windows');
|
|
212
|
+
} finally {
|
|
213
|
+
sink.close();
|
|
214
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
215
|
+
}
|
|
216
|
+
});
|
package/windows.mjs
CHANGED
|
@@ -84,8 +84,20 @@ export async function resolveProfileWindows(stateDir, cacheKey, baseURL, apiKey,
|
|
|
84
84
|
const entry = cache[cacheKey] || {};
|
|
85
85
|
const fresh = entry.at && now - entry.at < ttlMs;
|
|
86
86
|
if (fresh) return entry.models || {};
|
|
87
|
-
const
|
|
88
|
-
if (Object.keys(
|
|
87
|
+
const listed = await fetchProfileWindows(baseURL, apiKey);
|
|
88
|
+
if (Object.keys(listed).length) {
|
|
89
|
+
// Merged per model and per number: a list that names only an output limit keeps the cached windows.
|
|
90
|
+
// An output limit is never carried forward: the entry is stamped now, so it must be one this list states.
|
|
91
|
+
const models = {};
|
|
92
|
+
for (const [id, w] of Object.entries(entry.models || {})) {
|
|
93
|
+
const { output: _output, ...windows } = w && typeof w === 'object' ? w : {};
|
|
94
|
+
models[id] = windows;
|
|
95
|
+
}
|
|
96
|
+
for (const [id, w] of Object.entries(listed)) {
|
|
97
|
+
const merged = { ...(models[id] || {}) };
|
|
98
|
+
for (const [k, v] of Object.entries(w)) if (v > 0 || !(merged[k] > 0)) merged[k] = v;
|
|
99
|
+
models[id] = merged;
|
|
100
|
+
}
|
|
89
101
|
cache[cacheKey] = { at: now, baseURL, models };
|
|
90
102
|
writeWindowsCache(stateDir, cache);
|
|
91
103
|
return models;
|
|
@@ -93,6 +105,21 @@ export async function resolveProfileWindows(stateDir, cacheKey, baseURL, apiKey,
|
|
|
93
105
|
return entry.models || {};
|
|
94
106
|
}
|
|
95
107
|
|
|
108
|
+
// One address however it is spelled: scheme and host are case-blind, and a trailing slash adds nothing.
|
|
109
|
+
export function normalizeBaseURL(baseURL) {
|
|
110
|
+
return String(baseURL || '').trim().replace(/\/+$/, '').replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]*/i, m => m.toLowerCase());
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function windowsCacheKey(profileKey, baseURL) {
|
|
114
|
+
return `${profileKey || 'profile'}@${normalizeBaseURL(baseURL)}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The cached windows of one profile while they are fresh, without reading the list again.
|
|
118
|
+
export function cachedProfileWindows(stateDir, cacheKey, { ttlMs = DEFAULT_TTL_MS, now = Date.now() } = {}) {
|
|
119
|
+
const entry = readWindowsCache(stateDir)[cacheKey];
|
|
120
|
+
return entry?.at && now - entry.at < ttlMs ? entry.models || {} : {};
|
|
121
|
+
}
|
|
122
|
+
|
|
96
123
|
export function windowsCachePath(stateDir) {
|
|
97
124
|
return path.join(stateDir, 'model-windows.json');
|
|
98
125
|
}
|