llm-switcher 1.4.1 → 1.6.2
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 +97 -0
- package/README.md +48 -0
- package/README.vi.md +45 -0
- package/docs/session-2026-10-07-compaction.md +224 -0
- package/formats.mjs +85 -25
- package/idlecache.mjs +159 -0
- package/idlecall.mjs +59 -0
- package/idlecompact.mjs +217 -0
- package/package.json +1 -1
- package/proxy.mjs +160 -7
- package/shim.mjs +29 -0
- package/state.mjs +48 -2
- package/switch.mjs +61 -0
- package/tests/certs.test.mjs +7 -1
- package/tests/codex-daemon.test.mjs +57 -3
- package/tests/compact-cli.test.mjs +34 -0
- package/tests/dashboard.e2e.test.mjs +7 -1
- package/tests/formats.test.mjs +44 -1
- package/tests/idlecompact.e2e.test.mjs +203 -0
- package/tests/idlecompact.test.mjs +282 -0
- package/ui.html +481 -18
package/proxy.mjs
CHANGED
|
@@ -9,8 +9,8 @@ import { spawn } from 'node:child_process';
|
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import { createFrameReader } from './blindfold/wsframe.mjs';
|
|
11
11
|
import {
|
|
12
|
-
OUT_FORMATS, parseToIR, emitUpstreamBody, createUpstreamNormalizer, createCollector,
|
|
13
|
-
createThinkTagSplitter, splitThinkTags, healAnthropicPayload, estimateTokens, THINKING_MODES,
|
|
12
|
+
OUT_FORMATS, parseToIR, emitUpstreamBody, irToAnthropicBody, createUpstreamNormalizer, createCollector,
|
|
13
|
+
createThinkTagSplitter, splitThinkTags, healAnthropicPayload, estimateTokens, THINKING_MODES, VALID_EFFORTS,
|
|
14
14
|
toGeminiSchema, isAntigravityModel,
|
|
15
15
|
createAnthropicStream, createChatStream, createResponsesStream, createVertexStream,
|
|
16
16
|
buildAnthropicMessage, buildChatMessage, buildResponsesMessage, buildVertexMessage,
|
|
@@ -32,6 +32,11 @@ import { classifyCodexRole, classifyClaudeTier, syncLocalCatalog, refreshCatalog
|
|
|
32
32
|
import { checkForUpdate } from './version.mjs';
|
|
33
33
|
import { applyUpdate } from './update.mjs';
|
|
34
34
|
import { createContractLab, createHalfTap, tapClientWrites, capText, capJson, toolVersionFromUA, finishHalf, PROBE_HEADER, TRACE_ID_RE } from './contract.mjs';
|
|
35
|
+
import {
|
|
36
|
+
idleCompactPolicy, validateIdleCompactPatch, compactRawHistory, summaryReplacement, IDLE_COMPACT_KEY,
|
|
37
|
+
} from './idlecompact.mjs';
|
|
38
|
+
import { askSummary } from './idlecall.mjs';
|
|
39
|
+
import { IdlePrefixCache, compactCacheKey, policyFingerprint } from './idlecache.mjs';
|
|
35
40
|
|
|
36
41
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
37
42
|
const uiHtmlPath = path.join(__dirname, 'ui.html');
|
|
@@ -743,8 +748,102 @@ function cleanSchemaDeep(obj) {
|
|
|
743
748
|
// 1. Strip disallowed keywords ('encrypted', '$schema', 'cache_control')
|
|
744
749
|
// 2. Fix invalid schema values where a property has a string value "object" instead of a valid schema object
|
|
745
750
|
// 3. For ag/* targets (Gemini behind 9Router): rewrite to the strict Schema subset
|
|
746
|
-
|
|
747
|
-
|
|
751
|
+
// ---- idle compaction ----
|
|
752
|
+
|
|
753
|
+
// Persist the gateway's exact-prefix mapping, never the client's native history.
|
|
754
|
+
const idlePrefixCache = new IdlePrefixCache(STATE_DIR, {
|
|
755
|
+
onDiagnostic: code => console.warn(`[llm-switcher] idle compact: ${code}; forwarding is preserved`),
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
async function maybeCompactIdle(req, clientFormat, payload, ir, profile, profileKey, bodyBuffer) {
|
|
759
|
+
const policy = idleCompactPolicy(loadConfig());
|
|
760
|
+
const wants = policy.enabled && (clientFormat === 'anthropic' ||
|
|
761
|
+
(clientFormat === 'responses' && policy.codex));
|
|
762
|
+
if (!wants) return null;
|
|
763
|
+
const field = clientFormat === 'responses' ? 'input' : 'messages';
|
|
764
|
+
const history = payload[field];
|
|
765
|
+
if (!Array.isArray(history)) return null;
|
|
766
|
+
const key = compactCacheKey(clientFormat, req, payload, ir, profile, profileKey, policy);
|
|
767
|
+
// Shared opening prompts do not prove a session identity. Anonymous requests stay unchanged.
|
|
768
|
+
if (!key) return null;
|
|
769
|
+
const visited = idlePrefixCache.visit(key, history, policy);
|
|
770
|
+
const rewrite = messages => {
|
|
771
|
+
const next = { ...payload, [field]: messages };
|
|
772
|
+
return { payload: next, bodyBuffer: Buffer.from(JSON.stringify(next), 'utf8'), cacheKey: key };
|
|
773
|
+
};
|
|
774
|
+
if (visited.idleMs <= policy.idleMs || Buffer.byteLength(JSON.stringify(payload)) < policy.minBytes) {
|
|
775
|
+
return visited.history ? rewrite(visited.history) : null;
|
|
776
|
+
}
|
|
777
|
+
const compacted = compactRawHistory(history, clientFormat, policy);
|
|
778
|
+
// Reuse one stable prefix throughout a window. A later idle window can compact a larger
|
|
779
|
+
// original prefix; an unchanged prefix never asks for the same summary twice.
|
|
780
|
+
if (!compacted || (visited.record?.prefixCount && compacted.prefixCount <= visited.record.prefixCount)) {
|
|
781
|
+
return visited.history ? rewrite(visited.history) : null;
|
|
782
|
+
}
|
|
783
|
+
const record = idlePrefixCache.create(key, history, compacted.prefixCount, compacted.replacement, policy);
|
|
784
|
+
if (!record) return null; // persistence failed: send the complete original request
|
|
785
|
+
console.log(`[llm-switcher] idle compact ${key.slice(0, 12)} [${profileKey}]: ` +
|
|
786
|
+
`${Buffer.byteLength(JSON.stringify(history))} -> ${Buffer.byteLength(JSON.stringify(compacted.history))} bytes; ` +
|
|
787
|
+
(policy.model ? 'configured summary requested' : 'deterministic prefix cached; no model called'));
|
|
788
|
+
if (policy.model) {
|
|
789
|
+
const snapshotPolicy = policyFingerprint(policy);
|
|
790
|
+
const prefixIR = parseToIR(clientFormat, { ...payload, [field]: history.slice(0, compacted.prefixCount) });
|
|
791
|
+
const summaryModel = mapModel(policy.model, profile, clientFormat);
|
|
792
|
+
const summaryFormat = resolveOutFormat(profile, summaryModel);
|
|
793
|
+
askSummary({ ir: prefixIR, policy, profile, model: summaryModel,
|
|
794
|
+
build: (prof, model, summaryIR) => buildUpstreamRequest(prof, summaryFormat, summaryIR, model, req),
|
|
795
|
+
onDiagnostic: ({ code, status }) => console.warn(`[llm-switcher] idle compact ${key.slice(0, 12)}: ${code}${status ? ` (${status})` : ''}`),
|
|
796
|
+
}).then(summary => {
|
|
797
|
+
if (!summary) return;
|
|
798
|
+
const cfg = loadConfig();
|
|
799
|
+
const livePolicy = idleCompactPolicy(cfg);
|
|
800
|
+
const liveProfile = cfg?.profiles?.[profileKey];
|
|
801
|
+
if (policyFingerprint(livePolicy) !== snapshotPolicy || !liveProfile ||
|
|
802
|
+
compactCacheKey(clientFormat, req, payload, ir, liveProfile, profileKey, livePolicy) !== key) {
|
|
803
|
+
console.log(`[llm-switcher] idle compact ${key.slice(0, 12)}: summary discarded after configuration change`);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
const stored = idlePrefixCache.upgrade(key, record.generation, summaryReplacement(compacted, summary), policy);
|
|
807
|
+
console.log(`[llm-switcher] idle compact ${key.slice(0, 12)}: summary ${stored ? 'cached' : 'discarded after a newer generation or history change'}`);
|
|
808
|
+
}).catch(() => console.warn(`[llm-switcher] idle compact ${key.slice(0, 12)}: summary-call-failed`));
|
|
809
|
+
}
|
|
810
|
+
return rewrite(compacted.history);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function resolveSlot(requestedModel, mappedModel, profile, clientFormat) {
|
|
814
|
+
if (clientFormat === 'codeassist') return 'main';
|
|
815
|
+
const clean = String(requestedModel || '').replace(/\[1m\]/gi, '').trim().toLowerCase();
|
|
816
|
+
if (clientFormat === 'responses') {
|
|
817
|
+
if (clean.includes('review')) return 'review';
|
|
818
|
+
if (clean.includes('subagent')) return 'subagent';
|
|
819
|
+
return 'main';
|
|
820
|
+
}
|
|
821
|
+
if (clean.includes('fable')) return 'fable';
|
|
822
|
+
if (clean.includes('opus')) return 'opus';
|
|
823
|
+
if (clean.includes('haiku')) return 'haiku';
|
|
824
|
+
if (clean.includes('sonnet')) return 'sonnet';
|
|
825
|
+
if (profile?.defaultModels) {
|
|
826
|
+
for (const [s, m] of Object.entries(profile.defaultModels)) {
|
|
827
|
+
if (m && (m === mappedModel || m === requestedModel || String(m).toLowerCase() === clean)) return s;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
return 'default';
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function resolveThinkingEffort(profile, slot, mappedModel) {
|
|
834
|
+
if (profile?.modelEfforts && typeof profile.modelEfforts === 'object') {
|
|
835
|
+
if (slot && profile.modelEfforts[slot]) return profile.modelEfforts[slot];
|
|
836
|
+
if (mappedModel && profile.modelEfforts[mappedModel]) return profile.modelEfforts[mappedModel];
|
|
837
|
+
}
|
|
838
|
+
return profile?.thinkingEffort || 'auto';
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot = null) {
|
|
842
|
+
const effort = resolveThinkingEffort(profile, slot, mappedModel);
|
|
843
|
+
const upBody = emitUpstreamBody(outFormat, ir, mappedModel, {
|
|
844
|
+
thinkingMode: profile.thinkingMode,
|
|
845
|
+
thinkingEffort: effort,
|
|
846
|
+
});
|
|
748
847
|
if (upBody?.tools && Array.isArray(upBody.tools)) {
|
|
749
848
|
upBody.tools = cleanSchemaDeep(upBody.tools);
|
|
750
849
|
if (outFormat === 'openai-chat' && isAntigravityModel(mappedModel)) {
|
|
@@ -839,6 +938,22 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
|
|
|
839
938
|
// Code Assist names streaming in the path (:streamGenerateContent), not in the body.
|
|
840
939
|
if (clientFormat === 'codeassist') ir.stream = Boolean(opts.codeAssistStream);
|
|
841
940
|
|
|
941
|
+
// Compaction rewrites the request itself, not just the intermediate form: when the client and
|
|
942
|
+
// the provider speak the same shape this gateway forwards the client's own bytes, so a change
|
|
943
|
+
// made only to the parsed form would shorten nothing that leaves the building.
|
|
944
|
+
const shrunk = await maybeCompactIdle(req, clientFormat, payload, ir, profile, profileKey, bodyBuffer);
|
|
945
|
+
if (shrunk) {
|
|
946
|
+
try {
|
|
947
|
+
const compactedIR = parseToIR(clientFormat, shrunk.payload);
|
|
948
|
+
payload = shrunk.payload;
|
|
949
|
+
bodyBuffer = shrunk.bodyBuffer;
|
|
950
|
+
ir = compactedIR;
|
|
951
|
+
} catch {
|
|
952
|
+
idlePrefixCache.remove(shrunk.cacheKey);
|
|
953
|
+
console.warn('[llm-switcher] idle compact: cache-reparse-failed; forwarding original request');
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
842
957
|
const reqStartTime = Date.now();
|
|
843
958
|
const requestPreview = previewOf(ir);
|
|
844
959
|
const requestedModel = ir.model || payload.model || '';
|
|
@@ -912,7 +1027,8 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
|
|
|
912
1027
|
return;
|
|
913
1028
|
}
|
|
914
1029
|
|
|
915
|
-
const
|
|
1030
|
+
const slot = resolveSlot(requestedModel, mappedModel, profile, clientFormat);
|
|
1031
|
+
const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot);
|
|
916
1032
|
if (traceId) headers['x-intact-trace'] = traceId;
|
|
917
1033
|
debugLog(`[${profileKey}] ${clientFormat} -> ${outFormat} ${url} ::`, JSON.stringify(upBody).slice(0, 500));
|
|
918
1034
|
|
|
@@ -1140,6 +1256,15 @@ function validateProfileInput(p) {
|
|
|
1140
1256
|
if (p.outFormat && !OUT_FORMATS.includes(p.outFormat)) return `Invalid outFormat "${p.outFormat}"`;
|
|
1141
1257
|
if (p.mode && !VALID_MODES.includes(p.mode)) return `Invalid mode "${p.mode}"`;
|
|
1142
1258
|
if (p.thinkingMode && !THINKING_MODES.includes(p.thinkingMode)) return `Invalid thinkingMode "${p.thinkingMode}"`;
|
|
1259
|
+
if (p.thinkingEffort && !VALID_EFFORTS.includes(p.thinkingEffort)) return `Invalid thinkingEffort "${p.thinkingEffort}"`;
|
|
1260
|
+
if (p.modelEfforts !== undefined) {
|
|
1261
|
+
if (!p.modelEfforts || typeof p.modelEfforts !== 'object' || Array.isArray(p.modelEfforts)) {
|
|
1262
|
+
return 'modelEfforts must be an object';
|
|
1263
|
+
}
|
|
1264
|
+
for (const [slot, eff] of Object.entries(p.modelEfforts)) {
|
|
1265
|
+
if (eff && !VALID_EFFORTS.includes(eff)) return `Invalid modelEfforts.${slot} value "${eff}"`;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1143
1268
|
if (p.baseURL !== undefined) {
|
|
1144
1269
|
try {
|
|
1145
1270
|
const u = new URL(p.baseURL);
|
|
@@ -1575,6 +1700,30 @@ async function routeApi(req, res, method, pathname) {
|
|
|
1575
1700
|
return sendJson(res, 200, syncLocalCatalog(STATE_DIR));
|
|
1576
1701
|
}
|
|
1577
1702
|
|
|
1703
|
+
// Idle compaction. The settings live under one key so the dashboard and `switch compact`
|
|
1704
|
+
// write the same place, and the same defaults, instead of two views that can disagree.
|
|
1705
|
+
if (method === 'GET' && pathname === '/api/idle-compact') {
|
|
1706
|
+
return sendJson(res, 200, { idleCompact: idleCompactPolicy(loadConfig()) });
|
|
1707
|
+
}
|
|
1708
|
+
if (method === 'POST' && pathname === '/api/idle-compact') {
|
|
1709
|
+
let patch;
|
|
1710
|
+
try {
|
|
1711
|
+
patch = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8'));
|
|
1712
|
+
} catch (err) {
|
|
1713
|
+
return sendJson(res, err.status || 400, { error: err.message });
|
|
1714
|
+
}
|
|
1715
|
+
const invalid = validateIdleCompactPatch(patch);
|
|
1716
|
+
if (invalid) return sendJson(res, 400, { error: invalid });
|
|
1717
|
+
const cfg = loadConfig() || {};
|
|
1718
|
+
cfg[IDLE_COMPACT_KEY] = { ...(cfg[IDLE_COMPACT_KEY] || {}), ...patch };
|
|
1719
|
+
try {
|
|
1720
|
+
saveConfig(cfg);
|
|
1721
|
+
} catch (err) {
|
|
1722
|
+
return sendJson(res, 500, { error: err.message });
|
|
1723
|
+
}
|
|
1724
|
+
return sendJson(res, 200, { idleCompact: idleCompactPolicy(loadConfig()) });
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1578
1727
|
if (method !== 'POST') {
|
|
1579
1728
|
req.resume();
|
|
1580
1729
|
return sendJson(res, 404, { error: `Not found: ${method} ${pathname}` });
|
|
@@ -1716,9 +1865,12 @@ async function routeConfigApi(res, method, pathname, body) {
|
|
|
1716
1865
|
return sendJson(res, 400, { error: 'The stored API key is sent only to the baseURL and endpoints it was saved with. Enter the key again to use a new URL.' });
|
|
1717
1866
|
}
|
|
1718
1867
|
merged.apiKey = keepsKey ? (existing.apiKey || '') : String(profile.apiKey || '');
|
|
1719
|
-
for (const k of ['outFormat', 'optimizerURL', 'thinkingMode']) {
|
|
1868
|
+
for (const k of ['outFormat', 'optimizerURL', 'thinkingMode', 'thinkingEffort']) {
|
|
1720
1869
|
if (Object.hasOwn(profile, k) && !profile[k]) delete merged[k];
|
|
1721
1870
|
}
|
|
1871
|
+
if (Object.hasOwn(profile, 'modelEfforts')) {
|
|
1872
|
+
merged.modelEfforts = profile.modelEfforts || {};
|
|
1873
|
+
}
|
|
1722
1874
|
// The merge above keeps a key the payload does not mention, so a retired one has to be dropped
|
|
1723
1875
|
// here: the window comes from the model's own list now, and a flag left in config.json would
|
|
1724
1876
|
// be read by nothing while still telling the next reader that the window is a choice.
|
|
@@ -2036,7 +2188,8 @@ async function handleWsResponseCreate(socket, payload, req, ac, history = null)
|
|
|
2036
2188
|
|
|
2037
2189
|
let answered = false; // set only after a complete answer; the half upload depends on it
|
|
2038
2190
|
try {
|
|
2039
|
-
const
|
|
2191
|
+
const slot = resolveSlot(requestedModel, mappedModel, profile, clientFormat);
|
|
2192
|
+
const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot);
|
|
2040
2193
|
if (traceId) headers['x-intact-trace'] = traceId;
|
|
2041
2194
|
debugLog(`[${profileKey}:ws] ${clientFormat} -> ${outFormat} ${url} ::`, JSON.stringify(upBody).slice(0, 300));
|
|
2042
2195
|
|
package/shim.mjs
CHANGED
|
@@ -96,6 +96,24 @@ if [ -O "$SWITCHER_DIR" ] && [ -O "$SWITCHER_DIR/${envFile}" ] && [ -f "$SWITCHE
|
|
|
96
96
|
else
|
|
97
97
|
TOOL_ACTIVE=0
|
|
98
98
|
fi
|
|
99
|
+
# Codex builds its TLS client from this at startup and cannot start at all without reading it, so a
|
|
100
|
+
# CA that is not on disk takes Codex down instead of routing it. The shim cannot build the file, but
|
|
101
|
+
# it can refuse to hand over a path that is already dead, which leaves Codex on the system roots.
|
|
102
|
+
if [ -n "\${CODEX_CA_CERTIFICATE:-}" ]; then
|
|
103
|
+
if [ ! -e "\${CODEX_CA_CERTIFICATE}" ]; then
|
|
104
|
+
printf '[llm-switcher] %s missing; Codex uses the system roots.\\n' "\${CODEX_CA_CERTIFICATE}" >&2
|
|
105
|
+
unset CODEX_CA_CERTIFICATE
|
|
106
|
+
elif [ -d "\${CODEX_CA_CERTIFICATE}" ] || [ ! -f "\${CODEX_CA_CERTIFICATE}" ]; then
|
|
107
|
+
printf '[llm-switcher] %s nonregular; Codex uses the system roots.\\n' "\${CODEX_CA_CERTIFICATE}" >&2
|
|
108
|
+
unset CODEX_CA_CERTIFICATE
|
|
109
|
+
elif [ ! -r "\${CODEX_CA_CERTIFICATE}" ]; then
|
|
110
|
+
printf '[llm-switcher] %s unreadable; Codex uses the system roots.\\n' "\${CODEX_CA_CERTIFICATE}" >&2
|
|
111
|
+
unset CODEX_CA_CERTIFICATE
|
|
112
|
+
elif [ ! -s "\${CODEX_CA_CERTIFICATE}" ]; then
|
|
113
|
+
printf '[llm-switcher] %s empty; Codex uses the system roots.\\n' "\${CODEX_CA_CERTIFICATE}" >&2
|
|
114
|
+
unset CODEX_CA_CERTIFICATE
|
|
115
|
+
fi
|
|
116
|
+
fi
|
|
99
117
|
${name !== 'agy' ? '' : `
|
|
100
118
|
# The gateway proves itself before agy's token goes to it; any doubt keeps agy on its official endpoint.
|
|
101
119
|
if [ "$TOOL_ACTIVE" = "1" ] && ! node "${GATEWAY_CHECK}" "\${CLOUD_CODE_URL:-}" >/dev/null 2>&1; then
|
|
@@ -243,6 +261,17 @@ if exist "%SWITCHER_DIR%\\active.flag" if exist "%SWITCHER_DIR%\\${envFile}" (
|
|
|
243
261
|
for %%A in ("%SWITCHER_DIR%\\${envFile}") do if %%~zA GTR 0 set "TOOL_ACTIVE=1"
|
|
244
262
|
)
|
|
245
263
|
if "%TOOL_ACTIVE%"=="1" call "%SWITCHER_DIR%\\${envFile}"
|
|
264
|
+
if defined CODEX_CA_CERTIFICATE (
|
|
265
|
+
if not exist "%CODEX_CA_CERTIFICATE%" (
|
|
266
|
+
echo [llm-switcher] %CODEX_CA_CERTIFICATE% missing; Codex uses the system roots. 1>&2
|
|
267
|
+
set "CODEX_CA_CERTIFICATE="
|
|
268
|
+
) else (
|
|
269
|
+
for %%A in ("%CODEX_CA_CERTIFICATE%") do if %%~zA EQU 0 (
|
|
270
|
+
echo [llm-switcher] %CODEX_CA_CERTIFICATE% empty; Codex uses the system roots. 1>&2
|
|
271
|
+
set "CODEX_CA_CERTIFICATE="
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
)
|
|
246
275
|
${name !== 'agy' ? '' : `
|
|
247
276
|
REM The gateway proves itself before agy's token goes to it; any doubt keeps agy on its official endpoint.
|
|
248
277
|
if "%TOOL_ACTIVE%"=="1" call :CHECK_GATEWAY
|
package/state.mjs
CHANGED
|
@@ -1366,7 +1366,18 @@ export function computeLaunchState(cfg, port) {
|
|
|
1366
1366
|
// NODE_EXTRA_CA_CERTS is deliberately absent from env-claude: the shim decides it at launch,
|
|
1367
1367
|
// because only then does it know whether the user brought a CA of their own (R2).
|
|
1368
1368
|
if (claude) state.envClaude = proxyPairs();
|
|
1369
|
-
|
|
1369
|
+
// Codex builds its TLS client from CODEX_CA_CERTIFICATE the moment it starts, and a path it cannot
|
|
1370
|
+
// read is fatal: it dies before its first request, and its long-lived daemon then fails its own
|
|
1371
|
+
// update with "Failed to read CA certificate file". So the CA is only offered when it is really
|
|
1372
|
+
// usable. A missing or invalid one drops the switcher's proxy pairs as well, which leaves Codex
|
|
1373
|
+
// on the system roots talking directly to its official endpoint instead of breaking TLS.
|
|
1374
|
+
if (codex) {
|
|
1375
|
+
if (usableCACertificate(paths.blindfoldCA)) {
|
|
1376
|
+
state.envCodex = [...proxyPairs(), ['CODEX_CA_CERTIFICATE', paths.blindfoldCA]];
|
|
1377
|
+
} else {
|
|
1378
|
+
state.envCodex = [];
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1370
1381
|
// agy reaches the gateway directly over plain http: no proxy and no CA (Go on Windows reads only
|
|
1371
1382
|
// the system store). Remote control keeps its own host, so it never crosses the switcher.
|
|
1372
1383
|
if (agy) state.envAgy = [['CLOUD_CODE_URL', `http://127.0.0.1:${port}`]];
|
|
@@ -1488,6 +1499,29 @@ export function applyLaunchState(cfg, port, opts = {}) {
|
|
|
1488
1499
|
return st;
|
|
1489
1500
|
}
|
|
1490
1501
|
|
|
1502
|
+
export function usableCACertificate(file) {
|
|
1503
|
+
if (typeof file !== 'string' || !file.trim()) return false;
|
|
1504
|
+
try {
|
|
1505
|
+
const stat = fs.statSync(file);
|
|
1506
|
+
if (!stat.isFile() || stat.size === 0 || stat.size > 4 * 1024 * 1024) return false;
|
|
1507
|
+
fs.accessSync(file, fs.constants.R_OK);
|
|
1508
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
1509
|
+
const opens = text.match(/-----BEGIN CERTIFICATE-----/g) || [];
|
|
1510
|
+
const blocks = text.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) || [];
|
|
1511
|
+
if (opens.length === 0 || opens.length !== blocks.length) return false;
|
|
1512
|
+
return blocks.every(pem => {
|
|
1513
|
+
try {
|
|
1514
|
+
new crypto.X509Certificate(pem);
|
|
1515
|
+
return true;
|
|
1516
|
+
} catch {
|
|
1517
|
+
return false;
|
|
1518
|
+
}
|
|
1519
|
+
});
|
|
1520
|
+
} catch {
|
|
1521
|
+
return false;
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1491
1525
|
const LOOPBACK_URL = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?\/?$/i;
|
|
1492
1526
|
|
|
1493
1527
|
/**
|
|
@@ -1516,8 +1550,20 @@ export function restartCodexDaemon(pairs, {
|
|
|
1516
1550
|
const env = { ...baseEnv };
|
|
1517
1551
|
for (const k of ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy']) if (LOOPBACK_URL.test(env[k] || '')) delete env[k];
|
|
1518
1552
|
for (const k of ['NO_PROXY', 'no_proxy']) if (env[k] === '127.0.0.1,localhost') delete env[k];
|
|
1519
|
-
|
|
1553
|
+
// The daemon keeps the environment it started with, so a CA it cannot read outlives the switcher
|
|
1554
|
+
// writing it: the daemon comes back up unable to build a TLS client and fails its own update with
|
|
1555
|
+
// "Failed to read CA certificate file". The check has to come after the route is applied, because a
|
|
1556
|
+
// dead CA can arrive in the route as well as in the environment it was already carrying.
|
|
1520
1557
|
for (const [k, v] of pairs) env[k] = v;
|
|
1558
|
+
if (!usableCACertificate(env.CODEX_CA_CERTIFICATE)) {
|
|
1559
|
+
delete env.CODEX_CA_CERTIFICATE;
|
|
1560
|
+
for (const k of ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy']) {
|
|
1561
|
+
if (LOOPBACK_URL.test(env[k] || '')) delete env[k];
|
|
1562
|
+
}
|
|
1563
|
+
for (const k of ['NO_PROXY', 'no_proxy']) {
|
|
1564
|
+
if (env[k] === '127.0.0.1,localhost') delete env[k];
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1521
1567
|
|
|
1522
1568
|
try {
|
|
1523
1569
|
const child = spawnFn(bin, ['app-server', 'daemon', 'restart'], { env, stdio: 'ignore', detached: true });
|
package/switch.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
getMigrationError, getMigrationCollision, getLastLoadError,
|
|
14
14
|
casClearToolPointers, emptyToolEnvFiles, syncInterceptorTools
|
|
15
15
|
} from './state.mjs';
|
|
16
|
+
import { idleCompactPolicy, validateIdleCompactPatch } from './idlecompact.mjs';
|
|
16
17
|
import { runProbe, runCheck } from './contract.mjs';
|
|
17
18
|
import { installPlugin, uninstallPlugin, pluginStatus, PLUGIN_NAME } from './plugin.mjs';
|
|
18
19
|
import {
|
|
@@ -845,6 +846,64 @@ async function managePlugin(action = 'status') {
|
|
|
845
846
|
}
|
|
846
847
|
}
|
|
847
848
|
|
|
849
|
+
// Idle compaction: a conversation that comes back after a pause has lost the provider's prompt
|
|
850
|
+
// cache, so its history is paid for at full price. This turns that on and says what it will do.
|
|
851
|
+
async function manageCompact(action = 'status', arg = '') {
|
|
852
|
+
const cfg = loadConfig() || {};
|
|
853
|
+
const policy = idleCompactPolicy(cfg);
|
|
854
|
+
const key = 'idleCompact';
|
|
855
|
+
const set = (patch) => {
|
|
856
|
+
const invalid = validateIdleCompactPatch(patch);
|
|
857
|
+
if (invalid) { console.error(`[Error] ${invalid}`); process.exit(1); }
|
|
858
|
+
cfg[key] = { ...(cfg[key] || {}), ...patch };
|
|
859
|
+
saveConfig(cfg);
|
|
860
|
+
};
|
|
861
|
+
const act = (action || 'status').toLowerCase();
|
|
862
|
+
const show = () => {
|
|
863
|
+
const p = idleCompactPolicy(loadConfig() || {});
|
|
864
|
+
console.log('Idle compaction (gateway prefix cache)');
|
|
865
|
+
console.log(` Codex opt-in: ${p.codex ? 'yes' : 'no'}`);
|
|
866
|
+
console.log(` Enabled: ${p.enabled ? 'yes' : 'no'}`);
|
|
867
|
+
console.log(` Idle over: ${p.idleMinutes} minutes`);
|
|
868
|
+
console.log(` Context over: ${Math.round(p.minBytes / 1024)} KB`);
|
|
869
|
+
console.log(` Keep recent: ${p.keepRecent} messages`);
|
|
870
|
+
console.log(` Summary model: ${p.model || '(none set)'}`);
|
|
871
|
+
if (!p.enabled) console.log('\n Turn it on with: switch compact on');
|
|
872
|
+
else if (!p.model) console.log('\n No summary model: requests still go out shortened, and no model is called at all.');
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
if (act === 'on' || act === 'enable') {
|
|
876
|
+
set({ enabled: true });
|
|
877
|
+
console.log('[OK] Idle compaction is on.');
|
|
878
|
+
show();
|
|
879
|
+
} else if (act === 'off' || act === 'disable') {
|
|
880
|
+
set({ enabled: false });
|
|
881
|
+
console.log('[OK] Idle compaction is off.');
|
|
882
|
+
} else if (act === 'model') {
|
|
883
|
+
const m = String(arg || '').trim();
|
|
884
|
+
if (!m) { console.log(`Summary model is currently: ${policy.model || '(none set)'}`); return; }
|
|
885
|
+
if (m === 'none' || m === 'default') { set({ model: '' }); console.log('[OK] No summary model: no model is called.'); return; }
|
|
886
|
+
set({ model: m });
|
|
887
|
+
console.log(`[OK] Summaries will be written by: ${m}`);
|
|
888
|
+
} else if (act === 'codex') {
|
|
889
|
+
if (!['on', 'off'].includes(arg)) { console.log(`Codex prefix cache: ${policy.codex ? 'on' : 'off'} (switch compact codex on|off)`); return; }
|
|
890
|
+
set({ codex: arg === 'on' });
|
|
891
|
+
console.log(`[OK] Codex wire prefix cache is ${arg}; client history stays unchanged.`);
|
|
892
|
+
} else if (act === 'idle') {
|
|
893
|
+
const n = Number(arg);
|
|
894
|
+
if (!Number.isFinite(n) || n <= 0) { console.error('Usage: switch compact idle <minutes>'); process.exit(1); }
|
|
895
|
+
set({ idleMinutes: n });
|
|
896
|
+
console.log(`[OK] Compact a conversation idle over ${n} minutes.`);
|
|
897
|
+
} else if (act === 'min') {
|
|
898
|
+
const n = Number(arg);
|
|
899
|
+
if (!Number.isFinite(n) || n <= 0) { console.error('Usage: switch compact min <KB>'); process.exit(1); }
|
|
900
|
+
set({ minBytes: Math.floor(n) * 1024 });
|
|
901
|
+
console.log(`[OK] Compact a conversation over ${Math.floor(n)} KB.`);
|
|
902
|
+
} else {
|
|
903
|
+
show();
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
848
907
|
async function manageShim(action = 'status') {
|
|
849
908
|
const act = (action || 'status').toLowerCase();
|
|
850
909
|
|
|
@@ -1247,6 +1306,8 @@ if (cmd === 'off' || cmd === 'stop') {
|
|
|
1247
1306
|
await runDoctor();
|
|
1248
1307
|
} else if (cmd === 'service' || cmd === 'daemon') {
|
|
1249
1308
|
await manageService(subArg.toLowerCase() || 'status');
|
|
1309
|
+
} else if (cmd === 'compact') {
|
|
1310
|
+
await manageCompact(subArg.toLowerCase() || 'status', positionalArgs()[2] || '');
|
|
1250
1311
|
} else if (cmd === 'shim' || cmd === 'shims') {
|
|
1251
1312
|
await manageShim(subArg.toLowerCase() || 'status');
|
|
1252
1313
|
} else if (cmd === 'plugin' || cmd === 'plugins') {
|
package/tests/certs.test.mjs
CHANGED
|
@@ -60,7 +60,13 @@ test('openssl verifies the leaf, and the name constraints refuse a leaf for anot
|
|
|
60
60
|
// A leaked ca.key signs for any name; a client that obeys the constraints must refuse it.
|
|
61
61
|
const evil = path.join(dir, 'evil.pem');
|
|
62
62
|
fs.writeFileSync(evil, signLeaf(dir, ['evil.example']).cert);
|
|
63
|
-
assert.throws(() =>
|
|
63
|
+
assert.throws(() => {
|
|
64
|
+
try {
|
|
65
|
+
execFileSync('openssl', ['verify', '-CAfile', path.join(dir, 'ca.pem'), evil], { stdio: 'pipe' });
|
|
66
|
+
} catch (err) {
|
|
67
|
+
throw new Error([err.stdout?.toString() || '', err.stderr?.toString() || '', err.message].join(' '));
|
|
68
|
+
}
|
|
69
|
+
}, /permitted subtree violation/);
|
|
64
70
|
});
|
|
65
71
|
|
|
66
72
|
test('ensureBlindfoldCerts builds a missing set, rebuilds a missing leaf on the same CA, and leaves a good set alone', (t) => {
|
|
@@ -6,15 +6,21 @@ import fs from 'node:fs';
|
|
|
6
6
|
import os from 'node:os';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
|
|
9
|
+
import { buildCerts } from '../blindfold/certs.mjs';
|
|
10
|
+
|
|
9
11
|
// Temporary HOME and state dir before the import, so nothing here can reach the real Codex daemon.
|
|
10
12
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-codex-daemon-'));
|
|
11
13
|
process.env.HOME = path.join(root, 'home');
|
|
12
14
|
process.env.LLM_SWITCHER_STATE_DIR = path.join(root, 'state');
|
|
13
15
|
delete process.env.CODEX_HOME;
|
|
14
16
|
fs.mkdirSync(process.env.LLM_SWITCHER_STATE_DIR, { recursive: true });
|
|
15
|
-
const { restartCodexDaemon, applyLaunchState, clearLaunchState, emptyToolEnvFiles, STATE_DIR } = await import('../state.mjs');
|
|
17
|
+
const { restartCodexDaemon, applyLaunchState, clearLaunchState, emptyToolEnvFiles, computeLaunchState, STATE_DIR, INTERCEPT_HOSTS, usableCACertificate } = await import('../state.mjs');
|
|
16
18
|
test.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
17
19
|
|
|
20
|
+
const certsDir = path.join(root, 'certs');
|
|
21
|
+
buildCerts(certsDir, INTERCEPT_HOSTS);
|
|
22
|
+
const validCA = path.join(certsDir, 'ca.pem');
|
|
23
|
+
|
|
18
24
|
// A machine where the codex shim routes through `stateDir` and a daemon is running.
|
|
19
25
|
function machine(stateDir) {
|
|
20
26
|
const home = fs.mkdtempSync(path.join(root, 'm-'));
|
|
@@ -32,7 +38,7 @@ function machine(stateDir) {
|
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
const ROUTE = [['HTTPS_PROXY', 'http://127.0.0.1:3457'], ['https_proxy', 'http://127.0.0.1:3457'],
|
|
35
|
-
['NO_PROXY', '127.0.0.1,localhost'], ['no_proxy', '127.0.0.1,localhost'], ['CODEX_CA_CERTIFICATE',
|
|
41
|
+
['NO_PROXY', '127.0.0.1,localhost'], ['no_proxy', '127.0.0.1,localhost'], ['CODEX_CA_CERTIFICATE', validCA]];
|
|
36
42
|
|
|
37
43
|
test('a route change restarts the running daemon with the new Codex variables', () => {
|
|
38
44
|
const m = machine('/switcher/state');
|
|
@@ -42,10 +48,58 @@ test('a route change restarts the running daemon with the new Codex variables',
|
|
|
42
48
|
assert.equal(m.calls[0].cmd, m.bin);
|
|
43
49
|
assert.deepEqual(m.calls[0].args, ['app-server', 'daemon', 'restart']);
|
|
44
50
|
assert.equal(m.calls[0].env.HTTPS_PROXY, 'http://127.0.0.1:3457');
|
|
45
|
-
assert.equal(m.calls[0].env.CODEX_CA_CERTIFICATE, '/ca.pem');
|
|
46
51
|
assert.equal(m.calls[0].env.PATH, '/bin');
|
|
47
52
|
});
|
|
48
53
|
|
|
54
|
+
// Codex reads this path when it builds its TLS client and cannot start without it, and its daemon
|
|
55
|
+
// keeps the environment it started with. A path that is not on disk therefore does not route Codex,
|
|
56
|
+
// it takes Codex down, and the daemon then fails its own update with "Failed to read CA certificate
|
|
57
|
+
// file" -- which is what a caller sees as a daemon error while using Codex.
|
|
58
|
+
test('a CA that is not on disk is never handed to Codex', () => {
|
|
59
|
+
const m = machine('/switcher/state');
|
|
60
|
+
const deadRoute = [...ROUTE.filter(([k]) => k !== 'CODEX_CA_CERTIFICATE'), ['CODEX_CA_CERTIFICATE', '/nonexistent/ca.pem']];
|
|
61
|
+
restartCodexDaemon(deadRoute, { home: m.home, codexHome: m.codexHome, stateDir: '/switcher/state', spawnFn: m.spawnFn, baseEnv: { PATH: '/bin' } });
|
|
62
|
+
assert.equal(m.calls[0].env.CODEX_CA_CERTIFICATE, undefined,
|
|
63
|
+
'a CA the switcher never built must not reach the daemon');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('a CA that is on disk is passed through, so the interceptor still works', () => {
|
|
67
|
+
const m = machine('/switcher/state');
|
|
68
|
+
const certsDir = path.join(m.home, 'certs');
|
|
69
|
+
buildCerts(certsDir, INTERCEPT_HOSTS);
|
|
70
|
+
const ca = path.join(certsDir, 'ca.pem');
|
|
71
|
+
restartCodexDaemon([...ROUTE.filter(([k]) => k !== 'CODEX_CA_CERTIFICATE'), ['CODEX_CA_CERTIFICATE', ca]],
|
|
72
|
+
{ home: m.home, codexHome: m.codexHome, stateDir: '/switcher/state', spawnFn: m.spawnFn, baseEnv: { PATH: '/bin' }, ca });
|
|
73
|
+
assert.equal(m.calls[0].env.CODEX_CA_CERTIFICATE, ca, 'a CA that exists is the whole point of the route');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('directories, empty, garbage, and unreadable files are dropped with proxy pairs', () => {
|
|
77
|
+
const m = machine('/switcher/state');
|
|
78
|
+
const dir = path.join(m.home, 'certs-dir');
|
|
79
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
80
|
+
const empty = path.join(m.home, 'empty.pem');
|
|
81
|
+
fs.writeFileSync(empty, '');
|
|
82
|
+
const garbage = path.join(m.home, 'garbage.pem');
|
|
83
|
+
fs.writeFileSync(garbage, '-----BEGIN CERTIFICATE-----\nnot a valid cert\n-----END CERTIFICATE-----\n');
|
|
84
|
+
|
|
85
|
+
for (const bad of [dir, empty, garbage]) {
|
|
86
|
+
restartCodexDaemon([...ROUTE.filter(([k]) => k !== 'CODEX_CA_CERTIFICATE'), ['CODEX_CA_CERTIFICATE', bad]],
|
|
87
|
+
{ home: m.home, codexHome: m.codexHome, stateDir: '/switcher/state', spawnFn: m.spawnFn, baseEnv: { PATH: '/bin' } });
|
|
88
|
+
const env = m.calls[m.calls.length - 1].env;
|
|
89
|
+
assert.equal(env.CODEX_CA_CERTIFICATE, undefined);
|
|
90
|
+
assert.equal(env.HTTPS_PROXY, undefined);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// The route a restart hands over is the last word: the pairs win, so a CA in them reaches the daemon
|
|
95
|
+
// whatever the environment already held. This is the path that ends in a dead daemon.
|
|
96
|
+
test('the route written into env-codex carries the CA only when it exists', () => {
|
|
97
|
+
const st = computeLaunchState({ activeProfiles: { codex: 'p' }, profiles: { p: { tool: 'codex', baseURL: 'http://x/v1', apiKey: 'k' } } }, 3456);
|
|
98
|
+
const withCa = st.envCodex.find(([k]) => k === 'CODEX_CA_CERTIFICATE');
|
|
99
|
+
assert.ok(!withCa || fs.existsSync(withCa[1]),
|
|
100
|
+
`env-codex points Codex at ${withCa ? withCa[1] : 'nothing'}; a missing CA stops Codex entirely`);
|
|
101
|
+
});
|
|
102
|
+
|
|
49
103
|
test('switching Codex off restarts the daemon without the switcher variables, and keeps the user\'s own proxy', () => {
|
|
50
104
|
const m = machine('/switcher/state');
|
|
51
105
|
const baseEnv = { HTTPS_PROXY: 'http://127.0.0.1:3457', https_proxy: 'http://proxy.corp:8080', CODEX_CA_CERTIFICATE: '/ca.pem', NO_PROXY: '127.0.0.1,localhost' };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
test('compact CLI disables summary without fallback, exposes Codex opt-in, and rejects invalid bounds', t => {
|
|
10
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-compact-cli-'));
|
|
11
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
12
|
+
const config = path.join(dir, 'config.json');
|
|
13
|
+
fs.writeFileSync(config, JSON.stringify({ activeProfiles: {}, profiles: {},
|
|
14
|
+
idleCompact: { enabled: true, model: 'expensive-official' } }));
|
|
15
|
+
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
16
|
+
const run = (...args) => spawnSync(process.execPath, [path.join(root, 'switch.mjs'), 'compact', ...args], {
|
|
17
|
+
env: { ...process.env, LLM_SWITCHER_CONFIG: config, LLM_SWITCHER_STATE_DIR: dir,
|
|
18
|
+
LLM_SWITCHER_REGISTRY_URL: 'http://127.0.0.1:9/' }, encoding: 'utf8', timeout: 5000,
|
|
19
|
+
});
|
|
20
|
+
const disabled = run('model', 'default'); assert.equal(disabled.status, 0, disabled.stderr);
|
|
21
|
+
assert.match(disabled.stdout, /no model is called/);
|
|
22
|
+
assert.doesNotMatch(disabled.stdout, /own model/);
|
|
23
|
+
assert.equal(JSON.parse(fs.readFileSync(config)).idleCompact.model, '');
|
|
24
|
+
const codex = run('codex', 'on'); assert.equal(codex.status, 0, codex.stderr);
|
|
25
|
+
assert.match(codex.stdout, /wire prefix cache is on/);
|
|
26
|
+
assert.equal(JSON.parse(fs.readFileSync(config)).idleCompact.codex, true);
|
|
27
|
+
const before = fs.readFileSync(config);
|
|
28
|
+
for (const args of [['idle', '1441'], ['idle', '0'], ['min', '7']]) {
|
|
29
|
+
const invalid = run(...args); assert.equal(invalid.status, 1);
|
|
30
|
+
assert.deepEqual(fs.readFileSync(config), before);
|
|
31
|
+
}
|
|
32
|
+
const status = run('status'); assert.equal(status.status, 0);
|
|
33
|
+
assert.match(status.stdout, /gateway prefix cache/); assert.match(status.stdout, /Codex opt-in:\s+yes/);
|
|
34
|
+
});
|
|
@@ -594,7 +594,13 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
|
|
|
594
594
|
const view = () => page.evaluate(`[...document.querySelectorAll('.view-panel')].filter(p => p.style.display !== 'none').map(p => p.id)`);
|
|
595
595
|
|
|
596
596
|
it('opens each page from the sidebar and follows the back button', async () => {
|
|
597
|
-
for (const [nav, id, title] of [
|
|
597
|
+
for (const [nav, id, title] of [
|
|
598
|
+
['compact', 'view-compact', 'Idle Compaction'],
|
|
599
|
+
['models', 'view-models', 'Dynamic Model Catalog'],
|
|
600
|
+
['logs', 'view-logs', 'Request Inspector'],
|
|
601
|
+
['doctor', 'view-doctor', 'System Health & Doctor'],
|
|
602
|
+
['routes', 'view-routes', 'Routes & Profiles']
|
|
603
|
+
]) {
|
|
598
604
|
await act(() => page.click(`#sideNav a[data-nav="${nav}"]`));
|
|
599
605
|
assert.deepEqual(await view(), [id], `${nav}: wrong page`);
|
|
600
606
|
assert.equal(await page.text('#page-title'), title);
|
package/tests/formats.test.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
createChatStream, buildChatMessage, chatFinish, anthropicStopReason,
|
|
13
13
|
smartText, smartReasoning, sanitizeJsonSchema, normalizeUpstream, buildVertexMessage,
|
|
14
14
|
createCodeAssistStream,
|
|
15
|
-
budgetToEffort, effortToBudget, normalizeEffort, capEffort, EFFORT_LEVELS
|
|
15
|
+
budgetToEffort, effortToBudget, normalizeEffort, capEffort, mapEquivalentEffort, EFFORT_LEVELS
|
|
16
16
|
} from '../formats.mjs';
|
|
17
17
|
import { assertValidAnthropicEvents } from './helpers.mjs';
|
|
18
18
|
|
|
@@ -1010,3 +1010,46 @@ test('a level becomes the budget each API speaks, and Gemini gets one it accepts
|
|
|
1010
1010
|
// Anthropic keeps the budget it was given, only under max_tokens as its own rule requires.
|
|
1011
1011
|
assert.equal(irToAnthropicBody(anthropicToIR({ model: 'm', max_tokens: 200000, messages: [{ role: 'user', content: 'hi' }], thinking: { type: 'enabled', budget_tokens: 100000 } }), 'm').thinking.budget_tokens, 100000);
|
|
1012
1012
|
});
|
|
1013
|
+
|
|
1014
|
+
test('mapEquivalentEffort maps to nearest supported level when target lacks exact match', () => {
|
|
1015
|
+
const strict = ['low', 'medium', 'high'];
|
|
1016
|
+
assert.equal(mapEquivalentEffort('ultra', strict), 'high');
|
|
1017
|
+
assert.equal(mapEquivalentEffort('max', strict), 'high');
|
|
1018
|
+
assert.equal(mapEquivalentEffort('xhigh', strict), 'high');
|
|
1019
|
+
assert.equal(mapEquivalentEffort('high', strict), 'high');
|
|
1020
|
+
assert.equal(mapEquivalentEffort('medium', strict), 'medium');
|
|
1021
|
+
assert.equal(mapEquivalentEffort('low', strict), 'low');
|
|
1022
|
+
assert.equal(mapEquivalentEffort('minimal', strict), 'low');
|
|
1023
|
+
|
|
1024
|
+
// Gap in target levels: ['low', 'max']
|
|
1025
|
+
assert.equal(mapEquivalentEffort('xhigh', ['low', 'max']), 'max');
|
|
1026
|
+
assert.equal(mapEquivalentEffort('medium', ['low', 'max']), 'low');
|
|
1027
|
+
|
|
1028
|
+
// Intact synonyms: extra-low -> minimal
|
|
1029
|
+
assert.equal(mapEquivalentEffort('extra-low', ['minimal', 'medium', 'high']), 'minimal');
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
test('thinkingEffort profile option overrides client effort across all upstreams', () => {
|
|
1033
|
+
const clientIR = responsesToIR({ model: 'gpt-5', max_output_tokens: 16000, reasoning: { effort: 'low' }, input: [{ role: 'user', content: 'solve' }] });
|
|
1034
|
+
|
|
1035
|
+
// 1. Force 'high' on OpenAI Chat
|
|
1036
|
+
const chatBody = emitUpstreamBody('openai-chat', clientIR, 'gpt-5', { thinkingEffort: 'high' });
|
|
1037
|
+
assert.equal(chatBody.reasoning_effort, 'high');
|
|
1038
|
+
|
|
1039
|
+
// 2. Force 'high' on Anthropic
|
|
1040
|
+
const anthropicBody = emitUpstreamBody('anthropic', clientIR, 'claude-3-7-sonnet', { thinkingEffort: 'high' });
|
|
1041
|
+
assert.equal(anthropicBody.output_config?.effort, 'high');
|
|
1042
|
+
assert.equal(anthropicBody.thinking?.budget_tokens, 8000);
|
|
1043
|
+
|
|
1044
|
+
// 3. Force 'high' on Vertex (Gemini)
|
|
1045
|
+
const vertexBody = emitUpstreamBody('vertex', clientIR, 'gemini-2.5-pro', { thinkingEffort: 'high' });
|
|
1046
|
+
assert.equal(vertexBody.generationConfig?.thinkingConfig?.thinkingBudget, 8000);
|
|
1047
|
+
|
|
1048
|
+
// 4. Force 'xhigh' on strict native OpenAI caps to 'high'
|
|
1049
|
+
const nativeChat = emitUpstreamBody('openai-chat', clientIR, 'gpt-5', { thinkingMode: 'native', thinkingEffort: 'xhigh' });
|
|
1050
|
+
assert.equal(nativeChat.reasoning_effort, 'high');
|
|
1051
|
+
|
|
1052
|
+
// 5. 'auto' inherits client's original effort ('low')
|
|
1053
|
+
const autoChat = emitUpstreamBody('openai-chat', clientIR, 'gpt-5', { thinkingEffort: 'auto' });
|
|
1054
|
+
assert.equal(autoChat.reasoning_effort, 'low');
|
|
1055
|
+
});
|