@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 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` `/v1/models?client_version=1` 获取 Codex rich catalog,将根级 `model_provider`、`model_catalog_json` 指向 CPA,并追加 `[model_providers.cpac_cpa]`。其他用户 TOML 内容会保留;若已有同名 provider 且没有 CPAC state,CPAC 会拒绝接管。
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
- 第一次注入时,原文件按原始 bytes 备份到 `state_dir/config.toml.backup`。重复注入不会覆盖首次备份,并会保留 CPAC 管理字段之外的用户编辑。`restore` 按原 mode 逐字节恢复首次备份;若原文件不存在,则删除注入创建的 `config.toml`。
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
@@ -1,4 +1,5 @@
1
1
  {
2
2
  "cpa_url": "https://cpa.example.com",
3
- "api_key_env": "CPA_API_KEY"
3
+ "api_key_env": "CPA_API_KEY",
4
+ "codex_proxy_port": 10101
4
5
  }
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 STATE_KEYS = new Set(["config_path", "config_existed", "config_mode"]);
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 ROOT_KEY = /^\s*(?:model_provider|"model_provider"|'model_provider'|model_catalog_json|"model_catalog_json"|'model_catalog_json')\s*=/;
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 && (ROOT_KEY.test(line) || line.startsWith("# CPAC managed;")))
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, cpaUrl, apiKeyEnv, catalogPath) {
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 && ROOT_KEY.test(line));
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
- "# CPAC managed; run CPAC 'restore' to restore the original file.",
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 provider = [
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
- Object.keys(value).length !== STATE_KEYS.size) {
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
- atomicWrite(catalogPath, catalog.bytes);
439
- atomicWrite(config.codex_config, injected, originalMode);
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"), Buffer.from(`${JSON.stringify({
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 (cleanupError) {
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
- console.log(`Injected ${catalog.modelCount} CPA models into ${config.codex_config}`);
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
- originalBytes(config.state_dir, state, config.codex_config);
502
- if (!existsSync(config.codex_config)) {
503
- console.log(`injected (config missing: ${config.codex_config})`);
504
- return 2;
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
- const digest = createHash("sha256")
507
- .update(readFileSync(config.codex_config))
508
- .digest("hex")
509
- .slice(0, 12);
510
- console.log(`injected: ${config.codex_config} sha256=${digest}`);
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 injection status\n cpac restore Restore the original Codex config\n cpac --help Show command usage`);
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.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": {