@yhong91/cpac 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -4
- package/cpac.example.json +2 -1
- package/dist/cpac.js +532 -48
- package/dist/pi-extension.template +83 -0
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
CPAC 将 Codex 和 Claude Code 接入远端 CLIProxyAPI(CPA):
|
|
4
4
|
|
|
5
|
-
- **Codex**:注入模型目录和 provider,并可逐字节恢复原配置。
|
|
5
|
+
- **Codex**:注入模型目录和 loopback 网关,保留内置 `openai` provider,并可逐字节恢复原配置。
|
|
6
6
|
- **Claude Code**:通过临时环境变量启动,不修改 Claude Code 配置。
|
|
7
7
|
|
|
8
8
|
实现基于 TypeScript + Node.js,运行时零依赖。
|
|
@@ -110,7 +110,7 @@ ANTHROPIC_AUTH_TOKEN=<CPA_API_KEY>
|
|
|
110
110
|
|
|
111
111
|
同时清除可能覆盖网关选择的 `ANTHROPIC_API_KEY` 和 Claude Code 云 provider 环境变量。它不会修改 `~/.claude/`,并原样返回 Claude Code 的退出码。
|
|
112
112
|
|
|
113
|
-
> CPA 服务端必须支持 Claude Code 使用的 Anthropic Messages API(`/v1/messages`)。CPAC
|
|
113
|
+
> CPA 服务端必须支持 Claude Code 使用的 Anthropic Messages API(`/v1/messages`)。Claude 路径直接连接远端 CPA;CPAC 不负责协议转换。
|
|
114
114
|
|
|
115
115
|
### Codex
|
|
116
116
|
|
|
@@ -126,15 +126,45 @@ cpac inject
|
|
|
126
126
|
cpac status
|
|
127
127
|
```
|
|
128
128
|
|
|
129
|
+
`status` 列出所有支持的 agent 及当前状态:
|
|
130
|
+
|
|
131
|
+
```text
|
|
132
|
+
codex: injected proxy=http://127.0.0.1:10101/v1 sha256=...
|
|
133
|
+
claude: ready
|
|
134
|
+
pi: ready
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
- **codex**:`native`(未注入)、`injected`(已注入,附代理地址和配置摘要)、`injected (loopback proxy stopped)` 或 `injected (config missing)`
|
|
138
|
+
- **claude**:`ready`(密钥已配置)或 `not configured`
|
|
139
|
+
- **pi**:`ready`(密钥已配置)或 `not configured`
|
|
140
|
+
|
|
129
141
|
恢复注入前的原配置:
|
|
130
142
|
|
|
131
143
|
```bash
|
|
132
144
|
cpac restore
|
|
133
145
|
```
|
|
134
146
|
|
|
135
|
-
`inject`
|
|
147
|
+
`inject` 从远端 `/v1/models?client_version=1` 获取 Codex rich catalog,并启动只监听 `127.0.0.1` 的轻量转发代理。Codex 配置保留内置 `openai` provider,只写入根级 `model_catalog_json` 和:
|
|
148
|
+
|
|
149
|
+
```toml
|
|
150
|
+
openai_base_url = "http://127.0.0.1:10101/v1"
|
|
151
|
+
```
|
|
136
152
|
|
|
137
|
-
|
|
153
|
+
Codex App/CLI 发给本地代理的 ChatGPT bearer 不会转发到 CPA;代理会改用 `CPA_API_KEY`,并将请求和流式响应原样转发到远端 CPA。这保留了 Codex App 的原生 `openai` provider 身份、历史和账号相关界面。已有用户自定义根级 `openai_base_url` 或非 `openai` 的活动 `model_provider` 时,CPAC 会拒绝覆盖。
|
|
154
|
+
|
|
155
|
+
Codex App 的长驻 `app-server` 可能缓存旧目录。`inject` 会删除 `models_cache.json`;如果选择器仍未更新,请重启 Codex App。
|
|
156
|
+
|
|
157
|
+
第一次注入时,原文件按原始 bytes 备份到 `state_dir/config.toml.backup`。重复注入不会覆盖首次备份,并会保留 CPAC 管理字段之外的用户编辑。`restore` 按原 mode 逐字节恢复首次备份、关闭 CPAC loopback 代理;若原文件不存在,则删除注入创建的 `config.toml`。
|
|
158
|
+
|
|
159
|
+
loopback 代理是 detached 用户进程,不安装系统服务。机器重启或进程意外退出后,`cpac status` 会报告 `loopback proxy stopped`;重新执行 `cpac inject` 即可恢复。
|
|
160
|
+
|
|
161
|
+
需要由现有进程管理器监督时,可使用前台入口:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
cpac proxy
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
它复用当前注入记录的端口和实例身份;没有活动注入时会拒绝启动。
|
|
138
168
|
|
|
139
169
|
默认路径:
|
|
140
170
|
|
|
@@ -145,12 +175,30 @@ CPAC state:~/.cpac
|
|
|
145
175
|
|
|
146
176
|
为防止误删或覆盖,`state_dir` 不能是文件系统根目录、用户 home、系统临时目录,也不能包含 Codex 配置文件。
|
|
147
177
|
|
|
178
|
+
### Pi
|
|
179
|
+
|
|
180
|
+
安装 CPA provider 扩展到 Pi:
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
cpac pi install
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
它将生成 `cpac.ts` 写入 `~/.pi/agent/extensions/`,Pi 启动时自动加载并从 CPA 动态注册模型目录。卸载:
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
cpac pi uninstall
|
|
190
|
+
cpac pi status
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Pi 扩展尊重 `PI_CODING_AGENT_DIR` 环境变量。
|
|
194
|
+
|
|
148
195
|
## 环境变量
|
|
149
196
|
|
|
150
197
|
| 变量 | 默认值 | 作用 |
|
|
151
198
|
| --- | --- | --- |
|
|
152
199
|
| `CPA_API_KEY` | 无 | CPA Bearer key;可由 `cpac` 首次引导写入 shell 启动文件 |
|
|
153
200
|
| `CPA_BASE_URL` | `https://cpa.vibetime.cc` | 覆盖内置 CPA 地址 |
|
|
201
|
+
| `PI_CODING_AGENT_DIR` | `~/.pi/agent` | 覆盖 Pi agent 目录(影响 `cpac pi install` 写入位置) |
|
|
154
202
|
| `CPAC_CONFIG` | `~/.config/cpac/config.json` | 指定可选 JSON 配置路径 |
|
|
155
203
|
| `CODEX_HOME` | `~/.codex` | Codex home 目录 |
|
|
156
204
|
|
|
@@ -171,6 +219,7 @@ CPA_BASE_URL='https://cpa.example.com' cpac claude
|
|
|
171
219
|
"cpa_url": "https://cpa.example.com",
|
|
172
220
|
"api_key_env": "CPA_API_KEY",
|
|
173
221
|
"codex_config": "~/.codex/config.toml",
|
|
222
|
+
"codex_proxy_port": 10101,
|
|
174
223
|
"state_dir": "~/.cpac"
|
|
175
224
|
}
|
|
176
225
|
```
|
|
@@ -180,6 +229,7 @@ CPA_BASE_URL='https://cpa.example.com' cpac claude
|
|
|
180
229
|
- `cpa_url`:可选,绝对 `http(s)` URL,可带或不带 `/v1`
|
|
181
230
|
- `api_key_env`:可选,密钥环境变量名,默认 `CPA_API_KEY`
|
|
182
231
|
- `codex_config`:可选,Codex 配置路径
|
|
232
|
+
- `codex_proxy_port`:可选,本地 Codex 转发端口,默认 `10101`;设为 `0` 时自动选择空闲端口
|
|
183
233
|
- `state_dir`:可选,CPAC state 路径
|
|
184
234
|
|
|
185
235
|
使用自定义配置:
|
|
@@ -198,6 +248,7 @@ export CPAC_CONFIG=/path/to/cpac.json
|
|
|
198
248
|
## 安全行为
|
|
199
249
|
|
|
200
250
|
- API key 不写入 CPAC JSON、Codex 配置、备份、state、catalog 或日志。
|
|
251
|
+
- Codex loopback 代理仅监听 `127.0.0.1`,拒绝非本地浏览器 Origin,并在内存中用 CPA key 替换入站 Authorization。
|
|
201
252
|
- 引导输入不回显,写入 shell 时会正确引用特殊字符。
|
|
202
253
|
- shell 启动文件和 Codex 配置均通过同目录临时文件原子替换。
|
|
203
254
|
- CPA 请求失败、catalog 无效或 key 缺失时,首次注入不会修改 Codex 配置。
|
package/cpac.example.json
CHANGED
package/dist/cpac.js
CHANGED
|
@@ -1,24 +1,43 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
|
-
import { createHash } from "node:crypto";
|
|
4
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
+
import { createServer, request as httpRequest, } from "node:http";
|
|
6
|
+
import { request as httpsRequest } from "node:https";
|
|
5
7
|
import { homedir, tmpdir } from "node:os";
|
|
6
8
|
import { dirname, isAbsolute, join, parse, relative, resolve } from "node:path";
|
|
7
|
-
import { pathToFileURL } from "node:url";
|
|
9
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
10
|
import { createInterface } from "node:readline/promises";
|
|
9
11
|
import { Writable } from "node:stream";
|
|
10
12
|
export const PROVIDER = "cpac_cpa";
|
|
11
13
|
const DEFAULT_CPA_URL = "https://cpa.vibetime.cc";
|
|
14
|
+
const DEFAULT_CODEX_PROXY_PORT = 10101;
|
|
12
15
|
const MAX_CATALOG_BYTES = 16 * 1024 * 1024;
|
|
13
16
|
const CONFIG_KEYS = new Set([
|
|
14
17
|
"cpa_url",
|
|
15
18
|
"api_key_env",
|
|
16
19
|
"codex_config",
|
|
20
|
+
"codex_proxy_port",
|
|
17
21
|
"state_dir",
|
|
18
22
|
]);
|
|
19
|
-
const
|
|
23
|
+
const REQUIRED_STATE_KEYS = new Set([
|
|
24
|
+
"config_path",
|
|
25
|
+
"config_existed",
|
|
26
|
+
"config_mode",
|
|
27
|
+
]);
|
|
28
|
+
const STATE_KEYS = new Set([
|
|
29
|
+
...REQUIRED_STATE_KEYS,
|
|
30
|
+
"proxy_id",
|
|
31
|
+
"proxy_fingerprint",
|
|
32
|
+
"proxy_pid",
|
|
33
|
+
"proxy_port",
|
|
34
|
+
]);
|
|
20
35
|
const STATE_FILES = ["state.json", "config.toml.backup", "codex-models.json"];
|
|
21
|
-
const
|
|
36
|
+
const MODEL_PROVIDER_KEY = /^\s*(?:model_provider|"model_provider"|'model_provider')\s*=/;
|
|
37
|
+
const MODEL_CATALOG_KEY = /^\s*(?:model_catalog_json|"model_catalog_json"|'model_catalog_json')\s*=/;
|
|
38
|
+
const OPENAI_BASE_URL_KEY = /^\s*(?:openai_base_url|"openai_base_url"|'openai_base_url')\s*=/;
|
|
39
|
+
const ROOT_KEY = new RegExp(`(?:${MODEL_PROVIDER_KEY.source}|${MODEL_CATALOG_KEY.source}|${OPENAI_BASE_URL_KEY.source})`);
|
|
40
|
+
const MANAGED_MARKER = "# CPAC managed; run CPAC 'restore' to restore the original file.";
|
|
22
41
|
let atomicSequence = 0;
|
|
23
42
|
export class CPACError extends Error {
|
|
24
43
|
}
|
|
@@ -94,6 +113,12 @@ export function loadConfig(path, useDefaultsIfMissing = false) {
|
|
|
94
113
|
(typeof value.state_dir !== "string" || !value.state_dir.trim())) {
|
|
95
114
|
throw new CPACError("state_dir must be a non-empty string");
|
|
96
115
|
}
|
|
116
|
+
const codexProxyPort = value.codex_proxy_port ?? DEFAULT_CODEX_PROXY_PORT;
|
|
117
|
+
if (!Number.isInteger(codexProxyPort) ||
|
|
118
|
+
codexProxyPort < 0 ||
|
|
119
|
+
codexProxyPort > 65535) {
|
|
120
|
+
throw new CPACError("codex_proxy_port must be an integer from 0 to 65535");
|
|
121
|
+
}
|
|
97
122
|
const codexHome = expandUserPath(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"));
|
|
98
123
|
const codexConfig = resolve(expandUserPath(value.codex_config ||
|
|
99
124
|
join(codexHome, "config.toml")));
|
|
@@ -108,6 +133,7 @@ export function loadConfig(path, useDefaultsIfMissing = false) {
|
|
|
108
133
|
cpa_url: cpaUrl,
|
|
109
134
|
api_key_env: apiKeyEnv,
|
|
110
135
|
codex_config: codexConfig,
|
|
136
|
+
codex_proxy_port: codexProxyPort,
|
|
111
137
|
state_dir: stateDir,
|
|
112
138
|
};
|
|
113
139
|
}
|
|
@@ -245,6 +271,7 @@ function stripManagedConfig(content) {
|
|
|
245
271
|
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
|
246
272
|
let inRoot = true;
|
|
247
273
|
let inProvider = false;
|
|
274
|
+
let managedRootKeys = 0;
|
|
248
275
|
const kept = [];
|
|
249
276
|
for (const line of lines) {
|
|
250
277
|
if (inProvider && /^\s*\[/.test(line))
|
|
@@ -257,14 +284,20 @@ function stripManagedConfig(content) {
|
|
|
257
284
|
continue;
|
|
258
285
|
if (/^\s*\[/.test(line))
|
|
259
286
|
inRoot = false;
|
|
260
|
-
if (inRoot &&
|
|
287
|
+
if (inRoot && line === MANAGED_MARKER) {
|
|
288
|
+
managedRootKeys = 2;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (inRoot && managedRootKeys > 0 && ROOT_KEY.test(line)) {
|
|
292
|
+
managedRootKeys -= 1;
|
|
261
293
|
continue;
|
|
294
|
+
}
|
|
262
295
|
kept.push(line);
|
|
263
296
|
}
|
|
264
297
|
const output = kept.join("\n");
|
|
265
298
|
return Buffer.from(eol === "\n" ? output : output.replace(/\n/g, "\r\n"));
|
|
266
299
|
}
|
|
267
|
-
export function buildCodexConfig(original,
|
|
300
|
+
export function buildCodexConfig(original, proxyPort, catalogPath) {
|
|
268
301
|
let text;
|
|
269
302
|
try {
|
|
270
303
|
text = new TextDecoder("utf-8", { fatal: true }).decode(original);
|
|
@@ -275,33 +308,37 @@ export function buildCodexConfig(original, cpaUrl, apiKeyEnv, catalogPath) {
|
|
|
275
308
|
if (hasProviderTable(text)) {
|
|
276
309
|
throw new CPACError(`Codex config already contains [model_providers.${PROVIDER}]`);
|
|
277
310
|
}
|
|
311
|
+
const normalizedLines = text.replace(/\r\n/g, "\n").split("\n");
|
|
312
|
+
const firstTable = normalizedLines.findIndex((line) => /^\s*\[/.test(line));
|
|
313
|
+
const rootLines = normalizedLines.slice(0, firstTable === -1 ? undefined : firstTable);
|
|
314
|
+
const providerLine = rootLines.find((line) => MODEL_PROVIDER_KEY.test(line));
|
|
315
|
+
if (providerLine) {
|
|
316
|
+
const match = /=\s*["']([^"']+)["']/.exec(providerLine);
|
|
317
|
+
if (!match || match[1] !== "openai") {
|
|
318
|
+
throw new CPACError("Codex config selects an external model_provider; restore it to openai before injecting CPAC");
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (rootLines.some((line) => OPENAI_BASE_URL_KEY.test(line))) {
|
|
322
|
+
throw new CPACError("Codex config already contains openai_base_url; remove that user-owned override before injecting CPAC");
|
|
323
|
+
}
|
|
278
324
|
const eol = dominantEol(text);
|
|
279
325
|
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
|
280
326
|
let inRoot = true;
|
|
281
327
|
const kept = lines.filter((line) => {
|
|
282
328
|
if (/^\s*\[/.test(line))
|
|
283
329
|
inRoot = false;
|
|
284
|
-
return !(inRoot &&
|
|
330
|
+
return !(inRoot && MODEL_CATALOG_KEY.test(line));
|
|
285
331
|
});
|
|
286
332
|
let body = kept.join("\n");
|
|
287
333
|
if (body && !body.endsWith("\n"))
|
|
288
334
|
body += "\n";
|
|
289
335
|
const root = [
|
|
290
|
-
|
|
291
|
-
`model_provider = ${tomlString(PROVIDER)}`,
|
|
336
|
+
MANAGED_MARKER,
|
|
292
337
|
`model_catalog_json = ${tomlString(catalogPath)}`,
|
|
338
|
+
`openai_base_url = ${tomlString(`http://127.0.0.1:${proxyPort}/v1`)}`,
|
|
293
339
|
"",
|
|
294
340
|
].join("\n");
|
|
295
|
-
const
|
|
296
|
-
"",
|
|
297
|
-
`[model_providers.${PROVIDER}]`,
|
|
298
|
-
'name = "CPA"',
|
|
299
|
-
`base_url = ${tomlString(apiBase(cpaUrl))}`,
|
|
300
|
-
`env_key = ${tomlString(apiKeyEnv)}`,
|
|
301
|
-
'wire_api = "responses"',
|
|
302
|
-
"",
|
|
303
|
-
].join("\n");
|
|
304
|
-
const output = root + body + provider;
|
|
341
|
+
const output = root + body;
|
|
305
342
|
return Buffer.from(eol === "\n" ? output : output.replace(/\n/g, "\r\n"));
|
|
306
343
|
}
|
|
307
344
|
export function atomicWrite(path, data, mode = 0o600) {
|
|
@@ -335,6 +372,243 @@ export function atomicWrite(path, data, mode = 0o600) {
|
|
|
335
372
|
throw error;
|
|
336
373
|
}
|
|
337
374
|
}
|
|
375
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
376
|
+
"connection",
|
|
377
|
+
"keep-alive",
|
|
378
|
+
"proxy-authenticate",
|
|
379
|
+
"proxy-authorization",
|
|
380
|
+
"proxy-connection",
|
|
381
|
+
"te",
|
|
382
|
+
"trailer",
|
|
383
|
+
"transfer-encoding",
|
|
384
|
+
"upgrade",
|
|
385
|
+
]);
|
|
386
|
+
const CLIENT_CREDENTIAL_HEADERS = new Set([
|
|
387
|
+
"authorization",
|
|
388
|
+
"chatgpt-account-id",
|
|
389
|
+
"cookie",
|
|
390
|
+
"openai-organization",
|
|
391
|
+
"openai-project",
|
|
392
|
+
"x-api-key",
|
|
393
|
+
"x-opencodex-api-key",
|
|
394
|
+
]);
|
|
395
|
+
function localBrowserOrigin(value) {
|
|
396
|
+
if (!value)
|
|
397
|
+
return true;
|
|
398
|
+
try {
|
|
399
|
+
const hostname = new URL(value).hostname.toLowerCase();
|
|
400
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
function proxyHeaders(headers, apiKey) {
|
|
407
|
+
const forwarded = {};
|
|
408
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
409
|
+
if (value === undefined ||
|
|
410
|
+
name === "host" ||
|
|
411
|
+
CLIENT_CREDENTIAL_HEADERS.has(name) ||
|
|
412
|
+
HOP_BY_HOP_HEADERS.has(name)) {
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
forwarded[name] = value;
|
|
416
|
+
}
|
|
417
|
+
forwarded.authorization = `Bearer ${apiKey}`;
|
|
418
|
+
return forwarded;
|
|
419
|
+
}
|
|
420
|
+
function responseHeaders(headers) {
|
|
421
|
+
const forwarded = {};
|
|
422
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
423
|
+
if (value === undefined || HOP_BY_HOP_HEADERS.has(name))
|
|
424
|
+
continue;
|
|
425
|
+
forwarded[name] = value;
|
|
426
|
+
}
|
|
427
|
+
return forwarded;
|
|
428
|
+
}
|
|
429
|
+
function upstreamUrl(cpaUrl, requestUrl) {
|
|
430
|
+
const incoming = new URL(requestUrl, "http://127.0.0.1");
|
|
431
|
+
const suffix = incoming.pathname === "/v1"
|
|
432
|
+
? ""
|
|
433
|
+
: incoming.pathname.startsWith("/v1/")
|
|
434
|
+
? incoming.pathname.slice(3)
|
|
435
|
+
: incoming.pathname;
|
|
436
|
+
return new URL(`${apiBase(cpaUrl)}${suffix}${incoming.search}`);
|
|
437
|
+
}
|
|
438
|
+
export async function createLoopbackProxy(cpaUrl, apiKey, proxyId, port) {
|
|
439
|
+
const server = createServer((request, response) => {
|
|
440
|
+
const requestUrl = request.url ?? "/";
|
|
441
|
+
if (requestUrl === "/_cpac/health") {
|
|
442
|
+
if (request.headers["x-cpac-proxy-id"] !== proxyId) {
|
|
443
|
+
response.writeHead(404).end();
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
response.writeHead(204).end();
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (requestUrl === "/_cpac/shutdown" && request.method === "POST") {
|
|
450
|
+
if (request.headers["x-cpac-proxy-id"] !== proxyId) {
|
|
451
|
+
response.writeHead(404).end();
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
response.writeHead(204).end();
|
|
455
|
+
setImmediate(() => {
|
|
456
|
+
server.closeAllConnections?.();
|
|
457
|
+
server.close();
|
|
458
|
+
});
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (!localBrowserOrigin(request.headers.origin)) {
|
|
462
|
+
response.writeHead(403, { "content-type": "application/json" });
|
|
463
|
+
response.end(JSON.stringify({ error: "non-local origin rejected" }));
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
let target;
|
|
467
|
+
try {
|
|
468
|
+
target = upstreamUrl(cpaUrl, requestUrl);
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
response.writeHead(400).end("invalid request URL");
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
const send = target.protocol === "https:" ? httpsRequest : httpRequest;
|
|
475
|
+
const upstream = send(target, {
|
|
476
|
+
method: request.method,
|
|
477
|
+
headers: proxyHeaders(request.headers, apiKey),
|
|
478
|
+
}, (upstreamResponse) => {
|
|
479
|
+
response.writeHead(upstreamResponse.statusCode ?? 502, responseHeaders(upstreamResponse.headers));
|
|
480
|
+
upstreamResponse.pipe(response);
|
|
481
|
+
});
|
|
482
|
+
upstream.once("error", () => {
|
|
483
|
+
if (!response.headersSent) {
|
|
484
|
+
response.writeHead(502, { "content-type": "application/json" });
|
|
485
|
+
}
|
|
486
|
+
if (!response.writableEnded)
|
|
487
|
+
response.end(JSON.stringify({ error: "CPA upstream request failed" }));
|
|
488
|
+
});
|
|
489
|
+
request.once("aborted", () => upstream.destroy());
|
|
490
|
+
request.pipe(upstream);
|
|
491
|
+
});
|
|
492
|
+
server.on("upgrade", (_request, socket) => {
|
|
493
|
+
socket.end("HTTP/1.1 426 Upgrade Required\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
|
|
494
|
+
});
|
|
495
|
+
server.on("clientError", (_error, socket) => socket.destroy());
|
|
496
|
+
await new Promise((resolveListen, rejectListen) => {
|
|
497
|
+
server.once("error", rejectListen);
|
|
498
|
+
server.listen(port, "127.0.0.1", () => {
|
|
499
|
+
server.off("error", rejectListen);
|
|
500
|
+
resolveListen();
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
const address = server.address();
|
|
504
|
+
if (!address || typeof address === "string") {
|
|
505
|
+
server.close();
|
|
506
|
+
throw new CPACError("cannot determine loopback proxy port");
|
|
507
|
+
}
|
|
508
|
+
return { server, port: address.port };
|
|
509
|
+
}
|
|
510
|
+
async function runProxyChild(port) {
|
|
511
|
+
const cpaUrl = process.env.CPAC_PROXY_UPSTREAM;
|
|
512
|
+
const apiKey = process.env.CPAC_PROXY_API_KEY;
|
|
513
|
+
const proxyId = process.env.CPAC_PROXY_ID;
|
|
514
|
+
if (!cpaUrl || !apiKey || !proxyId)
|
|
515
|
+
return 1;
|
|
516
|
+
try {
|
|
517
|
+
const proxy = await createLoopbackProxy(cpaUrl, apiKey, proxyId, port);
|
|
518
|
+
process.send?.({ ready: true, port: proxy.port, pid: process.pid });
|
|
519
|
+
const close = () => {
|
|
520
|
+
proxy.server.closeAllConnections?.();
|
|
521
|
+
proxy.server.close();
|
|
522
|
+
};
|
|
523
|
+
process.once("SIGINT", close);
|
|
524
|
+
process.once("SIGTERM", close);
|
|
525
|
+
await new Promise((resolveClose) => proxy.server.once("close", resolveClose));
|
|
526
|
+
return 0;
|
|
527
|
+
}
|
|
528
|
+
catch (error) {
|
|
529
|
+
process.send?.({
|
|
530
|
+
ready: false,
|
|
531
|
+
error: error instanceof Error ? error.message : String(error),
|
|
532
|
+
});
|
|
533
|
+
return 1;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
async function proxyIsHealthy(proxy) {
|
|
537
|
+
try {
|
|
538
|
+
const response = await fetch(`http://127.0.0.1:${proxy.port}/_cpac/health`, {
|
|
539
|
+
headers: { "x-cpac-proxy-id": proxy.id },
|
|
540
|
+
signal: AbortSignal.timeout(1_000),
|
|
541
|
+
});
|
|
542
|
+
return response.status === 204;
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
return false;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
async function startProxyProcess(config, apiKey) {
|
|
549
|
+
const id = randomBytes(24).toString("hex");
|
|
550
|
+
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), "_proxy", String(config.codex_proxy_port)], {
|
|
551
|
+
detached: true,
|
|
552
|
+
env: {
|
|
553
|
+
...process.env,
|
|
554
|
+
CPAC_PROXY_API_KEY: apiKey,
|
|
555
|
+
CPAC_PROXY_ID: id,
|
|
556
|
+
CPAC_PROXY_UPSTREAM: config.cpa_url,
|
|
557
|
+
},
|
|
558
|
+
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
|
559
|
+
});
|
|
560
|
+
return await new Promise((resolveStart, rejectStart) => {
|
|
561
|
+
let settled = false;
|
|
562
|
+
let timeout;
|
|
563
|
+
const finish = (error, value) => {
|
|
564
|
+
if (settled)
|
|
565
|
+
return;
|
|
566
|
+
settled = true;
|
|
567
|
+
clearTimeout(timeout);
|
|
568
|
+
child.removeAllListeners();
|
|
569
|
+
if (child.connected)
|
|
570
|
+
child.disconnect();
|
|
571
|
+
child.unref();
|
|
572
|
+
if (error)
|
|
573
|
+
rejectStart(error);
|
|
574
|
+
else
|
|
575
|
+
resolveStart(value);
|
|
576
|
+
};
|
|
577
|
+
timeout = setTimeout(() => {
|
|
578
|
+
child.kill();
|
|
579
|
+
finish(new CPACError("loopback proxy did not become ready"));
|
|
580
|
+
}, 5_000);
|
|
581
|
+
child.once("error", (error) => finish(new CPACError(`cannot start loopback proxy: ${error.message}`)));
|
|
582
|
+
child.once("exit", (code) => finish(new CPACError(`loopback proxy exited before ready (${code ?? 1})`)));
|
|
583
|
+
child.once("message", (message) => {
|
|
584
|
+
if (objectValue(message) &&
|
|
585
|
+
message.ready === true &&
|
|
586
|
+
typeof message.pid === "number" &&
|
|
587
|
+
typeof message.port === "number") {
|
|
588
|
+
finish(undefined, { id, pid: message.pid, port: message.port });
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
const detail = objectValue(message) && typeof message.error === "string"
|
|
592
|
+
? `: ${message.error}`
|
|
593
|
+
: "";
|
|
594
|
+
finish(new CPACError(`cannot start loopback proxy${detail}`));
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
async function stopProxyProcess(proxy) {
|
|
599
|
+
if (!(await proxyIsHealthy(proxy)))
|
|
600
|
+
return;
|
|
601
|
+
try {
|
|
602
|
+
await fetch(`http://127.0.0.1:${proxy.port}/_cpac/shutdown`, {
|
|
603
|
+
method: "POST",
|
|
604
|
+
headers: { "x-cpac-proxy-id": proxy.id },
|
|
605
|
+
signal: AbortSignal.timeout(1_000),
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
// The server may close the connection while shutting down.
|
|
610
|
+
}
|
|
611
|
+
}
|
|
338
612
|
function readState(stateDir) {
|
|
339
613
|
const path = join(stateDir, "state.json");
|
|
340
614
|
if (!existsSync(path))
|
|
@@ -348,7 +622,7 @@ function readState(stateDir) {
|
|
|
348
622
|
}
|
|
349
623
|
if (!objectValue(value) ||
|
|
350
624
|
Object.keys(value).some((key) => !STATE_KEYS.has(key)) ||
|
|
351
|
-
|
|
625
|
+
[...REQUIRED_STATE_KEYS].some((key) => !(key in value))) {
|
|
352
626
|
throw new CPACError("invalid state file");
|
|
353
627
|
}
|
|
354
628
|
if (typeof value.config_path !== "string" || !isAbsolute(value.config_path)) {
|
|
@@ -361,8 +635,42 @@ function readState(stateDir) {
|
|
|
361
635
|
value.config_mode > 0o7777) {
|
|
362
636
|
throw new CPACError("invalid config_mode value in state");
|
|
363
637
|
}
|
|
638
|
+
const proxyKeys = ["proxy_id", "proxy_pid", "proxy_port"];
|
|
639
|
+
const proxyKeyCount = proxyKeys.filter((key) => key in value).length;
|
|
640
|
+
if (proxyKeyCount !== 0 &&
|
|
641
|
+
(proxyKeyCount !== proxyKeys.length ||
|
|
642
|
+
typeof value.proxy_id !== "string" ||
|
|
643
|
+
!/^[a-f0-9]{48}$/.test(value.proxy_id) ||
|
|
644
|
+
!Number.isInteger(value.proxy_pid) ||
|
|
645
|
+
value.proxy_pid <= 0 ||
|
|
646
|
+
!Number.isInteger(value.proxy_port) ||
|
|
647
|
+
value.proxy_port <= 0 ||
|
|
648
|
+
value.proxy_port > 65535)) {
|
|
649
|
+
throw new CPACError("invalid loopback proxy state");
|
|
650
|
+
}
|
|
651
|
+
if (value.proxy_fingerprint !== undefined &&
|
|
652
|
+
(typeof value.proxy_fingerprint !== "string" ||
|
|
653
|
+
!/^[a-f0-9]{64}$/.test(value.proxy_fingerprint))) {
|
|
654
|
+
throw new CPACError("invalid loopback proxy fingerprint");
|
|
655
|
+
}
|
|
364
656
|
return value;
|
|
365
657
|
}
|
|
658
|
+
function stateProxy(state) {
|
|
659
|
+
return state.proxy_id && state.proxy_pid && state.proxy_port
|
|
660
|
+
? { id: state.proxy_id, pid: state.proxy_pid, port: state.proxy_port }
|
|
661
|
+
: null;
|
|
662
|
+
}
|
|
663
|
+
function stateBytes(config, existed, mode, proxy, proxyFingerprint) {
|
|
664
|
+
return Buffer.from(`${JSON.stringify({
|
|
665
|
+
config_path: config.codex_config,
|
|
666
|
+
config_existed: existed,
|
|
667
|
+
config_mode: mode,
|
|
668
|
+
proxy_id: proxy.id,
|
|
669
|
+
proxy_fingerprint: proxyFingerprint,
|
|
670
|
+
proxy_pid: proxy.pid,
|
|
671
|
+
proxy_port: proxy.port,
|
|
672
|
+
}, null, 2)}\n`);
|
|
673
|
+
}
|
|
366
674
|
function originalBytes(stateDir, state, expectedPath) {
|
|
367
675
|
if (state.config_path !== expectedPath) {
|
|
368
676
|
throw new CPACError(`active injection belongs to ${state.config_path}; restore it first`);
|
|
@@ -388,6 +696,13 @@ function cleanupStateFiles(stateDir) {
|
|
|
388
696
|
throw error;
|
|
389
697
|
}
|
|
390
698
|
}
|
|
699
|
+
function proxyFingerprint(config, apiKey) {
|
|
700
|
+
return createHash("sha256")
|
|
701
|
+
.update(config.cpa_url)
|
|
702
|
+
.update("\0")
|
|
703
|
+
.update(apiKey)
|
|
704
|
+
.digest("hex");
|
|
705
|
+
}
|
|
391
706
|
export async function inject(config) {
|
|
392
707
|
const apiKey = process.env[config.api_key_env]?.trim();
|
|
393
708
|
if (!apiKey)
|
|
@@ -423,20 +738,58 @@ export async function inject(config) {
|
|
|
423
738
|
throw new CPACError(`cannot read Codex config: ${error instanceof Error ? error.message : String(error)}`);
|
|
424
739
|
}
|
|
425
740
|
}
|
|
426
|
-
const catalogPath = join(config.state_dir, "codex-models.json");
|
|
427
|
-
const injected = buildCodexConfig(original, config.cpa_url, config.api_key_env, catalogPath);
|
|
428
741
|
const catalog = await fetchCatalog(config.cpa_url, apiKey);
|
|
429
742
|
const stateDirExisted = existsSync(config.state_dir);
|
|
430
743
|
if (!state &&
|
|
431
744
|
STATE_FILES.some((name) => existsSync(join(config.state_dir, name)))) {
|
|
432
745
|
throw new CPACError("state_dir contains CPAC files without a valid state; refusing to overwrite them");
|
|
433
746
|
}
|
|
747
|
+
const fingerprint = proxyFingerprint(config, apiKey);
|
|
748
|
+
const previousProxy = state ? stateProxy(state) : null;
|
|
749
|
+
let proxy;
|
|
750
|
+
let startedProxy = false;
|
|
751
|
+
if (previousProxy &&
|
|
752
|
+
state?.proxy_fingerprint === fingerprint &&
|
|
753
|
+
(config.codex_proxy_port === 0 ||
|
|
754
|
+
config.codex_proxy_port === previousProxy.port) &&
|
|
755
|
+
(await proxyIsHealthy(previousProxy))) {
|
|
756
|
+
proxy = previousProxy;
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
if (previousProxy)
|
|
760
|
+
await stopProxyProcess(previousProxy);
|
|
761
|
+
try {
|
|
762
|
+
proxy = await startProxyProcess(config, apiKey);
|
|
763
|
+
startedProxy = true;
|
|
764
|
+
}
|
|
765
|
+
catch (error) {
|
|
766
|
+
throw new CPACError(`${error instanceof Error ? error.message : String(error)}; choose another codex_proxy_port if the port is occupied`);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
const catalogPath = join(config.state_dir, "codex-models.json");
|
|
770
|
+
let injected;
|
|
771
|
+
try {
|
|
772
|
+
injected = buildCodexConfig(original, proxy.port, catalogPath);
|
|
773
|
+
}
|
|
774
|
+
catch (error) {
|
|
775
|
+
if (startedProxy)
|
|
776
|
+
await stopProxyProcess(proxy);
|
|
777
|
+
throw error;
|
|
778
|
+
}
|
|
434
779
|
mkdirSync(config.state_dir, { recursive: true, mode: 0o700 });
|
|
435
780
|
if (!stateDirExisted)
|
|
436
781
|
chmodSync(config.state_dir, 0o700);
|
|
437
782
|
if (state) {
|
|
438
|
-
|
|
439
|
-
|
|
783
|
+
try {
|
|
784
|
+
atomicWrite(catalogPath, catalog.bytes);
|
|
785
|
+
atomicWrite(config.codex_config, injected, originalMode);
|
|
786
|
+
atomicWrite(join(config.state_dir, "state.json"), stateBytes(config, existed, originalMode, proxy, fingerprint));
|
|
787
|
+
}
|
|
788
|
+
catch (error) {
|
|
789
|
+
if (startedProxy)
|
|
790
|
+
await stopProxyProcess(proxy);
|
|
791
|
+
throw error;
|
|
792
|
+
}
|
|
440
793
|
}
|
|
441
794
|
else {
|
|
442
795
|
let configWritten = false;
|
|
@@ -446,11 +799,7 @@ export async function inject(config) {
|
|
|
446
799
|
atomicWrite(catalogPath, catalog.bytes);
|
|
447
800
|
atomicWrite(config.codex_config, injected, originalMode);
|
|
448
801
|
configWritten = true;
|
|
449
|
-
atomicWrite(join(config.state_dir, "state.json"),
|
|
450
|
-
config_path: config.codex_config,
|
|
451
|
-
config_existed: existed,
|
|
452
|
-
config_mode: originalMode,
|
|
453
|
-
}, null, 2)}\n`));
|
|
802
|
+
atomicWrite(join(config.state_dir, "state.json"), stateBytes(config, existed, originalMode, proxy, fingerprint));
|
|
454
803
|
}
|
|
455
804
|
catch (error) {
|
|
456
805
|
if (configWritten) {
|
|
@@ -464,18 +813,33 @@ export async function inject(config) {
|
|
|
464
813
|
throw new CPACError(`injection failed and config rollback failed; backup retained in ${config.state_dir}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
|
|
465
814
|
}
|
|
466
815
|
}
|
|
816
|
+
let cleanupError;
|
|
467
817
|
try {
|
|
468
818
|
cleanupStateFiles(config.state_dir);
|
|
469
819
|
}
|
|
470
|
-
catch (
|
|
820
|
+
catch (caught) {
|
|
821
|
+
cleanupError = caught;
|
|
822
|
+
}
|
|
823
|
+
if (startedProxy)
|
|
824
|
+
await stopProxyProcess(proxy);
|
|
825
|
+
if (cleanupError) {
|
|
471
826
|
throw new CPACError(`injection failed and state cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
|
|
472
827
|
}
|
|
473
828
|
throw error;
|
|
474
829
|
}
|
|
475
830
|
}
|
|
476
|
-
|
|
831
|
+
try {
|
|
832
|
+
rmSync(join(dirname(config.codex_config), "models_cache.json"), {
|
|
833
|
+
force: true,
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
catch {
|
|
837
|
+
console.warn("CPAC could not invalidate Codex models_cache.json; restart Codex App if its model list is stale.");
|
|
838
|
+
}
|
|
839
|
+
console.log(`Injected ${catalog.modelCount} CPA models into ${config.codex_config} via http://127.0.0.1:${proxy.port}/v1`);
|
|
840
|
+
console.log("Restart Codex App if its running app-server still shows the old model list.");
|
|
477
841
|
}
|
|
478
|
-
export function restore(config) {
|
|
842
|
+
export async function restore(config) {
|
|
479
843
|
const state = readState(config.state_dir);
|
|
480
844
|
if (!state)
|
|
481
845
|
throw new CPACError("no active CPAC injection");
|
|
@@ -484,6 +848,9 @@ export function restore(config) {
|
|
|
484
848
|
atomicWrite(config.codex_config, original, state.config_mode);
|
|
485
849
|
else
|
|
486
850
|
rmSync(config.codex_config, { force: true });
|
|
851
|
+
const proxy = stateProxy(state);
|
|
852
|
+
if (proxy)
|
|
853
|
+
await stopProxyProcess(proxy);
|
|
487
854
|
try {
|
|
488
855
|
cleanupStateFiles(config.state_dir);
|
|
489
856
|
}
|
|
@@ -492,22 +859,40 @@ export function restore(config) {
|
|
|
492
859
|
}
|
|
493
860
|
console.log(`Restored ${config.codex_config}`);
|
|
494
861
|
}
|
|
495
|
-
export function status(config) {
|
|
862
|
+
export async function status(config) {
|
|
863
|
+
const apiKey = process.env[config.api_key_env]?.trim();
|
|
864
|
+
const keyConfigured = !!apiKey;
|
|
496
865
|
const state = readState(config.state_dir);
|
|
497
866
|
if (!state) {
|
|
498
|
-
console.log("native");
|
|
499
|
-
return 1;
|
|
867
|
+
console.log("codex: native");
|
|
500
868
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
869
|
+
else {
|
|
870
|
+
originalBytes(config.state_dir, state, config.codex_config);
|
|
871
|
+
if (!existsSync(config.codex_config)) {
|
|
872
|
+
console.log(`codex: injected (config missing: ${config.codex_config})`);
|
|
873
|
+
}
|
|
874
|
+
else {
|
|
875
|
+
const proxy = stateProxy(state);
|
|
876
|
+
if (!proxy || !(await proxyIsHealthy(proxy))) {
|
|
877
|
+
console.log(`codex: injected (loopback proxy stopped: ${config.codex_config})`);
|
|
878
|
+
}
|
|
879
|
+
else {
|
|
880
|
+
const digest = createHash("sha256")
|
|
881
|
+
.update(readFileSync(config.codex_config))
|
|
882
|
+
.digest("hex")
|
|
883
|
+
.slice(0, 12);
|
|
884
|
+
console.log(`codex: injected proxy=http://127.0.0.1:${proxy.port}/v1 sha256=${digest}`);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
505
887
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
888
|
+
console.log(`claude: ${keyConfigured ? "ready" : "not configured"}`);
|
|
889
|
+
const piInstalled = isPiExtensionInstalled();
|
|
890
|
+
console.log(`pi: ${keyConfigured ? (piInstalled ? "ready" : "extension not installed; run: cpac pi install") : "not configured"}`);
|
|
891
|
+
if (!state)
|
|
892
|
+
return 1;
|
|
893
|
+
const proxy = stateProxy(state);
|
|
894
|
+
if (!proxy || !(await proxyIsHealthy(proxy)))
|
|
895
|
+
return 2;
|
|
511
896
|
return 0;
|
|
512
897
|
}
|
|
513
898
|
function claudeBaseUrl(cpaUrl) {
|
|
@@ -568,7 +953,7 @@ export function saveApiKeyExport(profile, name, apiKey) {
|
|
|
568
953
|
atomicWrite(profile, Buffer.from(content), mode);
|
|
569
954
|
}
|
|
570
955
|
async function guide(config) {
|
|
571
|
-
console.log(`CPA Companion\n\nCPA: ${config.cpa_url}\nCPA_API_KEY: ${process.env[config.api_key_env]?.trim() ? "configured" : "not configured"}\n\nCommands:\n cpac claude [args...] Launch Claude Code through CPA\n cpac inject Inject CPA into Codex\n cpac status Show Codex
|
|
956
|
+
console.log(`CPA Companion\n\nCPA: ${config.cpa_url}\nCPA_API_KEY: ${process.env[config.api_key_env]?.trim() ? "configured" : "not configured"}\n\nCommands:\n cpac claude [args...] Launch Claude Code through CPA\n cpac inject Inject CPA into Codex and start its loopback proxy\n cpac proxy Run the injected loopback proxy in the foreground\n cpac status Show agent support status (Codex, Claude, Pi)\n cpac restore Restore the original Codex config\n cpac pi install Install CPA provider extension for Pi\n cpac pi uninstall Remove the Pi CPA provider extension\n cpac pi status Show Pi extension install status\n cpac --help Show command usage`);
|
|
572
957
|
if (process.env[config.api_key_env]?.trim())
|
|
573
958
|
return 0;
|
|
574
959
|
const apiKey = await promptSecret(config.api_key_env);
|
|
@@ -600,17 +985,106 @@ export async function runClaude(config, args, executable = "claude") {
|
|
|
600
985
|
child.once("close", (code) => resolve(code ?? 1));
|
|
601
986
|
});
|
|
602
987
|
}
|
|
988
|
+
export async function runProxy(config) {
|
|
989
|
+
const apiKey = await resolveApiKey(config.api_key_env);
|
|
990
|
+
const state = readState(config.state_dir);
|
|
991
|
+
if (!state)
|
|
992
|
+
throw new CPACError("no active CPAC injection; run cpac inject first");
|
|
993
|
+
originalBytes(config.state_dir, state, config.codex_config);
|
|
994
|
+
const recorded = stateProxy(state);
|
|
995
|
+
if (!recorded) {
|
|
996
|
+
throw new CPACError("injection has no loopback proxy state; run cpac inject to migrate it");
|
|
997
|
+
}
|
|
998
|
+
if (await proxyIsHealthy(recorded)) {
|
|
999
|
+
throw new CPACError(`loopback proxy is already running on 127.0.0.1:${recorded.port}`);
|
|
1000
|
+
}
|
|
1001
|
+
const proxy = await createLoopbackProxy(config.cpa_url, apiKey, recorded.id, recorded.port);
|
|
1002
|
+
try {
|
|
1003
|
+
atomicWrite(join(config.state_dir, "state.json"), stateBytes(config, state.config_existed, state.config_mode, { id: recorded.id, pid: process.pid, port: proxy.port }, proxyFingerprint(config, apiKey)));
|
|
1004
|
+
}
|
|
1005
|
+
catch (error) {
|
|
1006
|
+
proxy.server.closeAllConnections?.();
|
|
1007
|
+
proxy.server.close();
|
|
1008
|
+
throw error;
|
|
1009
|
+
}
|
|
1010
|
+
console.log(`CPAC loopback proxy listening on http://127.0.0.1:${proxy.port}/v1`);
|
|
1011
|
+
const close = () => {
|
|
1012
|
+
proxy.server.closeAllConnections?.();
|
|
1013
|
+
proxy.server.close();
|
|
1014
|
+
};
|
|
1015
|
+
process.once("SIGINT", close);
|
|
1016
|
+
process.once("SIGTERM", close);
|
|
1017
|
+
await new Promise((resolveClose) => proxy.server.once("close", resolveClose));
|
|
1018
|
+
return 0;
|
|
1019
|
+
}
|
|
1020
|
+
function piExtensionsDir() {
|
|
1021
|
+
return join(expandUserPath(process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent")), "extensions");
|
|
1022
|
+
}
|
|
1023
|
+
export function isPiExtensionInstalled() {
|
|
1024
|
+
return existsSync(join(piExtensionsDir(), "cpac.ts"));
|
|
1025
|
+
}
|
|
1026
|
+
function piTemplatePath() {
|
|
1027
|
+
return join(dirname(fileURLToPath(import.meta.url)), "pi-extension.template");
|
|
1028
|
+
}
|
|
1029
|
+
function piExtensionContent(cpaUrl) {
|
|
1030
|
+
return readFileSync(piTemplatePath(), "utf8").replace("__CPA_URL__", cpaUrl);
|
|
1031
|
+
}
|
|
1032
|
+
export async function installPiExtension(config) {
|
|
1033
|
+
const dir = piExtensionsDir();
|
|
1034
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
1035
|
+
const target = join(dir, "cpac.ts");
|
|
1036
|
+
atomicWrite(target, Buffer.from(piExtensionContent(config.cpa_url)), 0o644);
|
|
1037
|
+
console.log(`Installed Pi extension: ${target}`);
|
|
1038
|
+
}
|
|
1039
|
+
export async function uninstallPiExtension() {
|
|
1040
|
+
const target = join(piExtensionsDir(), "cpac.ts");
|
|
1041
|
+
if (!existsSync(target))
|
|
1042
|
+
throw new CPACError("Pi extension is not installed");
|
|
1043
|
+
unlinkSync(target);
|
|
1044
|
+
console.log(`Removed Pi extension: ${target}`);
|
|
1045
|
+
}
|
|
1046
|
+
export async function runPi(config, action) {
|
|
1047
|
+
if (action === "install") {
|
|
1048
|
+
await installPiExtension(config);
|
|
1049
|
+
return 0;
|
|
1050
|
+
}
|
|
1051
|
+
if (action === "uninstall") {
|
|
1052
|
+
await uninstallPiExtension();
|
|
1053
|
+
return 0;
|
|
1054
|
+
}
|
|
1055
|
+
if (action === "status") {
|
|
1056
|
+
const installed = isPiExtensionInstalled();
|
|
1057
|
+
console.log(`Pi extension: ${installed ? "installed" : "not installed"}`);
|
|
1058
|
+
return installed ? 0 : 1;
|
|
1059
|
+
}
|
|
1060
|
+
throw new CPACError(`unknown pi action: ${action}; use install, uninstall, or status`);
|
|
1061
|
+
}
|
|
603
1062
|
function usage() {
|
|
604
1063
|
return [
|
|
605
1064
|
"Usage: cpac",
|
|
606
1065
|
" cpac <inject|status|restore> [--config PATH]",
|
|
1066
|
+
" cpac proxy [--config PATH]",
|
|
607
1067
|
" cpac claude [--config PATH] [--] [claude args...]",
|
|
1068
|
+
" cpac pi <install|uninstall|status> [--config PATH]",
|
|
608
1069
|
].join("\n");
|
|
609
1070
|
}
|
|
610
1071
|
function parseArgs(args) {
|
|
611
1072
|
if (args.length === 0) {
|
|
612
1073
|
return { command: "guide", configPath: defaultConfigPath() };
|
|
613
1074
|
}
|
|
1075
|
+
if (args[0] === "pi") {
|
|
1076
|
+
let configPath = defaultConfigPath();
|
|
1077
|
+
let index = 1;
|
|
1078
|
+
if (args[index] === "--config") {
|
|
1079
|
+
const value = args[++index];
|
|
1080
|
+
if (!value)
|
|
1081
|
+
throw new CPACError("--config requires a path");
|
|
1082
|
+
configPath = resolve(expandUserPath(value));
|
|
1083
|
+
index += 1;
|
|
1084
|
+
}
|
|
1085
|
+
const action = args[index] || "status";
|
|
1086
|
+
return { command: "pi", configPath, action };
|
|
1087
|
+
}
|
|
614
1088
|
if (args[0] === "claude") {
|
|
615
1089
|
let configPath = defaultConfigPath();
|
|
616
1090
|
let index = 1;
|
|
@@ -645,7 +1119,7 @@ function parseArgs(args) {
|
|
|
645
1119
|
positional.push(args[index]);
|
|
646
1120
|
}
|
|
647
1121
|
if (positional.length !== 1 ||
|
|
648
|
-
!["inject", "status", "restore"].includes(positional[0])) {
|
|
1122
|
+
!["inject", "status", "restore", "proxy"].includes(positional[0])) {
|
|
649
1123
|
throw new CPACError(usage());
|
|
650
1124
|
}
|
|
651
1125
|
return {
|
|
@@ -655,6 +1129,12 @@ function parseArgs(args) {
|
|
|
655
1129
|
}
|
|
656
1130
|
export async function main(args = process.argv.slice(2)) {
|
|
657
1131
|
try {
|
|
1132
|
+
if (args[0] === "_proxy") {
|
|
1133
|
+
const port = Number(args[1]);
|
|
1134
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
1135
|
+
return 1;
|
|
1136
|
+
return await runProxyChild(port);
|
|
1137
|
+
}
|
|
658
1138
|
const parsed = parseArgs(args);
|
|
659
1139
|
if (!parsed)
|
|
660
1140
|
return 0;
|
|
@@ -663,12 +1143,16 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
663
1143
|
return await guide(config);
|
|
664
1144
|
if (parsed.command === "claude")
|
|
665
1145
|
return await runClaude(config, parsed.args);
|
|
1146
|
+
if (parsed.command === "proxy")
|
|
1147
|
+
return await runProxy(config);
|
|
1148
|
+
if (parsed.command === "pi")
|
|
1149
|
+
return await runPi(config, parsed.action);
|
|
666
1150
|
if (parsed.command === "inject")
|
|
667
1151
|
await inject(config);
|
|
668
1152
|
else if (parsed.command === "restore")
|
|
669
|
-
restore(config);
|
|
1153
|
+
await restore(config);
|
|
670
1154
|
else
|
|
671
|
-
return status(config);
|
|
1155
|
+
return await status(config);
|
|
672
1156
|
return 0;
|
|
673
1157
|
}
|
|
674
1158
|
catch (error) {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// CPAC Pi extension: register CPA models as Pi providers
|
|
2
|
+
// Installed by: cpac pi install
|
|
3
|
+
|
|
4
|
+
const CPA = "__CPA_URL__";
|
|
5
|
+
const BUILTIN = new Set(["openai", "github-copilot", "xai", "deepseek", "anthropic", "google"]);
|
|
6
|
+
const VENDOR_PREFIXES = [
|
|
7
|
+
["gpt-", "openai"], ["o1-", "openai"], ["o3-", "openai"], ["o4-", "openai"],
|
|
8
|
+
["claude-", "anthropic"], ["gemini-", "google"], ["grok-", "xai"],
|
|
9
|
+
["deepseek-", "deepseek"], ["glm-", "zhipu"], ["kimi-", "moonshot"],
|
|
10
|
+
["mimo-", "xiaomi"], ["doubao-", "volcengine"], ["ark-", "volcengine"],
|
|
11
|
+
["minimax-", "minimax"], ["step-", "stepfun"], ["qwen-", "alibaba"],
|
|
12
|
+
["hunyuan-", "tencent"],
|
|
13
|
+
];
|
|
14
|
+
const MAX_TOKENS = {
|
|
15
|
+
"gpt-5.6-sol": 128000, "gpt-5.6-terra": 128000, "gpt-5.6-luna": 128000,
|
|
16
|
+
"claude-opus-4-6-thinking": 128000, "claude-sonnet-4-6": 64000,
|
|
17
|
+
"gemini-3.6-flash": 65536, "gemini-3.6-flash-high": 65536,
|
|
18
|
+
"deepseek-v4-pro": 128000, "deepseek-v4-flash": 128000,
|
|
19
|
+
"glm-5.2": 128000, "kimi-k2.7-code": 128000, "minimax-m3": 128000,
|
|
20
|
+
"mimo-v2.5": 131072, "mimo-v2.5-pro": 131072, "grok-4.5": 128000,
|
|
21
|
+
"doubao-seed-2.0-lite": 128000, "doubao-seed-2.1-turbo": 128000,
|
|
22
|
+
};
|
|
23
|
+
const DEFAULT_MAX_TOKENS = 65536;
|
|
24
|
+
|
|
25
|
+
function vendorFor(slug) {
|
|
26
|
+
for (const [prefix, vendor] of VENDOR_PREFIXES) {
|
|
27
|
+
if (slug.startsWith(prefix)) return vendor;
|
|
28
|
+
}
|
|
29
|
+
return "misc";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function groupName(vendor) {
|
|
33
|
+
return BUILTIN.has(vendor) ? vendor + "-cpa" : vendor;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function intField(value) {
|
|
37
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
38
|
+
? Math.floor(value) : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default async function (pi) {
|
|
42
|
+
const apiKey = (process.env.CPA_API_KEY || "").trim();
|
|
43
|
+
if (!apiKey) return;
|
|
44
|
+
let payload;
|
|
45
|
+
try {
|
|
46
|
+
const res = await fetch(CPA + "/v1/models?client_version=1", {
|
|
47
|
+
headers: { Authorization: "Bearer " + apiKey },
|
|
48
|
+
signal: AbortSignal.timeout(10000),
|
|
49
|
+
});
|
|
50
|
+
if (!res.ok) return;
|
|
51
|
+
payload = await res.json();
|
|
52
|
+
} catch { return; }
|
|
53
|
+
if (!payload || typeof payload !== "object" || !Array.isArray(payload.models)) return;
|
|
54
|
+
const rows = payload.models.filter(function (m) {
|
|
55
|
+
return m && typeof m === "object" && typeof m.slug === "string";
|
|
56
|
+
});
|
|
57
|
+
const groups = new Map();
|
|
58
|
+
for (const m of rows) {
|
|
59
|
+
const name = groupName(vendorFor(m.slug));
|
|
60
|
+
if (!groups.has(name)) groups.set(name, []);
|
|
61
|
+
groups.get(name).push(m);
|
|
62
|
+
}
|
|
63
|
+
for (const [provName, models] of groups) {
|
|
64
|
+
const vendor = provName.replace(/-cpa$/, "");
|
|
65
|
+
pi.registerProvider(provName, {
|
|
66
|
+
name: vendor + " (cpa)",
|
|
67
|
+
baseUrl: CPA + "/v1",
|
|
68
|
+
apiKey: apiKey,
|
|
69
|
+
api: "openai-responses",
|
|
70
|
+
models: models.map(function (m) {
|
|
71
|
+
return {
|
|
72
|
+
id: m.slug,
|
|
73
|
+
name: typeof m.display_name === "string" ? m.display_name : m.slug,
|
|
74
|
+
reasoning: Array.isArray(m.supported_reasoning_levels) && m.supported_reasoning_levels.length > 0,
|
|
75
|
+
input: ["text", "image"],
|
|
76
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
77
|
+
contextWindow: intField(m.context_window) || 200000,
|
|
78
|
+
maxTokens: MAX_TOKENS[m.slug] || DEFAULT_MAX_TOKENS,
|
|
79
|
+
};
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yhong91/cpac",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Connect Codex and Claude Code to a remote CLIProxyAPI gateway",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"dist/cpac.js",
|
|
11
|
+
"dist/pi-extension.template",
|
|
11
12
|
"cpac.example.json",
|
|
12
13
|
"README.md"
|
|
13
14
|
],
|
|
@@ -28,10 +29,10 @@
|
|
|
28
29
|
},
|
|
29
30
|
"scripts": {
|
|
30
31
|
"clean": "node -e \"for (const d of ['dist','dist-test']) require('fs').rmSync(d,{recursive:true,force:true})\"",
|
|
31
|
-
"build": "npm run clean --if-present && tsc -p tsconfig.build.json && node -e \"const fs=require('fs');const p='dist/cpac.js';const s=fs.readFileSync(p,'utf8');if(!s.startsWith('#!'))fs.writeFileSync(p,'#!/usr/bin/env node\\n'+s);try{fs.chmodSync(p,0o755)}catch{}\"",
|
|
32
|
+
"build": "npm run clean --if-present && tsc -p tsconfig.build.json && cp src/pi-extension.template dist/ && node -e \"const fs=require('fs');const p='dist/cpac.js';const s=fs.readFileSync(p,'utf8');if(!s.startsWith('#!'))fs.writeFileSync(p,'#!/usr/bin/env node\\n'+s);try{fs.chmodSync(p,0o755)}catch{}\"",
|
|
32
33
|
"check": "tsc -p tsconfig.json --noEmit",
|
|
33
34
|
"check:pi": "tsc -p tsconfig.pi.json --noEmit",
|
|
34
|
-
"test": "npm run clean && tsc -p tsconfig.test.json && node --test --test-reporter=spec dist-test/cpac.test.js",
|
|
35
|
+
"test": "npm run clean && tsc -p tsconfig.test.json && cp src/pi-extension.template dist-test/src/ && node --test --test-reporter=spec dist-test/cpac.test.js",
|
|
35
36
|
"pack:check": "npm pack --dry-run"
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|