@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 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
- Claude Code 的发现协议不携带 context 信息,未知模型默认按 200K 处理。`cpac claude` 启动时会从 CPA 目录取最大 `context_window`,通过 `CLAUDE_CODE_MAX_CONTEXT_TOKENS` 告知 Claude Code(对非 claude 名称的模型生效);你自己 export 该变量时以你为准。claude 系模型如需 1M 窗口,在模型名后加 `[1m]`(如 `cpac claude --model claude-sonnet-4-6[1m]`)。
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
- const data = [];
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 alias = id.startsWith("claude") ? id : `${CLAUDE_ALIAS_PREFIX}${id}`;
556
- data.push({
557
- type: "model",
558
- id: alias,
559
- display_name: typeof model.display_name === "string" && model.display_name.trim()
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
- parsed.model = parsed.model.slice(CLAUDE_ALIAS_PREFIX.length);
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 defaults
1101
- // unknown models to 200K. CLAUDE_CODE_MAX_CONTEXT_TOKENS applies to
1102
- // non-claude model names, so feed it the largest catalog window.
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
- if (typeof row.context_window === "number" && row.context_window > maxContext) {
1109
- maxContext = row.context_window;
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 (maxContext > 0 && !(Number(process.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS) > 0)) {
1124
- env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(maxContext);
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 hasProviderTable(readFileSync(config.codex_config, "utf8"));
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
- return await runInstall(config, parsed.targets, {
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")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yhong91/cpac",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Connect Codex and Claude Code to a remote CLIProxyAPI gateway",
5
5
  "type": "module",
6
6
  "bin": {