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 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`. `auto` mode always sends the field, and `off` never sends it. The model name and the client request do not change this rule. |
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`. `auto` luôn gửi, `off` không bao giờ gửi. Tên model và request của client không đổi quy tắc này. |
867
+ | `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 || '') + reasoning; 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 += texts.join('\n');
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
- const src = payload.messages.filter(m => m && (m.role === 'user' || m.role === 'assistant'));
1748
- let changed = src.length !== payload.messages.length;
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 part = { functionCall: { ...(ids && tc.id ? { id: tc.id } : {}), name: tc.name || 'tool', args: parseArgs(tc.args) } };
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 part = { functionCall: { ...(ids && t.id ? { id: t.id } : {}), name: t.name, args: parseArgs(t.args || '{}') } };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.6.11",
3
+ "version": "1.6.12",
4
4
  "description": "Zero-dependency multi-protocol edge gateway & provider switcher for Claude Code and Codex, with OpenAI-compatible, Anthropic and Vertex upstreams",
5
5
  "keywords": [
6
6
  "llm",
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, readWindowsCache } 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(/(?:maximum context length|context window|limit) (?:is |of )?(\d+)/i)?.[1];
392
- const asked = message.match(/(?:requested|resulted in|prompt (?:is|has|contains)|input (?:is|has)) (?:about )?(\d+)/i)?.[1];
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) ir._vocab = createToolVocab(ir.tools, { allowHyphens: outFormat !== 'vertex', names: [...toolNameMapForIR(ir).forward.keys()] });
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 the profile's own model list names for this model, or 0 when no cached list
832
- // names one. The cache is filled by /v1/models; this only reads it.
833
- function modelOutputLimit(profile, mappedModel) {
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
- for (const entry of Object.values(readWindowsCache(STATE_DIR))) {
836
- const output = entry?.baseURL === profile.baseURL ? Number(entry.models?.[mappedModel]?.output) : 0;
837
- if (output > 0) return output;
838
- }
839
- return 0;
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
- body = JSON.stringify(reqData.upBody);
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 = await fetch(url, { method: 'POST', headers, body, signal: gateAC.signal });
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, `${profileKey || 'profile'}@${profile.baseURL}`, profile.baseURL, profile.apiKey);
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
 
@@ -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
- return fs.mkdtempSync(path.join(os.tmpdir(), `lsw-${name}-`));
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.
@@ -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 tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'llm-sw-datadir-'));
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();
@@ -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
+ });
@@ -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(os.tmpdir(), 'shimenv-'));
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(os.tmpdir(), 'shimargv-'));
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(os.tmpdir(), 'shimtest-'));
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(os.tmpdir(), 'shimempty-'));
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(os.tmpdir(), 'agyposix-'));
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 });
@@ -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
- const tmpState = () => fs.mkdtempSync(path.join(os.tmpdir(), 'lsw-version-'));
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);
@@ -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 models = await fetchProfileWindows(baseURL, apiKey);
88
- if (Object.keys(models).length) {
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
  }