claude-multi-proxy 0.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/LICENSE +21 -0
- package/README.md +93 -0
- package/bin/claude-multi-proxy.js +683 -0
- package/package.json +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pinion05
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# claude-multi-proxy
|
|
2
|
+
|
|
3
|
+
[](https://nodei.co/npm/claude-multi-proxy/)
|
|
4
|
+
|
|
5
|
+
Use Claude Code with multiple AI providers. Switch between Claude (Anthropic) and Codex (OpenAI) models using `/model`.
|
|
6
|
+
|
|
7
|
+
> Forked from [pinion05/codex-claudecode-proxy](https://github.com/pinion05/codex-claudecode-proxy)
|
|
8
|
+
|
|
9
|
+
## How it works
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
Claude Code ──→ Local Proxy (CLIProxyAPI) ──→ Anthropic API (Claude OAuth)
|
|
13
|
+
↓
|
|
14
|
+
└──→ Codex API (OpenAI OAuth)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Both providers use **OAuth authentication** — no API keys needed. Just log in with your existing subscriptions.
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx -y claude-multi-proxy
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
This will:
|
|
26
|
+
1. Download and install CLIProxyAPI
|
|
27
|
+
2. Prompt OAuth login for Claude and Codex
|
|
28
|
+
3. Configure Claude Code to route through the proxy
|
|
29
|
+
4. Set up a LaunchAgent for auto-start
|
|
30
|
+
|
|
31
|
+
## Model Switching
|
|
32
|
+
|
|
33
|
+
After installation, use `/model` in Claude Code:
|
|
34
|
+
|
|
35
|
+
| Command | Model | Provider |
|
|
36
|
+
|---------|-------|----------|
|
|
37
|
+
| `/model opus` | Claude Opus | Anthropic |
|
|
38
|
+
| `/model sonnet` | Claude Sonnet | Anthropic |
|
|
39
|
+
| `/model haiku` | Claude Haiku | Anthropic |
|
|
40
|
+
| **`/model codex`** | **GPT-5.3 Codex** | **OpenAI** |
|
|
41
|
+
|
|
42
|
+
## Commands
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# Install (safe to re-run)
|
|
46
|
+
npx -y claude-multi-proxy
|
|
47
|
+
|
|
48
|
+
# OAuth login (individual)
|
|
49
|
+
npx -y claude-multi-proxy claude-login
|
|
50
|
+
npx -y claude-multi-proxy codex-login
|
|
51
|
+
|
|
52
|
+
# Status
|
|
53
|
+
npx -y claude-multi-proxy status
|
|
54
|
+
|
|
55
|
+
# Start/stop
|
|
56
|
+
npx -y claude-multi-proxy start
|
|
57
|
+
npx -y claude-multi-proxy stop
|
|
58
|
+
|
|
59
|
+
# Uninstall: stop proxy and restore Claude Code settings
|
|
60
|
+
npx -y claude-multi-proxy uninstall
|
|
61
|
+
|
|
62
|
+
# Purge: uninstall + remove all proxy files
|
|
63
|
+
npx -y claude-multi-proxy purge
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Requirements
|
|
67
|
+
|
|
68
|
+
- macOS (LaunchAgent-based)
|
|
69
|
+
- Node.js >= 18
|
|
70
|
+
- Claude Code installed
|
|
71
|
+
- Anthropic account (Claude subscription)
|
|
72
|
+
- OpenAI account (for Codex)
|
|
73
|
+
|
|
74
|
+
## Differences from upstream
|
|
75
|
+
|
|
76
|
+
| Feature | [codex-claudecode-proxy](https://github.com/pinion05/codex-claudecode-proxy) | claude-multi-proxy |
|
|
77
|
+
|---------|----------------------------------------------|-------------------|
|
|
78
|
+
| Target user | No Claude subscription | Both subscriptions |
|
|
79
|
+
| Providers | Codex only | Claude + Codex |
|
|
80
|
+
| Model slots | All overridden to Codex | Original Claude models preserved |
|
|
81
|
+
| Codex access | Replaces Sonnet/Opus/Haiku | Separate `/model codex` |
|
|
82
|
+
| Auth method | Codex CLI token sync | CLIProxyAPI native OAuth |
|
|
83
|
+
| LaunchAgents | 2 (proxy + token sync) | 1 (proxy only) |
|
|
84
|
+
|
|
85
|
+
## Safety
|
|
86
|
+
|
|
87
|
+
- Claude Code settings are backed up before any changes
|
|
88
|
+
- `uninstall` restores original Claude Code settings
|
|
89
|
+
- Proxy binds to `127.0.0.1` only (localhost)
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
MIT
|
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* eslint-disable no-console */
|
|
3
|
+
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_PORT = 8317;
|
|
10
|
+
const CODEX_MODEL_ALIAS = "codex";
|
|
11
|
+
const CODEX_MODEL_TARGET = "gpt-5.3-codex";
|
|
12
|
+
const LABEL_PROXY = "com.claude-multi-proxy";
|
|
13
|
+
|
|
14
|
+
function nowTs() {
|
|
15
|
+
return Date.now().toString();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function log(msg) {
|
|
19
|
+
console.log(`[claude-multi-proxy] ${msg}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function warn(msg) {
|
|
23
|
+
console.error(`[claude-multi-proxy][WARN] ${msg}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function fail(msg, code = 1) {
|
|
27
|
+
console.error(`[claude-multi-proxy][FAIL] ${msg}`);
|
|
28
|
+
process.exit(code);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function usage(code = 0) {
|
|
32
|
+
const txt = `Usage:
|
|
33
|
+
claude-multi-proxy [command]
|
|
34
|
+
|
|
35
|
+
Commands:
|
|
36
|
+
install Install + configure + start (default)
|
|
37
|
+
claude-login Login to Claude (Anthropic) via OAuth
|
|
38
|
+
codex-login Login to Codex (OpenAI) via OAuth
|
|
39
|
+
start Start proxy LaunchAgent
|
|
40
|
+
stop Stop proxy LaunchAgent
|
|
41
|
+
status Show status
|
|
42
|
+
uninstall Remove LaunchAgent + restore Claude Code settings (keeps proxy files)
|
|
43
|
+
purge Uninstall + remove proxy files
|
|
44
|
+
help Show this help
|
|
45
|
+
|
|
46
|
+
Examples:
|
|
47
|
+
npx -y claude-multi-proxy@latest
|
|
48
|
+
npx -y claude-multi-proxy@latest claude-login
|
|
49
|
+
npx -y claude-multi-proxy@latest codex-login
|
|
50
|
+
npx -y claude-multi-proxy@latest status
|
|
51
|
+
npx -y claude-multi-proxy@latest purge
|
|
52
|
+
|
|
53
|
+
How it works:
|
|
54
|
+
1. Install CLIProxyAPI as a local proxy
|
|
55
|
+
2. Login to both Claude and Codex via OAuth
|
|
56
|
+
3. Use /model in Claude Code to switch between providers:
|
|
57
|
+
- /model opus → Claude Opus (Anthropic)
|
|
58
|
+
- /model sonnet → Claude Sonnet (Anthropic)
|
|
59
|
+
- /model haiku → Claude Haiku (Anthropic)
|
|
60
|
+
- /model codex → GPT-5.3 Codex (OpenAI)
|
|
61
|
+
`;
|
|
62
|
+
console.log(txt);
|
|
63
|
+
process.exit(code);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseArgs(argv) {
|
|
67
|
+
const args = [...argv];
|
|
68
|
+
const out = { command: "install" };
|
|
69
|
+
|
|
70
|
+
if (args.length > 0 && !args[0].startsWith("-")) {
|
|
71
|
+
out.command = args.shift();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
while (args.length > 0) {
|
|
75
|
+
const a = args.shift();
|
|
76
|
+
if (a === "--help" || a === "-h" || a === "help") return { ...out, command: "help" };
|
|
77
|
+
if (a === "--yes" || a === "-y") continue;
|
|
78
|
+
fail(`unknown arg: ${a}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function exists(p) {
|
|
85
|
+
try {
|
|
86
|
+
fs.accessSync(p);
|
|
87
|
+
return true;
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function ensureDir(p) {
|
|
94
|
+
fs.mkdirSync(p, { recursive: true });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readText(p) {
|
|
98
|
+
return fs.readFileSync(p, "utf8");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function writeFileAtomic(p, content, mode) {
|
|
102
|
+
const dir = path.dirname(p);
|
|
103
|
+
ensureDir(dir);
|
|
104
|
+
const tmp = `${p}.tmp.${process.pid}.${nowTs()}`;
|
|
105
|
+
fs.writeFileSync(tmp, content, "utf8");
|
|
106
|
+
if (mode != null) fs.chmodSync(tmp, mode);
|
|
107
|
+
fs.renameSync(tmp, p);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function backupFile(p) {
|
|
111
|
+
if (!exists(p)) return null;
|
|
112
|
+
const bak = `${p}.backup.${nowTs()}`;
|
|
113
|
+
fs.copyFileSync(p, bak);
|
|
114
|
+
return bak;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function run(cmd, args, opts = {}) {
|
|
118
|
+
const {
|
|
119
|
+
cwd,
|
|
120
|
+
allowFail = false,
|
|
121
|
+
captureStdout = true,
|
|
122
|
+
captureStderr = true,
|
|
123
|
+
inherit = false,
|
|
124
|
+
} = opts;
|
|
125
|
+
|
|
126
|
+
const r = spawnSync(cmd, args, {
|
|
127
|
+
cwd,
|
|
128
|
+
encoding: "utf8",
|
|
129
|
+
stdio: inherit
|
|
130
|
+
? "inherit"
|
|
131
|
+
: [
|
|
132
|
+
"ignore",
|
|
133
|
+
captureStdout ? "pipe" : "inherit",
|
|
134
|
+
captureStderr ? "pipe" : "inherit",
|
|
135
|
+
],
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (!allowFail && (r.error || r.status !== 0)) {
|
|
139
|
+
const msg = [
|
|
140
|
+
`${cmd} ${args.join(" ")}`,
|
|
141
|
+
r.error ? String(r.error) : "",
|
|
142
|
+
r.stdout ? `stdout:\n${r.stdout}` : "",
|
|
143
|
+
r.stderr ? `stderr:\n${r.stderr}` : "",
|
|
144
|
+
].filter(Boolean).join("\n");
|
|
145
|
+
fail(msg);
|
|
146
|
+
}
|
|
147
|
+
return r;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function fetchJson(url) {
|
|
151
|
+
const res = await fetch(url, {
|
|
152
|
+
headers: { "user-agent": "claude-multi-proxy" },
|
|
153
|
+
});
|
|
154
|
+
if (!res.ok) {
|
|
155
|
+
throw new Error(`HTTP ${res.status} ${res.statusText} (${url})`);
|
|
156
|
+
}
|
|
157
|
+
return await res.json();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function downloadToFile(url, destPath) {
|
|
161
|
+
const res = await fetch(url, {
|
|
162
|
+
redirect: "follow",
|
|
163
|
+
headers: { "user-agent": "claude-multi-proxy" },
|
|
164
|
+
});
|
|
165
|
+
if (!res.ok) throw new Error(`download failed: HTTP ${res.status} ${res.statusText}`);
|
|
166
|
+
ensureDir(path.dirname(destPath));
|
|
167
|
+
const tmp = `${destPath}.tmp.${process.pid}.${nowTs()}`;
|
|
168
|
+
const ab = await res.arrayBuffer();
|
|
169
|
+
fs.writeFileSync(tmp, Buffer.from(ab));
|
|
170
|
+
fs.renameSync(tmp, destPath);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function findFileRecursive(rootDir, names) {
|
|
174
|
+
const stack = [rootDir];
|
|
175
|
+
while (stack.length > 0) {
|
|
176
|
+
const dir = stack.pop();
|
|
177
|
+
const items = fs.readdirSync(dir, { withFileTypes: true });
|
|
178
|
+
for (const it of items) {
|
|
179
|
+
const p = path.join(dir, it.name);
|
|
180
|
+
if (it.isDirectory()) {
|
|
181
|
+
stack.push(p);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (it.isFile() && names.includes(it.name)) {
|
|
185
|
+
return p;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function sleep(ms) {
|
|
193
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function readPortFromProxyConfig(configFile) {
|
|
197
|
+
if (!exists(configFile)) return null;
|
|
198
|
+
try {
|
|
199
|
+
const m = readText(configFile).match(/^\s*port:\s*(\d+)\s*$/m);
|
|
200
|
+
if (!m) return null;
|
|
201
|
+
const n = Number(m[1]);
|
|
202
|
+
if (!Number.isInteger(n) || n <= 0 || n > 65535) return null;
|
|
203
|
+
return n;
|
|
204
|
+
} catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function resolveProxyPort({ configFile }) {
|
|
210
|
+
const fromConfig = readPortFromProxyConfig(configFile);
|
|
211
|
+
// Always use the configured port (or default). During install we stop
|
|
212
|
+
// existing proxy first, so the port should be free.
|
|
213
|
+
return fromConfig ?? DEFAULT_PORT;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function proxyHealthcheck(port) {
|
|
217
|
+
try {
|
|
218
|
+
const ctrl = new AbortController();
|
|
219
|
+
const t = setTimeout(() => ctrl.abort(), 2000);
|
|
220
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/models`, {
|
|
221
|
+
headers: { Authorization: "Bearer sk-dummy" },
|
|
222
|
+
signal: ctrl.signal,
|
|
223
|
+
});
|
|
224
|
+
clearTimeout(t);
|
|
225
|
+
return res.ok;
|
|
226
|
+
} catch {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function proxyConfigYaml({ port }) {
|
|
232
|
+
return `# claude-multi-proxy config
|
|
233
|
+
# Claude + Codex dual OAuth proxy
|
|
234
|
+
|
|
235
|
+
host: "127.0.0.1"
|
|
236
|
+
port: ${port}
|
|
237
|
+
|
|
238
|
+
auth-dir: "~/.cli-proxy-api"
|
|
239
|
+
|
|
240
|
+
api-keys:
|
|
241
|
+
- "sk-dummy"
|
|
242
|
+
|
|
243
|
+
request-retry: 3
|
|
244
|
+
|
|
245
|
+
# "codex" alias → ${CODEX_MODEL_TARGET}
|
|
246
|
+
# Use /model codex in Claude Code to switch
|
|
247
|
+
oauth-model-alias:
|
|
248
|
+
codex:
|
|
249
|
+
- name: "${CODEX_MODEL_TARGET}"
|
|
250
|
+
alias: "${CODEX_MODEL_ALIAS}"
|
|
251
|
+
fork: true
|
|
252
|
+
|
|
253
|
+
payload:
|
|
254
|
+
override:
|
|
255
|
+
- models:
|
|
256
|
+
- name: "gpt-*"
|
|
257
|
+
protocol: "codex"
|
|
258
|
+
params:
|
|
259
|
+
"reasoning.effort": "high"
|
|
260
|
+
`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function buildPlistProxy({ labelProxy, proxyBin, configFile, homeDir, proxyLog }) {
|
|
264
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
265
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
266
|
+
<plist version="1.0"><dict>
|
|
267
|
+
<key>Label</key><string>${labelProxy}</string>
|
|
268
|
+
<key>ProgramArguments</key>
|
|
269
|
+
<array>
|
|
270
|
+
<string>${proxyBin}</string>
|
|
271
|
+
<string>-config</string>
|
|
272
|
+
<string>${configFile}</string>
|
|
273
|
+
</array>
|
|
274
|
+
<key>RunAtLoad</key><true/>
|
|
275
|
+
<key>KeepAlive</key><true/>
|
|
276
|
+
<key>WorkingDirectory</key><string>${homeDir}</string>
|
|
277
|
+
<key>StandardOutPath</key><string>${proxyLog}</string>
|
|
278
|
+
<key>StandardErrorPath</key><string>${proxyLog}</string>
|
|
279
|
+
</dict></plist>
|
|
280
|
+
`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function installCliProxyApiBinary({ proxyBin }) {
|
|
284
|
+
if (exists(proxyBin)) {
|
|
285
|
+
log(`CLIProxyAPI already installed: ${proxyBin}`);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const arch = process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "amd64" : null;
|
|
290
|
+
if (!arch) fail(`unsupported architecture: ${process.arch}`);
|
|
291
|
+
|
|
292
|
+
ensureDir(path.dirname(proxyBin));
|
|
293
|
+
|
|
294
|
+
log("Downloading CLIProxyAPI release from GitHub...");
|
|
295
|
+
const rel = await fetchJson("https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/latest");
|
|
296
|
+
const suffix = `darwin_${arch}.tar.gz`;
|
|
297
|
+
const asset = (rel.assets || []).find((a) => typeof a?.name === "string" && a.name.includes(suffix));
|
|
298
|
+
if (!asset?.browser_download_url) {
|
|
299
|
+
fail(`could not find asset containing: ${suffix}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-multi-proxy-"));
|
|
303
|
+
const tarball = path.join(tmpDir, "cli-proxy-api.tar.gz");
|
|
304
|
+
await downloadToFile(asset.browser_download_url, tarball);
|
|
305
|
+
|
|
306
|
+
log("Extracting tarball...");
|
|
307
|
+
run("tar", ["-xzf", tarball, "-C", tmpDir]);
|
|
308
|
+
|
|
309
|
+
const found = findFileRecursive(tmpDir, ["cli-proxy-api", "CLIProxyAPI"]);
|
|
310
|
+
if (!found) fail("failed to locate extracted binary");
|
|
311
|
+
|
|
312
|
+
fs.copyFileSync(found, proxyBin);
|
|
313
|
+
fs.chmodSync(proxyBin, 0o755);
|
|
314
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
315
|
+
|
|
316
|
+
log(`Installed: ${proxyBin}`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function updateClaudeSettings({ claudeSettingsPath, port }) {
|
|
320
|
+
ensureDir(path.dirname(claudeSettingsPath));
|
|
321
|
+
if (!exists(claudeSettingsPath)) {
|
|
322
|
+
writeFileAtomic(claudeSettingsPath, "{}\n", 0o600);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
backupFile(claudeSettingsPath);
|
|
326
|
+
|
|
327
|
+
let json;
|
|
328
|
+
try {
|
|
329
|
+
json = JSON.parse(readText(claudeSettingsPath));
|
|
330
|
+
} catch {
|
|
331
|
+
fail(`failed to parse JSON: ${claudeSettingsPath}`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (!json || typeof json !== "object") json = {};
|
|
335
|
+
if (!json.env || typeof json.env !== "object") json.env = {};
|
|
336
|
+
|
|
337
|
+
// Only set proxy routing — preserve original Claude model slots
|
|
338
|
+
json.env.ANTHROPIC_BASE_URL = `http://127.0.0.1:${port}`;
|
|
339
|
+
json.env.ANTHROPIC_AUTH_TOKEN = "sk-dummy";
|
|
340
|
+
|
|
341
|
+
// Keep original Claude models for each slot
|
|
342
|
+
json.env.ANTHROPIC_DEFAULT_OPUS_MODEL = "claude-opus-4-6";
|
|
343
|
+
json.env.ANTHROPIC_DEFAULT_SONNET_MODEL = "claude-sonnet-4-5-20250929";
|
|
344
|
+
json.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = "claude-haiku-4-5-20251001";
|
|
345
|
+
|
|
346
|
+
writeFileAtomic(claudeSettingsPath, `${JSON.stringify(json, null, 2)}\n`, 0o600);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function cleanupClaudeSettings({ claudeSettingsPath }) {
|
|
350
|
+
if (!exists(claudeSettingsPath)) return;
|
|
351
|
+
|
|
352
|
+
backupFile(claudeSettingsPath);
|
|
353
|
+
|
|
354
|
+
let json;
|
|
355
|
+
try {
|
|
356
|
+
json = JSON.parse(readText(claudeSettingsPath));
|
|
357
|
+
} catch {
|
|
358
|
+
fail(`failed to parse JSON: ${claudeSettingsPath}`);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (!json || typeof json !== "object") return;
|
|
362
|
+
|
|
363
|
+
// Remove model if it was set to codex
|
|
364
|
+
if (json.model === CODEX_MODEL_ALIAS || json.model === CODEX_MODEL_TARGET) {
|
|
365
|
+
delete json.model;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (json.env && typeof json.env === "object") {
|
|
369
|
+
delete json.env.ANTHROPIC_BASE_URL;
|
|
370
|
+
delete json.env.ANTHROPIC_AUTH_TOKEN;
|
|
371
|
+
delete json.env.ANTHROPIC_DEFAULT_SONNET_MODEL;
|
|
372
|
+
delete json.env.ANTHROPIC_DEFAULT_OPUS_MODEL;
|
|
373
|
+
delete json.env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
writeFileAtomic(claudeSettingsPath, `${JSON.stringify(json, null, 2)}\n`, 0o600);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function getUsername() {
|
|
380
|
+
if (process.env.USER && process.env.USER.trim()) return process.env.USER.trim();
|
|
381
|
+
return os.userInfo().username;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function getUid() {
|
|
385
|
+
const r = run("id", ["-u"]);
|
|
386
|
+
return Number(String(r.stdout || "").trim());
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function launchctlBootout(uid, label) {
|
|
390
|
+
run("launchctl", ["bootout", `gui/${uid}/${label}`], { allowFail: true });
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function launchctlBootstrap(uid, plistPath) {
|
|
394
|
+
run("launchctl", ["bootstrap", `gui/${uid}`, plistPath], { allowFail: true });
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function launchctlKickstart(uid, label) {
|
|
398
|
+
run("launchctl", ["kickstart", "-k", `gui/${uid}/${label}`], { allowFail: true });
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function launchctlPrint(uid, label) {
|
|
402
|
+
const r = run("launchctl", ["print", `gui/${uid}/${label}`], { allowFail: true });
|
|
403
|
+
return r.status === 0;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function waitForHealthy(port, msTotal = 8000) {
|
|
407
|
+
const started = Date.now();
|
|
408
|
+
while (Date.now() - started < msTotal) {
|
|
409
|
+
if (await proxyHealthcheck(port)) return true;
|
|
410
|
+
await sleep(250);
|
|
411
|
+
}
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function getProxyBin(homeDir) {
|
|
416
|
+
return path.join(homeDir, ".cli-proxy-api", "cli-proxy-api");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function hasAuthFiles(proxyDir) {
|
|
420
|
+
const files = fs.readdirSync(proxyDir).filter((f) => f.endsWith(".json"));
|
|
421
|
+
const hasClaude = files.some((f) => f.startsWith("claude-"));
|
|
422
|
+
const hasCodex = files.some((f) => f.startsWith("codex-"));
|
|
423
|
+
return { hasClaude, hasCodex, files };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function stopExistingProxy(uid) {
|
|
427
|
+
// Stop our own LaunchAgent
|
|
428
|
+
launchctlBootout(uid, LABEL_PROXY);
|
|
429
|
+
|
|
430
|
+
// Also clean up known legacy labels from upstream codex-claudecode-proxy
|
|
431
|
+
const username = getUsername();
|
|
432
|
+
for (const legacy of [
|
|
433
|
+
`com.${username}.cli-proxy-api`,
|
|
434
|
+
`com.${username}.cli-proxy-api-token-sync`,
|
|
435
|
+
"com.cliproxyapi",
|
|
436
|
+
]) {
|
|
437
|
+
launchctlBootout(uid, legacy);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Wait for port to free up
|
|
441
|
+
await sleep(1000);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function installFlow() {
|
|
445
|
+
if (process.platform !== "darwin") {
|
|
446
|
+
fail("macOS only (LaunchAgents-based install).");
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const homeDir = os.homedir();
|
|
450
|
+
const uid = getUid();
|
|
451
|
+
|
|
452
|
+
const proxyDir = path.join(homeDir, ".cli-proxy-api");
|
|
453
|
+
const configFile = path.join(proxyDir, "config.yaml");
|
|
454
|
+
const proxyBin = getProxyBin(homeDir);
|
|
455
|
+
const proxyLog = path.join(proxyDir, "proxy.log");
|
|
456
|
+
const claudeSettingsPath = path.join(homeDir, ".claude", "settings.json");
|
|
457
|
+
const plistProxy = path.join(homeDir, "Library", "LaunchAgents", `${LABEL_PROXY}.plist`);
|
|
458
|
+
|
|
459
|
+
ensureDir(proxyDir);
|
|
460
|
+
ensureDir(path.dirname(plistProxy));
|
|
461
|
+
|
|
462
|
+
// 1. Stop existing proxy first (prevents port conflict)
|
|
463
|
+
log("Stopping existing proxy...");
|
|
464
|
+
await stopExistingProxy(uid);
|
|
465
|
+
|
|
466
|
+
// 2. Install CLIProxyAPI binary
|
|
467
|
+
await installCliProxyApiBinary({ proxyBin });
|
|
468
|
+
|
|
469
|
+
// 3. Resolve port and write config
|
|
470
|
+
const port = await resolveProxyPort({ configFile });
|
|
471
|
+
log("Writing proxy config...");
|
|
472
|
+
writeFileAtomic(configFile, proxyConfigYaml({ port }), 0o644);
|
|
473
|
+
|
|
474
|
+
// 4. Check OAuth status
|
|
475
|
+
const auth = hasAuthFiles(proxyDir);
|
|
476
|
+
if (!auth.hasClaude) {
|
|
477
|
+
log("");
|
|
478
|
+
log("Claude OAuth not found. Running claude-login...");
|
|
479
|
+
run(proxyBin, ["-config", configFile, "-claude-login"], { inherit: true, allowFail: true });
|
|
480
|
+
}
|
|
481
|
+
if (!auth.hasCodex) {
|
|
482
|
+
log("");
|
|
483
|
+
log("Codex OAuth not found. Running codex-login...");
|
|
484
|
+
run(proxyBin, ["-config", configFile, "-codex-login"], { inherit: true, allowFail: true });
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Re-check auth after login attempts
|
|
488
|
+
const authAfter = hasAuthFiles(proxyDir);
|
|
489
|
+
if (!authAfter.hasClaude && !authAfter.hasCodex) {
|
|
490
|
+
fail("No OAuth credentials found. Run 'claude-login' or 'codex-login' first.");
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// 5. Write and load LaunchAgent
|
|
494
|
+
log("Writing LaunchAgent...");
|
|
495
|
+
writeFileAtomic(plistProxy, buildPlistProxy({ labelProxy: LABEL_PROXY, proxyBin, configFile, homeDir, proxyLog }), 0o644);
|
|
496
|
+
|
|
497
|
+
log("Starting proxy...");
|
|
498
|
+
launchctlBootstrap(uid, plistProxy);
|
|
499
|
+
launchctlKickstart(uid, LABEL_PROXY);
|
|
500
|
+
|
|
501
|
+
const healthy = await waitForHealthy(port, 10000);
|
|
502
|
+
if (!healthy) fail(`proxy did not become healthy (check ${proxyLog})`);
|
|
503
|
+
|
|
504
|
+
// 6. Update Claude Code settings
|
|
505
|
+
log("Updating Claude Code settings...");
|
|
506
|
+
updateClaudeSettings({ claudeSettingsPath, port });
|
|
507
|
+
|
|
508
|
+
log("");
|
|
509
|
+
log("All done!");
|
|
510
|
+
log(` Proxy: http://127.0.0.1:${port}`);
|
|
511
|
+
log(` Config: ${configFile}`);
|
|
512
|
+
log(` Log: ${proxyLog}`);
|
|
513
|
+
log("");
|
|
514
|
+
log("Available models in Claude Code (/model):");
|
|
515
|
+
log(" opus → Claude Opus (Anthropic)");
|
|
516
|
+
log(" sonnet → Claude Sonnet (Anthropic)");
|
|
517
|
+
log(" haiku → Claude Haiku (Anthropic)");
|
|
518
|
+
log(" codex → GPT-5.3 Codex (OpenAI)");
|
|
519
|
+
log("");
|
|
520
|
+
if (!authAfter.hasClaude) {
|
|
521
|
+
warn("Claude OAuth missing — run: npx claude-multi-proxy claude-login");
|
|
522
|
+
}
|
|
523
|
+
if (!authAfter.hasCodex) {
|
|
524
|
+
warn("Codex OAuth missing — run: npx claude-multi-proxy codex-login");
|
|
525
|
+
}
|
|
526
|
+
log("Restart Claude Code to apply changes.");
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function oauthLoginFlow(provider) {
|
|
530
|
+
if (process.platform !== "darwin") fail("macOS only.");
|
|
531
|
+
const homeDir = os.homedir();
|
|
532
|
+
const proxyDir = path.join(homeDir, ".cli-proxy-api");
|
|
533
|
+
const configFile = path.join(proxyDir, "config.yaml");
|
|
534
|
+
const proxyBin = getProxyBin(homeDir);
|
|
535
|
+
|
|
536
|
+
if (!exists(proxyBin)) {
|
|
537
|
+
fail(`CLIProxyAPI not installed. Run 'install' first.`);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const flag = provider === "claude" ? "-claude-login" : "-codex-login";
|
|
541
|
+
log(`Starting ${provider} OAuth login...`);
|
|
542
|
+
run(proxyBin, ["-config", configFile, flag], { inherit: true });
|
|
543
|
+
log(`${provider} login completed.`);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function startFlow() {
|
|
547
|
+
if (process.platform !== "darwin") fail("macOS only.");
|
|
548
|
+
const homeDir = os.homedir();
|
|
549
|
+
const uid = getUid();
|
|
550
|
+
const configFile = path.join(homeDir, ".cli-proxy-api", "config.yaml");
|
|
551
|
+
const port = readPortFromProxyConfig(configFile) ?? DEFAULT_PORT;
|
|
552
|
+
const plistProxy = path.join(homeDir, "Library", "LaunchAgents", `${LABEL_PROXY}.plist`);
|
|
553
|
+
|
|
554
|
+
if (!exists(plistProxy)) fail(`missing plist: ${plistProxy} (run install first)`);
|
|
555
|
+
launchctlBootstrap(uid, plistProxy);
|
|
556
|
+
launchctlKickstart(uid, LABEL_PROXY);
|
|
557
|
+
|
|
558
|
+
const healthy = await waitForHealthy(port, 10000);
|
|
559
|
+
if (!healthy) fail("proxy did not become healthy");
|
|
560
|
+
log("proxy started");
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async function stopFlow() {
|
|
564
|
+
if (process.platform !== "darwin") fail("macOS only.");
|
|
565
|
+
const uid = getUid();
|
|
566
|
+
launchctlBootout(uid, LABEL_PROXY);
|
|
567
|
+
log("proxy stopped");
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function statusFlow() {
|
|
571
|
+
const homeDir = os.homedir();
|
|
572
|
+
const proxyDir = path.join(homeDir, ".cli-proxy-api");
|
|
573
|
+
const configFile = path.join(proxyDir, "config.yaml");
|
|
574
|
+
const port = readPortFromProxyConfig(configFile) ?? DEFAULT_PORT;
|
|
575
|
+
const portOk = await proxyHealthcheck(port);
|
|
576
|
+
|
|
577
|
+
log(`Proxy: ${portOk ? "RUNNING" : "NOT RUNNING"} (http://127.0.0.1:${port})`);
|
|
578
|
+
|
|
579
|
+
if (process.platform === "darwin") {
|
|
580
|
+
const uid = getUid();
|
|
581
|
+
log(`LaunchAgent: ${launchctlPrint(uid, LABEL_PROXY) ? "loaded" : "not loaded"}`);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (exists(proxyDir)) {
|
|
585
|
+
const auth = hasAuthFiles(proxyDir);
|
|
586
|
+
log(`Claude OAuth: ${auth.hasClaude ? "configured" : "not configured"}`);
|
|
587
|
+
log(`Codex OAuth: ${auth.hasCodex ? "configured" : "not configured"}`);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (portOk) {
|
|
591
|
+
try {
|
|
592
|
+
const ctrl = new AbortController();
|
|
593
|
+
const t = setTimeout(() => ctrl.abort(), 3000);
|
|
594
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/models`, {
|
|
595
|
+
headers: { Authorization: "Bearer sk-dummy" },
|
|
596
|
+
signal: ctrl.signal,
|
|
597
|
+
});
|
|
598
|
+
clearTimeout(t);
|
|
599
|
+
if (res.ok) {
|
|
600
|
+
const data = await res.json();
|
|
601
|
+
const models = (data.data || []).map((m) => m.id).sort();
|
|
602
|
+
log(`Models (${models.length}): ${models.join(", ")}`);
|
|
603
|
+
}
|
|
604
|
+
} catch {
|
|
605
|
+
// ignore
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async function uninstallFlow(opts) {
|
|
611
|
+
if (process.platform !== "darwin") fail("macOS only.");
|
|
612
|
+
const homeDir = os.homedir();
|
|
613
|
+
const username = getUsername();
|
|
614
|
+
const uid = getUid();
|
|
615
|
+
const plistProxy = path.join(homeDir, "Library", "LaunchAgents", `${LABEL_PROXY}.plist`);
|
|
616
|
+
const claudeSettingsPath = path.join(homeDir, ".claude", "settings.json");
|
|
617
|
+
const proxyDir = path.join(homeDir, ".cli-proxy-api");
|
|
618
|
+
|
|
619
|
+
// Stop our proxy and clean up legacy labels
|
|
620
|
+
await stopExistingProxy(uid);
|
|
621
|
+
|
|
622
|
+
// Remove legacy plist files
|
|
623
|
+
const legacyPlists = [
|
|
624
|
+
`com.${username}.cli-proxy-api`,
|
|
625
|
+
`com.${username}.cli-proxy-api-token-sync`,
|
|
626
|
+
"com.cliproxyapi",
|
|
627
|
+
].map((l) => path.join(homeDir, "Library", "LaunchAgents", `${l}.plist`));
|
|
628
|
+
|
|
629
|
+
for (const p of [...legacyPlists, plistProxy]) {
|
|
630
|
+
if (exists(p)) fs.rmSync(p, { force: true });
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Restore Claude Code settings
|
|
634
|
+
cleanupClaudeSettings({ claudeSettingsPath });
|
|
635
|
+
|
|
636
|
+
if (opts.command === "purge") {
|
|
637
|
+
if (exists(proxyDir)) fs.rmSync(proxyDir, { recursive: true, force: true });
|
|
638
|
+
log("purge completed (proxy files removed)");
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
log("uninstall completed (proxy files left in place)");
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async function main() {
|
|
646
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
647
|
+
if (opts.command === "help") usage(0);
|
|
648
|
+
|
|
649
|
+
try {
|
|
650
|
+
switch (opts.command) {
|
|
651
|
+
case "install":
|
|
652
|
+
await installFlow();
|
|
653
|
+
break;
|
|
654
|
+
case "claude-login":
|
|
655
|
+
await oauthLoginFlow("claude");
|
|
656
|
+
break;
|
|
657
|
+
case "codex-login":
|
|
658
|
+
await oauthLoginFlow("codex");
|
|
659
|
+
break;
|
|
660
|
+
case "start":
|
|
661
|
+
await startFlow();
|
|
662
|
+
break;
|
|
663
|
+
case "stop":
|
|
664
|
+
await stopFlow();
|
|
665
|
+
break;
|
|
666
|
+
case "status":
|
|
667
|
+
await statusFlow();
|
|
668
|
+
break;
|
|
669
|
+
case "uninstall":
|
|
670
|
+
await uninstallFlow(opts);
|
|
671
|
+
break;
|
|
672
|
+
case "purge":
|
|
673
|
+
await uninstallFlow(opts);
|
|
674
|
+
break;
|
|
675
|
+
default:
|
|
676
|
+
usage(1);
|
|
677
|
+
}
|
|
678
|
+
} catch (e) {
|
|
679
|
+
fail(e?.stack || String(e));
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
await main();
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-multi-proxy",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Use Claude Code with multiple AI providers (Claude + Codex) via CLIProxyAPI. Switch models with /model.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/donggi-lee-bit/codex-claudecode-proxy.git"
|
|
9
|
+
},
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/donggi-lee-bit/codex-claudecode-proxy/issues"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/donggi-lee-bit/codex-claudecode-proxy#readme",
|
|
14
|
+
"bin": {
|
|
15
|
+
"claude-multi-proxy": "bin/claude-multi-proxy.js"
|
|
16
|
+
},
|
|
17
|
+
"type": "module",
|
|
18
|
+
"files": [
|
|
19
|
+
"bin/"
|
|
20
|
+
],
|
|
21
|
+
"os": [
|
|
22
|
+
"darwin"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"claude-code",
|
|
29
|
+
"codex",
|
|
30
|
+
"multi-provider",
|
|
31
|
+
"oauth",
|
|
32
|
+
"proxy",
|
|
33
|
+
"CLIProxyAPI"
|
|
34
|
+
]
|
|
35
|
+
}
|