llm-switcher 1.6.0 → 1.6.3
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 +30 -0
- package/README.md +42 -34
- package/README.vi.md +38 -33
- package/docs/session-2026-10-07-compaction.md +224 -0
- package/formats.mjs +85 -25
- package/idlecache.mjs +159 -0
- package/idlecall.mjs +46 -54
- package/idlecompact.mjs +171 -238
- package/package.json +1 -1
- package/proxy.mjs +111 -135
- package/shim.mjs +29 -0
- package/state.mjs +48 -2
- package/switch.mjs +12 -5
- 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 +233 -279
- package/ui.html +657 -85
- package/claudesession.mjs +0 -196
- package/codexsession.mjs +0 -141
package/proxy.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
10
10
|
import { createFrameReader } from './blindfold/wsframe.mjs';
|
|
11
11
|
import {
|
|
12
12
|
OUT_FORMATS, parseToIR, emitUpstreamBody, irToAnthropicBody, createUpstreamNormalizer, createCollector,
|
|
13
|
-
createThinkTagSplitter, splitThinkTags, healAnthropicPayload, estimateTokens, THINKING_MODES,
|
|
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,
|
|
@@ -33,11 +33,10 @@ 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
35
|
import {
|
|
36
|
-
idleCompactPolicy,
|
|
36
|
+
idleCompactPolicy, validateIdleCompactPatch, compactRawHistory, summaryReplacement, IDLE_COMPACT_KEY,
|
|
37
37
|
} from './idlecompact.mjs';
|
|
38
38
|
import { askSummary } from './idlecall.mjs';
|
|
39
|
-
import {
|
|
40
|
-
import { writeCodexCompaction } from './codexsession.mjs';
|
|
39
|
+
import { IdlePrefixCache, compactCacheKey, policyFingerprint } from './idlecache.mjs';
|
|
41
40
|
|
|
42
41
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
43
42
|
const uiHtmlPath = path.join(__dirname, 'ui.html');
|
|
@@ -751,124 +750,100 @@ function cleanSchemaDeep(obj) {
|
|
|
751
750
|
// 3. For ag/* targets (Gemini behind 9Router): rewrite to the strict Schema subset
|
|
752
751
|
// ---- idle compaction ----
|
|
753
752
|
|
|
754
|
-
//
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
+
|
|
759
758
|
async function maybeCompactIdle(req, clientFormat, payload, ir, profile, profileKey, bodyBuffer) {
|
|
760
759
|
const policy = idleCompactPolicy(loadConfig());
|
|
761
|
-
const
|
|
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.
|
|
762
768
|
if (!key) return null;
|
|
763
|
-
const
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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`));
|
|
773
809
|
}
|
|
774
|
-
|
|
775
|
-
return null;
|
|
810
|
+
return rewrite(compacted.history);
|
|
776
811
|
}
|
|
777
812
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
if (
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
// A summary is only made when the reader named a model for it, and never with the conversation's
|
|
794
|
-
// own: that would spend the expensive model of an official account to save the tokens of the
|
|
795
|
-
// turn, which is a bad trade and, on a plan with limits, worse. With no model named there is
|
|
796
|
-
// still a saving, because the request leaves shortened, and without a summary there is nothing to
|
|
797
|
-
// write to the session file -- so a model call here would cost tokens and buy nothing.
|
|
798
|
-
const summary = policy.model
|
|
799
|
-
? askSummary({ ir, policy, profile, model: policy.model, build })
|
|
800
|
-
: Promise.resolve('');
|
|
801
|
-
|
|
802
|
-
// The request goes out shortened now, before the summary exists. The summary belongs in the
|
|
803
|
-
// next resume, not in this answer, so this turn carries the text that stands in for the middle
|
|
804
|
-
// and nothing else; a turn that also carried the summary would pay for it twice.
|
|
805
|
-
// Written back into the client's own body. Claude Code goes through the renderer this gateway
|
|
806
|
-
// already uses, because it is translated on the way out. Codex is not: a Responses client
|
|
807
|
-
// reaches a Responses provider as the bytes it sent, so its history is shortened where it
|
|
808
|
-
// lives, in the request itself.
|
|
809
|
-
const field = clientFormat === 'responses' ? 'input' : 'messages';
|
|
810
|
-
const rendered = clientFormat === 'responses'
|
|
811
|
-
? compactResponsesInput(payload.input, policy)
|
|
812
|
-
: irToAnthropicBody({ ...ir, messages: compacted.messages }, ir.model || payload.model).messages;
|
|
813
|
-
const next = { ...payload, [field]: rendered };
|
|
814
|
-
const nextBuf = Buffer.from(JSON.stringify(next), 'utf8');
|
|
815
|
-
|
|
816
|
-
console.log(
|
|
817
|
-
`[llm-switcher] idle compact ${key.slice(0, 12)} [${profileKey}]: ${before} -> ${JSON.stringify(rendered).length} bytes, ` +
|
|
818
|
-
`${compacted.middle} messages kept as text, ${compacted.dropped} dropped; summary call in flight`);
|
|
819
|
-
|
|
820
|
-
// The summary is not waited for. It serves the next resume, not this answer, and holding the
|
|
821
|
-
// client's turn open for a call nobody reads would make the saving feel like a cost. The file is
|
|
822
|
-
// written when it lands, which is also later than the response and therefore never races the
|
|
823
|
-
// entries Claude Code is appending right now.
|
|
824
|
-
Promise.resolve(summary).then(async (s) => {
|
|
825
|
-
if (!s) {
|
|
826
|
-
console.log(`[llm-switcher] idle compact ${key.slice(0, 12)}: the summary call produced nothing; the request went out shortened anyway`);
|
|
827
|
-
return;
|
|
828
|
-
}
|
|
829
|
-
// Found by session id rather than by working directory: nothing in the request says what the
|
|
830
|
-
// client's directory is, and a compaction written to the wrong project is one nothing reads.
|
|
831
|
-
// Codex and Claude Code keep their history in different places, so each is written its own
|
|
832
|
-
// way: Codex in its rollout file and its store, Claude Code in its session file.
|
|
833
|
-
let written;
|
|
834
|
-
if (clientFormat === 'responses') {
|
|
835
|
-
written = await writeCodexCompaction({ summary: s });
|
|
836
|
-
} else {
|
|
837
|
-
const sessionId = claudeSessionId(req, payload);
|
|
838
|
-
const file = sessionId ? findSessionFile(sessionId) : '';
|
|
839
|
-
written = file
|
|
840
|
-
? writeCompaction({
|
|
841
|
-
file, summary: s, sessionId,
|
|
842
|
-
preTokens: Math.round(before / 4), postTokens: Math.round(JSON.stringify(rendered).length / 4),
|
|
843
|
-
})
|
|
844
|
-
: null;
|
|
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;
|
|
845
828
|
}
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
return { payload: next, bodyBuffer: nextBuf };
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
// The summary goes in as a user message right after the opening request, which is where Claude
|
|
853
|
-
// Code puts its own. A conversation handed a hole in it reads the hole as something it forgot
|
|
854
|
-
// unless something says otherwise.
|
|
855
|
-
function withSummary(msgs, insertAt, summary) {
|
|
856
|
-
const at = Math.min(Math.max(1, insertAt), msgs.length);
|
|
857
|
-
return [
|
|
858
|
-
...msgs.slice(0, at),
|
|
859
|
-
{ role: 'user', content: [{ type: 'text', text: summaryNotice(summary) }] },
|
|
860
|
-
...msgs.slice(at),
|
|
861
|
-
];
|
|
829
|
+
}
|
|
830
|
+
return 'default';
|
|
862
831
|
}
|
|
863
832
|
|
|
864
|
-
function
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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';
|
|
868
839
|
}
|
|
869
840
|
|
|
870
|
-
function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req) {
|
|
871
|
-
const
|
|
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
|
+
});
|
|
872
847
|
if (upBody?.tools && Array.isArray(upBody.tools)) {
|
|
873
848
|
upBody.tools = cleanSchemaDeep(upBody.tools);
|
|
874
849
|
if (outFormat === 'openai-chat' && isAntigravityModel(mappedModel)) {
|
|
@@ -968,15 +943,14 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
|
|
|
968
943
|
// made only to the parsed form would shorten nothing that leaves the building.
|
|
969
944
|
const shrunk = await maybeCompactIdle(req, clientFormat, payload, ir, profile, profileKey, bodyBuffer);
|
|
970
945
|
if (shrunk) {
|
|
971
|
-
payload = shrunk.payload;
|
|
972
|
-
bodyBuffer = shrunk.bodyBuffer;
|
|
973
946
|
try {
|
|
974
|
-
|
|
947
|
+
const compactedIR = parseToIR(clientFormat, shrunk.payload);
|
|
948
|
+
payload = shrunk.payload;
|
|
949
|
+
bodyBuffer = shrunk.bodyBuffer;
|
|
950
|
+
ir = compactedIR;
|
|
975
951
|
} catch {
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
payload = JSON.parse(bodyBuffer.toString('utf8'));
|
|
979
|
-
return sendClientError(res, clientFormat, 400, 'Cannot reparse the compacted request');
|
|
952
|
+
idlePrefixCache.remove(shrunk.cacheKey);
|
|
953
|
+
console.warn('[llm-switcher] idle compact: cache-reparse-failed; forwarding original request');
|
|
980
954
|
}
|
|
981
955
|
}
|
|
982
956
|
|
|
@@ -1053,7 +1027,8 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
|
|
|
1053
1027
|
return;
|
|
1054
1028
|
}
|
|
1055
1029
|
|
|
1056
|
-
const
|
|
1030
|
+
const slot = resolveSlot(requestedModel, mappedModel, profile, clientFormat);
|
|
1031
|
+
const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot);
|
|
1057
1032
|
if (traceId) headers['x-intact-trace'] = traceId;
|
|
1058
1033
|
debugLog(`[${profileKey}] ${clientFormat} -> ${outFormat} ${url} ::`, JSON.stringify(upBody).slice(0, 500));
|
|
1059
1034
|
|
|
@@ -1281,6 +1256,15 @@ function validateProfileInput(p) {
|
|
|
1281
1256
|
if (p.outFormat && !OUT_FORMATS.includes(p.outFormat)) return `Invalid outFormat "${p.outFormat}"`;
|
|
1282
1257
|
if (p.mode && !VALID_MODES.includes(p.mode)) return `Invalid mode "${p.mode}"`;
|
|
1283
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
|
+
}
|
|
1284
1268
|
if (p.baseURL !== undefined) {
|
|
1285
1269
|
try {
|
|
1286
1270
|
const u = new URL(p.baseURL);
|
|
@@ -1728,20 +1712,8 @@ async function routeApi(req, res, method, pathname) {
|
|
|
1728
1712
|
} catch (err) {
|
|
1729
1713
|
return sendJson(res, err.status || 400, { error: err.message });
|
|
1730
1714
|
}
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
}
|
|
1734
|
-
// Per key, not per type-group. A loose check lets "yes" through for a boolean, and the
|
|
1735
|
-
// policy then reads it as false: a typo would switch the feature off without saying so.
|
|
1736
|
-
const kinds = { enabled: 'boolean', model: 'string', idleMinutes: 'number', minBytes: 'number', keepRecent: 'number' };
|
|
1737
|
-
for (const [k, want] of Object.entries(kinds)) {
|
|
1738
|
-
if (!(k in patch)) continue;
|
|
1739
|
-
const v = patch[k];
|
|
1740
|
-
const ok = want === 'number'
|
|
1741
|
-
? (typeof v === 'number' && Number.isFinite(v))
|
|
1742
|
-
: typeof v === want;
|
|
1743
|
-
if (!ok) return sendJson(res, 400, { error: `${k} must be ${want === 'number' ? 'a number' : `a ${want}`}` });
|
|
1744
|
-
}
|
|
1715
|
+
const invalid = validateIdleCompactPatch(patch);
|
|
1716
|
+
if (invalid) return sendJson(res, 400, { error: invalid });
|
|
1745
1717
|
const cfg = loadConfig() || {};
|
|
1746
1718
|
cfg[IDLE_COMPACT_KEY] = { ...(cfg[IDLE_COMPACT_KEY] || {}), ...patch };
|
|
1747
1719
|
try {
|
|
@@ -1893,9 +1865,12 @@ async function routeConfigApi(res, method, pathname, body) {
|
|
|
1893
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.' });
|
|
1894
1866
|
}
|
|
1895
1867
|
merged.apiKey = keepsKey ? (existing.apiKey || '') : String(profile.apiKey || '');
|
|
1896
|
-
for (const k of ['outFormat', 'optimizerURL', 'thinkingMode']) {
|
|
1868
|
+
for (const k of ['outFormat', 'optimizerURL', 'thinkingMode', 'thinkingEffort']) {
|
|
1897
1869
|
if (Object.hasOwn(profile, k) && !profile[k]) delete merged[k];
|
|
1898
1870
|
}
|
|
1871
|
+
if (Object.hasOwn(profile, 'modelEfforts')) {
|
|
1872
|
+
merged.modelEfforts = profile.modelEfforts || {};
|
|
1873
|
+
}
|
|
1899
1874
|
// The merge above keeps a key the payload does not mention, so a retired one has to be dropped
|
|
1900
1875
|
// here: the window comes from the model's own list now, and a flag left in config.json would
|
|
1901
1876
|
// be read by nothing while still telling the next reader that the window is a choice.
|
|
@@ -2213,7 +2188,8 @@ async function handleWsResponseCreate(socket, payload, req, ac, history = null)
|
|
|
2213
2188
|
|
|
2214
2189
|
let answered = false; // set only after a complete answer; the half upload depends on it
|
|
2215
2190
|
try {
|
|
2216
|
-
const
|
|
2191
|
+
const slot = resolveSlot(requestedModel, mappedModel, profile, clientFormat);
|
|
2192
|
+
const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot);
|
|
2217
2193
|
if (traceId) headers['x-intact-trace'] = traceId;
|
|
2218
2194
|
debugLog(`[${profileKey}:ws] ${clientFormat} -> ${outFormat} ${url} ::`, JSON.stringify(upBody).slice(0, 300));
|
|
2219
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,7 +13,7 @@ import {
|
|
|
13
13
|
getMigrationError, getMigrationCollision, getLastLoadError,
|
|
14
14
|
casClearToolPointers, emptyToolEnvFiles, syncInterceptorTools
|
|
15
15
|
} from './state.mjs';
|
|
16
|
-
import { idleCompactPolicy } from './idlecompact.mjs';
|
|
16
|
+
import { idleCompactPolicy, validateIdleCompactPatch } from './idlecompact.mjs';
|
|
17
17
|
import { runProbe, runCheck } from './contract.mjs';
|
|
18
18
|
import { installPlugin, uninstallPlugin, pluginStatus, PLUGIN_NAME } from './plugin.mjs';
|
|
19
19
|
import {
|
|
@@ -853,13 +853,16 @@ async function manageCompact(action = 'status', arg = '') {
|
|
|
853
853
|
const policy = idleCompactPolicy(cfg);
|
|
854
854
|
const key = 'idleCompact';
|
|
855
855
|
const set = (patch) => {
|
|
856
|
+
const invalid = validateIdleCompactPatch(patch);
|
|
857
|
+
if (invalid) { console.error(`[Error] ${invalid}`); process.exit(1); }
|
|
856
858
|
cfg[key] = { ...(cfg[key] || {}), ...patch };
|
|
857
859
|
saveConfig(cfg);
|
|
858
860
|
};
|
|
859
861
|
const act = (action || 'status').toLowerCase();
|
|
860
862
|
const show = () => {
|
|
861
863
|
const p = idleCompactPolicy(loadConfig() || {});
|
|
862
|
-
console.log('Idle compaction (
|
|
864
|
+
console.log('Idle compaction (gateway prefix cache)');
|
|
865
|
+
console.log(` Codex opt-in: ${p.codex ? 'yes' : 'no'}`);
|
|
863
866
|
console.log(` Enabled: ${p.enabled ? 'yes' : 'no'}`);
|
|
864
867
|
console.log(` Idle over: ${p.idleMinutes} minutes`);
|
|
865
868
|
console.log(` Context over: ${Math.round(p.minBytes / 1024)} KB`);
|
|
@@ -879,14 +882,18 @@ async function manageCompact(action = 'status', arg = '') {
|
|
|
879
882
|
} else if (act === 'model') {
|
|
880
883
|
const m = String(arg || '').trim();
|
|
881
884
|
if (!m) { console.log(`Summary model is currently: ${policy.model || '(none set)'}`); return; }
|
|
882
|
-
if (m === 'none' || m === 'default') { set({ model: '' }); console.log('[OK]
|
|
885
|
+
if (m === 'none' || m === 'default') { set({ model: '' }); console.log('[OK] No summary model: no model is called.'); return; }
|
|
883
886
|
set({ model: m });
|
|
884
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.`);
|
|
885
892
|
} else if (act === 'idle') {
|
|
886
893
|
const n = Number(arg);
|
|
887
894
|
if (!Number.isFinite(n) || n <= 0) { console.error('Usage: switch compact idle <minutes>'); process.exit(1); }
|
|
888
|
-
set({ idleMinutes:
|
|
889
|
-
console.log(`[OK] Compact a conversation idle over ${
|
|
895
|
+
set({ idleMinutes: n });
|
|
896
|
+
console.log(`[OK] Compact a conversation idle over ${n} minutes.`);
|
|
890
897
|
} else if (act === 'min') {
|
|
891
898
|
const n = Number(arg);
|
|
892
899
|
if (!Number.isFinite(n) || n <= 0) { console.error('Usage: switch compact min <KB>'); process.exit(1); }
|
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
|
+
});
|