llm-switcher 1.1.0
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/.gitattributes +16 -0
- package/LICENSE +21 -0
- package/README.md +587 -0
- package/README.vi.md +585 -0
- package/blindfold/blindfold.mjs +633 -0
- package/blindfold/make-certs.sh +88 -0
- package/blindfold/wsframe.mjs +176 -0
- package/codex-catalog-template.json +1 -0
- package/config.example.json +84 -0
- package/contract-exclusions.json +41 -0
- package/contract.mjs +561 -0
- package/docs/LLM-RESPONSE-MATRIX.md +165 -0
- package/docs/TOKEN-OPTIMIZER-INTEROP.md +110 -0
- package/docs/codex-blindfold.md +214 -0
- package/docs/cross-platform.md +136 -0
- package/docs/diagrams/blindfold-request-routing.html +14972 -0
- package/docs/diagrams/blindfold-request-routing.sequence.json +175 -0
- package/docs/diagrams/blindfold-switch-lifecycle.html +14958 -0
- package/docs/diagrams/blindfold-switch-lifecycle.lifecycle.json +159 -0
- package/docs/diagrams/codex-model-name-resolution.html +15005 -0
- package/docs/diagrams/codex-model-name-resolution.workflow.json +71 -0
- package/docs/response-matrix.json +1131 -0
- package/formats.mjs +2308 -0
- package/mcp.mjs +340 -0
- package/package.json +36 -0
- package/proxy.mjs +1743 -0
- package/service.mjs +132 -0
- package/shim.mjs +292 -0
- package/skills/llm-switcher/SKILL.md +88 -0
- package/state.mjs +978 -0
- package/switch +5 -0
- package/switch.cmd +2 -0
- package/switch.mjs +930 -0
- package/tests/blindfold.test.mjs +307 -0
- package/tests/blindfold.wire.test.mjs +170 -0
- package/tests/contract/run.test.mjs +214 -0
- package/tests/contract-check.test.mjs +458 -0
- package/tests/contract-lab.test.mjs +755 -0
- package/tests/datadir.test.mjs +37 -0
- package/tests/formats.test.mjs +794 -0
- package/tests/gateway.e2e.test.mjs +999 -0
- package/tests/helpers.mjs +24 -0
- package/tests/lifecycle.test.mjs +416 -0
- package/tests/live-optimizer-interop.mjs +205 -0
- package/tests/mcp.test.mjs +91 -0
- package/tests/service.test.mjs +69 -0
- package/tests/shim.test.mjs +228 -0
- package/tests/state.test.mjs +675 -0
- package/tests/switch.test.mjs +156 -0
- package/tests/wsframe.test.mjs +154 -0
- package/ui.html +2234 -0
package/service.mjs
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// The text of each OS service definition that `switch service install` writes, and how the CLI
|
|
2
|
+
// reads the port back. Pure functions, apart from writeServiceFile, so each one is testable here.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
// A service does not inherit the installing shell. Without these it would read another config.json
|
|
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'];
|
|
9
|
+
|
|
10
|
+
export function serviceEnv(env = process.env) {
|
|
11
|
+
return SERVICE_ENV_KEYS.filter(k => env[k]).map(k => [k, env[k]]);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const xmlEscape = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
15
|
+
const systemdQuote = (s) => `"${String(s).replace(/(["\\])/g, '\\$1')}"`;
|
|
16
|
+
|
|
17
|
+
export function systemdUnit({ nodeBin, script, port, env = [] }) {
|
|
18
|
+
return `[Unit]
|
|
19
|
+
Description=LLM Switcher Local Gateway
|
|
20
|
+
After=network.target
|
|
21
|
+
|
|
22
|
+
[Service]
|
|
23
|
+
ExecStart=${systemdQuote(nodeBin)} ${systemdQuote(script)} --port ${port}
|
|
24
|
+
${env.map(([k, v]) => `Environment=${systemdQuote(`${k}=${v}`)}\n`).join('')}Restart=always
|
|
25
|
+
|
|
26
|
+
[Install]
|
|
27
|
+
WantedBy=default.target
|
|
28
|
+
`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function launchdPlist({ nodeBin, script, port, logPath, env = [] }) {
|
|
32
|
+
const envBlock = env.length
|
|
33
|
+
? ` <key>EnvironmentVariables</key>
|
|
34
|
+
<dict>
|
|
35
|
+
${env.map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v)}</string>`).join('\n')}
|
|
36
|
+
</dict>
|
|
37
|
+
`
|
|
38
|
+
: '';
|
|
39
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
40
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
41
|
+
<plist version="1.0">
|
|
42
|
+
<dict>
|
|
43
|
+
<key>Label</key>
|
|
44
|
+
<string>com.llmswitcher.gateway</string>
|
|
45
|
+
<key>ProgramArguments</key>
|
|
46
|
+
<array>
|
|
47
|
+
<string>${xmlEscape(nodeBin)}</string>
|
|
48
|
+
<string>${xmlEscape(script)}</string>
|
|
49
|
+
<string>--port</string>
|
|
50
|
+
<string>${port}</string>
|
|
51
|
+
</array>
|
|
52
|
+
${envBlock} <key>StandardOutPath</key>
|
|
53
|
+
<string>${xmlEscape(logPath)}</string>
|
|
54
|
+
<key>StandardErrorPath</key>
|
|
55
|
+
<string>${xmlEscape(logPath)}</string>
|
|
56
|
+
<key>RunAtLoad</key>
|
|
57
|
+
<true/>
|
|
58
|
+
<key>KeepAlive</key>
|
|
59
|
+
<true/>
|
|
60
|
+
</dict>
|
|
61
|
+
</plist>
|
|
62
|
+
`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Task Scheduler reads the command and its arguments from two elements, so no path goes through
|
|
66
|
+
// the quoting rules of a /TR command line. A task made with /TR also stops after 72 hours by
|
|
67
|
+
// default; PT0S removes that limit.
|
|
68
|
+
export function scheduledTaskXml({ nodeBin, script, port, userId }) {
|
|
69
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
70
|
+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
71
|
+
<RegistrationInfo>
|
|
72
|
+
<Description>LLM Switcher Local Gateway</Description>
|
|
73
|
+
</RegistrationInfo>
|
|
74
|
+
<Triggers>
|
|
75
|
+
<LogonTrigger>
|
|
76
|
+
<Enabled>true</Enabled>
|
|
77
|
+
<UserId>${xmlEscape(userId)}</UserId>
|
|
78
|
+
</LogonTrigger>
|
|
79
|
+
</Triggers>
|
|
80
|
+
<Principals>
|
|
81
|
+
<Principal id="Author">
|
|
82
|
+
<UserId>${xmlEscape(userId)}</UserId>
|
|
83
|
+
<LogonType>InteractiveToken</LogonType>
|
|
84
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
85
|
+
</Principal>
|
|
86
|
+
</Principals>
|
|
87
|
+
<Settings>
|
|
88
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
89
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
90
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
91
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
92
|
+
<Enabled>true</Enabled>
|
|
93
|
+
</Settings>
|
|
94
|
+
<Actions Context="Author">
|
|
95
|
+
<Exec>
|
|
96
|
+
<Command>${xmlEscape(nodeBin)}</Command>
|
|
97
|
+
<Arguments>${xmlEscape(`"${script}" --port ${port}`)}</Arguments>
|
|
98
|
+
</Exec>
|
|
99
|
+
</Actions>
|
|
100
|
+
</Task>
|
|
101
|
+
`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// schtasks prints /Query /XML as UTF-16 when its output is a pipe.
|
|
105
|
+
export function decodeConsoleText(buf) {
|
|
106
|
+
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) return buf.subarray(2).toString('utf16le');
|
|
107
|
+
if (buf.length >= 2 && buf[1] === 0x00) return buf.toString('utf16le');
|
|
108
|
+
return buf.toString('utf8');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The port on a service definition's command line, or null. */
|
|
112
|
+
export function portFromServiceText(text) {
|
|
113
|
+
const flat = String(text).replace(/<\/?string>\s*/g, ' ');
|
|
114
|
+
const n = parseInt(/--port\s+(\d+)/.exec(flat)?.[1], 10);
|
|
115
|
+
return Number.isInteger(n) && n > 0 && n <= 65535 ? n : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Writes a service definition. Returns the backup path when it replaced different content. */
|
|
119
|
+
export function writeServiceFile(file, content) {
|
|
120
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
121
|
+
let backup = null;
|
|
122
|
+
try {
|
|
123
|
+
if (fs.readFileSync(file, 'utf8') !== content) {
|
|
124
|
+
backup = `${file}.bak`;
|
|
125
|
+
fs.copyFileSync(file, backup);
|
|
126
|
+
}
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (err.code !== 'ENOENT') throw err;
|
|
129
|
+
}
|
|
130
|
+
fs.writeFileSync(file, content, 'utf8');
|
|
131
|
+
return backup;
|
|
132
|
+
}
|
package/shim.mjs
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// shim.mjs — auto-inject gateway env for CLIs launched outside the wrapper
|
|
3
|
+
//
|
|
4
|
+
// PROBLEM
|
|
5
|
+
// `switch on` writes env.sh and cleans ~/.claude/settings.json (so Claude Code doesn't
|
|
6
|
+
// show the "custom API" banner). Consequence: a `claude` session started from a shell
|
|
7
|
+
// that has NOT sourced env.sh will lack ANTHROPIC_BASE_URL and call
|
|
8
|
+
// api.anthropic.com directly — bypassing the gateway (loses Healer, 1M, pooled quota).
|
|
9
|
+
// Exactly the case of `claude --resume` reopening an old session in a clean shell.
|
|
10
|
+
//
|
|
11
|
+
// FIX
|
|
12
|
+
// Install executable shims into ~/.llm-switcher/bin/ (placed BEFORE the real binary
|
|
13
|
+
// in PATH). The shim sources env.sh then execs the real binary, so EVERY `claude`
|
|
14
|
+
// /`codex` invocation — including --resume, including from shells without env — goes
|
|
15
|
+
// through the gateway. Doesn't touch settings.json => no banner.
|
|
16
|
+
//
|
|
17
|
+
// SAFETY
|
|
18
|
+
// - The shim finds the real binary by removing the shim directory itself from PATH,
|
|
19
|
+
// so it never calls itself recursively.
|
|
20
|
+
// - If the gateway is OFF (no active.flag) the shim runs the real binary
|
|
21
|
+
// as-is — no forced routing, no blocking the user.
|
|
22
|
+
// - If the real binary isn't found, the shim reports a clear error instead of staying silent.
|
|
23
|
+
// ============================================================
|
|
24
|
+
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import { execFileSync } from 'node:child_process';
|
|
29
|
+
import { paths, STATE_DIR } from './state.mjs';
|
|
30
|
+
|
|
31
|
+
export const SHIM_DIR = path.join(os.homedir(), '.llm-switcher', 'bin');
|
|
32
|
+
|
|
33
|
+
// Local model catalog: generated by applyLaunchState from the active profile's
|
|
34
|
+
// publicModels, so it holds official OpenAI slugs only. It gives Codex the metadata
|
|
35
|
+
// for the names it requests (otherwise the CLI falls back to full-context mode) and
|
|
36
|
+
// it is what the /model picker renders — the picker never calls /v1/models.
|
|
37
|
+
// Forward slashes for the TOML value. The Windows `if exist` test uses the native path instead.
|
|
38
|
+
export const CODE_X_CATALOG_PATH = paths.codexCatalog.replace(/\\/g, '/');
|
|
39
|
+
|
|
40
|
+
// CLIs to wrap. `claude` is the most important case (--resume), codex included for completeness.
|
|
41
|
+
export const SHIMMED = ['claude', 'codex'];
|
|
42
|
+
|
|
43
|
+
const POSIX_TEMPLATE = (name) => `#!/usr/bin/env bash
|
|
44
|
+
# Auto-generated by LLM Switcher — DO NOT EDIT.
|
|
45
|
+
# Load the gateway env then run the real "${name}", even when the shell hasn't sourced env.sh
|
|
46
|
+
# (e.g.: claude --resume reopening an old session in a clean terminal).
|
|
47
|
+
SWITCHER_DIR="${STATE_DIR}"
|
|
48
|
+
SHIM_DIR="\${BASH_SOURCE%/*}"
|
|
49
|
+
|
|
50
|
+
# Only load env when the gateway is on; when off, let the CLI run as-is. The shim runs what env.sh says,
|
|
51
|
+
# so the directory and the file must belong to this account: another one could have created them.
|
|
52
|
+
if [ -O "$SWITCHER_DIR" ] && [ -O "$SWITCHER_DIR/env.sh" ] && [ -f "$SWITCHER_DIR/active.flag" ]; then
|
|
53
|
+
. "$SWITCHER_DIR/env.sh"
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
# Find the real binary: drop the shim directory from PATH so it doesn't call itself.
|
|
57
|
+
CLEAN_PATH=""
|
|
58
|
+
IFS=':'
|
|
59
|
+
for p in $PATH; do
|
|
60
|
+
case "$p" in
|
|
61
|
+
"$SHIM_DIR"|"$SHIM_DIR"/) continue ;;
|
|
62
|
+
esac
|
|
63
|
+
[ -z "$p" ] && continue
|
|
64
|
+
if [ -z "$CLEAN_PATH" ]; then CLEAN_PATH="$p"; else CLEAN_PATH="$CLEAN_PATH:$p"; fi
|
|
65
|
+
done
|
|
66
|
+
unset IFS
|
|
67
|
+
|
|
68
|
+
REAL="$(PATH="$CLEAN_PATH" command -v ${name} 2>/dev/null)"
|
|
69
|
+
if [ -z "$REAL" ]; then
|
|
70
|
+
echo "[llm-switcher] cannot find the real '${name}' on PATH (\\$SHIM_DIR excluded)." >&2
|
|
71
|
+
echo "[llm-switcher] go shim: switch shim uninstall" >&2
|
|
72
|
+
exit 127
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
${name === 'codex' ? `# Codex-only variables (blindfold proxy + CA). The claude shim must not load these.
|
|
76
|
+
if [ -O "$SWITCHER_DIR" ] && [ -O "$SWITCHER_DIR/env-codex.sh" ] && [ -f "$SWITCHER_DIR/active.flag" ]; then
|
|
77
|
+
. "$SWITCHER_DIR/env-codex.sh"
|
|
78
|
+
fi
|
|
79
|
+
|
|
80
|
+
CODEX_SWITCHER_ARGS=()
|
|
81
|
+
if [ -n "\${LLM_SWITCHER_CODEX_BASE_URL:-}" ]; then
|
|
82
|
+
CODEX_SWITCHER_ARGS+=(--config "openai_base_url=\${LLM_SWITCHER_CODEX_BASE_URL}")
|
|
83
|
+
fi
|
|
84
|
+
if [ -O "${CODE_X_CATALOG_PATH}" ]; then
|
|
85
|
+
CODEX_SWITCHER_ARGS+=(--config "model_catalog_json=${CODE_X_CATALOG_PATH}")
|
|
86
|
+
fi
|
|
87
|
+
if [ -n "\${LLM_SWITCHER_CODEX_MAIN_MODEL:-}" ]; then
|
|
88
|
+
CODEX_SWITCHER_ARGS+=(--config "model=\\"\${LLM_SWITCHER_CODEX_MAIN_MODEL}\\"")
|
|
89
|
+
fi
|
|
90
|
+
if [ -n "\${LLM_SWITCHER_CODEX_REVIEW_MODEL:-}" ]; then
|
|
91
|
+
CODEX_SWITCHER_ARGS+=(--config "review_model=\\"\${LLM_SWITCHER_CODEX_REVIEW_MODEL}\\"")
|
|
92
|
+
fi
|
|
93
|
+
if [ -n "\${LLM_SWITCHER_CODEX_SUBAGENT_MODEL:-}" ]; then
|
|
94
|
+
CODEX_SWITCHER_ARGS+=(--config "agents.default_subagent_model=\\"\${LLM_SWITCHER_CODEX_SUBAGENT_MODEL}\\"")
|
|
95
|
+
fi
|
|
96
|
+
if [ -n "\${LLM_SWITCHER_CODEX_CONTEXT_WINDOW:-}" ]; then
|
|
97
|
+
CODEX_SWITCHER_ARGS+=(--config "model_context_window=\${LLM_SWITCHER_CODEX_CONTEXT_WINDOW}")
|
|
98
|
+
fi
|
|
99
|
+
if [ -n "\${LLM_SWITCHER_CODEX_AUTO_COMPACT_LIMIT:-}" ]; then
|
|
100
|
+
CODEX_SWITCHER_ARGS+=(--config "model_auto_compact_token_limit=\${LLM_SWITCHER_CODEX_AUTO_COMPACT_LIMIT}")
|
|
101
|
+
fi
|
|
102
|
+
|
|
103
|
+
exec "$REAL" "\${CODEX_SWITCHER_ARGS[@]}" "$@"` : `exec "$REAL" "$@"`}
|
|
104
|
+
`;
|
|
105
|
+
|
|
106
|
+
const WINDOWS_TEMPLATE = (name) => `@echo off
|
|
107
|
+
REM Auto-generated by LLM Switcher - DO NOT EDIT.
|
|
108
|
+
set "SWITCHER_DIR=${STATE_DIR}"
|
|
109
|
+
if exist "%SWITCHER_DIR%\\active.flag" if exist "%SWITCHER_DIR%\\env.cmd" call "%SWITCHER_DIR%\\env.cmd"
|
|
110
|
+
${name === 'codex' ? `REM Codex-only variables (blindfold proxy + CA). The claude shim must not load these.
|
|
111
|
+
if exist "%SWITCHER_DIR%\\active.flag" if exist "%SWITCHER_DIR%\\env-codex.cmd" call "%SWITCHER_DIR%\\env-codex.cmd"
|
|
112
|
+
set "CODEX_SWITCHER_ARGS="
|
|
113
|
+
if defined LLM_SWITCHER_CODEX_BASE_URL set "CODEX_SWITCHER_ARGS=%CODEX_SWITCHER_ARGS% --config openai_base_url=%LLM_SWITCHER_CODEX_BASE_URL%"
|
|
114
|
+
if exist "%SWITCHER_DIR%\\model-catalog.json" set "CODEX_SWITCHER_ARGS=%CODEX_SWITCHER_ARGS% --config model_catalog_json=${CODE_X_CATALOG_PATH}"
|
|
115
|
+
if defined LLM_SWITCHER_CODEX_MAIN_MODEL set "CODEX_SWITCHER_ARGS=%CODEX_SWITCHER_ARGS% --config model=%LLM_SWITCHER_CODEX_MAIN_MODEL%"
|
|
116
|
+
if defined LLM_SWITCHER_CODEX_REVIEW_MODEL set "CODEX_SWITCHER_ARGS=%CODEX_SWITCHER_ARGS% --config review_model=%LLM_SWITCHER_CODEX_REVIEW_MODEL%"
|
|
117
|
+
if defined LLM_SWITCHER_CODEX_SUBAGENT_MODEL set "CODEX_SWITCHER_ARGS=%CODEX_SWITCHER_ARGS% --config agents.default_subagent_model=%LLM_SWITCHER_CODEX_SUBAGENT_MODEL%"
|
|
118
|
+
if defined LLM_SWITCHER_CODEX_CONTEXT_WINDOW set "CODEX_SWITCHER_ARGS=%CODEX_SWITCHER_ARGS% --config model_context_window=%LLM_SWITCHER_CODEX_CONTEXT_WINDOW%"
|
|
119
|
+
if defined LLM_SWITCHER_CODEX_AUTO_COMPACT_LIMIT set "CODEX_SWITCHER_ARGS=%CODEX_SWITCHER_ARGS% --config model_auto_compact_token_limit=%LLM_SWITCHER_CODEX_AUTO_COMPACT_LIMIT%"` : ''}
|
|
120
|
+
for /f "delims=" %%i in ('where ${name}.cmd 2^>nul ^| findstr /v /i "\\.llm-switcher\\\\bin"') do (
|
|
121
|
+
call "%%i" ${name === 'codex' ? '%CODEX_SWITCHER_ARGS% ' : ''}%*
|
|
122
|
+
exit /b
|
|
123
|
+
)
|
|
124
|
+
for /f "delims=" %%i in ('where ${name}.exe 2^>nul ^| findstr /v /i "\\.llm-switcher\\\\bin"') do (
|
|
125
|
+
call "%%i" ${name === 'codex' ? '%CODEX_SWITCHER_ARGS% ' : ''}%*
|
|
126
|
+
exit /b
|
|
127
|
+
)
|
|
128
|
+
echo [llm-switcher] cannot find the real '${name}' on PATH.>&2
|
|
129
|
+
exit /b 127
|
|
130
|
+
`;
|
|
131
|
+
|
|
132
|
+
export function renderShim(name, platform = process.platform) {
|
|
133
|
+
return platform === 'win32' ? WINDOWS_TEMPLATE(name) : POSIX_TEMPLATE(name);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function shimPath(name) {
|
|
137
|
+
return path.join(SHIM_DIR, process.platform === 'win32' ? `${name}.cmd` : name);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Install shims for the CLIs. Returns {installed:[], skipped:[], error?} */
|
|
141
|
+
export function installShims(names = SHIMMED) {
|
|
142
|
+
try {
|
|
143
|
+
fs.mkdirSync(SHIM_DIR, { recursive: true });
|
|
144
|
+
} catch (err) {
|
|
145
|
+
return { installed: [], skipped: [], error: `cannot create ${SHIM_DIR}: ${err.message}` };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const installed = [];
|
|
149
|
+
const skipped = [];
|
|
150
|
+
for (const name of names) {
|
|
151
|
+
const target = shimPath(name);
|
|
152
|
+
const body = renderShim(name);
|
|
153
|
+
try {
|
|
154
|
+
// Don't overwrite unfamiliar files created by the user.
|
|
155
|
+
if (fs.existsSync(target)) {
|
|
156
|
+
const cur = fs.readFileSync(target, 'utf8');
|
|
157
|
+
if (!cur.includes('Auto-generated by LLM Switcher')) {
|
|
158
|
+
skipped.push({ name, reason: 'a file that is not a switcher shim already exists there' });
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
fs.writeFileSync(target, body, 'utf8');
|
|
163
|
+
if (process.platform !== 'win32') fs.chmodSync(target, 0o755);
|
|
164
|
+
installed.push(name);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
skipped.push({ name, reason: err.message });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { installed, skipped };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function uninstallShims(names = SHIMMED) {
|
|
173
|
+
const removed = [];
|
|
174
|
+
const failed = [];
|
|
175
|
+
for (const name of names) {
|
|
176
|
+
const target = shimPath(name);
|
|
177
|
+
try {
|
|
178
|
+
if (!fs.existsSync(target)) continue;
|
|
179
|
+
const cur = fs.readFileSync(target, 'utf8');
|
|
180
|
+
if (!cur.includes('Auto-generated by LLM Switcher')) continue;
|
|
181
|
+
fs.unlinkSync(target);
|
|
182
|
+
removed.push(name);
|
|
183
|
+
} catch (err) {
|
|
184
|
+
failed.push({ name, reason: err.message });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { removed, failed };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Is SHIM_DIR already ahead of the real binary in PATH? */
|
|
191
|
+
export function shimStatus(names = SHIMMED) {
|
|
192
|
+
const entries = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
193
|
+
const idx = entries.findIndex(p => path.resolve(p) === path.resolve(SHIM_DIR));
|
|
194
|
+
const out = { dir: SHIM_DIR, onPath: idx !== -1, position: idx, shims: [] };
|
|
195
|
+
|
|
196
|
+
for (const name of names) {
|
|
197
|
+
const target = shimPath(name);
|
|
198
|
+
const exists = fs.existsSync(target);
|
|
199
|
+
let effective = null; // which binary will run when the command name is typed
|
|
200
|
+
for (const p of entries) {
|
|
201
|
+
const cand = path.join(p, process.platform === 'win32' ? `${name}.cmd` : name);
|
|
202
|
+
const candExe = path.join(p, name);
|
|
203
|
+
if (fs.existsSync(cand)) { effective = cand; break; }
|
|
204
|
+
if (fs.existsSync(candExe)) { effective = candExe; break; }
|
|
205
|
+
}
|
|
206
|
+
out.shims.push({
|
|
207
|
+
name,
|
|
208
|
+
installed: exists,
|
|
209
|
+
effective,
|
|
210
|
+
active: exists && effective !== null && path.resolve(effective) === path.resolve(target)
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Line to add to the shell rc so the shim comes before the real binary. */
|
|
217
|
+
// Windows: setx truncates at 1024 characters and would copy the merged system+user PATH into the
|
|
218
|
+
// user key, and PowerShell does not expand %PATH%. Prepend to the User-scope Path only.
|
|
219
|
+
export function pathExportLine(platform = process.platform) {
|
|
220
|
+
if (platform === 'win32') {
|
|
221
|
+
return `powershell -NoProfile -Command "[Environment]::SetEnvironmentVariable('Path', '${SHIM_DIR};' + [Environment]::GetEnvironmentVariable('Path', 'User'), 'User')"`;
|
|
222
|
+
}
|
|
223
|
+
return `export PATH="${SHIM_DIR}:$PATH"`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Which rc file to edit, based on the current shell. Windows has none: the command above is run once. */
|
|
227
|
+
export function suggestedRcFiles(platform = process.platform) {
|
|
228
|
+
if (platform === 'win32') return [];
|
|
229
|
+
const home = os.homedir();
|
|
230
|
+
const shell = path.basename(process.env.SHELL || '');
|
|
231
|
+
if (shell === 'zsh') return [path.join(home, '.zshrc'), path.join(home, '.zprofile')];
|
|
232
|
+
if (shell === 'bash') return [path.join(home, '.bashrc'), path.join(home, '.bash_profile')];
|
|
233
|
+
return [path.join(home, '.profile')];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Check whether running CLI processes have the gateway env.
|
|
238
|
+
* This is the hardest failure to spot: a `claude` session opened BEFORE the gateway
|
|
239
|
+
* was enabled (or from a shell that hasn't sourced env.sh) will call api.anthropic.com
|
|
240
|
+
* directly. The shim intercepts NEW sessions, but live processes must be detected and reported so they can be restarted.
|
|
241
|
+
*/
|
|
242
|
+
// What in a process's command line and environment shows that it goes through the gateway.
|
|
243
|
+
// Codex takes the gateway URL as a --config override, or the interceptor as HTTPS_PROXY.
|
|
244
|
+
const ROUTE_EVIDENCE = {
|
|
245
|
+
claude: /(^|\s)ANTHROPIC_BASE_URL=/,
|
|
246
|
+
codex: /(^|\s)(LLM_SWITCHER_CODEX_BASE_URL=|https?_proxy=http:\/\/(127\.0\.0\.1|localhost)[:/]|openai_base_url=http:\/\/(127\.0\.0\.1|localhost)[:/])/i
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/** true | false, or null when the dump holds no environment (macOS ps for most processes). */
|
|
250
|
+
export function routeEvidence(name, envDump) {
|
|
251
|
+
// The shim's own `--config key=value` arguments look like variables; only the rest can be environment.
|
|
252
|
+
if (ROUTE_EVIDENCE[name].test(envDump)) return true;
|
|
253
|
+
const rest = envDump.replace(/(^|\s)(--config|-c)\s+\S+/g, ' ');
|
|
254
|
+
return /\s[A-Za-z_][A-Za-z0-9_]*=/.test(rest) ? false : null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// `names` are the CLIs whose target is active: a CLI whose target is off uses the official endpoint
|
|
258
|
+
// on purpose, so a missing gateway variable there is not a bypass.
|
|
259
|
+
export function auditRunningProcesses(names = SHIMMED) {
|
|
260
|
+
if (process.platform === 'win32') return { supported: false, procs: [] };
|
|
261
|
+
const procs = [];
|
|
262
|
+
try {
|
|
263
|
+
// Get pid + command of the CLIs of interest (excluding the pgrep/grep command itself).
|
|
264
|
+
const pattern = names.join('|');
|
|
265
|
+
const listed = execFileSync('pgrep', ['-f', `(${pattern})`], { encoding: 'utf8' })
|
|
266
|
+
.split('\n').map(s => s.trim()).filter(Boolean);
|
|
267
|
+
|
|
268
|
+
for (const pid of listed) {
|
|
269
|
+
let cmd = '';
|
|
270
|
+
try {
|
|
271
|
+
cmd = execFileSync('ps', ['-p', pid, '-o', 'command='], { encoding: 'utf8' }).trim();
|
|
272
|
+
} catch { continue; }
|
|
273
|
+
if (!cmd) continue;
|
|
274
|
+
// Keep only processes that are actually the CLI under check, skip helper processes.
|
|
275
|
+
if (!names.some(n => new RegExp(`(^|/|\\s)${n}(\\s|$)`).test(cmd))) continue;
|
|
276
|
+
if (/pgrep|llm-switcher\/(switch|proxy)\.mjs/.test(cmd)) continue;
|
|
277
|
+
|
|
278
|
+
const name = names.find(n => new RegExp(`(^|/|\\s)${n}(\\s|$)`).test(cmd));
|
|
279
|
+
let hasEnv = null; // null = cannot read that process's env
|
|
280
|
+
try {
|
|
281
|
+
// `ps eww` appends the environment, but only for processes this account may read.
|
|
282
|
+
hasEnv = routeEvidence(name, execFileSync('ps', ['eww', '-p', pid, '-o', 'command='], { encoding: 'utf8' }));
|
|
283
|
+
} catch {}
|
|
284
|
+
procs.push({ pid, name, cmd: cmd.slice(0, 90), hasEnv });
|
|
285
|
+
}
|
|
286
|
+
} catch {
|
|
287
|
+
// pgrep returns a non-zero exit code when there are no processes — not an error.
|
|
288
|
+
}
|
|
289
|
+
return { supported: true, procs };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export { paths };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: llm-switcher
|
|
3
|
+
description: Mandatory guidance for AI coding agents and token compression tools (Headroom, RTK, Ponytail, Repomix). Enforces routing all LLM traffic through the local LLM Switcher edge gateway (http://127.0.0.1:3456) instead of directly calling provider endpoints.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# LLM Switcher Edge Gateway Skill
|
|
7
|
+
|
|
8
|
+
This skill governs how AI coding agents (Claude Code, Codex, Cursor, Windsurf, Opencode) and prompt optimization/compression tools (Headroom, RTK, Ponytail, Repomix) must interact with LLM providers on this workstation.
|
|
9
|
+
|
|
10
|
+
## 1. The Core Directive
|
|
11
|
+
|
|
12
|
+
> **ALL OUTBOUND LLM REQUESTS MUST ROUTE THROUGH LLM SWITCHER (`http://127.0.0.1:3456`).**
|
|
13
|
+
>
|
|
14
|
+
> Never configure any tool, script, sub-agent, or token compression proxy to call external provider endpoints directly (such as `https://api.anthropic.com`, `https://api.openai.com`, or `https://generativelanguage.googleapis.com`).
|
|
15
|
+
>
|
|
16
|
+
> If a token compression tool (Headroom, RTK, Ponytail) is used, it MUST be positioned as an **inner processing layer**, and its **upstream destination MUST be configured to point to LLM Switcher** at `http://127.0.0.1:3456`.
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
[Client CLI / Agent]
|
|
20
|
+
│
|
|
21
|
+
▼
|
|
22
|
+
[Token Compressor: RTK / Headroom / Ponytail] <-- Inner Layer
|
|
23
|
+
│
|
|
24
|
+
▼ (MANDATORY: Upstream MUST point to http://127.0.0.1:3456)
|
|
25
|
+
[LLM Switcher Edge Gateway (:3456)] <-- Outermost Gatekeeper
|
|
26
|
+
│
|
|
27
|
+
▼ (Heals schemas, maps 1M context, extracts thinking, converts protocol)
|
|
28
|
+
[Internet / 9Router / Upstream LLM]
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## 2. Why This is Mandatory
|
|
32
|
+
|
|
33
|
+
Direct outbound calls from token compressors will break the developer environment for several reasons:
|
|
34
|
+
|
|
35
|
+
1. **Schema Breakage & The Healer Engine:**
|
|
36
|
+
Token optimizers frequently prune conversation history to save tokens. In doing so, they often delete an `assistant` turn containing a `tool_use`, while leaving the subsequent `user` turn containing a `tool_result`.
|
|
37
|
+
- Anthropic and OpenAI APIs strictly reject this with `HTTP 400 (orphaned tool_use_id)`.
|
|
38
|
+
- **LLM Switcher** intercepts these malformed requests and runs its **Healer Engine**, automatically converting orphaned results into safe context text blocks and merging consecutive same-role turns so upstream providers accept the payload cleanly.
|
|
39
|
+
2. **Thinking / Reasoning Protection:**
|
|
40
|
+
Many token compressors naively strip `thinking` parameters or reasoning budgets to reduce token count.
|
|
41
|
+
- When routed through LLM Switcher, the gateway detects reasoning models (such as `ag/claude-opus-4-6-thinking` or `ag/gemini-3.8-flash`) and **automatically restores thinking parameters**, ensuring the model retains its full reasoning capabilities.
|
|
42
|
+
3. **1M Context Window Unlocking:**
|
|
43
|
+
LLM Switcher dynamically injects `ANTHROPIC_DEFAULT_<TIER>_MODEL=<tier>[1m]` for every tier the profile marks 1M and calculates auto-compact thresholds (`900,000` tokens) without mutating permanent settings.
|
|
44
|
+
4. **Centralized Gateway Connection (9Router):**
|
|
45
|
+
LLM Switcher connects directly to server-side gateways like **9Router**, which manage multi-account pooling, quota tracking, load balancing, and rate-limit retries far better than any local tool.
|
|
46
|
+
|
|
47
|
+
## 3. Configuring Token Compression Tools
|
|
48
|
+
|
|
49
|
+
When setting up or assisting the user with token compression tools, apply these exact configurations:
|
|
50
|
+
|
|
51
|
+
### A. Headroom (Prompt Compression Proxy)
|
|
52
|
+
When configuring Headroom's upstream endpoint:
|
|
53
|
+
- In Headroom config (`headroom.yaml` or environment variables):
|
|
54
|
+
```bash
|
|
55
|
+
ANTHROPIC_BASE_URL="http://127.0.0.1:3456"
|
|
56
|
+
OPENAI_BASE_URL="http://127.0.0.1:3456/v1"
|
|
57
|
+
```
|
|
58
|
+
- Point your CLI to Headroom's listening port (e.g. `http://127.0.0.1:8787`). Headroom compresses the prompt and forwards it to LLM Switcher on port `3456`.
|
|
59
|
+
|
|
60
|
+
### B. RTK (Rust Token Killer / Repomix)
|
|
61
|
+
- In RTK configuration or execution flags:
|
|
62
|
+
```bash
|
|
63
|
+
# Ensure RTK's target upstream proxy is LLM Switcher
|
|
64
|
+
export UPSTREAM_URL="http://127.0.0.1:3456"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### C. Ponytail / Custom Proxies
|
|
68
|
+
- Always set the proxy's upstream host to:
|
|
69
|
+
`http://127.0.0.1:3456` (or `http://127.0.0.1:3456/v1` for OpenAI-compatible tools).
|
|
70
|
+
|
|
71
|
+
### D. Sub-Agents & Spawned Terminal Commands
|
|
72
|
+
When an agent spawns sub-processes via `bash` or terminal execution that call LLM CLIs:
|
|
73
|
+
- **On Windows:** Always run `call "path\to\llm-switcher\env.cmd"` first.
|
|
74
|
+
- **On macOS / Linux:** Always run `source "path/to/llm-switcher/env.sh"` first.
|
|
75
|
+
- This prevents sub-agents from leaking calls directly to expensive official APIs.
|
|
76
|
+
|
|
77
|
+
## 4. Operational Rules for AI Agents
|
|
78
|
+
|
|
79
|
+
1. **NEVER edit `~/.claude/settings.json` directly.**
|
|
80
|
+
LLM Switcher uses zero-mutation launcher flags (`active.flag`, `1m.flag`). Writing custom base URLs or auth tokens into `settings.json` triggers warning banners in Claude Code and risks breaking the user's official subscription. Use `switch` CLI commands instead.
|
|
81
|
+
2. **Check Gateway Health Before Complex Operations:**
|
|
82
|
+
Run `switch status` or call the `switcher_audit` MCP tool to confirm:
|
|
83
|
+
- LLM Switcher is active on port `3456`.
|
|
84
|
+
- The active profile matches the intended CLI target (Claude Code, Codex, or OpenAI).
|
|
85
|
+
3. **Verify Routing When Errors Occur:**
|
|
86
|
+
If a tool fails with `HTTP 400`, `HTTP 502`, or connection errors:
|
|
87
|
+
- Run `switch doctor` to audit port collisions and environment variables.
|
|
88
|
+
- Inspect recent request logs via `http://127.0.0.1:3456/ui` (Tab 4: Live Inspector) to see if an intermediary tool mangled the payload.
|