@yhong91/cpac 0.1.7 → 0.1.8
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 +14 -2
- package/cpac.example.json +7 -1
- package/dist/cpac.js +460 -25
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -97,7 +97,7 @@ cpac --help
|
|
|
97
97
|
|
|
98
98
|
```bash
|
|
99
99
|
cpac detect [--json] [--home PATH]
|
|
100
|
-
cpac install [--target codex,claude,pi,kimi] [--all] [--dry-run] [--force] [--home PATH]
|
|
100
|
+
cpac install [--target codex,claude,pi,kimi] [--all] [--dry-run] [--force] [--v2_off] [--v2_models] [--home PATH]
|
|
101
101
|
cpac uninstall [--target codex,claude,pi,kimi] [--all] [--dry-run] [--home PATH]
|
|
102
102
|
cpac upgrade [--check]
|
|
103
103
|
cpac version
|
|
@@ -105,6 +105,8 @@ cpac version
|
|
|
105
105
|
|
|
106
106
|
- `detect` 列出每个目标是否被检测到、是否已接入 CPAC;`--json` 输出机器可读结果。
|
|
107
107
|
- `install` 默认作用于检测到的目标;`--target` 指定目标、`--all` 全部。各目标映射到既有逻辑:`codex` → `cpac inject`,`pi` → `cpac pi install`,`kimi` → `cpac kimi install`;`claude` 无持久化安装(用 `cpac claude` 启动)。`--dry-run` 只打印将要做的事,`--force` 允许重装已安装目标。
|
|
108
|
+
- Codex 注入默认开启 multi-agent v2(写入 `[features.multi_agent_v2] enabled = true`);`install --target codex --v2_off` 写入 `enabled = false`。服务端还需在 CPA 配置中开启 `codex.optimize-multi-agent-v2`。
|
|
109
|
+
- `--v2_models` 进入 arrow-key 复选框选择最多 5 个 spawn_agent 模型(勾选顺序即推荐顺序),选择结果存入配置文件的 `spawn_models`,注入时把这些模型排到 `codex-models.json` 最前——Codex 客户端只把 catalog 前 5 个可见模型广告为 spawn 覆盖项。
|
|
108
110
|
- `uninstall` 默认作用于已安装目标,映射到 `cpac restore` / `cpac pi uninstall` / `cpac kimi uninstall`。
|
|
109
111
|
- `--home PATH` 临时把 `HOME` 指向给定目录,便于在隔离环境中试验。
|
|
110
112
|
- `upgrade` 检查 npm 上是否有新版本,`--check` 只报告不提示安装命令。
|
|
@@ -132,7 +134,17 @@ CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST=1
|
|
|
132
134
|
|
|
133
135
|
代理开启 Claude Code 的 gateway model discovery:`/model` 选择器会列出 CPA 目录里的全部模型。claude 系模型直接使用原 id;其它模型以 `claude-cpac--<模型名>` 别名出现(例如 `claude-cpac--gpt-5.6-sol`),请求发出时由代理还原为真实模型名。
|
|
134
136
|
|
|
135
|
-
|
|
137
|
+
启动时还会把模型槽位(`ANTHROPIC_DEFAULT_OPUS/SONNET/HAIKU_MODEL` 及 `ANTHROPIC_SMALL_FAST_MODEL`)指向目录里的模型,避免 haiku/subagent 任务打到网关不认识的默认 id。默认自动选择(claude 系优先:最大窗口给 opus/sonnet,最小窗口给 haiku),可用命令覆盖:
|
|
138
|
+
|
|
139
|
+
```sh
|
|
140
|
+
cpac claude-models # 查看当前覆盖
|
|
141
|
+
cpac claude-models --opus claude-opus-4-8 --haiku glm-5.3 # 设置/合并
|
|
142
|
+
cpac claude-models --reset # 恢复自动选择
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
你自己 export 同名环境变量时以你为准。
|
|
146
|
+
|
|
147
|
+
Claude Code 的发现协议不携带 context 信息,claude 前缀的未知模型默认按 200K 记账,且 `CLAUDE_CODE_MAX_CONTEXT_TOKENS` 对 claude 前缀的名字不生效。cpac 采用与 opencodex 相同的机制:目录里窗口足够大的别名模型会带 `[1m]` 标记出现在选择器中(Claude Code 按 1M 记账,发请求前自己剥掉标记),同时 `cpac claude` 注入 `CLAUDE_CODE_AUTO_COMPACT_WINDOW`(默认 350K,与 opencodex 一致,接受范围 100K–1M)作为压缩阈值。你自己 export 该变量时以你为准。claude 系模型如需 1M 窗口,在模型名后加 `[1m]`(如 `cpac claude --model claude-sonnet-4-6[1m]`)。
|
|
136
148
|
|
|
137
149
|
> CPA 服务端必须支持 Claude Code 使用的 Anthropic Messages API(`/v1/messages`)。
|
|
138
150
|
|
package/cpac.example.json
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"cpa_url": "https://cpa.example.com",
|
|
3
3
|
"api_key_env": "CPA_API_KEY",
|
|
4
|
-
"codex_proxy_port": 10101
|
|
4
|
+
"codex_proxy_port": 10101,
|
|
5
|
+
"claude_models": {
|
|
6
|
+
"opus": "claude-opus-4-8",
|
|
7
|
+
"sonnet": "claude-sonnet-4-6",
|
|
8
|
+
"haiku": "glm-5.3"
|
|
9
|
+
},
|
|
10
|
+
"spawn_models": ["gpt-5.5", "claude-opus-4-8"]
|
|
5
11
|
}
|
package/dist/cpac.js
CHANGED
|
@@ -19,7 +19,13 @@ const CONFIG_KEYS = new Set([
|
|
|
19
19
|
"codex_config",
|
|
20
20
|
"codex_proxy_port",
|
|
21
21
|
"state_dir",
|
|
22
|
+
"claude_models",
|
|
23
|
+
"spawn_models",
|
|
22
24
|
]);
|
|
25
|
+
// Codex advertises only the first 5 picker-visible catalog models as
|
|
26
|
+
// spawn_agent overrides (codex-rs MAX_SPAWN_AGENT_MODEL_OVERRIDES).
|
|
27
|
+
const MAX_SPAWN_MODELS = 5;
|
|
28
|
+
const CLAUDE_SLOT_KEYS = ["opus", "sonnet", "haiku"];
|
|
23
29
|
const REQUIRED_STATE_KEYS = new Set([
|
|
24
30
|
"config_path",
|
|
25
31
|
"config_existed",
|
|
@@ -119,6 +125,39 @@ export function loadConfig(path, useDefaultsIfMissing = false) {
|
|
|
119
125
|
codexProxyPort > 65535) {
|
|
120
126
|
throw new CPACError("codex_proxy_port must be an integer from 0 to 65535");
|
|
121
127
|
}
|
|
128
|
+
const claudeModelsRaw = value.claude_models;
|
|
129
|
+
let claudeModels;
|
|
130
|
+
if (claudeModelsRaw !== undefined) {
|
|
131
|
+
if (!objectValue(claudeModelsRaw))
|
|
132
|
+
throw new CPACError("claude_models must be an object");
|
|
133
|
+
const unknownSlots = Object.keys(claudeModelsRaw)
|
|
134
|
+
.filter((key) => !CLAUDE_SLOT_KEYS.includes(key))
|
|
135
|
+
.sort();
|
|
136
|
+
if (unknownSlots.length)
|
|
137
|
+
throw new CPACError(`unknown claude_models slots: ${unknownSlots.join(", ")}`);
|
|
138
|
+
claudeModels = {};
|
|
139
|
+
for (const slot of CLAUDE_SLOT_KEYS) {
|
|
140
|
+
const model = claudeModelsRaw[slot];
|
|
141
|
+
if (model === undefined)
|
|
142
|
+
continue;
|
|
143
|
+
if (typeof model !== "string" || !model.trim())
|
|
144
|
+
throw new CPACError(`claude_models.${slot} must be a non-empty string`);
|
|
145
|
+
claudeModels[slot] = model.trim();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const spawnModelsRaw = value.spawn_models;
|
|
149
|
+
let spawnModels;
|
|
150
|
+
if (spawnModelsRaw !== undefined) {
|
|
151
|
+
if (!Array.isArray(spawnModelsRaw))
|
|
152
|
+
throw new CPACError("spawn_models must be an array of model slugs");
|
|
153
|
+
if (spawnModelsRaw.length > MAX_SPAWN_MODELS)
|
|
154
|
+
throw new CPACError(`spawn_models accepts at most ${MAX_SPAWN_MODELS} models`);
|
|
155
|
+
spawnModels = spawnModelsRaw.map((model, index) => {
|
|
156
|
+
if (typeof model !== "string" || !model.trim())
|
|
157
|
+
throw new CPACError(`spawn_models[${index}] must be a non-empty string`);
|
|
158
|
+
return model.trim();
|
|
159
|
+
});
|
|
160
|
+
}
|
|
122
161
|
const codexHome = expandUserPath(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"));
|
|
123
162
|
const codexConfig = resolve(expandUserPath(value.codex_config ||
|
|
124
163
|
join(codexHome, "config.toml")));
|
|
@@ -135,6 +174,8 @@ export function loadConfig(path, useDefaultsIfMissing = false) {
|
|
|
135
174
|
codex_config: codexConfig,
|
|
136
175
|
codex_proxy_port: codexProxyPort,
|
|
137
176
|
state_dir: stateDir,
|
|
177
|
+
claude_models: claudeModels,
|
|
178
|
+
spawn_models: spawnModels,
|
|
138
179
|
};
|
|
139
180
|
}
|
|
140
181
|
export function apiBase(cpaUrl) {
|
|
@@ -215,6 +256,118 @@ export async function fetchCatalog(cpaUrl, apiKey) {
|
|
|
215
256
|
modelCount: models.length,
|
|
216
257
|
};
|
|
217
258
|
}
|
|
259
|
+
// Move `order` slugs to the front of the rich catalog (stable for the rest),
|
|
260
|
+
// so the Codex client's first-5 spawn_agent advertisement picks them.
|
|
261
|
+
export function reorderCatalog(bytes, order) {
|
|
262
|
+
const document = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
263
|
+
const rank = new Map(order.map((slug, index) => [slug, index]));
|
|
264
|
+
const models = document.models.map((model, index) => ({ model, index }));
|
|
265
|
+
models.sort((left, right) => {
|
|
266
|
+
const leftRank = rank.get(String(left.model.slug));
|
|
267
|
+
const rightRank = rank.get(String(right.model.slug));
|
|
268
|
+
if (leftRank === undefined && rightRank === undefined)
|
|
269
|
+
return left.index - right.index;
|
|
270
|
+
if (leftRank === undefined)
|
|
271
|
+
return 1;
|
|
272
|
+
if (rightRank === undefined)
|
|
273
|
+
return -1;
|
|
274
|
+
return leftRank - rightRank;
|
|
275
|
+
});
|
|
276
|
+
document.models = models.map((entry) => entry.model);
|
|
277
|
+
return Buffer.from(`${JSON.stringify(document, null, 2)}\n`);
|
|
278
|
+
}
|
|
279
|
+
export function saveSpawnModels(configPath, models) {
|
|
280
|
+
let document = {};
|
|
281
|
+
if (existsSync(configPath)) {
|
|
282
|
+
const parsed = JSON.parse(readFileSync(configPath, "utf8"));
|
|
283
|
+
if (!objectValue(parsed))
|
|
284
|
+
throw new CPACError("config must be a JSON object");
|
|
285
|
+
document = parsed;
|
|
286
|
+
}
|
|
287
|
+
document.spawn_models = models;
|
|
288
|
+
atomicWrite(configPath, Buffer.from(`${JSON.stringify(document, null, 2)}\n`));
|
|
289
|
+
}
|
|
290
|
+
// Arrow-key checkbox picker on raw stdin; resolves with the checked labels in
|
|
291
|
+
// check order (which becomes the spawn advertisement ranking).
|
|
292
|
+
async function checkboxPicker(title, items, max) {
|
|
293
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
|
294
|
+
throw new CPACError("model selection requires an interactive terminal");
|
|
295
|
+
}
|
|
296
|
+
const stdin = process.stdin;
|
|
297
|
+
const out = process.stderr;
|
|
298
|
+
const selected = new Set();
|
|
299
|
+
let cursor = 0;
|
|
300
|
+
const lines = items.length + 1;
|
|
301
|
+
const render = () => {
|
|
302
|
+
out.write(`${title} [space: toggle, up/down: move, enter: confirm, q: cancel, max ${max}]\n`);
|
|
303
|
+
items.forEach((item, index) => {
|
|
304
|
+
const box = selected.has(index) ? "[x]" : "[ ]";
|
|
305
|
+
out.write(`${index === cursor ? ">" : " "} ${box} ${item}\n`);
|
|
306
|
+
});
|
|
307
|
+
};
|
|
308
|
+
const redraw = () => {
|
|
309
|
+
out.write(`\x1b[${lines}F`);
|
|
310
|
+
for (let line = 0; line < lines; line += 1)
|
|
311
|
+
out.write("\x1b[2K\x1b[1E");
|
|
312
|
+
out.write(`\x1b[${lines}F`);
|
|
313
|
+
render();
|
|
314
|
+
};
|
|
315
|
+
return await new Promise((resolvePromise, rejectPromise) => {
|
|
316
|
+
const finish = (error) => {
|
|
317
|
+
stdin.removeListener("data", onData);
|
|
318
|
+
try {
|
|
319
|
+
stdin.setRawMode(false);
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
// Terminal already gone; nothing to restore.
|
|
323
|
+
}
|
|
324
|
+
stdin.pause();
|
|
325
|
+
out.write("\n");
|
|
326
|
+
if (error)
|
|
327
|
+
rejectPromise(error);
|
|
328
|
+
else
|
|
329
|
+
resolvePromise([...selected].map((index) => items[index]));
|
|
330
|
+
};
|
|
331
|
+
const onData = (chunk) => {
|
|
332
|
+
const key = chunk.toString("utf8");
|
|
333
|
+
if (key === "\x03" || key === "q" || key === "\x1b") {
|
|
334
|
+
finish(new CPACError("model selection cancelled"));
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (key === "\r" || key === "\n") {
|
|
338
|
+
finish();
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (key === "\x1b[A")
|
|
342
|
+
cursor = (cursor + items.length - 1) % items.length;
|
|
343
|
+
else if (key === "\x1b[B")
|
|
344
|
+
cursor = (cursor + 1) % items.length;
|
|
345
|
+
else if (key === " ") {
|
|
346
|
+
if (selected.has(cursor))
|
|
347
|
+
selected.delete(cursor);
|
|
348
|
+
else if (selected.size < max)
|
|
349
|
+
selected.add(cursor);
|
|
350
|
+
}
|
|
351
|
+
redraw();
|
|
352
|
+
};
|
|
353
|
+
stdin.setRawMode(true);
|
|
354
|
+
stdin.resume();
|
|
355
|
+
stdin.on("data", onData);
|
|
356
|
+
render();
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
export async function pickSpawnModels(config) {
|
|
360
|
+
const apiKey = process.env[config.api_key_env]?.trim();
|
|
361
|
+
if (!apiKey)
|
|
362
|
+
throw new CPACError(`environment variable ${config.api_key_env} is not set`);
|
|
363
|
+
const catalog = await fetchCatalog(config.cpa_url, apiKey);
|
|
364
|
+
const document = JSON.parse(new TextDecoder().decode(catalog.bytes));
|
|
365
|
+
const slugs = document.models.map((model) => model.slug);
|
|
366
|
+
const picked = await checkboxPicker("Select spawn_agent models", slugs, MAX_SPAWN_MODELS);
|
|
367
|
+
if (picked.length === 0)
|
|
368
|
+
throw new CPACError("no models selected");
|
|
369
|
+
return picked;
|
|
370
|
+
}
|
|
218
371
|
export function tomlString(value) {
|
|
219
372
|
return JSON.stringify(value);
|
|
220
373
|
}
|
|
@@ -341,6 +494,94 @@ export function buildCodexConfig(original, proxyPort, catalogPath) {
|
|
|
341
494
|
const output = root + body;
|
|
342
495
|
return Buffer.from(eol === "\n" ? output : output.replace(/\n/g, "\r\n"));
|
|
343
496
|
}
|
|
497
|
+
// Codex multi-agent v2 (features.multi_agent_v2). Accepts the same TOML
|
|
498
|
+
// shapes codex-rs does: a [features.multi_agent_v2] table, an inline
|
|
499
|
+
// `multi_agent_v2 = ...` under [features], or dotted root keys.
|
|
500
|
+
const V2_TABLE_HEADER = /^\s*\[\s*(?:features|"features"|'features')\s*\.\s*(?:multi_agent_v2|"multi_agent_v2"|'multi_agent_v2')\s*\]\s*(?:#.*)?$/;
|
|
501
|
+
const FEATURES_TABLE_HEADER = /^\s*\[\s*(?:features|"features"|'features')\s*\]\s*(?:#.*)?$/;
|
|
502
|
+
const V2_KEY = /^\s*(?:multi_agent_v2|"multi_agent_v2"|'multi_agent_v2')\s*=/;
|
|
503
|
+
const V2_DOTTED_KEY = /^\s*(?:features|"features"|'features')\s*\.\s*(?:multi_agent_v2|"multi_agent_v2"|'multi_agent_v2')(?:\s*\.\s*(?:enabled|"enabled"|'enabled'))?\s*=/;
|
|
504
|
+
function v2LineValue(line) {
|
|
505
|
+
const inline = line.match(/\{[^}]*\benabled\s*=\s*(true|false)/);
|
|
506
|
+
if (inline)
|
|
507
|
+
return inline[1] === "true";
|
|
508
|
+
const plain = line.match(/=\s*(true|false)(?![A-Za-z0-9_])/);
|
|
509
|
+
if (plain)
|
|
510
|
+
return plain[1] === "true";
|
|
511
|
+
return undefined;
|
|
512
|
+
}
|
|
513
|
+
export function multiAgentV2Enabled(content) {
|
|
514
|
+
let table = "root";
|
|
515
|
+
for (const line of content.replace(/\r\n/g, "\n").split("\n")) {
|
|
516
|
+
if (V2_TABLE_HEADER.test(line)) {
|
|
517
|
+
table = "v2";
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
if (FEATURES_TABLE_HEADER.test(line)) {
|
|
521
|
+
table = "features";
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (/^\s*\[/.test(line)) {
|
|
525
|
+
table = "other";
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
if (table === "v2" && /^\s*(?:enabled|"enabled"|'enabled')\s*=/.test(line)) {
|
|
529
|
+
return v2LineValue(line) ?? false;
|
|
530
|
+
}
|
|
531
|
+
if (table === "features" && V2_KEY.test(line))
|
|
532
|
+
return v2LineValue(line) ?? false;
|
|
533
|
+
if (table === "root" && V2_DOTTED_KEY.test(line))
|
|
534
|
+
return v2LineValue(line) ?? false;
|
|
535
|
+
}
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
function stripMultiAgentV2(content) {
|
|
539
|
+
const lines = content.replace(/\r\n/g, "\n").split("\n");
|
|
540
|
+
const kept = [];
|
|
541
|
+
let skipTable = false;
|
|
542
|
+
let table = "root";
|
|
543
|
+
for (const line of lines) {
|
|
544
|
+
if (/^\s*\[/.test(line)) {
|
|
545
|
+
skipTable = V2_TABLE_HEADER.test(line);
|
|
546
|
+
if (skipTable) {
|
|
547
|
+
table = "other";
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
table = FEATURES_TABLE_HEADER.test(line) ? "features" : "other";
|
|
551
|
+
}
|
|
552
|
+
if (skipTable)
|
|
553
|
+
continue;
|
|
554
|
+
if (table === "features" && V2_KEY.test(line))
|
|
555
|
+
continue;
|
|
556
|
+
if (table === "root" && V2_DOTTED_KEY.test(line))
|
|
557
|
+
continue;
|
|
558
|
+
kept.push(line);
|
|
559
|
+
}
|
|
560
|
+
return kept.join("\n");
|
|
561
|
+
}
|
|
562
|
+
function hasAgentsMaxThreads(content) {
|
|
563
|
+
let table = "root";
|
|
564
|
+
for (const line of content.replace(/\r\n/g, "\n").split("\n")) {
|
|
565
|
+
if (/^\s*\[/.test(line)) {
|
|
566
|
+
table = /^\s*\[\s*(?:agents|"agents"|'agents')\s*\]\s*(?:#.*)?$/.test(line)
|
|
567
|
+
? "agents"
|
|
568
|
+
: "other";
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
if (table === "agents" && /^\s*max_threads\s*=/.test(line))
|
|
572
|
+
return true;
|
|
573
|
+
if (table === "root" && /^\s*(?:agents|"agents"|'agents')\s*\.\s*max_threads\s*=/.test(line))
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
return false;
|
|
577
|
+
}
|
|
578
|
+
export function withMultiAgentV2(content, enabled, eol) {
|
|
579
|
+
let body = stripMultiAgentV2(content);
|
|
580
|
+
if (body && !body.endsWith("\n"))
|
|
581
|
+
body += "\n";
|
|
582
|
+
const output = body + `\n[features.multi_agent_v2]\nenabled = ${enabled}\n`;
|
|
583
|
+
return eol === "\n" ? output : output.replace(/\n/g, "\r\n");
|
|
584
|
+
}
|
|
344
585
|
export function atomicWrite(path, data, mode = 0o600) {
|
|
345
586
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
346
587
|
const temporary = `${path}.cpac.${process.pid}.${Date.now()}.${++atomicSequence}.tmp`;
|
|
@@ -508,6 +749,60 @@ export async function createLoopbackProxy(cpaUrl, apiKey, proxyId, port) {
|
|
|
508
749
|
return { server, port: address.port };
|
|
509
750
|
}
|
|
510
751
|
const CLAUDE_ALIAS_PREFIX = "claude-cpac--";
|
|
752
|
+
// Claude Code accepts CLAUDE_CODE_AUTO_COMPACT_WINDOW in 100K–1M (binary-verified).
|
|
753
|
+
// A single global window cannot be per-model; 350K is opencodex's user-approved
|
|
754
|
+
// default — high enough to mark mid-size models (372K/500K), low enough that
|
|
755
|
+
// marking never outgrows a model's real window.
|
|
756
|
+
const ONE_MILLION = 1_000_000;
|
|
757
|
+
const COMPACT_WINDOW_DEFAULT = 350_000;
|
|
758
|
+
function claudeCompactWindow(maxContext) {
|
|
759
|
+
return maxContext > 200_000 ? COMPACT_WINDOW_DEFAULT : undefined;
|
|
760
|
+
}
|
|
761
|
+
// [1m] marking predicate (opencodex rule): native claude models manage their own
|
|
762
|
+
// marker; aliases mark when the real window is >= 1M, or when it clears 200K and
|
|
763
|
+
// can host the compact window (marking below it would trip API limits mid-session).
|
|
764
|
+
function shouldMarkOneMillion(id, window, compactWindow) {
|
|
765
|
+
if (id.startsWith("claude"))
|
|
766
|
+
return false;
|
|
767
|
+
if (window >= ONE_MILLION)
|
|
768
|
+
return true;
|
|
769
|
+
return compactWindow !== undefined && window > 200_000 && window >= compactWindow;
|
|
770
|
+
}
|
|
771
|
+
// Auto tier defaults: prefer claude-family models; biggest window serves
|
|
772
|
+
// opus/sonnet, smallest known window serves haiku.
|
|
773
|
+
function defaultClaudeSlots(models) {
|
|
774
|
+
if (!models.length)
|
|
775
|
+
return undefined;
|
|
776
|
+
const claudeRows = models.filter((model) => model.id.startsWith("claude"));
|
|
777
|
+
const pool = claudeRows.length ? claudeRows : models;
|
|
778
|
+
const biggest = [...pool].sort((a, b) => b.window - a.window)[0];
|
|
779
|
+
const smallest = pool
|
|
780
|
+
.filter((model) => model.window > 0)
|
|
781
|
+
.sort((a, b) => a.window - b.window)[0];
|
|
782
|
+
return {
|
|
783
|
+
opus: biggest.id,
|
|
784
|
+
sonnet: biggest.id,
|
|
785
|
+
haiku: (smallest ?? biggest).id,
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
// Resolve the tier-model env values: config overrides win over catalog
|
|
789
|
+
// defaults; values are [1m]-marked per the predicate above (Claude Code
|
|
790
|
+
// strips the marker before the request leaves).
|
|
791
|
+
export function claudeTierSlots(override, models, compactWindow) {
|
|
792
|
+
const defaults = defaultClaudeSlots(models);
|
|
793
|
+
if (!defaults)
|
|
794
|
+
return undefined;
|
|
795
|
+
const windowById = new Map(models.map((model) => [model.id, model.window]));
|
|
796
|
+
const mark = (id) => !/\[1m\]$/i.test(id) &&
|
|
797
|
+
shouldMarkOneMillion(id, windowById.get(id) ?? 0, compactWindow)
|
|
798
|
+
? `${id}[1m]`
|
|
799
|
+
: id;
|
|
800
|
+
return {
|
|
801
|
+
opus: mark(override?.opus ?? defaults.opus),
|
|
802
|
+
sonnet: mark(override?.sonnet ?? defaults.sonnet),
|
|
803
|
+
haiku: mark(override?.haiku ?? defaults.haiku),
|
|
804
|
+
};
|
|
805
|
+
}
|
|
511
806
|
function catalogModelRows(document) {
|
|
512
807
|
if (!objectValue(document))
|
|
513
808
|
return undefined;
|
|
@@ -546,21 +841,38 @@ async function claudeModelList(cpaUrl, apiKey, response) {
|
|
|
546
841
|
return;
|
|
547
842
|
}
|
|
548
843
|
// Claude Code's /model picker only lists ids starting with claude/anthropic;
|
|
549
|
-
// expose every other catalog model as claude-cpac--<id>.
|
|
550
|
-
|
|
844
|
+
// expose every other catalog model as claude-cpac--<id>. Append the [1m]
|
|
845
|
+
// marker when the model can host the auto-compact window — that marker is
|
|
846
|
+
// the only signal that lifts context accounting above the 200K default
|
|
847
|
+
// (Claude Code strips it before the request reaches the proxy).
|
|
848
|
+
const rowsWithWindow = [];
|
|
849
|
+
let maxContext = 0;
|
|
551
850
|
for (const model of rows) {
|
|
552
851
|
const id = catalogModelId(model);
|
|
553
852
|
if (!id)
|
|
554
853
|
continue;
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
854
|
+
const window = typeof model.context_window === "number" && model.context_window > 0
|
|
855
|
+
? Math.floor(model.context_window)
|
|
856
|
+
: 0;
|
|
857
|
+
if (window > maxContext)
|
|
858
|
+
maxContext = window;
|
|
859
|
+
rowsWithWindow.push({
|
|
860
|
+
id,
|
|
861
|
+
window,
|
|
862
|
+
alias: id.startsWith("claude") ? id : `${CLAUDE_ALIAS_PREFIX}${id}`,
|
|
863
|
+
display: typeof model.display_name === "string" && model.display_name.trim()
|
|
560
864
|
? model.display_name
|
|
561
865
|
: id,
|
|
562
866
|
});
|
|
563
867
|
}
|
|
868
|
+
const compactWindow = claudeCompactWindow(maxContext);
|
|
869
|
+
const data = rowsWithWindow.map((row) => ({
|
|
870
|
+
type: "model",
|
|
871
|
+
id: shouldMarkOneMillion(row.id, row.window, compactWindow)
|
|
872
|
+
? `${row.alias}[1m]`
|
|
873
|
+
: row.alias,
|
|
874
|
+
display_name: row.display,
|
|
875
|
+
}));
|
|
564
876
|
response.writeHead(200, { "content-type": "application/json" });
|
|
565
877
|
response.end(JSON.stringify({
|
|
566
878
|
data,
|
|
@@ -595,7 +907,10 @@ export async function createClaudeProxy(cpaUrl, apiKey) {
|
|
|
595
907
|
if (objectValue(parsed) &&
|
|
596
908
|
typeof parsed.model === "string" &&
|
|
597
909
|
parsed.model.startsWith(CLAUDE_ALIAS_PREFIX)) {
|
|
598
|
-
|
|
910
|
+
// Claude Code strips [1m] itself; keep the strip as a guard.
|
|
911
|
+
parsed.model = parsed.model
|
|
912
|
+
.slice(CLAUDE_ALIAS_PREFIX.length)
|
|
913
|
+
.replace(/\[(1|2)m\]$/i, "");
|
|
599
914
|
body = Buffer.from(JSON.stringify(parsed));
|
|
600
915
|
}
|
|
601
916
|
}
|
|
@@ -831,7 +1146,7 @@ function proxyFingerprint(config, apiKey) {
|
|
|
831
1146
|
.update(apiKey)
|
|
832
1147
|
.digest("hex");
|
|
833
1148
|
}
|
|
834
|
-
export async function inject(config) {
|
|
1149
|
+
export async function inject(config, v2Off = false) {
|
|
835
1150
|
const apiKey = process.env[config.api_key_env]?.trim();
|
|
836
1151
|
if (!apiKey)
|
|
837
1152
|
throw new CPACError(`environment variable ${config.api_key_env} is not set`);
|
|
@@ -867,6 +1182,8 @@ export async function inject(config) {
|
|
|
867
1182
|
}
|
|
868
1183
|
}
|
|
869
1184
|
const catalog = await fetchCatalog(config.cpa_url, apiKey);
|
|
1185
|
+
if (config.spawn_models?.length)
|
|
1186
|
+
catalog.bytes = reorderCatalog(catalog.bytes, config.spawn_models);
|
|
870
1187
|
const stateDirExisted = existsSync(config.state_dir);
|
|
871
1188
|
if (!state &&
|
|
872
1189
|
STATE_FILES.some((name) => existsSync(join(config.state_dir, name)))) {
|
|
@@ -898,6 +1215,11 @@ export async function inject(config) {
|
|
|
898
1215
|
let injected;
|
|
899
1216
|
try {
|
|
900
1217
|
injected = buildCodexConfig(original, proxy.port, catalogPath);
|
|
1218
|
+
const injectedText = new TextDecoder("utf-8").decode(injected);
|
|
1219
|
+
injected = Buffer.from(withMultiAgentV2(injectedText, !v2Off, dominantEol(injectedText)));
|
|
1220
|
+
if (!v2Off && hasAgentsMaxThreads(injectedText)) {
|
|
1221
|
+
console.warn("warning: [agents] max_threads is set; codex refuses to start with multi_agent_v2 enabled, remove it");
|
|
1222
|
+
}
|
|
901
1223
|
}
|
|
902
1224
|
catch (error) {
|
|
903
1225
|
if (startedProxy)
|
|
@@ -964,7 +1286,7 @@ export async function inject(config) {
|
|
|
964
1286
|
catch {
|
|
965
1287
|
console.warn("CPAC could not invalidate Codex models_cache.json; restart Codex App if its model list is stale.");
|
|
966
1288
|
}
|
|
967
|
-
console.log(`Injected ${catalog.modelCount} CPA models into ${config.codex_config} via http://127.0.0.1:${proxy.port}/v1`);
|
|
1289
|
+
console.log(`Injected ${catalog.modelCount} CPA models into ${config.codex_config} via http://127.0.0.1:${proxy.port}/v1 (multi-agent v2: ${v2Off ? "off" : "on"})`);
|
|
968
1290
|
console.log("Restart Codex App if its running app-server still shows the old model list.");
|
|
969
1291
|
}
|
|
970
1292
|
export async function restore(config) {
|
|
@@ -1011,6 +1333,12 @@ export async function status(config) {
|
|
|
1011
1333
|
.slice(0, 12);
|
|
1012
1334
|
console.log(`codex: injected proxy=http://127.0.0.1:${proxy.port}/v1 sha256=${digest}`);
|
|
1013
1335
|
}
|
|
1336
|
+
try {
|
|
1337
|
+
console.log(`codex multi-agent v2: ${multiAgentV2Enabled(readFileSync(config.codex_config, "utf8")) ? "on" : "off"}`);
|
|
1338
|
+
}
|
|
1339
|
+
catch {
|
|
1340
|
+
// Unreadable config: the injected line above already reports it.
|
|
1341
|
+
}
|
|
1014
1342
|
}
|
|
1015
1343
|
}
|
|
1016
1344
|
console.log(`claude: ${keyConfigured ? "ready" : "not configured"}`);
|
|
@@ -1090,6 +1418,45 @@ async function guide(config) {
|
|
|
1090
1418
|
console.log(`\nSaved ${config.api_key_env} to ${profile}. Open a new terminal or run:\n source ${profile}`);
|
|
1091
1419
|
return 0;
|
|
1092
1420
|
}
|
|
1421
|
+
export function runClaudeModels(parsed) {
|
|
1422
|
+
if (!parsed.reset && !Object.keys(parsed.models).length) {
|
|
1423
|
+
const config = loadConfig(parsed.configPath);
|
|
1424
|
+
console.log(config.claude_models
|
|
1425
|
+
? JSON.stringify(config.claude_models, null, 2)
|
|
1426
|
+
: "claude_models not set; cpac claude auto-picks tier models from the CPA catalog");
|
|
1427
|
+
return 0;
|
|
1428
|
+
}
|
|
1429
|
+
let raw = {};
|
|
1430
|
+
try {
|
|
1431
|
+
const file = JSON.parse(readFileSync(parsed.configPath, "utf8"));
|
|
1432
|
+
if (!objectValue(file))
|
|
1433
|
+
throw new CPACError("config must be a JSON object");
|
|
1434
|
+
raw = file;
|
|
1435
|
+
}
|
|
1436
|
+
catch (error) {
|
|
1437
|
+
if (error.code !== "ENOENT") {
|
|
1438
|
+
if (error instanceof CPACError)
|
|
1439
|
+
throw error;
|
|
1440
|
+
throw new CPACError(`cannot read config: ${error instanceof Error ? error.message : String(error)}`);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
if (parsed.reset) {
|
|
1444
|
+
delete raw.claude_models;
|
|
1445
|
+
}
|
|
1446
|
+
else {
|
|
1447
|
+
const existing = objectValue(raw.claude_models)
|
|
1448
|
+
? { ...raw.claude_models }
|
|
1449
|
+
: {};
|
|
1450
|
+
Object.assign(existing, parsed.models);
|
|
1451
|
+
raw.claude_models = existing;
|
|
1452
|
+
}
|
|
1453
|
+
atomicWrite(parsed.configPath, Buffer.from(`${JSON.stringify(raw, null, 2)}\n`));
|
|
1454
|
+
loadConfig(parsed.configPath);
|
|
1455
|
+
console.log(parsed.reset
|
|
1456
|
+
? "claude_models cleared"
|
|
1457
|
+
: `claude_models updated: ${JSON.stringify(raw.claude_models)}`);
|
|
1458
|
+
return 0;
|
|
1459
|
+
}
|
|
1093
1460
|
export async function runClaude(config, args, executable = "claude") {
|
|
1094
1461
|
const apiKey = await resolveApiKey(config.api_key_env);
|
|
1095
1462
|
const proxy = await createClaudeProxy(config.cpa_url, apiKey);
|
|
@@ -1097,22 +1464,31 @@ export async function runClaude(config, args, executable = "claude") {
|
|
|
1097
1464
|
proxy.server.closeAllConnections?.();
|
|
1098
1465
|
proxy.server.close();
|
|
1099
1466
|
};
|
|
1100
|
-
// Gateway discovery only carries {id, display_name}; Claude Code
|
|
1101
|
-
// unknown models
|
|
1102
|
-
//
|
|
1467
|
+
// Gateway discovery only carries {id, display_name}; Claude Code accounts
|
|
1468
|
+
// claude-prefixed unknown models at 200K and ignores MAX_CONTEXT_TOKENS for
|
|
1469
|
+
// them. The [1m] marker on the alias (added by the models listing) plus the
|
|
1470
|
+
// auto-compact window is opencodex's working mechanism for bigger windows.
|
|
1103
1471
|
let maxContext = 0;
|
|
1472
|
+
const catalogWindows = [];
|
|
1104
1473
|
try {
|
|
1105
1474
|
const catalog = await fetchCatalog(config.cpa_url, apiKey);
|
|
1106
1475
|
const rows = catalogModelRows(JSON.parse(new TextDecoder("utf-8").decode(catalog.bytes))) ?? [];
|
|
1107
1476
|
for (const row of rows) {
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1477
|
+
const id = catalogModelId(row);
|
|
1478
|
+
const window = typeof row.context_window === "number" && row.context_window > 0
|
|
1479
|
+
? Math.floor(row.context_window)
|
|
1480
|
+
: 0;
|
|
1481
|
+
if (id)
|
|
1482
|
+
catalogWindows.push({ id, window });
|
|
1483
|
+
if (window > maxContext)
|
|
1484
|
+
maxContext = window;
|
|
1111
1485
|
}
|
|
1112
1486
|
}
|
|
1113
1487
|
catch {
|
|
1114
1488
|
// keep the default window when the catalog is unreachable
|
|
1115
1489
|
}
|
|
1490
|
+
const compactWindow = claudeCompactWindow(maxContext);
|
|
1491
|
+
const slots = claudeTierSlots(config.claude_models, catalogWindows, compactWindow);
|
|
1116
1492
|
const env = {
|
|
1117
1493
|
...process.env,
|
|
1118
1494
|
ANTHROPIC_BASE_URL: `http://127.0.0.1:${proxy.port}`,
|
|
@@ -1120,8 +1496,20 @@ export async function runClaude(config, args, executable = "claude") {
|
|
|
1120
1496
|
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1",
|
|
1121
1497
|
CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: "1",
|
|
1122
1498
|
};
|
|
1123
|
-
if (
|
|
1124
|
-
env.
|
|
1499
|
+
if (compactWindow !== undefined && !(Number(process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW) > 0)) {
|
|
1500
|
+
env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(compactWindow);
|
|
1501
|
+
}
|
|
1502
|
+
if (slots) {
|
|
1503
|
+
// Route haiku/subagent tier traffic at catalog models instead of the
|
|
1504
|
+
// stock claude-haiku-* ids the gateway does not carry.
|
|
1505
|
+
const setSlot = (name, value) => {
|
|
1506
|
+
if (process.env[name] === undefined)
|
|
1507
|
+
env[name] = value;
|
|
1508
|
+
};
|
|
1509
|
+
setSlot("ANTHROPIC_DEFAULT_OPUS_MODEL", slots.opus);
|
|
1510
|
+
setSlot("ANTHROPIC_DEFAULT_SONNET_MODEL", slots.sonnet);
|
|
1511
|
+
setSlot("ANTHROPIC_DEFAULT_HAIKU_MODEL", slots.haiku);
|
|
1512
|
+
setSlot("ANTHROPIC_SMALL_FAST_MODEL", slots.haiku);
|
|
1125
1513
|
}
|
|
1126
1514
|
delete env.ANTHROPIC_API_KEY;
|
|
1127
1515
|
delete env.CLAUDE_CODE_USE_ANTHROPIC_AWS;
|
|
@@ -1348,18 +1736,18 @@ const TARGETS = [
|
|
|
1348
1736
|
},
|
|
1349
1737
|
installed: (config) => {
|
|
1350
1738
|
try {
|
|
1351
|
-
return
|
|
1739
|
+
return readFileSync(config.codex_config, "utf8").includes(MANAGED_MARKER);
|
|
1352
1740
|
}
|
|
1353
1741
|
catch {
|
|
1354
1742
|
return false;
|
|
1355
1743
|
}
|
|
1356
1744
|
},
|
|
1357
|
-
install: async (config, dryRun) => {
|
|
1745
|
+
install: async (config, dryRun, v2Off) => {
|
|
1358
1746
|
if (dryRun) {
|
|
1359
1747
|
console.log("would inject CPA catalog and loopback proxy into Codex");
|
|
1360
1748
|
return;
|
|
1361
1749
|
}
|
|
1362
|
-
await inject(config);
|
|
1750
|
+
await inject(config, v2Off);
|
|
1363
1751
|
},
|
|
1364
1752
|
uninstall: async (config, dryRun) => {
|
|
1365
1753
|
if (dryRun) {
|
|
@@ -1462,7 +1850,7 @@ export async function runInstall(config, requested, options) {
|
|
|
1462
1850
|
console.log(`${target.id}: already installed; use --force to reinstall`);
|
|
1463
1851
|
continue;
|
|
1464
1852
|
}
|
|
1465
|
-
await target.install(config, options.dryRun);
|
|
1853
|
+
await target.install(config, options.dryRun, options.v2Off);
|
|
1466
1854
|
}
|
|
1467
1855
|
return 0;
|
|
1468
1856
|
}
|
|
@@ -1531,10 +1919,11 @@ function usage() {
|
|
|
1531
1919
|
" cpac <inject|status|restore> [--config PATH]",
|
|
1532
1920
|
" cpac proxy [--config PATH]",
|
|
1533
1921
|
" cpac claude [--config PATH] [--] [claude args...]",
|
|
1922
|
+
" cpac claude-models [--opus M] [--sonnet M] [--haiku M] [--reset] [--config PATH]",
|
|
1534
1923
|
" cpac pi <install|uninstall|status> [--config PATH]",
|
|
1535
1924
|
" cpac kimi <install|uninstall|status> [--config PATH]",
|
|
1536
1925
|
" cpac detect [--json] [--home PATH]",
|
|
1537
|
-
" cpac install [--target codex,claude,pi,kimi] [--all] [--dry-run] [--force] [--home PATH]",
|
|
1926
|
+
" cpac install [--target codex,claude,pi,kimi] [--all] [--dry-run] [--force] [--v2_off] [--v2_models] [--home PATH]",
|
|
1538
1927
|
" cpac uninstall [--target codex,claude,pi,kimi] [--all] [--dry-run] [--home PATH]",
|
|
1539
1928
|
" cpac upgrade [--check]",
|
|
1540
1929
|
" cpac version",
|
|
@@ -1558,6 +1947,8 @@ function parseArgs(args) {
|
|
|
1558
1947
|
dryRun: false,
|
|
1559
1948
|
force: false,
|
|
1560
1949
|
check: false,
|
|
1950
|
+
v2Off: false,
|
|
1951
|
+
v2Models: false,
|
|
1561
1952
|
};
|
|
1562
1953
|
for (let index = 1; index < args.length; index++) {
|
|
1563
1954
|
const arg = args[index];
|
|
@@ -1592,6 +1983,12 @@ function parseArgs(args) {
|
|
|
1592
1983
|
throw new CPACError("--config requires a path");
|
|
1593
1984
|
parsed.configPath = resolve(expandUserPath(value));
|
|
1594
1985
|
}
|
|
1986
|
+
else if (arg === "--v2_off" || arg === "--v2-off") {
|
|
1987
|
+
parsed.v2Off = true;
|
|
1988
|
+
}
|
|
1989
|
+
else if (arg === "--v2_models" || arg === "--v2-models") {
|
|
1990
|
+
parsed.v2Models = true;
|
|
1991
|
+
}
|
|
1595
1992
|
else {
|
|
1596
1993
|
throw new CPACError(`unknown option: ${arg}`);
|
|
1597
1994
|
}
|
|
@@ -1611,6 +2008,33 @@ function parseArgs(args) {
|
|
|
1611
2008
|
const action = args[index] || "status";
|
|
1612
2009
|
return { command: args[0], configPath, action };
|
|
1613
2010
|
}
|
|
2011
|
+
if (args[0] === "claude-models") {
|
|
2012
|
+
let configPath = defaultConfigPath();
|
|
2013
|
+
const models = {};
|
|
2014
|
+
let reset = false;
|
|
2015
|
+
for (let index = 1; index < args.length; index += 1) {
|
|
2016
|
+
const arg = args[index];
|
|
2017
|
+
if (arg === "--config") {
|
|
2018
|
+
const value = args[++index];
|
|
2019
|
+
if (!value)
|
|
2020
|
+
throw new CPACError("--config requires a path");
|
|
2021
|
+
configPath = resolve(expandUserPath(value));
|
|
2022
|
+
}
|
|
2023
|
+
else if (arg === "--reset") {
|
|
2024
|
+
reset = true;
|
|
2025
|
+
}
|
|
2026
|
+
else if (arg === "--opus" || arg === "--sonnet" || arg === "--haiku") {
|
|
2027
|
+
const value = args[++index];
|
|
2028
|
+
if (typeof value !== "string" || !value.trim())
|
|
2029
|
+
throw new CPACError(`${arg} requires a model id`);
|
|
2030
|
+
models[arg.slice(2)] = value.trim();
|
|
2031
|
+
}
|
|
2032
|
+
else {
|
|
2033
|
+
throw new CPACError(`unknown option: ${arg}`);
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
return { command: "claude-models", configPath, models, reset };
|
|
2037
|
+
}
|
|
1614
2038
|
if (args[0] === "claude") {
|
|
1615
2039
|
let configPath = defaultConfigPath();
|
|
1616
2040
|
let index = 1;
|
|
@@ -1671,12 +2095,21 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
1671
2095
|
return await guide(config);
|
|
1672
2096
|
if (parsed.command === "detect")
|
|
1673
2097
|
return await runDetect(config, parsed.json);
|
|
1674
|
-
if (parsed.command === "install")
|
|
1675
|
-
|
|
2098
|
+
if (parsed.command === "install") {
|
|
2099
|
+
let installConfig = config;
|
|
2100
|
+
if (parsed.v2Models) {
|
|
2101
|
+
const picked = await pickSpawnModels(config);
|
|
2102
|
+
saveSpawnModels(parsed.configPath, picked);
|
|
2103
|
+
console.log(`spawn_models saved to ${parsed.configPath}: ${picked.join(", ")}`);
|
|
2104
|
+
installConfig = { ...config, spawn_models: picked };
|
|
2105
|
+
}
|
|
2106
|
+
return await runInstall(installConfig, parsed.targets, {
|
|
1676
2107
|
all: parsed.all,
|
|
1677
2108
|
dryRun: parsed.dryRun,
|
|
1678
2109
|
force: parsed.force,
|
|
2110
|
+
v2Off: parsed.v2Off,
|
|
1679
2111
|
});
|
|
2112
|
+
}
|
|
1680
2113
|
if (parsed.command === "uninstall")
|
|
1681
2114
|
return await runUninstall(config, parsed.targets, {
|
|
1682
2115
|
all: parsed.all,
|
|
@@ -1690,6 +2123,8 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
1690
2123
|
}
|
|
1691
2124
|
if (parsed.command === "claude")
|
|
1692
2125
|
return await runClaude(config, parsed.args);
|
|
2126
|
+
if (parsed.command === "claude-models")
|
|
2127
|
+
return runClaudeModels(parsed);
|
|
1693
2128
|
if (parsed.command === "proxy")
|
|
1694
2129
|
return await runProxy(config);
|
|
1695
2130
|
if (parsed.command === "pi")
|