llm-switcher 1.2.7 → 1.2.9
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 +49 -0
- package/README.md +87 -4
- package/README.vi.md +88 -4
- package/blindfold/blindfold.mjs +2 -1
- package/docs/cross-platform.md +3 -1
- package/hook-status.mjs +51 -0
- package/notify-route.ps1 +46 -0
- package/package.json +2 -2
- package/plugin.mjs +204 -0
- package/proxy.mjs +76 -7
- package/service.mjs +1 -1
- package/shim.mjs +28 -2
- package/state.mjs +41 -5
- package/switch.mjs +53 -2
- package/tests/blindfold.test.mjs +1 -1
- package/tests/cdp.mjs +21 -2
- package/tests/contract-lab.test.mjs +5 -0
- package/tests/dashboard.e2e.test.mjs +116 -0
- package/tests/gateway.e2e.test.mjs +51 -2
- package/tests/hook-status.test.mjs +119 -0
- package/tests/plugin.test.mjs +132 -0
- package/tests/real-user-sim.test.mjs +2 -2
- package/tests/service.test.mjs +2 -0
- package/tests/shim.test.mjs +112 -0
- package/tests/state.test.mjs +130 -5
- package/ui.html +86 -7
package/plugin.mjs
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// plugin.mjs - installs the launch hook into the two coding tools, and edits no configuration.
|
|
3
|
+
//
|
|
4
|
+
// WHY A HOOK AT ALL
|
|
5
|
+
// The shim raises a notice at launch, but only when the shim runs, and that needs the shim
|
|
6
|
+
// directory first on PATH. A person who keeps their own `claude` wrapper never sees it. A hook
|
|
7
|
+
// runs inside the tool, so it reports whatever the launcher is.
|
|
8
|
+
//
|
|
9
|
+
// WHY NOTHING IS EDITED
|
|
10
|
+
// Claude Code loads any folder under a skills directory that holds `.claude-plugin/plugin.json`
|
|
11
|
+
// as a plugin, on the next session, with no marketplace and no install step. Codex loads
|
|
12
|
+
// `$CODEX_HOME/hooks.json` by itself. So each tool reads a file of its own, and `settings.json`
|
|
13
|
+
// and `config.toml` are never opened.
|
|
14
|
+
//
|
|
15
|
+
// WHY IT IS OPT-IN
|
|
16
|
+
// The notice is a reminder, not a part of the routing. A person who does not want it installs
|
|
17
|
+
// nothing and loses nothing: the traffic still goes through the gateway, and the shim toast
|
|
18
|
+
// still fires.
|
|
19
|
+
// ============================================================
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
const HOOK_SCRIPT = path.join(path.dirname(fileURLToPath(import.meta.url)), 'hook-status.mjs');
|
|
27
|
+
|
|
28
|
+
export const PLUGIN_NAME = 'llm-switcher-status';
|
|
29
|
+
// Written into every file this module owns, and the only thing that authorizes an overwrite.
|
|
30
|
+
const MARK = 'Generated by LLM Switcher - safe to delete';
|
|
31
|
+
// A fresh start and a resumed session. A resume is the case the switcher exists for, and compact
|
|
32
|
+
// is left out on purpose: the notice belongs at the start of a session, not in the middle of one.
|
|
33
|
+
const SESSION_SOURCES = 'startup|resume';
|
|
34
|
+
|
|
35
|
+
export function claudeSkillsDir(env = process.env, home = os.homedir()) {
|
|
36
|
+
return path.join(env.CLAUDE_CONFIG_DIR || path.join(home, '.claude'), 'skills');
|
|
37
|
+
}
|
|
38
|
+
export function codexHome(env = process.env, home = os.homedir()) {
|
|
39
|
+
return env.CODEX_HOME || path.join(home, '.codex');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function renderClaudePlugin(version = 'dev') {
|
|
43
|
+
return `${JSON.stringify({
|
|
44
|
+
name: PLUGIN_NAME,
|
|
45
|
+
version,
|
|
46
|
+
description: 'Reports at the start of a session when LLM Switcher routes this tool to another provider.',
|
|
47
|
+
author: { name: 'LLM Switcher' }
|
|
48
|
+
}, null, 2)}\n`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The args form, not one command string: a Windows path holds backslashes and spaces, and this
|
|
52
|
+
// way no shell has to quote it.
|
|
53
|
+
export function renderClaudeHooks() {
|
|
54
|
+
return `${JSON.stringify({
|
|
55
|
+
description: MARK,
|
|
56
|
+
hooks: {
|
|
57
|
+
SessionStart: [{
|
|
58
|
+
matcher: SESSION_SOURCES,
|
|
59
|
+
hooks: [{ type: 'command', command: 'node', args: [HOOK_SCRIPT, 'claude'], timeout: 10 }]
|
|
60
|
+
}]
|
|
61
|
+
}
|
|
62
|
+
}, null, 2)}\n`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Codex takes one command string. It is quoted here, once, for the same reason.
|
|
66
|
+
export function renderCodexHooks() {
|
|
67
|
+
return `${JSON.stringify({ description: MARK, hooks: { SessionStart: [codexEntry()] } }, null, 2)}\n`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function codexEntry() {
|
|
71
|
+
return {
|
|
72
|
+
matcher: SESSION_SOURCES,
|
|
73
|
+
hooks: [{ type: 'command', command: `node "${HOOK_SCRIPT}" codex`, timeout: 10 }]
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Ours is the entry that runs our script. Nothing else in the file is touched, by any operation.
|
|
78
|
+
const isOurs = (group) => JSON.stringify(group).includes('hook-status.mjs');
|
|
79
|
+
|
|
80
|
+
function writeAtomic(file, content) {
|
|
81
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
82
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
83
|
+
try {
|
|
84
|
+
fs.writeFileSync(tmp, content, 'utf8');
|
|
85
|
+
fs.renameSync(tmp, file);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
fs.rmSync(tmp, { force: true });
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readJsonFile(file) {
|
|
93
|
+
if (!fs.existsSync(file)) return { missing: true };
|
|
94
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
95
|
+
try { return { value: JSON.parse(raw) }; } catch (err) { return { error: err.message }; }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---- Claude Code -----------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
function pluginDir(opts) {
|
|
101
|
+
return path.join(opts.claudeSkillsDir || claudeSkillsDir(), PLUGIN_NAME);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function installClaude(opts, version) {
|
|
105
|
+
const dir = pluginDir(opts);
|
|
106
|
+
const manifest = path.join(dir, '.claude-plugin', 'plugin.json');
|
|
107
|
+
// A folder with a manifest that is not ours belongs to someone else, whatever its name is.
|
|
108
|
+
if (fs.existsSync(manifest)) {
|
|
109
|
+
const cur = readJsonFile(manifest);
|
|
110
|
+
if (cur.error) return { ok: false, reason: `${manifest} does not parse: ${cur.error}` };
|
|
111
|
+
if (cur.value?.name !== PLUGIN_NAME) {
|
|
112
|
+
return { ok: false, reason: `${dir} holds another plugin (${cur.value?.name}), so nothing was changed` };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
writeAtomic(manifest, renderClaudePlugin(version));
|
|
116
|
+
writeAtomic(path.join(dir, 'hooks', 'hooks.json'), renderClaudeHooks());
|
|
117
|
+
return { ok: true, path: dir };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function uninstallClaude(opts) {
|
|
121
|
+
const dir = pluginDir(opts);
|
|
122
|
+
const manifest = path.join(dir, '.claude-plugin', 'plugin.json');
|
|
123
|
+
if (!fs.existsSync(manifest)) return { ok: true, path: dir, absent: true };
|
|
124
|
+
const cur = readJsonFile(manifest);
|
|
125
|
+
if (cur.value?.name !== PLUGIN_NAME) return { ok: false, reason: `${dir} is not our plugin, so nothing was removed` };
|
|
126
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
127
|
+
return { ok: true, path: dir };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---- Codex -----------------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
function codexHooksPath(opts) {
|
|
133
|
+
return path.join(opts.codexHome || codexHome(), 'hooks.json');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The file may already hold the hooks of this machine, so it is merged, never replaced. A file
|
|
137
|
+
// that does not parse is left exactly as it is: it can hold work that no backup would return.
|
|
138
|
+
function installCodex(opts) {
|
|
139
|
+
const file = codexHooksPath(opts);
|
|
140
|
+
const cur = readJsonFile(file);
|
|
141
|
+
if (cur.error) return { ok: false, reason: `${file} does not parse: ${cur.error}, so nothing was changed` };
|
|
142
|
+
if (cur.missing) {
|
|
143
|
+
writeAtomic(file, renderCodexHooks());
|
|
144
|
+
return { ok: true, path: file };
|
|
145
|
+
}
|
|
146
|
+
const doc = cur.value && typeof cur.value === 'object' ? cur.value : {};
|
|
147
|
+
doc.hooks = doc.hooks && typeof doc.hooks === 'object' ? doc.hooks : {};
|
|
148
|
+
const groups = Array.isArray(doc.hooks.SessionStart) ? doc.hooks.SessionStart : [];
|
|
149
|
+
doc.hooks.SessionStart = [...groups.filter(g => !isOurs(g)), codexEntry()];
|
|
150
|
+
writeAtomic(file, `${JSON.stringify(doc, null, 2)}\n`);
|
|
151
|
+
return { ok: true, path: file };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function uninstallCodex(opts) {
|
|
155
|
+
const file = codexHooksPath(opts);
|
|
156
|
+
const cur = readJsonFile(file);
|
|
157
|
+
if (cur.missing) return { ok: true, path: file, absent: true };
|
|
158
|
+
if (cur.error) return { ok: false, reason: `${file} does not parse: ${cur.error}, so nothing was changed` };
|
|
159
|
+
const doc = cur.value && typeof cur.value === 'object' ? cur.value : {};
|
|
160
|
+
const groups = Array.isArray(doc.hooks?.SessionStart) ? doc.hooks.SessionStart : [];
|
|
161
|
+
const kept = groups.filter(g => !isOurs(g));
|
|
162
|
+
if (kept.length) doc.hooks.SessionStart = kept;
|
|
163
|
+
else delete doc.hooks?.SessionStart;
|
|
164
|
+
// Only a file that holds nothing but our own mark is removed. Anything else is written back.
|
|
165
|
+
const empty = doc.hooks && Object.keys(doc.hooks).length === 0;
|
|
166
|
+
if (empty && doc.description === MARK) {
|
|
167
|
+
fs.rmSync(file, { force: true });
|
|
168
|
+
return { ok: true, path: file };
|
|
169
|
+
}
|
|
170
|
+
writeAtomic(file, `${JSON.stringify(doc, null, 2)}\n`);
|
|
171
|
+
return { ok: true, path: file };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---- The three commands ----------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
function summarize(results) {
|
|
177
|
+
const out = { installed: [], failed: [], paths: {} };
|
|
178
|
+
for (const [tool, r] of Object.entries(results)) {
|
|
179
|
+
if (r.ok) out.installed.push(tool); else out.failed.push({ tool, reason: r.reason });
|
|
180
|
+
if (r.path) out.paths[tool] = r.path;
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** { installed: ['claude','codex'], failed: [{ tool, reason }], paths: { claude, codex } } */
|
|
186
|
+
export function installPlugin(opts = {}, version = 'dev') {
|
|
187
|
+
return summarize({ claude: installClaude(opts, version), codex: installCodex(opts) });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function uninstallPlugin(opts = {}) {
|
|
191
|
+
return summarize({ claude: uninstallClaude(opts), codex: uninstallCodex(opts) });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function pluginStatus(opts = {}) {
|
|
195
|
+
const dir = pluginDir(opts);
|
|
196
|
+
const claudeManifest = readJsonFile(path.join(dir, '.claude-plugin', 'plugin.json'));
|
|
197
|
+
const file = codexHooksPath(opts);
|
|
198
|
+
const codexDoc = readJsonFile(file);
|
|
199
|
+
const codexGroups = Array.isArray(codexDoc.value?.hooks?.SessionStart) ? codexDoc.value.hooks.SessionStart : [];
|
|
200
|
+
return {
|
|
201
|
+
claude: { installed: claudeManifest.value?.name === PLUGIN_NAME, path: dir, id: `${PLUGIN_NAME}@skills-dir` },
|
|
202
|
+
codex: { installed: codexGroups.some(isOurs), path: file }
|
|
203
|
+
};
|
|
204
|
+
}
|
package/proxy.mjs
CHANGED
|
@@ -500,7 +500,54 @@ function previewOf(ir) {
|
|
|
500
500
|
// ----------------------------------------------------
|
|
501
501
|
const HOP_BY_HOP = new Set(['content-length', 'content-encoding', 'transfer-encoding', 'connection', 'keep-alive']);
|
|
502
502
|
|
|
503
|
-
|
|
503
|
+
// Bifrost: intact names, per model, the User-Agent prefix of the provider's own client (bifrost_ua).
|
|
504
|
+
// When the caller is that client, the request crosses as it is: only the key changes.
|
|
505
|
+
const bifrostUACache = new Map();
|
|
506
|
+
async function bifrostUAFor(profile, model) {
|
|
507
|
+
const base = String(profile.baseURL || '').replace(/\/+$/, '');
|
|
508
|
+
if (!base || !model) return '';
|
|
509
|
+
const key = `${base}\n${model}`;
|
|
510
|
+
const hit = bifrostUACache.get(key);
|
|
511
|
+
if (hit && hit.until > Date.now()) return hit.ua;
|
|
512
|
+
let ua = '';
|
|
513
|
+
let ttl = 10 * 60 * 1000;
|
|
514
|
+
try {
|
|
515
|
+
const r = await fetch(`${base}/models/${model.split('/').map(encodeURIComponent).join('/')}`, {
|
|
516
|
+
headers: { authorization: `Bearer ${profile.apiKey || ''}` }, signal: AbortSignal.timeout(3000)
|
|
517
|
+
});
|
|
518
|
+
if (r.ok) ua = String((await r.json())?.bifrost_ua || '');
|
|
519
|
+
} catch {
|
|
520
|
+
ttl = 30 * 1000; // an unreachable list must not pin the decision for long
|
|
521
|
+
}
|
|
522
|
+
bifrostUACache.set(key, { ua, until: Date.now() + ttl });
|
|
523
|
+
return ua;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async function crossesBifrost(req, profile, model) {
|
|
527
|
+
const clientUA = String(req.headers['user-agent'] || '');
|
|
528
|
+
if (!clientUA) return false;
|
|
529
|
+
const ua = await bifrostUAFor(profile, model);
|
|
530
|
+
return Boolean(ua) && clientUA.startsWith(ua);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Every client header except its own credentials, the switcher's control headers and hop-by-hop.
|
|
534
|
+
function bifrostHeaders(req, profile) {
|
|
535
|
+
const headers = {};
|
|
536
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
537
|
+
const lk = k.toLowerCase();
|
|
538
|
+
if (BLOCKED_PASSTHROUGH.has(lk) || HOP_BY_HOP.has(lk) || lk === 'host' || lk === 'authorization' || lk === 'accept-encoding') continue;
|
|
539
|
+
headers[lk] = v;
|
|
540
|
+
}
|
|
541
|
+
headers['x-api-key'] = profile.apiKey || '';
|
|
542
|
+
return headers;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function withClientQuery(url, req) {
|
|
546
|
+
const q = String(req.url || '').indexOf('?');
|
|
547
|
+
return q < 0 ? url : url + (url.includes('?') ? '&' : '?') + req.url.slice(q + 1);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function forwardAnthropicDirect(res, payload, bodyBuffer, url, headers, mappedModel, signal, profile, bifrost = false) {
|
|
504
551
|
debugLog('Direct forward to native Anthropic endpoint:', url);
|
|
505
552
|
|
|
506
553
|
// Only re-serialize when a fix is actually needed; otherwise forward the client's original bytes.
|
|
@@ -510,13 +557,13 @@ async function forwardAnthropicDirect(res, payload, bodyBuffer, url, headers, ma
|
|
|
510
557
|
json = { ...json, model: mappedModel };
|
|
511
558
|
modified = true;
|
|
512
559
|
}
|
|
513
|
-
const healed = healAnthropicPayload(json);
|
|
560
|
+
const healed = bifrost ? { changed: false, notes: [] } : healAnthropicPayload(json);
|
|
514
561
|
if (healed.changed) {
|
|
515
562
|
json = healed.payload;
|
|
516
563
|
modified = true;
|
|
517
564
|
debugLog('Healer (direct):', healed.notes.join('; '));
|
|
518
565
|
}
|
|
519
|
-
if (profile?.thinkingMode === 'off' && json.thinking) {
|
|
566
|
+
if (!bifrost && profile?.thinkingMode === 'off' && json.thinking) {
|
|
520
567
|
json = { ...json };
|
|
521
568
|
delete json.thinking;
|
|
522
569
|
modified = true;
|
|
@@ -752,6 +799,25 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
|
|
|
752
799
|
let answered = false; // set only after a complete 2xx answer; the half upload depends on it
|
|
753
800
|
|
|
754
801
|
try {
|
|
802
|
+
// Bifrost: the provider's own client reaching its own account through intact. Bytes and headers
|
|
803
|
+
// go as sent, only the key changes: no healer, no thinkingMode, no conversion.
|
|
804
|
+
if (clientFormat === 'anthropic' && await crossesBifrost(req, profile, mappedModel)) {
|
|
805
|
+
const { url } = upstreamEndpoint(profile, 'anthropic', mappedModel, ir.stream, req);
|
|
806
|
+
const headers = bifrostHeaders(req, profile);
|
|
807
|
+
if (traceId) headers['x-intact-trace'] = traceId;
|
|
808
|
+
try {
|
|
809
|
+
const r = await forwardAnthropicDirect(res, payload, bodyBuffer, withClientQuery(url, req), headers, mappedModel, ac.signal, profile, true);
|
|
810
|
+
answered = !r.error && r.status >= 200 && r.status < 300;
|
|
811
|
+
log({ outFormat: 'anthropic', status: r.status, tokens: r.tokens, responsePreview: '(bifrost)', error: r.error || undefined });
|
|
812
|
+
} catch (err) {
|
|
813
|
+
if (ac.signal.aborted) return log({ status: 499, error: 'client disconnected' });
|
|
814
|
+
console.error(`[${profileKey}] Bifrost forward error:`, err.message);
|
|
815
|
+
sendClientError(res, clientFormat, 502, `Bifrost forward error: ${err.message}`);
|
|
816
|
+
log({ status: 502, error: err.message });
|
|
817
|
+
}
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
|
|
755
821
|
// Fast path: anthropic in/out goes straight through, preserving original bytes (including thinking signatures).
|
|
756
822
|
// Note: this branch skips the Healer Engine because it bypasses the IR.
|
|
757
823
|
if (clientFormat === 'anthropic' && outFormat === 'anthropic') {
|
|
@@ -889,11 +955,14 @@ async function handleCountTokens(req, res, buf) {
|
|
|
889
955
|
const { profile } = getActiveProfile('anthropic', req);
|
|
890
956
|
if (profile) {
|
|
891
957
|
const mappedModel = mapModel(payload.model || '', profile, 'anthropic');
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
const
|
|
958
|
+
const bifrost = await crossesBifrost(req, profile, mappedModel);
|
|
959
|
+
if (bifrost || resolveOutFormat(profile, mappedModel) === 'anthropic') {
|
|
960
|
+
const ep = upstreamEndpoint(profile, 'anthropic', mappedModel, false, req);
|
|
961
|
+
const headers = bifrost ? bifrostHeaders(req, profile) : ep.headers;
|
|
962
|
+
let countUrl = profile.endpoints?.countTokens || ep.url.replace(/\/messages$/, '/messages/count_tokens');
|
|
963
|
+
if (bifrost) countUrl = withClientQuery(countUrl, req);
|
|
895
964
|
try {
|
|
896
|
-
const body = healAnthropicPayload({ ...payload, model: mappedModel }).payload;
|
|
965
|
+
const body = bifrost ? { ...payload, model: mappedModel } : healAnthropicPayload({ ...payload, model: mappedModel }).payload;
|
|
897
966
|
const r = await fetch(countUrl, { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(15000) });
|
|
898
967
|
if (r.ok) {
|
|
899
968
|
const j = await r.json();
|
package/service.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import path from 'node:path';
|
|
|
5
5
|
|
|
6
6
|
// A service does not inherit the installing shell. Without these it would read another config.json
|
|
7
7
|
// or clean another settings.json than the shell that installed it.
|
|
8
|
-
const SERVICE_ENV_KEYS = ['CLAUDE_CONFIG_DIR', 'LLM_SWITCHER_CONFIG', 'LLM_SWITCHER_STATE_DIR', 'LLM_SWITCHER_BLINDFOLD_CERTS'];
|
|
8
|
+
const SERVICE_ENV_KEYS = ['LLM_SWITCHER_HOME', 'CLAUDE_CONFIG_DIR', 'LLM_SWITCHER_CONFIG', 'LLM_SWITCHER_STATE_DIR', 'LLM_SWITCHER_BLINDFOLD_CERTS'];
|
|
9
9
|
|
|
10
10
|
export function serviceEnv(env = process.env) {
|
|
11
11
|
return SERVICE_ENV_KEYS.filter(k => env[k]).map(k => [k, env[k]]);
|
package/shim.mjs
CHANGED
|
@@ -36,15 +36,18 @@ export const SHIM_DIR = path.join(os.homedir(), '.llm-switcher', 'bin');
|
|
|
36
36
|
// `node -e`, so neither cmd.exe quoting nor URL encoding can corrupt it: pathToFileURL turns a
|
|
37
37
|
// space in the checkout into %20, which cmd.exe then reads as the (undefined) argument %2.
|
|
38
38
|
const STATE_HELPER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'ensure-ca-bundle.mjs');
|
|
39
|
+
const ROUTE_NOTIFIER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'notify-route.ps1');
|
|
39
40
|
|
|
40
41
|
// CLIs to wrap. `claude` is the most important case (--resume), codex included for completeness.
|
|
41
42
|
export const SHIMMED = ['claude', 'codex'];
|
|
42
43
|
|
|
43
44
|
// One tool, one env file. Sourcing the shared one is how one tool used to capture the other.
|
|
44
45
|
const envFileOf = (name) => (name === 'codex' ? 'env-codex.sh' : 'env-claude.sh');
|
|
46
|
+
const routeFileOf = (name) => (name === 'codex' ? 'route-codex.txt' : 'route-claude.txt');
|
|
45
47
|
|
|
46
48
|
const POSIX_TEMPLATE = (name) => {
|
|
47
49
|
const envFile = envFileOf(name);
|
|
50
|
+
const routeFile = routeFileOf(name);
|
|
48
51
|
const caBlock = name === 'codex' ? '' : `
|
|
49
52
|
# R2: one bundle holds the user's own CA and the switcher CA, so trusting the interceptor never
|
|
50
53
|
# costs a certificate the user already had. Decided here, at launch, because only now is it known
|
|
@@ -85,6 +88,13 @@ else
|
|
|
85
88
|
TOOL_ACTIVE=0
|
|
86
89
|
fi
|
|
87
90
|
|
|
91
|
+
# Nothing on screen tells the person that the switcher took this tool's traffic, so say it once,
|
|
92
|
+
# here. Only while THIS tool is routed: an empty route file means the tool is off, so the tool
|
|
93
|
+
# reaches its official endpoint and there is nothing to warn about.
|
|
94
|
+
if [ "$TOOL_ACTIVE" = "1" ] && [ -s "$SWITCHER_DIR/${routeFile}" ]; then
|
|
95
|
+
printf '[llm-switcher] %s\\n' "$(cat "$SWITCHER_DIR/${routeFile}")" >&2
|
|
96
|
+
fi
|
|
97
|
+
|
|
88
98
|
# R8: a variable is removed ONLY when its value is one an older switcher wrote. The user's own
|
|
89
99
|
# proxy, base URL, model or LLM_SWITCHER_* setting is theirs and is kept.
|
|
90
100
|
GW_PORT="$( { [ -O "$SWITCHER_DIR/gateway.port" ] && tr -d '[:space:]' < "$SWITCHER_DIR/gateway.port"; } 2>/dev/null )"
|
|
@@ -167,6 +177,7 @@ exec "$REAL" "$@"
|
|
|
167
177
|
|
|
168
178
|
const WINDOWS_TEMPLATE = (name) => {
|
|
169
179
|
const envFile = envFileOf(name).replace(/\.sh$/, '.cmd');
|
|
180
|
+
const routeFile = routeFileOf(name);
|
|
170
181
|
const caBlock = name === 'codex' ? '' : `
|
|
171
182
|
REM R2: one bundle holds the user's own CA and the switcher CA, so trusting the interceptor
|
|
172
183
|
REM never costs a certificate the user already had. Decided here, at launch, because only now
|
|
@@ -202,6 +213,11 @@ if exist "%SWITCHER_DIR%\\active.flag" if exist "%SWITCHER_DIR%\\${envFile}" (
|
|
|
202
213
|
)
|
|
203
214
|
if "%TOOL_ACTIVE%"=="1" call "%SWITCHER_DIR%\\${envFile}"
|
|
204
215
|
|
|
216
|
+
REM Nothing on screen tells the person that the switcher took this tool's traffic, and both TUIs
|
|
217
|
+
REM can claim the whole screen, so a printed line would be hidden. A toast is not. It is detached:
|
|
218
|
+
REM the tool never waits for it. Only while THIS tool is routed (%TOOL_ACTIVE%).
|
|
219
|
+
if "%TOOL_ACTIVE%"=="1" if exist "%SWITCHER_DIR%\\${routeFile}" start "" /b powershell -NoProfile -ExecutionPolicy Bypass -File "${ROUTE_NOTIFIER}" "%SWITCHER_DIR%\\${routeFile}" >nul 2>&1
|
|
220
|
+
|
|
205
221
|
REM R8: a variable is removed ONLY when its value is one an older switcher wrote. The user's own
|
|
206
222
|
REM proxy, base URL, model or LLM_SWITCHER_* setting is theirs and is kept.
|
|
207
223
|
set "_GWPORT="
|
|
@@ -359,10 +375,13 @@ export function shimStatus(names = SHIMMED) {
|
|
|
359
375
|
/** Line to add to the shell rc so the shim comes before the real binary. */
|
|
360
376
|
// Windows: setx truncates at 1024 characters and would copy the merged system+user PATH into the
|
|
361
377
|
// user key, and PowerShell does not expand %PATH%. Prepend to the User-scope Path only.
|
|
362
|
-
export function pathExportLine(platform = process.platform) {
|
|
378
|
+
export function pathExportLine(platform = process.platform, shell = currentShell()) {
|
|
363
379
|
if (platform === 'win32') {
|
|
364
380
|
return `powershell -NoProfile -Command "[Environment]::SetEnvironmentVariable('Path', '${SHIM_DIR};' + [Environment]::GetEnvironmentVariable('Path', 'User'), 'User')"`;
|
|
365
381
|
}
|
|
382
|
+
// fish has no `export`, and it prepends through the fish_user_paths universal variable.
|
|
383
|
+
// `-m` moves the directory to the front when it is on the path already.
|
|
384
|
+
if (shell === 'fish') return `fish_add_path -m "${SHIM_DIR}"`;
|
|
366
385
|
return `export PATH="${SHIM_DIR}:$PATH"`;
|
|
367
386
|
}
|
|
368
387
|
|
|
@@ -370,12 +389,19 @@ export function pathExportLine(platform = process.platform) {
|
|
|
370
389
|
export function suggestedRcFiles(platform = process.platform) {
|
|
371
390
|
if (platform === 'win32') return [];
|
|
372
391
|
const home = os.homedir();
|
|
373
|
-
const shell =
|
|
392
|
+
const shell = currentShell();
|
|
374
393
|
if (shell === 'zsh') return [path.join(home, '.zshrc'), path.join(home, '.zprofile')];
|
|
375
394
|
if (shell === 'bash') return [path.join(home, '.bashrc'), path.join(home, '.bash_profile')];
|
|
395
|
+
// fish reads neither .profile nor any POSIX rc file.
|
|
396
|
+
if (shell === 'fish') return [path.join(home, '.config', 'fish', 'config.fish')];
|
|
376
397
|
return [path.join(home, '.profile')];
|
|
377
398
|
}
|
|
378
399
|
|
|
400
|
+
/** The basename of the login shell, which decides both the file to edit and the syntax of the line. */
|
|
401
|
+
function currentShell() {
|
|
402
|
+
return path.basename(process.env.SHELL || '');
|
|
403
|
+
}
|
|
404
|
+
|
|
379
405
|
// The shim is on PATH but behind the real binary: a line that runs later prepends another directory
|
|
380
406
|
// (npm-global, Homebrew). Login shells read the profile file, interactive shells the rc file.
|
|
381
407
|
export function pathOrderHint(platform = process.platform) {
|
package/state.mjs
CHANGED
|
@@ -134,6 +134,10 @@ export const paths = {
|
|
|
134
134
|
envClaudeSh: path.join(STATE_DIR, 'env-claude.sh'),
|
|
135
135
|
envCodexCmd: path.join(STATE_DIR, 'env-codex.cmd'),
|
|
136
136
|
envCodexSh: path.join(STATE_DIR, 'env-codex.sh'),
|
|
137
|
+
// One line per tool, written with that tool's env file: what the launcher shows the person at
|
|
138
|
+
// launch. Empty exactly when the tool is not routed, so the launcher needs no second condition.
|
|
139
|
+
routeClaude: path.join(STATE_DIR, 'route-claude.txt'),
|
|
140
|
+
routeCodex: path.join(STATE_DIR, 'route-codex.txt'),
|
|
137
141
|
// The gateway port of the last switch on. The shim scrubs a stale loopback base URL
|
|
138
142
|
// against it, so a shell opened before a port change still reaches the right gateway.
|
|
139
143
|
gatewayPort: path.join(STATE_DIR, 'gateway.port'),
|
|
@@ -454,6 +458,11 @@ export function validateProfileInput(p) {
|
|
|
454
458
|
return null;
|
|
455
459
|
}
|
|
456
460
|
|
|
461
|
+
/** True while a profile still carries the example config's placeholder baseURL or apiKey. */
|
|
462
|
+
export function hasPlaceholder(p) {
|
|
463
|
+
return !p?.baseURL || /YOUR-|REPLACE-ME/i.test(`${p.baseURL} ${p.apiKey ?? ''}`);
|
|
464
|
+
}
|
|
465
|
+
|
|
457
466
|
export function needsMigration(cfg) {
|
|
458
467
|
if (!cfg || typeof cfg !== 'object') return false;
|
|
459
468
|
if (Object.hasOwn(cfg, 'activeProfile')) return true;
|
|
@@ -1227,8 +1236,9 @@ export function setTargetProfile(cfg, target, profileKey) {
|
|
|
1227
1236
|
if (!profileAcceptsTarget(p, tool)) {
|
|
1228
1237
|
return `Profile "${profileKey}" only accepts "${p.tool || p.inFormat}" input and cannot serve target "${tool}"`;
|
|
1229
1238
|
}
|
|
1239
|
+
// No legacy `activeProfile` here: needsMigration flags it, so every switch would re-run the
|
|
1240
|
+
// migration on the next load and leave another config.json.bak-* holding the API keys.
|
|
1230
1241
|
map[tool] = profileKey;
|
|
1231
|
-
cfg.activeProfile = profileKey;
|
|
1232
1242
|
return null;
|
|
1233
1243
|
}
|
|
1234
1244
|
|
|
@@ -1240,7 +1250,6 @@ export function activateProfile(cfg, profileKey) {
|
|
|
1240
1250
|
for (const t of TOOLS) {
|
|
1241
1251
|
if (profileAcceptsTarget(p, t)) map[t] = profileKey;
|
|
1242
1252
|
}
|
|
1243
|
-
cfg.activeProfile = profileKey;
|
|
1244
1253
|
return null;
|
|
1245
1254
|
}
|
|
1246
1255
|
|
|
@@ -1300,6 +1309,16 @@ export function primaryModel(profile) {
|
|
|
1300
1309
|
return '';
|
|
1301
1310
|
}
|
|
1302
1311
|
|
|
1312
|
+
// What a person needs at launch: which profile took the traffic, the host it goes to, the model it
|
|
1313
|
+
// maps to, and whether the 1M window is on, because that changes what the session costs. Never the
|
|
1314
|
+
// API key: a desktop notification reads this line, and so can any shell.
|
|
1315
|
+
function routeLine(tool, key, profile) {
|
|
1316
|
+
let host = '';
|
|
1317
|
+
try { host = new URL(profile.baseURL).host; } catch { /* a profile may carry no address yet */ }
|
|
1318
|
+
return [`${tool} -> ${key}`, host, primaryModel(profile), anyTier1M(profile) ? '1M' : '']
|
|
1319
|
+
.filter(Boolean).join(' | ');
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1303
1322
|
// Derive launcher state from activeProfiles (single source of truth).
|
|
1304
1323
|
export function computeLaunchState(cfg, port) {
|
|
1305
1324
|
const map = getActiveMap(cfg);
|
|
@@ -1310,7 +1329,15 @@ export function computeLaunchState(cfg, port) {
|
|
|
1310
1329
|
|
|
1311
1330
|
// One interceptor serves both tools (R3) and its port lives at the top level of config.json
|
|
1312
1331
|
// (R3b). The per-profile port went with the per-profile host and prefix.
|
|
1313
|
-
|
|
1332
|
+
// Precedence mirrors resolvePort for the gateway: the environment, then the file, then the
|
|
1333
|
+
// default. Without the variable a second instance could not move the interceptor, and a gateway
|
|
1334
|
+
// sent to 3457 by --port or by LLM_SWITCHER_PORT landed on the interceptor's own port.
|
|
1335
|
+
let bfPort = parsePort(process.env.LLM_SWITCHER_BLINDFOLD_PORT)
|
|
1336
|
+
|| parsePort(cfg?.blindfold?.port)
|
|
1337
|
+
|| DEFAULT_BLINDFOLD_PORT;
|
|
1338
|
+
if (bfPort === port) {
|
|
1339
|
+
bfPort = port === DEFAULT_BLINDFOLD_PORT ? DEFAULT_BLINDFOLD_PORT + 1 : DEFAULT_BLINDFOLD_PORT;
|
|
1340
|
+
}
|
|
1314
1341
|
const interceptor = `http://127.0.0.1:${bfPort}`;
|
|
1315
1342
|
const loopbackOnly = '127.0.0.1,localhost';
|
|
1316
1343
|
const proxyPairs = () => [
|
|
@@ -1339,7 +1366,10 @@ export function computeLaunchState(cfg, port) {
|
|
|
1339
1366
|
// no --config (F1, F3, F4). The shim of the tool may therefore never capture the traffic
|
|
1340
1367
|
// of the other tool, and a restart cannot leave a stale override behind.
|
|
1341
1368
|
envClaude: [],
|
|
1342
|
-
envCodex: []
|
|
1369
|
+
envCodex: [],
|
|
1370
|
+
// The launcher notice, one line per tool. Empty for a tool that is not routed (A1).
|
|
1371
|
+
routeClaude: claude ? routeLine('claude', map.claude, claude) : '',
|
|
1372
|
+
routeCodex: codex ? routeLine('codex', map.codex, codex) : ''
|
|
1343
1373
|
};
|
|
1344
1374
|
|
|
1345
1375
|
// NODE_EXTRA_CA_CERTS is deliberately absent from env-claude: the shim decides it at launch,
|
|
@@ -1446,6 +1476,7 @@ export function emptyToolEnvFiles(tool) {
|
|
|
1446
1476
|
const before = codex ? readOrEmpty(paths.envCodexSh) : '';
|
|
1447
1477
|
writeAtomic(codex ? paths.envCodexCmd : paths.envClaudeCmd, '');
|
|
1448
1478
|
writeAtomic(codex ? paths.envCodexSh : paths.envClaudeSh, '');
|
|
1479
|
+
writeAtomic(codex ? paths.routeCodex : paths.routeClaude, '');
|
|
1449
1480
|
return before !== '';
|
|
1450
1481
|
});
|
|
1451
1482
|
return { codexRouteChanged, codexDaemon: codexRouteChanged ? restartCodexDaemon([]) : undefined };
|
|
@@ -1513,6 +1544,10 @@ function writeLaunchState(cfg, port) {
|
|
|
1513
1544
|
try {
|
|
1514
1545
|
writeToolFiles(st.envClaude, paths.envClaudeSh, paths.envClaudeCmd);
|
|
1515
1546
|
writeToolFiles(st.envCodex, paths.envCodexSh, paths.envCodexCmd);
|
|
1547
|
+
writeAtomic(paths.routeClaude, st.routeClaude ? `${st.routeClaude}
|
|
1548
|
+
` : '');
|
|
1549
|
+
writeAtomic(paths.routeCodex, st.routeCodex ? `${st.routeCodex}
|
|
1550
|
+
` : '');
|
|
1516
1551
|
st.codexRouteChanged = readOrEmpty(paths.envCodexSh) !== codexBefore;
|
|
1517
1552
|
// Recorded on switch on so a shell opened while the gateway ran can still recognize its own
|
|
1518
1553
|
// stale loopback URL later, after the port changed (R8).
|
|
@@ -1537,7 +1572,8 @@ export function clearLaunchState(port) {
|
|
|
1537
1572
|
// leave env.sh present and containing no export and no unset.
|
|
1538
1573
|
writeAtomic(paths.envCmd, STUB_CMD);
|
|
1539
1574
|
writeAtomic(paths.envSh, STUB_SH);
|
|
1540
|
-
for (const f of [paths.envClaudeCmd, paths.envClaudeSh, paths.envCodexCmd, paths.envCodexSh
|
|
1575
|
+
for (const f of [paths.envClaudeCmd, paths.envClaudeSh, paths.envCodexCmd, paths.envCodexSh,
|
|
1576
|
+
paths.routeClaude, paths.routeCodex]) {
|
|
1541
1577
|
writeAtomic(f, '');
|
|
1542
1578
|
}
|
|
1543
1579
|
return codexBefore !== '';
|
package/switch.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import { spawn, execFileSync } from 'node:child_process';
|
|
6
6
|
import {
|
|
7
|
-
ROOT_DIR, STATE_DIR, TOOLS, configPath, claudeSettingsPath, paths, certCoversHost, loadConfig, getConfigLoadError, saveConfig,
|
|
7
|
+
ROOT_DIR, STATE_DIR, TOOLS, configPath, hasPlaceholder, claudeSettingsPath, paths, certCoversHost, loadConfig, getConfigLoadError, saveConfig,
|
|
8
8
|
resolvePort, parsePort, findProfileKey, getActiveMap, setTargetProfile, activateProfile, deactivateAll,
|
|
9
9
|
applyLaunchState, clearLaunchState, computeLaunchState,
|
|
10
10
|
modelSlotsForProfile, modelForSlot, model1MForSlot, readAdminToken, adminTokenPath, openLog,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
casClearToolPointers, emptyToolEnvFiles, syncInterceptorTools
|
|
15
15
|
} from './state.mjs';
|
|
16
16
|
import { runProbe, runCheck } from './contract.mjs';
|
|
17
|
+
import { installPlugin, uninstallPlugin, pluginStatus, PLUGIN_NAME } from './plugin.mjs';
|
|
17
18
|
import {
|
|
18
19
|
SHIM_DIR, installShims, uninstallShims, shimStatus, pathExportLine, pathOrderHint,
|
|
19
20
|
suggestedRcFiles, auditRunningProcesses
|
|
@@ -391,6 +392,10 @@ async function turnOn(profileName, cliTarget) {
|
|
|
391
392
|
|
|
392
393
|
// Plan on a copy. Nothing is written until every check below passes.
|
|
393
394
|
let planned = planSwitch(config, key, cliTarget);
|
|
395
|
+
// The example config routes to YOUR-ROUTER-HOST; activating it silently makes every request fail.
|
|
396
|
+
if (hasPlaceholder(planned.profiles[key])) {
|
|
397
|
+
console.warn(`[WARN] Profile "${show(key)}" still has a placeholder baseURL/apiKey. Edit ${configPath} first, or requests will fail.`);
|
|
398
|
+
}
|
|
394
399
|
console.log(`Activating profile: [${show(planned.profiles[key].name || key)}] (${show(key)})${cliTarget ? ` for ${cliTarget}` : ''} on port ${port}...`);
|
|
395
400
|
|
|
396
401
|
const gatewayState = await probeGateway(port);
|
|
@@ -777,6 +782,47 @@ async function manageService(action) {
|
|
|
777
782
|
console.log('Usage: switch service [install|uninstall]');
|
|
778
783
|
}
|
|
779
784
|
|
|
785
|
+
// The launch notice, as a hook inside each tool. Opt-in: the routing does not depend on it, and a
|
|
786
|
+
// person who installs nothing still gets the toast from the shim.
|
|
787
|
+
async function managePlugin(action = 'status') {
|
|
788
|
+
const act = (action || 'status').toLowerCase();
|
|
789
|
+
|
|
790
|
+
if (act === 'install' || act === 'on') {
|
|
791
|
+
const { CURRENT_VERSION } = await import('./version.mjs');
|
|
792
|
+
const { installed, failed, paths } = installPlugin({}, CURRENT_VERSION);
|
|
793
|
+
for (const tool of installed) console.log(`[OK] ${tool}: ${paths[tool]}`);
|
|
794
|
+
for (const f of failed) console.error(`[Error] ${f.tool}: ${f.reason}`);
|
|
795
|
+
if (installed.length) {
|
|
796
|
+
console.log('\nNeither settings.json nor config.toml was opened. Each tool reads a file of its own.');
|
|
797
|
+
console.log(`Claude Code loads it on the next session as ${PLUGIN_NAME}@skills-dir.`);
|
|
798
|
+
console.log('Codex asks you once to trust a new hook. Accept it, or the hook stays off.');
|
|
799
|
+
console.log('\nTo remove it: switch plugin uninstall');
|
|
800
|
+
}
|
|
801
|
+
if (failed.length) process.exit(1);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (act === 'uninstall' || act === 'off' || act === 'remove') {
|
|
806
|
+
const { installed, failed, paths } = uninstallPlugin();
|
|
807
|
+
for (const tool of installed) console.log(`[OK] ${tool}: removed from ${paths[tool]}`);
|
|
808
|
+
for (const f of failed) console.error(`[Error] ${f.tool}: ${f.reason}`);
|
|
809
|
+
if (failed.length) process.exit(1);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const st = pluginStatus();
|
|
814
|
+
console.log('=== Launch notice (hook inside each coding tool) ===\n');
|
|
815
|
+
console.log(`Claude Code: ${st.claude.installed ? `INSTALLED as ${st.claude.id}` : 'not installed'}`);
|
|
816
|
+
console.log(` ${st.claude.path}`);
|
|
817
|
+
console.log(`Codex : ${st.codex.installed ? 'INSTALLED' : 'not installed'}`);
|
|
818
|
+
console.log(` ${st.codex.path}`);
|
|
819
|
+
if (!st.claude.installed || !st.codex.installed) {
|
|
820
|
+
console.log(`\nThe notice is optional. Without it the gateway still routes every request,`);
|
|
821
|
+
console.log(`and the shim still raises its own notice at launch.`);
|
|
822
|
+
console.log(`To add it: switch plugin install`);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
780
826
|
async function manageShim(action = 'status') {
|
|
781
827
|
const act = (action || 'status').toLowerCase();
|
|
782
828
|
|
|
@@ -862,7 +908,7 @@ async function runDoctor() {
|
|
|
862
908
|
if (!key) continue;
|
|
863
909
|
const p = config.profiles[key];
|
|
864
910
|
if (!p) warn(`[WARN] Target ${t} points to missing profile "${show(key)}".`);
|
|
865
|
-
else if (
|
|
911
|
+
else if (hasPlaceholder(p)) warn(`[WARN] Profile "${show(key)}" (${t}) still has placeholder baseURL/apiKey.`);
|
|
866
912
|
if (p && t === 'codex') {
|
|
867
913
|
const codexWarning = codexPublicModelsWarning(show(key), p);
|
|
868
914
|
if (codexWarning) warn(`[WARN] ${codexWarning}`);
|
|
@@ -1109,6 +1155,8 @@ if (cmd === 'off' || cmd === 'stop') {
|
|
|
1109
1155
|
await manageService(subArg.toLowerCase() || 'status');
|
|
1110
1156
|
} else if (cmd === 'shim' || cmd === 'shims') {
|
|
1111
1157
|
await manageShim(subArg.toLowerCase() || 'status');
|
|
1158
|
+
} else if (cmd === 'plugin' || cmd === 'plugins') {
|
|
1159
|
+
await managePlugin(subArg.toLowerCase() || 'status');
|
|
1112
1160
|
} else if (Object.hasOwn(TARGET_ALIASES, cmd)) {
|
|
1113
1161
|
await turnOn(subArg, TARGET_ALIASES[cmd]);
|
|
1114
1162
|
} else if (cmd === 'ui' || cmd === 'web' || cmd === 'gui') {
|
|
@@ -1141,6 +1189,9 @@ if (cmd === 'off' || cmd === 'stop') {
|
|
|
1141
1189
|
console.log(' switch shim install # Auto-inject env into resumed sessions (claude --resume)');
|
|
1142
1190
|
console.log(' switch shim status # Check shims + detect sessions bypassing the gateway');
|
|
1143
1191
|
console.log(' switch shim uninstall # Remove launcher shims');
|
|
1192
|
+
console.log(' switch plugin install # Optional launch notice inside Claude Code & Codex');
|
|
1193
|
+
console.log(' switch plugin status # Is that notice installed');
|
|
1194
|
+
console.log(' switch plugin uninstall # Remove that notice');
|
|
1144
1195
|
console.log(' switch off [target] # Restore official endpoints (all, or one target)');
|
|
1145
1196
|
console.log(' switch models [--refresh] # Show discovered models catalog for Claude Code & Codex');
|
|
1146
1197
|
console.log(' switch contract-probe [--model m] # Drive the contract-lab variants through the gateway');
|
package/tests/blindfold.test.mjs
CHANGED
|
@@ -137,7 +137,7 @@ test('a compressed response body is decoded for the capture', () => {
|
|
|
137
137
|
assert.equal(decodeBody(zlib.gzipSync(Buffer.from(sse)), 'GZIP'), sse, 'must be case-insensitive');
|
|
138
138
|
assert.equal(decodeBody(zlib.brotliCompressSync(Buffer.from(sse)), 'br'), sse);
|
|
139
139
|
assert.equal(decodeBody(zlib.deflateSync(Buffer.from(sse)), 'deflate'), sse);
|
|
140
|
-
assert.equal(decodeBody(zlib.zstdCompressSync(Buffer.from(sse)), 'zstd'), sse);
|
|
140
|
+
if (typeof zlib.zstdCompressSync === 'function') assert.equal(decodeBody(zlib.zstdCompressSync(Buffer.from(sse)), 'zstd'), sse);
|
|
141
141
|
});
|
|
142
142
|
|
|
143
143
|
test('an unencoded body is passed through untouched', () => {
|