llm-switcher 1.6.12 → 1.6.14
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 +19 -0
- package/README.md +2 -1
- package/README.vi.md +2 -1
- package/formats.mjs +63 -21
- package/package.json +1 -1
- package/proxy.mjs +34 -28
- package/scripts/run-tests.mjs +60 -24
- package/switch.mjs +38 -13
- package/tests/blindfold.test.mjs +1 -1
- package/tests/compaction.e2e.test.mjs +5 -1
- package/tests/contract-lab.test.mjs +2 -2
- package/tests/formats.test.mjs +191 -5
- package/tests/gateway.e2e.test.mjs +102 -1
- package/tests/goalcheck.e2e.test.mjs +44 -6
- package/tests/idlecompact.e2e.test.mjs +6 -1
- package/tests/lifecycle.test.mjs +5 -1
- package/tests/run-tests.test.mjs +50 -0
- package/tests/shim.test.mjs +16 -13
- package/tests/switch.test.mjs +46 -0
- package/tests/update.e2e.test.mjs +6 -1
- package/tests/windows.test.mjs +85 -3
- package/windows.mjs +35 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog — LLM Switcher
|
|
2
2
|
|
|
3
|
+
## Release 1.6.14 — 2026-10-12
|
|
4
|
+
|
|
5
|
+
- **System turn with an odd content shape.** On the translated route, a `system` turn whose `content` is not text and not a list is dropped. Before, the request failed with a `TypeError`.
|
|
6
|
+
|
|
7
|
+
## Release 1.6.13 — 2026-10-12
|
|
8
|
+
|
|
9
|
+
- **`switch on` with no profile.** `switch on` gives each tool the profile that it had before `switch off`. A tool that was off stays off. A tool that is on keeps its profile.
|
|
10
|
+
- **System turns on the translated route.** A `system` turn inside the `messages` of an Anthropic request now goes into the system prompt when the proxy translates the request.
|
|
11
|
+
- **System messages on the direct Anthropic route.** A folded `[system] ` text keeps its place between the turns around it. It never moves past an assistant turn. A system message with no text is dropped, and the healer note now says so. A system message after a final assistant prefill becomes the last user turn, so the request no longer ends on the prefill.
|
|
12
|
+
- **Gemini `_placeholder` in `anyOf`.** If one branch of an `anyOf` declares a real `_placeholder` property, the proxy keeps the key in the arguments.
|
|
13
|
+
- **Gemini `_placeholder` and tool names.** If two padded tools map back to one client tool name, the proxy removes nothing for that name.
|
|
14
|
+
- **Retry to another upstream format.** A request that the proxy builds again for another upstream format gets a new tool vocabulary and a new placeholder rule.
|
|
15
|
+
- **Reasoning before a compaction item.** In a Codex request, reasoning that waits for an assistant turn is dropped at a `compaction` item.
|
|
16
|
+
- **Model list cache time.** A cache entry with a time in the future is stale. It no longer stays fresh forever.
|
|
17
|
+
- **More token count forms.** A context overflow message with `128k`, `1m`, `131 072` (no-break space), `131'072` or `1,31,072` gives the full number to Claude Code. A decimal number after `limit` is not read as a token count. A plain `limit N` counts as the maximum only when `tokens` follows the number. A count of zero, or a count too large to be exact, is not read as a token count.
|
|
18
|
+
- **Output limit for every profile.** If a converted request finds no fresh model list for its profile, the proxy reads the list in the background. The request does not wait. The next request gets the limit. Each profile keeps its own limit, also on a shared base URL. The proxy reads `<baseURL>/models` with the API key of the profile, except for a profile with an `endpoints` override. That profile gets no output limit. The `/v1/models` list also does not send the key of that profile to `<baseURL>/models`.
|
|
19
|
+
- **Goal check when the evaluator call fails.** If the native evaluator call fails, the goal check does not let the goal through. A test now holds this behavior.
|
|
20
|
+
- **Test cleanup.** Every test file that makes a temp directory deletes it, also when a test fails. `npm test` deletes `node-compile-cache` only when npm made it for this run. A failed delete no longer changes the exit status of the tests.
|
|
21
|
+
|
|
3
22
|
## Release 1.6.12 — 2026-10-11
|
|
4
23
|
|
|
5
24
|
- **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.
|
package/README.md
CHANGED
|
@@ -519,7 +519,8 @@ switch status # Display status for all active CLI targets
|
|
|
519
519
|
switch version # Show the version and tell you when npm has a newer one
|
|
520
520
|
switch doctor # Audit environment, settings & routing
|
|
521
521
|
switch update # Install the newest release and restart the gateway on it
|
|
522
|
-
switch on
|
|
522
|
+
switch on # Start the gateway; each tool gets back the profile it had before `switch off`
|
|
523
|
+
switch on <profile> # Start the gateway and activate a profile
|
|
523
524
|
switch <profile> # Activate a profile for both tools
|
|
524
525
|
switch claude <profile> # Set the active profile for Claude Code only
|
|
525
526
|
switch codex <profile> # Set the active profile for Codex only
|
package/README.vi.md
CHANGED
|
@@ -516,7 +516,8 @@ switch status # Xem trạng thái kích hoạt của tất cả
|
|
|
516
516
|
switch version # Xem version đang chạy và báo khi npm có bản mới
|
|
517
517
|
switch doctor # Quét & thanh tra toàn bộ môi trường, settings và định tuyến
|
|
518
518
|
switch update # Cài bản mới nhất và khởi động lại gateway trên bản đó
|
|
519
|
-
switch on
|
|
519
|
+
switch on # Khởi động gateway; mỗi công cụ lấy lại profile nó có trước `switch off`
|
|
520
|
+
switch on <profile> # Khởi động gateway và kích hoạt một profile
|
|
520
521
|
switch <profile> # Kích hoạt một profile cho cả hai công cụ
|
|
521
522
|
switch claude <profile> # Đặt profile kích hoạt riêng cho Claude Code
|
|
522
523
|
switch codex <profile> # Đặt profile kích hoạt riêng cho Codex
|
package/formats.mjs
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
// }
|
|
25
25
|
// ============================================================
|
|
26
26
|
|
|
27
|
-
import { sanitizeToolName, toolNameMapForIR, rewriteToolNameMentions, rewriteToolSearchArgs } from './toolvocab.mjs';
|
|
27
|
+
import { sanitizeToolName, toolNameMapForIR, rewriteToolNameMentions, rewriteToolSearchArgs, createToolVocab, NULL_TOOL_VOCAB } from './toolvocab.mjs';
|
|
28
28
|
import { decodeCompaction, COMPACTION_INSTRUCTION } from './compaction.mjs';
|
|
29
29
|
|
|
30
30
|
export const OUT_FORMATS = ['openai-chat', 'anthropic', 'vertex'];
|
|
@@ -455,9 +455,22 @@ function anthropicToIR(payload) {
|
|
|
455
455
|
}
|
|
456
456
|
}
|
|
457
457
|
|
|
458
|
+
// Messages has no system role. Left in place, the emitters send such a turn as user text, so fold
|
|
459
|
+
// it into the system prompt, as responsesToIR and the Chat reader already do.
|
|
460
|
+
const foldSystemTurn = (msg) => {
|
|
461
|
+
if (msg.role !== 'system' && msg.role !== 'developer') return false;
|
|
462
|
+
const text =
|
|
463
|
+
typeof msg.content === 'string'
|
|
464
|
+
? msg.content
|
|
465
|
+
: Array.isArray(msg.content) ? msg.content.filter(p => p && p.type === 'text').map(p => p.text).join('\n') : '';
|
|
466
|
+
if (text.trim()) ir.system = ir.system ? `${ir.system}\n\n${text}` : text;
|
|
467
|
+
return true;
|
|
468
|
+
};
|
|
469
|
+
|
|
458
470
|
if (Array.isArray(payload.messages)) {
|
|
459
471
|
for (const msg of payload.messages) {
|
|
460
472
|
if (!msg) continue;
|
|
473
|
+
if (foldSystemTurn(msg)) continue;
|
|
461
474
|
if (typeof msg.content === 'string') {
|
|
462
475
|
ir.messages.push({ role: msg.role, content: msg.content });
|
|
463
476
|
continue;
|
|
@@ -693,8 +706,8 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
|
|
|
693
706
|
if (text) ir.system = ir.system ? `${ir.system}\n\n${text}` : text;
|
|
694
707
|
};
|
|
695
708
|
|
|
696
|
-
// A reasoning item precedes the assistant output it belongs to. A user message
|
|
697
|
-
// so reasoning still pending there came from an aborted turn and is dropped.
|
|
709
|
+
// A reasoning item precedes the assistant output it belongs to. A user message or a compaction item
|
|
710
|
+
// ends that turn, so reasoning still pending there came from an aborted turn and is dropped.
|
|
698
711
|
let reasoning = '';
|
|
699
712
|
const joinLines = (a, b) => (a ? `${a}\n${b}` : b);
|
|
700
713
|
const takeReasoning = (m) => {
|
|
@@ -763,6 +776,7 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
|
|
|
763
776
|
} else if (item.type === 'additional_tools' && Array.isArray(item.tools)) {
|
|
764
777
|
toolList.push(...item.tools);
|
|
765
778
|
} else if (item.type === 'compaction') {
|
|
779
|
+
reasoning = '';
|
|
766
780
|
if (allowOpaqueCompaction && !item.encrypted_content?.startsWith('llm-gateway-compact-v1:')) { ir.opaqueCompaction = true; continue; }
|
|
767
781
|
pushText('user', `Conversation summary from an earlier turn:\n${decodeCompaction(item.encrypted_content)}`);
|
|
768
782
|
} else if (item.type === 'compaction_trigger') {
|
|
@@ -1592,7 +1606,10 @@ export function stripGeminiPlaceholders(value, schema) {
|
|
|
1592
1606
|
const next = stripGeminiPlaceholders(v, props[k]);
|
|
1593
1607
|
if (next !== v) out = { ...out, [k]: next };
|
|
1594
1608
|
}
|
|
1595
|
-
|
|
1609
|
+
// A branch that declares a real `_placeholder` makes the key the client's own, so no padded branch strips it.
|
|
1610
|
+
const branches = Array.isArray(schema.anyOf) ? schema.anyOf : [];
|
|
1611
|
+
const declared = branches.some(b => !isGeminiPadded(b) && b?.properties && Object.hasOwn(b.properties, '_placeholder'));
|
|
1612
|
+
for (const branch of branches) if (!(declared && isGeminiPadded(branch))) out = stripGeminiPlaceholders(out, branch);
|
|
1596
1613
|
return out;
|
|
1597
1614
|
}
|
|
1598
1615
|
|
|
@@ -1618,8 +1635,14 @@ export function geminiPaddedTools(upBody) {
|
|
|
1618
1635
|
// whose needsArgsRewrite is true until the JSON is whole, so the strip also works on a stream.
|
|
1619
1636
|
export function withGeminiPlaceholderStrip(vocab, padded) {
|
|
1620
1637
|
if (!padded?.size) return vocab;
|
|
1621
|
-
|
|
1622
|
-
|
|
1638
|
+
// By client name too. Two padded tools that one client name maps back to make that name
|
|
1639
|
+
// ambiguous, so it gets no strip, as the vocabulary refuses a tie.
|
|
1640
|
+
const byClient = new Map();
|
|
1641
|
+
for (const [upstream, schema] of padded) {
|
|
1642
|
+
const client = vocab.name(upstream);
|
|
1643
|
+
byClient.set(client, byClient.has(client) ? null : schema);
|
|
1644
|
+
}
|
|
1645
|
+
const schemaOf = (name) => padded.get(name) || byClient.get(vocab.name(name)) || byClient.get(name);
|
|
1623
1646
|
const args = (name, argsJson) => {
|
|
1624
1647
|
const out = vocab.args(name, argsJson);
|
|
1625
1648
|
const schema = schemaOf(name);
|
|
@@ -1640,6 +1663,26 @@ export function withGeminiPlaceholderStrip(vocab, padded) {
|
|
|
1640
1663
|
};
|
|
1641
1664
|
}
|
|
1642
1665
|
|
|
1666
|
+
// The tool vocabulary the caller declared, so a model that answers with a name from its own
|
|
1667
|
+
// training is written back in the caller's words instead of ending the turn with "No such tool
|
|
1668
|
+
// available". Built once per request from the tools the caller actually sent, so a request that
|
|
1669
|
+
// declared none pays nothing and a turn where the model agreed with the list changes nothing.
|
|
1670
|
+
export function toolVocabForIR(ir, outFormat) {
|
|
1671
|
+
if (!ir || typeof ir !== 'object') return NULL_TOOL_VOCAB;
|
|
1672
|
+
if (ir._vocab?.outFormat !== outFormat) {
|
|
1673
|
+
const vocab = createToolVocab(ir.tools, { allowHyphens: outFormat !== 'vertex', names: [...toolNameMapForIR(ir).forward.keys()] });
|
|
1674
|
+
ir._vocab = { outFormat, vocab: withGeminiPlaceholderStrip(vocab, ir._geminiPadded) };
|
|
1675
|
+
}
|
|
1676
|
+
return ir._vocab.vocab;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
// Read by toolVocabForIR when the answer is rendered: the arguments lose the keys padding added.
|
|
1680
|
+
// Called on every emit, so a rebuild of the same IR never keeps the strip of an earlier body.
|
|
1681
|
+
export function recordGeminiPadding(ir, upBody, gemini) {
|
|
1682
|
+
ir._geminiPadded = gemini && Array.isArray(upBody?.tools) ? geminiPaddedTools(upBody) : null;
|
|
1683
|
+
ir._vocab = null;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1643
1686
|
function toGeminiSchema(schema, root, seen) {
|
|
1644
1687
|
const nested = Boolean(root);
|
|
1645
1688
|
const emptyProperties = () => (nested ? { ...GEMINI_PLACEHOLDER_PROPERTIES } : {});
|
|
@@ -1824,29 +1867,28 @@ function endsInUnsignedToolLoop(messages) {
|
|
|
1824
1867
|
function healAnthropicPayload(payload) {
|
|
1825
1868
|
const notes = [];
|
|
1826
1869
|
if (!payload || !Array.isArray(payload.messages)) return { payload, changed: false, notes };
|
|
1827
|
-
// Anthropic has no system role inside messages. Its text
|
|
1828
|
-
//
|
|
1870
|
+
// Anthropic has no system role inside messages. Its text becomes a user turn in the same place, so it
|
|
1871
|
+
// never moves past an assistant turn; Step 0 merges it into a user turn beside it, Steps 1-3 keep a tool_result first.
|
|
1829
1872
|
const src = [];
|
|
1830
|
-
let
|
|
1831
|
-
|
|
1873
|
+
let folded = false;
|
|
1874
|
+
let droppedSystem = false;
|
|
1832
1875
|
for (const m of payload.messages) {
|
|
1833
1876
|
if (m?.role === 'system') {
|
|
1834
|
-
const
|
|
1835
|
-
|
|
1877
|
+
const blocks = toAnthropicBlocks(m.content);
|
|
1878
|
+
const kept = blocks.filter(b => b.type === 'text' && !isBlankText(b));
|
|
1879
|
+
if (!kept.length || kept.length !== blocks.length) droppedSystem = true;
|
|
1880
|
+
if (kept.length) {
|
|
1881
|
+
src.push({ role: 'user', content: [{ type: 'text', text: `[system] ${kept.map(b => b.text).join('\n')}` }] });
|
|
1882
|
+
folded = true;
|
|
1883
|
+
}
|
|
1836
1884
|
continue;
|
|
1837
1885
|
}
|
|
1838
1886
|
if (!m || (m.role !== 'user' && m.role !== 'assistant')) continue;
|
|
1839
|
-
if (m.role === 'user' && pendingSystem.length) { src.push(withSystem(m)); pendingSystem = []; continue; }
|
|
1840
1887
|
src.push(m);
|
|
1841
1888
|
}
|
|
1842
|
-
if (
|
|
1843
|
-
|
|
1844
|
-
|
|
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;
|
|
1889
|
+
if (folded) notes.push('folded system messages into user turns');
|
|
1890
|
+
if (droppedSystem) notes.push('dropped system content with no text');
|
|
1891
|
+
let changed = folded || droppedSystem || src.length !== payload.messages.length;
|
|
1850
1892
|
|
|
1851
1893
|
let system = payload.system;
|
|
1852
1894
|
if (Array.isArray(system) && system.some(isBlankText)) {
|
package/package.json
CHANGED
package/proxy.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { createFrameReader } from './blindfold/wsframe.mjs';
|
|
|
11
11
|
import {
|
|
12
12
|
OUT_FORMATS, parseToIR, emitUpstreamBody, irToAnthropicBody, createUpstreamNormalizer, createCollector,
|
|
13
13
|
createThinkTagSplitter, splitThinkTags, healAnthropicPayload, estimateTokens, THINKING_MODES, VALID_EFFORTS,
|
|
14
|
-
|
|
14
|
+
toolVocabForIR, recordGeminiPadding,
|
|
15
15
|
toGeminiSchema, isAntigravityModel, refusesImageInput, omitChatImages,
|
|
16
16
|
createAnthropicStream, createChatStream, createResponsesStream, createVertexStream,
|
|
17
17
|
buildAnthropicMessage, buildChatMessage, buildResponsesMessage, buildVertexMessage,
|
|
@@ -27,8 +27,7 @@ import {
|
|
|
27
27
|
codexModelEntry,
|
|
28
28
|
contractLabSettings, STATE_DIR, stopRecordedBlindfold
|
|
29
29
|
} from './state.mjs';
|
|
30
|
-
import { parseModelWindows, resolveProfileWindows, readLocalCodexWindows, windowsCacheKey, cachedProfileWindows } from './windows.mjs';
|
|
31
|
-
import { createToolVocab, NULL_TOOL_VOCAB, toolNameMapForIR } from './toolvocab.mjs';
|
|
30
|
+
import { parseModelWindows, resolveProfileWindows, readLocalCodexWindows, windowsCacheKey, cachedProfileWindows, createWindowsRefresher } from './windows.mjs';
|
|
32
31
|
import { readUpstreamPayloads } from './upstream-stream.mjs';
|
|
33
32
|
import { compactionResponse, emitCompaction, expandGatewayCompactions, collectCompaction } from './compaction.mjs';
|
|
34
33
|
import { classifyCodexRole, classifyClaudeTier, syncLocalCatalog, refreshCatalog, checkVersionAndRefresh } from './catalog.mjs';
|
|
@@ -387,15 +386,26 @@ function isContextOverflow(status, message) {
|
|
|
387
386
|
}
|
|
388
387
|
|
|
389
388
|
// 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 (
|
|
391
|
-
|
|
392
|
-
|
|
389
|
+
// A count may group its digits (131,072 / 131.072 / 131 072 / 131'072 / 1,31,072, also with U+00A0 or U+202F) or
|
|
390
|
+
// carry a k or m suffix. A dot before exactly three digits reads as a group, so 3.141 reads as 3141: the cost of
|
|
391
|
+
// reading 131.072. Any other decimal part (0.125, 3.14) with no k or m after it is no token count.
|
|
392
|
+
const TOKEN_COUNT = String.raw`(\d+(?:\.\d+)?[km](?![a-z])|[1-9]\d?(?:,\d\d)+,\d{3}(?![.,]?\d)|[1-9]\d{0,2}(?:[,. _'\u00a0\u202f]\d{3})+(?![.,]?\d)|\d+(?![.,]?\d))`;
|
|
393
|
+
// A bare "limit" may be a rate or a time, so it counts only when "tokens" follows the number.
|
|
394
|
+
const MAX_TOKENS_RE = new RegExp(String.raw`(?:maximum context length|context window|tokens? limit) (?:is |of )?${TOKEN_COUNT}|limit (?:is |of )?${TOKEN_COUNT}(?= ?tokens?\b)`, 'i');
|
|
393
395
|
const ASKED_TOKENS_RE = new RegExp(String.raw`(?:requested|resulted in|prompt (?:is|has|contains)|input (?:is|has)) (?:about )?` + TOKEN_COUNT, 'i');
|
|
394
396
|
|
|
397
|
+
function tokenCount(text) {
|
|
398
|
+
if (!text) return '';
|
|
399
|
+
const short = text.match(/^(\d+(?:\.\d+)?)([km])$/i);
|
|
400
|
+
const n = short ? Math.round(Number(short[1]) * (short[2].toLowerCase() === 'k' ? 1e3 : 1e6)) : Number(text.replace(/\D/g, ''));
|
|
401
|
+
return Number.isSafeInteger(n) && n > 0 ? String(n) : '';
|
|
402
|
+
}
|
|
403
|
+
|
|
395
404
|
function promptTooLongMessage(message) {
|
|
396
405
|
if (/^prompt is too long/i.test(message)) return message;
|
|
397
|
-
const
|
|
398
|
-
const
|
|
406
|
+
const m = message.match(MAX_TOKENS_RE);
|
|
407
|
+
const max = tokenCount(m?.[1] ?? m?.[2]);
|
|
408
|
+
const asked = tokenCount(message.match(ASKED_TOKENS_RE)?.[1]);
|
|
399
409
|
return max && asked ? `prompt is too long: ${asked} tokens > ${max} maximum (${message})` : `prompt is too long: ${message}`;
|
|
400
410
|
}
|
|
401
411
|
|
|
@@ -494,19 +504,6 @@ function upstreamEndpoint(profile, outFormat, model, stream, req) {
|
|
|
494
504
|
return { url: ov['openai-chat'] || `${base}/chat/completions`, headers };
|
|
495
505
|
}
|
|
496
506
|
|
|
497
|
-
// The tool vocabulary the caller declared, so a model that answers with a name from its own
|
|
498
|
-
// training is written back in the caller's words instead of ending the turn with "No such tool
|
|
499
|
-
// available". Built once per request from the tools the caller actually sent, so a request that
|
|
500
|
-
// declared none pays nothing and a turn where the model agreed with the list changes nothing.
|
|
501
|
-
function toolVocabFor(ir, outFormat) {
|
|
502
|
-
if (!ir || typeof ir !== 'object') return NULL_TOOL_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
|
-
}
|
|
507
|
-
return ir._vocab;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
507
|
function clientRenderer(clientFormat, res, model, opts = {}) {
|
|
511
508
|
if (clientFormat === 'anthropic') return createAnthropicStream((e, d) => sendSSE(res, e, d), model, opts);
|
|
512
509
|
if (clientFormat === 'responses') return createResponsesStream((e, d) => sendSSE(res, e, d), model, opts);
|
|
@@ -837,11 +834,20 @@ function resolveThinkingEffort(profile, slot, mappedModel) {
|
|
|
837
834
|
return profile?.thinkingEffort || 'auto';
|
|
838
835
|
}
|
|
839
836
|
|
|
837
|
+
const refreshProfileWindows = createWindowsRefresher(STATE_DIR, { debug: (...a) => debugLog(...a) });
|
|
838
|
+
|
|
839
|
+
// A profile with an endpoints override sends its requests past baseURL, so its key must not go to baseURL/models.
|
|
840
|
+
const routesThroughOverride = (profile) => Object.values(profile?.endpoints || {}).some(Boolean);
|
|
841
|
+
|
|
840
842
|
// The output limit this profile's own model list names for this model, or 0 when no fresh cached
|
|
841
|
-
// list names one.
|
|
843
|
+
// list names one. With no fresh entry the list is read in the background; this request never waits.
|
|
842
844
|
function modelOutputLimit(profileKey, profile, mappedModel) {
|
|
843
845
|
if (!profile?.baseURL || !mappedModel) return 0;
|
|
844
|
-
|
|
846
|
+
if (routesThroughOverride(profile)) return 0;
|
|
847
|
+
const key = windowsCacheKey(profileKey, profile.baseURL);
|
|
848
|
+
const models = cachedProfileWindows(STATE_DIR, key);
|
|
849
|
+
if (!Object.keys(models).length) refreshProfileWindows(key, profile.baseURL, profile.apiKey);
|
|
850
|
+
const output = Number(models[mappedModel]?.output);
|
|
845
851
|
return output > 0 ? output : 0;
|
|
846
852
|
}
|
|
847
853
|
|
|
@@ -858,9 +864,8 @@ function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot = n
|
|
|
858
864
|
if (outFormat === 'openai-chat' && isAntigravityModel(mappedModel)) {
|
|
859
865
|
upBody.tools = geminiSafeTools(upBody.tools);
|
|
860
866
|
}
|
|
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);
|
|
863
867
|
}
|
|
868
|
+
recordGeminiPadding(ir, upBody, outFormat === 'vertex' || (outFormat === 'openai-chat' && isAntigravityModel(mappedModel)));
|
|
864
869
|
const { url, headers } = upstreamEndpoint(profile, outFormat, mappedModel, ir.stream, req);
|
|
865
870
|
return { url, headers, upBody };
|
|
866
871
|
}
|
|
@@ -1382,7 +1387,7 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
|
|
|
1382
1387
|
const out = clientMessage(clientFormat, {
|
|
1383
1388
|
model: requestedModel || mappedModel, think, text: [split.text], tools, toolMeta: ir.toolMeta,
|
|
1384
1389
|
finish: col.finish, prompt: col.prompt, completion, cached: col.cached,
|
|
1385
|
-
reasoning: col.reasoning, sig: col.sig, vocab:
|
|
1390
|
+
reasoning: col.reasoning, sig: col.sig, vocab: toolVocabForIR(ir, outFormat)
|
|
1386
1391
|
});
|
|
1387
1392
|
sendJson(res, 200, out);
|
|
1388
1393
|
answered = true;
|
|
@@ -1399,7 +1404,7 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
|
|
|
1399
1404
|
'Connection': 'keep-alive',
|
|
1400
1405
|
'X-Accel-Buffering': 'no'
|
|
1401
1406
|
});
|
|
1402
|
-
const renderer = clientRenderer(clientFormat, res, requestedModel || mappedModel, { toolMeta: ir.toolMeta, vocab:
|
|
1407
|
+
const renderer = clientRenderer(clientFormat, res, requestedModel || mappedModel, { toolMeta: ir.toolMeta, vocab: toolVocabForIR(ir, outFormat) });
|
|
1403
1408
|
renderer.start();
|
|
1404
1409
|
const splitter = createThinkTagSplitter(t => renderer.think(t), t => renderer.text(t));
|
|
1405
1410
|
const streamError = await pumpStream(upstreamRes, { normalize, col, renderer, splitter, signal: ac.signal, sink: res, tag: profileKey });
|
|
@@ -1972,6 +1977,7 @@ async function servedModels(req) {
|
|
|
1972
1977
|
// still gets the cached numbers, because an entry with the template window is better than none.
|
|
1973
1978
|
async function modelWindowsFor(profileKey, profile) {
|
|
1974
1979
|
if (!profile?.baseURL) return {};
|
|
1980
|
+
if (routesThroughOverride(profile)) return readLocalCodexWindows();
|
|
1975
1981
|
const listed = await resolveProfileWindows(STATE_DIR, windowsCacheKey(profileKey, profile.baseURL), profile.baseURL, profile.apiKey);
|
|
1976
1982
|
if (Object.keys(listed).length) return listed;
|
|
1977
1983
|
return readLocalCodexWindows();
|
|
@@ -2629,7 +2635,7 @@ async function handleWsResponseCreate(socket, payload, req, ac, history = null)
|
|
|
2629
2635
|
if (socket.writable) {
|
|
2630
2636
|
socket.write(encodeWsFrame(JSON.stringify(d)));
|
|
2631
2637
|
}
|
|
2632
|
-
}, requestedModel || mappedModel, { toolMeta: ir.toolMeta, vocab:
|
|
2638
|
+
}, requestedModel || mappedModel, { toolMeta: ir.toolMeta, vocab: toolVocabForIR(ir, outFormat) });
|
|
2633
2639
|
|
|
2634
2640
|
renderer.start();
|
|
2635
2641
|
const splitter = createThinkTagSplitter(t => renderer.think(t), t => renderer.text(t));
|
package/scripts/run-tests.mjs
CHANGED
|
@@ -6,28 +6,64 @@ import path from 'node:path';
|
|
|
6
6
|
import { spawnSync } from 'node:child_process';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
(
|
|
12
|
-
for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
13
|
-
const p = path.join(dir, e.name);
|
|
14
|
-
if (e.isDirectory()) walk(p);
|
|
15
|
-
else if (e.name.endsWith('.test.mjs')) files.push(path.relative(root, p));
|
|
9
|
+
function holdsFile(dir) {
|
|
10
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
11
|
+
if (!e.isDirectory() || holdsFile(path.join(dir, e.name))) return true;
|
|
16
12
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// npm makes node-compile-cache in TMPDIR as it starts and writes files only as it exits, so an empty cache is taken
|
|
17
|
+
// as made by this run. Not exact: the empty cache of another npm run still in progress goes too, which costs that
|
|
18
|
+
// run a rebuild only. npm makes the directory before this script starts, so no earlier existence check can tell.
|
|
19
|
+
export function compileCacheIsNew(dir, env = process.env) {
|
|
20
|
+
if (!env.npm_lifecycle_event) return false;
|
|
21
|
+
try {
|
|
22
|
+
return !holdsFile(dir);
|
|
23
|
+
} catch {
|
|
24
|
+
return false; // missing or unreadable: nothing of ours to remove
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function removeQuietly(dir, rm = fs.rmSync) {
|
|
29
|
+
try {
|
|
30
|
+
rm(dir, { recursive: true, force: true });
|
|
31
|
+
} catch (err) {
|
|
32
|
+
console.error(`run-tests: could not remove ${dir}: ${err.message}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function main() {
|
|
37
|
+
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
38
|
+
const files = [];
|
|
39
|
+
(function walk(dir) {
|
|
40
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
41
|
+
const p = path.join(dir, e.name);
|
|
42
|
+
if (e.isDirectory()) walk(p);
|
|
43
|
+
else if (e.name.endsWith('.test.mjs')) files.push(path.relative(root, p));
|
|
44
|
+
}
|
|
45
|
+
})(path.join(root, 'tests'));
|
|
46
|
+
|
|
47
|
+
// A version check must never reach the real npm registry.
|
|
48
|
+
const env = { ...process.env, LLM_SWITCHER_REGISTRY_URL: 'http://127.0.0.1:9/' };
|
|
49
|
+
const compileCache = path.join(os.tmpdir(), 'node-compile-cache');
|
|
50
|
+
const npmMadeCache = compileCacheIsNew(compileCache);
|
|
51
|
+
|
|
52
|
+
const r = spawnSync(process.execPath, ['--test', ...process.argv.slice(2), ...files], { cwd: root, stdio: 'inherit', env });
|
|
53
|
+
if (npmMadeCache) removeQuietly(compileCache);
|
|
54
|
+
process.exit(r.status ?? 1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// node runs a symlinked script from its real path, so only real paths compare. A path that cannot be
|
|
58
|
+
// resolved is not this script: returning false keeps an import by the test from running the suite.
|
|
59
|
+
export function isMainModule(argv1, selfUrl = import.meta.url) {
|
|
60
|
+
if (!argv1) return false;
|
|
61
|
+
try {
|
|
62
|
+
return fs.realpathSync(path.resolve(argv1)) === fs.realpathSync(fileURLToPath(selfUrl));
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Imported by its test, run by `npm test`.
|
|
69
|
+
if (isMainModule(process.argv[1])) main();
|
package/switch.mjs
CHANGED
|
@@ -357,6 +357,7 @@ function printTargets(activeMap) {
|
|
|
357
357
|
// Applies the switch to a copy of `base`. Exits on an error; nothing is written here.
|
|
358
358
|
function planSwitch(base, key, cliTarget) {
|
|
359
359
|
const planned = structuredClone(base);
|
|
360
|
+
delete planned.resumeProfiles;
|
|
360
361
|
const err = cliTarget ? setTargetProfile(planned, cliTarget, key) : activateProfile(planned, key);
|
|
361
362
|
if (err) {
|
|
362
363
|
console.error(`[Error] ${show(err)}`);
|
|
@@ -365,6 +366,18 @@ function planSwitch(base, key, cliTarget) {
|
|
|
365
366
|
return planned;
|
|
366
367
|
}
|
|
367
368
|
|
|
369
|
+
// Bare `switch on`: the targets `switch off` turned off come back as they were. A target the user
|
|
370
|
+
// switched off stays off, and a target that is on keeps its profile.
|
|
371
|
+
function planResume(base) {
|
|
372
|
+
const planned = structuredClone(base);
|
|
373
|
+
const saved = planned.resumeProfiles;
|
|
374
|
+
delete planned.resumeProfiles;
|
|
375
|
+
if (saved && typeof saved === 'object' && !Object.values(getActiveMap(planned)).some(Boolean)) {
|
|
376
|
+
for (const t of TOOLS) if (saved[t]) setTargetProfile(planned, t, saved[t]);
|
|
377
|
+
}
|
|
378
|
+
return planned;
|
|
379
|
+
}
|
|
380
|
+
|
|
368
381
|
function refuseBlindfoldProblem(planned, port) {
|
|
369
382
|
const bf = computeLaunchState(planned, port).blindfold;
|
|
370
383
|
// A missing or outdated certificate is built here; only a set that cannot be built stops the switch.
|
|
@@ -386,21 +399,26 @@ async function turnOn(profileName, cliTarget) {
|
|
|
386
399
|
}
|
|
387
400
|
|
|
388
401
|
const port = getTargetPort();
|
|
389
|
-
const
|
|
390
|
-
const key = findProfileKey(config,
|
|
391
|
-
if (!key) {
|
|
392
|
-
console.error(`[Error] Profile "${show(
|
|
402
|
+
const resume = !profileName && !cliTarget;
|
|
403
|
+
const key = resume ? null : findProfileKey(config, profileName);
|
|
404
|
+
if (!resume && !key) {
|
|
405
|
+
console.error(`[Error] Profile "${show(profileName)}" not found in config.json!`);
|
|
393
406
|
console.error(`Available profiles: ${show(Object.keys(config.profiles).join(', ')) || '(none)'}`);
|
|
394
407
|
process.exit(1);
|
|
395
408
|
}
|
|
396
409
|
|
|
397
410
|
// Plan on a copy. Nothing is written until every check below passes.
|
|
398
|
-
|
|
411
|
+
const plan = (base) => (resume ? planResume(base) : planSwitch(base, key, cliTarget));
|
|
412
|
+
let planned = plan(config);
|
|
399
413
|
// The example config routes to YOUR-ROUTER-HOST; activating it silently makes every request fail.
|
|
400
|
-
|
|
401
|
-
|
|
414
|
+
for (const k of new Set(resume ? Object.values(getActiveMap(planned)).filter(Boolean) : [key])) {
|
|
415
|
+
if (hasPlaceholder(planned.profiles[k])) {
|
|
416
|
+
console.warn(`[WARN] Profile "${show(k)}" still has a placeholder baseURL/apiKey. Edit ${configPath} first, or requests will fail.`);
|
|
417
|
+
}
|
|
402
418
|
}
|
|
403
|
-
console.log(
|
|
419
|
+
console.log(resume
|
|
420
|
+
? `Starting the gateway on port ${port} with the targets as they were...`
|
|
421
|
+
: `Activating profile: [${show(planned.profiles[key].name || key)}] (${show(key)})${cliTarget ? ` for ${cliTarget}` : ''} on port ${port}...`);
|
|
404
422
|
|
|
405
423
|
const gatewayState = await probeGateway(port);
|
|
406
424
|
if (isHeld(gatewayState)) refuseForeignPort(port, 'this switcher', gatewayState);
|
|
@@ -417,9 +435,8 @@ async function turnOn(profileName, cliTarget) {
|
|
|
417
435
|
console.error(`[Error] config.json does not parse any more: ${getLastLoadError()?.message}. Nothing was changed.`);
|
|
418
436
|
process.exit(1);
|
|
419
437
|
}
|
|
420
|
-
planned =
|
|
438
|
+
planned = plan(current);
|
|
421
439
|
refuseBlindfoldProblem(planned, port);
|
|
422
|
-
const profile = planned.profiles[key];
|
|
423
440
|
|
|
424
441
|
const previousBytes = fs.readFileSync(configPath);
|
|
425
442
|
try {
|
|
@@ -471,8 +488,14 @@ async function turnOn(profileName, cliTarget) {
|
|
|
471
488
|
console.warn(`[Shim] ${err.message}`);
|
|
472
489
|
}
|
|
473
490
|
|
|
474
|
-
|
|
475
|
-
|
|
491
|
+
if (resume) {
|
|
492
|
+
console.log('\n[SUCCESS] Gateway is on.');
|
|
493
|
+
if (!Object.values(getActiveMap(planned)).some(Boolean)) console.log('No target is on. Turn one on with: switch <profile>');
|
|
494
|
+
} else {
|
|
495
|
+
const profile = planned.profiles[key];
|
|
496
|
+
console.log(`\n[SUCCESS] Switched to profile "${show(profile.name || key)}".`);
|
|
497
|
+
printProfile(profile);
|
|
498
|
+
}
|
|
476
499
|
console.log('\nActive targets:');
|
|
477
500
|
printTargets(getActiveMap(planned));
|
|
478
501
|
const codexKey = getActiveMap(planned).codex;
|
|
@@ -578,6 +601,7 @@ async function turnOff(targetArg) {
|
|
|
578
601
|
console.log('\n[SUCCESS] Switched back to Claude Official Subscription. Run `switch on` to re-enable.');
|
|
579
602
|
return;
|
|
580
603
|
}
|
|
604
|
+
config.resumeProfiles = getActiveMap(config);
|
|
581
605
|
deactivateAll(config);
|
|
582
606
|
saveConfig(config);
|
|
583
607
|
reportSettings(clearLaunchState(port));
|
|
@@ -1337,7 +1361,8 @@ if (cmd === 'off' || cmd === 'stop') {
|
|
|
1337
1361
|
console.log(' switch version # Show the version and check for a newer one');
|
|
1338
1362
|
console.log(' switch doctor # Audit environment, settings & routing');
|
|
1339
1363
|
console.log(' switch update # Install the newest release and restart the gateway');
|
|
1340
|
-
console.log(' switch on
|
|
1364
|
+
console.log(' switch on # Start gateway; each target gets back what it had before `switch off`');
|
|
1365
|
+
console.log(' switch on <profile> # Start gateway & activate profile for all compatible targets');
|
|
1341
1366
|
console.log(' switch <profile> # Activate profile for all compatible targets');
|
|
1342
1367
|
console.log(' switch claude <profile> # Set active profile for Claude Code');
|
|
1343
1368
|
console.log(' switch codex <profile> # Set active profile for Codex');
|
package/tests/blindfold.test.mjs
CHANGED
|
@@ -207,7 +207,7 @@ test('make-certs.sh writes private files and refuses a directory it does not own
|
|
|
207
207
|
}
|
|
208
208
|
// A failed rebuild (here: no openssl on PATH) must leave the working set untouched.
|
|
209
209
|
const caBefore = fs.readFileSync(path.join(out, 'ca.pem'));
|
|
210
|
-
const bin = fs.mkdtempSync(path.join(
|
|
210
|
+
const bin = fs.mkdtempSync(path.join(base, 'bin-'));
|
|
211
211
|
for (const tool of ['mkdir', 'stat', 'id', 'chmod', 'rm', 'cat', 'dirname', 'mv', 'mktemp']) {
|
|
212
212
|
const real = execFileSync('bash', ['-c', `command -v ${tool}`], { encoding: 'utf8' }).trim();
|
|
213
213
|
fs.symlinkSync(real, path.join(bin, tool));
|
|
@@ -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';
|
|
@@ -9,9 +9,13 @@ import { fileURLToPath } from 'node:url';
|
|
|
9
9
|
import { openResponsesWs } from './ws-client.mjs';
|
|
10
10
|
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
11
11
|
const listen = server => new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
12
|
+
// Every temp directory goes once more after the file, so a setup that fails early leaves none behind.
|
|
13
|
+
const made = [];
|
|
14
|
+
after(() => { for (const d of made) fs.rmSync(d, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); });
|
|
12
15
|
async function until(check) { const deadline = Date.now() + 6000; while (Date.now() < deadline) { if (await check()) return; await new Promise(r => setTimeout(r, 20)); } throw new Error('timeout'); }
|
|
13
16
|
async function fixture(t) {
|
|
14
17
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-compaction-'));
|
|
18
|
+
made.push(dir);
|
|
15
19
|
const received = []; let finish = 'stop', truncate = false;
|
|
16
20
|
const up = http.createServer((req, res) => {
|
|
17
21
|
let raw = ''; req.on('data', b => raw += b); req.on('end', () => {
|
|
@@ -70,7 +70,7 @@ test('the tool version is read from the User-Agent, and a bad one is dropped', (
|
|
|
70
70
|
|
|
71
71
|
// ---------------------------------------------------------------- unit: config block
|
|
72
72
|
|
|
73
|
-
test('contractLab is absent by default, and a saved block survives a rewrite', () => {
|
|
73
|
+
test('contractLab is absent by default, and a saved block survives a rewrite', (t) => {
|
|
74
74
|
assert.deepEqual(contractLabSettings({}), { url: '', apiKey: '', enabled: false });
|
|
75
75
|
assert.deepEqual(contractLabSettings(null), { url: '', apiKey: '', enabled: false });
|
|
76
76
|
// Enabled needs a real http(s) URL and a key: a half-filled block stays off.
|
|
@@ -80,6 +80,7 @@ test('contractLab is absent by default, and a saved block survives a rewrite', (
|
|
|
80
80
|
assert.equal(contractLabSettings({ contractLab: { url: 'http://127.0.0.1:9/', apiKey: 'k', enabled: true } }).url, 'http://127.0.0.1:9');
|
|
81
81
|
|
|
82
82
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llm-switcher-lab-'));
|
|
83
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
83
84
|
const cfgPath = path.join(dir, 'config.json');
|
|
84
85
|
const cfg = { port: 3456, profiles: {}, contractLab: { url: 'http://127.0.0.1:20142', apiKey: 'sk-lab', enabled: true } };
|
|
85
86
|
fs.writeFileSync(cfgPath, JSON.stringify(cfg));
|
|
@@ -94,7 +95,6 @@ test('contractLab is absent by default, and a saved block survives a rewrite', (
|
|
|
94
95
|
// The dashboard must never read the intact key back out of /api/config.
|
|
95
96
|
assert.equal(out.masked.apiKey, MASKED_KEY);
|
|
96
97
|
assert.equal(out.masked.hasApiKey, true);
|
|
97
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
98
98
|
});
|
|
99
99
|
|
|
100
100
|
// state.mjs reads its config path once per process, so a config test runs in its own process.
|