@yhong91/cpac 0.1.7 → 0.1.9

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,21 @@ 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,否则最大窗口给 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 --haiku # 不带值:arrow-key 下拉选择
143
+ cpac claude-models --classifier claude-sonnet-4-6 # Auto mode 审批分类器模型
144
+ cpac claude-models --reset # 恢复自动选择
145
+ ```
146
+
147
+ 所有设定写入配置文件持久缓存,每次 `cpac claude` 启动自动生效。`--classifier` 对应 `CLAUDE_CODE_AUTO_MODE_MODEL`(Auto mode 的审批分类器,默认跟随 Claude Code 内置选择);不设置则不注入。
148
+
149
+ 你自己 export 同名环境变量时以你为准。
150
+
151
+ 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
152
 
137
153
  > CPA 服务端必须支持 Claude Code 使用的 Anthropic Messages API(`/v1/messages`)。
138
154
 
package/cpac.example.json CHANGED
@@ -1,5 +1,12 @@
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
+ "classifier": "claude-sonnet-4-6"
10
+ },
11
+ "spawn_models": ["gpt-5.5", "claude-opus-4-8"]
5
12
  }
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", "classifier"];
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,124 @@ 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 (max === 1) {
347
+ selected.clear();
348
+ selected.add(cursor);
349
+ finish();
350
+ return;
351
+ }
352
+ if (selected.has(cursor))
353
+ selected.delete(cursor);
354
+ else if (selected.size < max)
355
+ selected.add(cursor);
356
+ }
357
+ redraw();
358
+ };
359
+ stdin.setRawMode(true);
360
+ stdin.resume();
361
+ stdin.on("data", onData);
362
+ render();
363
+ });
364
+ }
365
+ export async function pickSpawnModels(config) {
366
+ const apiKey = process.env[config.api_key_env]?.trim();
367
+ if (!apiKey)
368
+ throw new CPACError(`environment variable ${config.api_key_env} is not set`);
369
+ const catalog = await fetchCatalog(config.cpa_url, apiKey);
370
+ const document = JSON.parse(new TextDecoder().decode(catalog.bytes));
371
+ const slugs = document.models.map((model) => model.slug);
372
+ const picked = await checkboxPicker("Select spawn_agent models", slugs, MAX_SPAWN_MODELS);
373
+ if (picked.length === 0)
374
+ throw new CPACError("no models selected");
375
+ return picked;
376
+ }
218
377
  export function tomlString(value) {
219
378
  return JSON.stringify(value);
220
379
  }
@@ -341,6 +500,94 @@ export function buildCodexConfig(original, proxyPort, catalogPath) {
341
500
  const output = root + body;
342
501
  return Buffer.from(eol === "\n" ? output : output.replace(/\n/g, "\r\n"));
343
502
  }
503
+ // Codex multi-agent v2 (features.multi_agent_v2). Accepts the same TOML
504
+ // shapes codex-rs does: a [features.multi_agent_v2] table, an inline
505
+ // `multi_agent_v2 = ...` under [features], or dotted root keys.
506
+ const V2_TABLE_HEADER = /^\s*\[\s*(?:features|"features"|'features')\s*\.\s*(?:multi_agent_v2|"multi_agent_v2"|'multi_agent_v2')\s*\]\s*(?:#.*)?$/;
507
+ const FEATURES_TABLE_HEADER = /^\s*\[\s*(?:features|"features"|'features')\s*\]\s*(?:#.*)?$/;
508
+ const V2_KEY = /^\s*(?:multi_agent_v2|"multi_agent_v2"|'multi_agent_v2')\s*=/;
509
+ 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*=/;
510
+ function v2LineValue(line) {
511
+ const inline = line.match(/\{[^}]*\benabled\s*=\s*(true|false)/);
512
+ if (inline)
513
+ return inline[1] === "true";
514
+ const plain = line.match(/=\s*(true|false)(?![A-Za-z0-9_])/);
515
+ if (plain)
516
+ return plain[1] === "true";
517
+ return undefined;
518
+ }
519
+ export function multiAgentV2Enabled(content) {
520
+ let table = "root";
521
+ for (const line of content.replace(/\r\n/g, "\n").split("\n")) {
522
+ if (V2_TABLE_HEADER.test(line)) {
523
+ table = "v2";
524
+ continue;
525
+ }
526
+ if (FEATURES_TABLE_HEADER.test(line)) {
527
+ table = "features";
528
+ continue;
529
+ }
530
+ if (/^\s*\[/.test(line)) {
531
+ table = "other";
532
+ continue;
533
+ }
534
+ if (table === "v2" && /^\s*(?:enabled|"enabled"|'enabled')\s*=/.test(line)) {
535
+ return v2LineValue(line) ?? false;
536
+ }
537
+ if (table === "features" && V2_KEY.test(line))
538
+ return v2LineValue(line) ?? false;
539
+ if (table === "root" && V2_DOTTED_KEY.test(line))
540
+ return v2LineValue(line) ?? false;
541
+ }
542
+ return false;
543
+ }
544
+ function stripMultiAgentV2(content) {
545
+ const lines = content.replace(/\r\n/g, "\n").split("\n");
546
+ const kept = [];
547
+ let skipTable = false;
548
+ let table = "root";
549
+ for (const line of lines) {
550
+ if (/^\s*\[/.test(line)) {
551
+ skipTable = V2_TABLE_HEADER.test(line);
552
+ if (skipTable) {
553
+ table = "other";
554
+ continue;
555
+ }
556
+ table = FEATURES_TABLE_HEADER.test(line) ? "features" : "other";
557
+ }
558
+ if (skipTable)
559
+ continue;
560
+ if (table === "features" && V2_KEY.test(line))
561
+ continue;
562
+ if (table === "root" && V2_DOTTED_KEY.test(line))
563
+ continue;
564
+ kept.push(line);
565
+ }
566
+ return kept.join("\n");
567
+ }
568
+ function hasAgentsMaxThreads(content) {
569
+ let table = "root";
570
+ for (const line of content.replace(/\r\n/g, "\n").split("\n")) {
571
+ if (/^\s*\[/.test(line)) {
572
+ table = /^\s*\[\s*(?:agents|"agents"|'agents')\s*\]\s*(?:#.*)?$/.test(line)
573
+ ? "agents"
574
+ : "other";
575
+ continue;
576
+ }
577
+ if (table === "agents" && /^\s*max_threads\s*=/.test(line))
578
+ return true;
579
+ if (table === "root" && /^\s*(?:agents|"agents"|'agents')\s*\.\s*max_threads\s*=/.test(line))
580
+ return true;
581
+ }
582
+ return false;
583
+ }
584
+ export function withMultiAgentV2(content, enabled, eol) {
585
+ let body = stripMultiAgentV2(content);
586
+ if (body && !body.endsWith("\n"))
587
+ body += "\n";
588
+ const output = body + `\n[features.multi_agent_v2]\nenabled = ${enabled}\n`;
589
+ return eol === "\n" ? output : output.replace(/\n/g, "\r\n");
590
+ }
344
591
  export function atomicWrite(path, data, mode = 0o600) {
345
592
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
346
593
  const temporary = `${path}.cpac.${process.pid}.${Date.now()}.${++atomicSequence}.tmp`;
@@ -508,6 +755,63 @@ export async function createLoopbackProxy(cpaUrl, apiKey, proxyId, port) {
508
755
  return { server, port: address.port };
509
756
  }
510
757
  const CLAUDE_ALIAS_PREFIX = "claude-cpac--";
758
+ // Claude Code accepts CLAUDE_CODE_AUTO_COMPACT_WINDOW in 100K–1M (binary-verified).
759
+ // A single global window cannot be per-model; 350K is opencodex's user-approved
760
+ // default — high enough to mark mid-size models (372K/500K), low enough that
761
+ // marking never outgrows a model's real window.
762
+ const ONE_MILLION = 1_000_000;
763
+ const COMPACT_WINDOW_DEFAULT = 350_000;
764
+ function claudeCompactWindow(maxContext) {
765
+ return maxContext > 200_000 ? COMPACT_WINDOW_DEFAULT : undefined;
766
+ }
767
+ // [1m] marking predicate (opencodex rule): native claude models manage their own
768
+ // marker; aliases mark when the real window is >= 1M, or when it clears 200K and
769
+ // can host the compact window (marking below it would trip API limits mid-session).
770
+ function shouldMarkOneMillion(id, window, compactWindow) {
771
+ if (id.startsWith("claude"))
772
+ return false;
773
+ if (window >= ONE_MILLION)
774
+ return true;
775
+ return compactWindow !== undefined && window > 200_000 && window >= compactWindow;
776
+ }
777
+ // Auto tier defaults: prefer claude-family models; within the pool match by
778
+ // family keyword first (opus/sonnet/haiku) so equal context windows don't
779
+ // collapse every slot onto the first catalog entry, then fall back to
780
+ // biggest-window (opus/sonnet) and smallest-window (haiku).
781
+ function defaultClaudeSlots(models) {
782
+ if (!models.length)
783
+ return undefined;
784
+ const claudeRows = models.filter((model) => model.id.startsWith("claude"));
785
+ const pool = claudeRows.length ? claudeRows : models;
786
+ const biggest = [...pool].sort((a, b) => b.window - a.window)[0];
787
+ const smallest = pool
788
+ .filter((model) => model.window > 0)
789
+ .sort((a, b) => a.window - b.window)[0];
790
+ const family = (keyword) => pool.find((model) => model.id.includes(keyword));
791
+ return {
792
+ opus: (family("opus") ?? biggest).id,
793
+ sonnet: (family("sonnet") ?? biggest).id,
794
+ haiku: (family("haiku") ?? smallest ?? biggest).id,
795
+ };
796
+ }
797
+ // Resolve the tier-model env values: config overrides win over catalog
798
+ // defaults; values are [1m]-marked per the predicate above (Claude Code
799
+ // strips the marker before the request leaves).
800
+ export function claudeTierSlots(override, models, compactWindow) {
801
+ const defaults = defaultClaudeSlots(models);
802
+ if (!defaults)
803
+ return undefined;
804
+ const windowById = new Map(models.map((model) => [model.id, model.window]));
805
+ const mark = (id) => !/\[1m\]$/i.test(id) &&
806
+ shouldMarkOneMillion(id, windowById.get(id) ?? 0, compactWindow)
807
+ ? `${id}[1m]`
808
+ : id;
809
+ return {
810
+ opus: mark(override?.opus ?? defaults.opus),
811
+ sonnet: mark(override?.sonnet ?? defaults.sonnet),
812
+ haiku: mark(override?.haiku ?? defaults.haiku),
813
+ };
814
+ }
511
815
  function catalogModelRows(document) {
512
816
  if (!objectValue(document))
513
817
  return undefined;
@@ -546,21 +850,38 @@ async function claudeModelList(cpaUrl, apiKey, response) {
546
850
  return;
547
851
  }
548
852
  // 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 = [];
853
+ // expose every other catalog model as claude-cpac--<id>. Append the [1m]
854
+ // marker when the model can host the auto-compact window — that marker is
855
+ // the only signal that lifts context accounting above the 200K default
856
+ // (Claude Code strips it before the request reaches the proxy).
857
+ const rowsWithWindow = [];
858
+ let maxContext = 0;
551
859
  for (const model of rows) {
552
860
  const id = catalogModelId(model);
553
861
  if (!id)
554
862
  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()
863
+ const window = typeof model.context_window === "number" && model.context_window > 0
864
+ ? Math.floor(model.context_window)
865
+ : 0;
866
+ if (window > maxContext)
867
+ maxContext = window;
868
+ rowsWithWindow.push({
869
+ id,
870
+ window,
871
+ alias: id.startsWith("claude") ? id : `${CLAUDE_ALIAS_PREFIX}${id}`,
872
+ display: typeof model.display_name === "string" && model.display_name.trim()
560
873
  ? model.display_name
561
874
  : id,
562
875
  });
563
876
  }
877
+ const compactWindow = claudeCompactWindow(maxContext);
878
+ const data = rowsWithWindow.map((row) => ({
879
+ type: "model",
880
+ id: shouldMarkOneMillion(row.id, row.window, compactWindow)
881
+ ? `${row.alias}[1m]`
882
+ : row.alias,
883
+ display_name: row.display,
884
+ }));
564
885
  response.writeHead(200, { "content-type": "application/json" });
565
886
  response.end(JSON.stringify({
566
887
  data,
@@ -595,7 +916,10 @@ export async function createClaudeProxy(cpaUrl, apiKey) {
595
916
  if (objectValue(parsed) &&
596
917
  typeof parsed.model === "string" &&
597
918
  parsed.model.startsWith(CLAUDE_ALIAS_PREFIX)) {
598
- parsed.model = parsed.model.slice(CLAUDE_ALIAS_PREFIX.length);
919
+ // Claude Code strips [1m] itself; keep the strip as a guard.
920
+ parsed.model = parsed.model
921
+ .slice(CLAUDE_ALIAS_PREFIX.length)
922
+ .replace(/\[(1|2)m\]$/i, "");
599
923
  body = Buffer.from(JSON.stringify(parsed));
600
924
  }
601
925
  }
@@ -831,7 +1155,7 @@ function proxyFingerprint(config, apiKey) {
831
1155
  .update(apiKey)
832
1156
  .digest("hex");
833
1157
  }
834
- export async function inject(config) {
1158
+ export async function inject(config, v2Off = false) {
835
1159
  const apiKey = process.env[config.api_key_env]?.trim();
836
1160
  if (!apiKey)
837
1161
  throw new CPACError(`environment variable ${config.api_key_env} is not set`);
@@ -867,6 +1191,8 @@ export async function inject(config) {
867
1191
  }
868
1192
  }
869
1193
  const catalog = await fetchCatalog(config.cpa_url, apiKey);
1194
+ if (config.spawn_models?.length)
1195
+ catalog.bytes = reorderCatalog(catalog.bytes, config.spawn_models);
870
1196
  const stateDirExisted = existsSync(config.state_dir);
871
1197
  if (!state &&
872
1198
  STATE_FILES.some((name) => existsSync(join(config.state_dir, name)))) {
@@ -898,6 +1224,11 @@ export async function inject(config) {
898
1224
  let injected;
899
1225
  try {
900
1226
  injected = buildCodexConfig(original, proxy.port, catalogPath);
1227
+ const injectedText = new TextDecoder("utf-8").decode(injected);
1228
+ injected = Buffer.from(withMultiAgentV2(injectedText, !v2Off, dominantEol(injectedText)));
1229
+ if (!v2Off && hasAgentsMaxThreads(injectedText)) {
1230
+ console.warn("warning: [agents] max_threads is set; codex refuses to start with multi_agent_v2 enabled, remove it");
1231
+ }
901
1232
  }
902
1233
  catch (error) {
903
1234
  if (startedProxy)
@@ -964,7 +1295,7 @@ export async function inject(config) {
964
1295
  catch {
965
1296
  console.warn("CPAC could not invalidate Codex models_cache.json; restart Codex App if its model list is stale.");
966
1297
  }
967
- console.log(`Injected ${catalog.modelCount} CPA models into ${config.codex_config} via http://127.0.0.1:${proxy.port}/v1`);
1298
+ 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
1299
  console.log("Restart Codex App if its running app-server still shows the old model list.");
969
1300
  }
970
1301
  export async function restore(config) {
@@ -1011,6 +1342,12 @@ export async function status(config) {
1011
1342
  .slice(0, 12);
1012
1343
  console.log(`codex: injected proxy=http://127.0.0.1:${proxy.port}/v1 sha256=${digest}`);
1013
1344
  }
1345
+ try {
1346
+ console.log(`codex multi-agent v2: ${multiAgentV2Enabled(readFileSync(config.codex_config, "utf8")) ? "on" : "off"}`);
1347
+ }
1348
+ catch {
1349
+ // Unreadable config: the injected line above already reports it.
1350
+ }
1014
1351
  }
1015
1352
  }
1016
1353
  console.log(`claude: ${keyConfigured ? "ready" : "not configured"}`);
@@ -1090,6 +1427,60 @@ async function guide(config) {
1090
1427
  console.log(`\nSaved ${config.api_key_env} to ${profile}. Open a new terminal or run:\n source ${profile}`);
1091
1428
  return 0;
1092
1429
  }
1430
+ export async function runClaudeModels(parsed) {
1431
+ if (parsed.pick.length) {
1432
+ const config = loadConfig(parsed.configPath);
1433
+ const apiKey = process.env[config.api_key_env]?.trim();
1434
+ if (!apiKey)
1435
+ throw new CPACError(`environment variable ${config.api_key_env} is not set`);
1436
+ const catalog = await fetchCatalog(config.cpa_url, apiKey);
1437
+ const document = JSON.parse(new TextDecoder().decode(catalog.bytes));
1438
+ const slugs = document.models.map((model) => model.slug);
1439
+ for (const slot of parsed.pick) {
1440
+ const [picked] = await checkboxPicker(`Select ${slot} model`, slugs, 1);
1441
+ if (!picked)
1442
+ throw new CPACError("no model selected");
1443
+ parsed.models[slot] = picked;
1444
+ }
1445
+ }
1446
+ if (!parsed.reset && !Object.keys(parsed.models).length) {
1447
+ const config = loadConfig(parsed.configPath);
1448
+ console.log(config.claude_models
1449
+ ? JSON.stringify(config.claude_models, null, 2)
1450
+ : "claude_models not set; cpac claude auto-picks tier models from the CPA catalog");
1451
+ return 0;
1452
+ }
1453
+ let raw = {};
1454
+ try {
1455
+ const file = JSON.parse(readFileSync(parsed.configPath, "utf8"));
1456
+ if (!objectValue(file))
1457
+ throw new CPACError("config must be a JSON object");
1458
+ raw = file;
1459
+ }
1460
+ catch (error) {
1461
+ if (error.code !== "ENOENT") {
1462
+ if (error instanceof CPACError)
1463
+ throw error;
1464
+ throw new CPACError(`cannot read config: ${error instanceof Error ? error.message : String(error)}`);
1465
+ }
1466
+ }
1467
+ if (parsed.reset) {
1468
+ delete raw.claude_models;
1469
+ }
1470
+ else {
1471
+ const existing = objectValue(raw.claude_models)
1472
+ ? { ...raw.claude_models }
1473
+ : {};
1474
+ Object.assign(existing, parsed.models);
1475
+ raw.claude_models = existing;
1476
+ }
1477
+ atomicWrite(parsed.configPath, Buffer.from(`${JSON.stringify(raw, null, 2)}\n`));
1478
+ loadConfig(parsed.configPath);
1479
+ console.log(parsed.reset
1480
+ ? "claude_models cleared"
1481
+ : `claude_models updated: ${JSON.stringify(raw.claude_models)}`);
1482
+ return 0;
1483
+ }
1093
1484
  export async function runClaude(config, args, executable = "claude") {
1094
1485
  const apiKey = await resolveApiKey(config.api_key_env);
1095
1486
  const proxy = await createClaudeProxy(config.cpa_url, apiKey);
@@ -1097,22 +1488,31 @@ export async function runClaude(config, args, executable = "claude") {
1097
1488
  proxy.server.closeAllConnections?.();
1098
1489
  proxy.server.close();
1099
1490
  };
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.
1491
+ // Gateway discovery only carries {id, display_name}; Claude Code accounts
1492
+ // claude-prefixed unknown models at 200K and ignores MAX_CONTEXT_TOKENS for
1493
+ // them. The [1m] marker on the alias (added by the models listing) plus the
1494
+ // auto-compact window is opencodex's working mechanism for bigger windows.
1103
1495
  let maxContext = 0;
1496
+ const catalogWindows = [];
1104
1497
  try {
1105
1498
  const catalog = await fetchCatalog(config.cpa_url, apiKey);
1106
1499
  const rows = catalogModelRows(JSON.parse(new TextDecoder("utf-8").decode(catalog.bytes))) ?? [];
1107
1500
  for (const row of rows) {
1108
- if (typeof row.context_window === "number" && row.context_window > maxContext) {
1109
- maxContext = row.context_window;
1110
- }
1501
+ const id = catalogModelId(row);
1502
+ const window = typeof row.context_window === "number" && row.context_window > 0
1503
+ ? Math.floor(row.context_window)
1504
+ : 0;
1505
+ if (id)
1506
+ catalogWindows.push({ id, window });
1507
+ if (window > maxContext)
1508
+ maxContext = window;
1111
1509
  }
1112
1510
  }
1113
1511
  catch {
1114
1512
  // keep the default window when the catalog is unreachable
1115
1513
  }
1514
+ const compactWindow = claudeCompactWindow(maxContext);
1515
+ const slots = claudeTierSlots(config.claude_models, catalogWindows, compactWindow);
1116
1516
  const env = {
1117
1517
  ...process.env,
1118
1518
  ANTHROPIC_BASE_URL: `http://127.0.0.1:${proxy.port}`,
@@ -1120,9 +1520,23 @@ export async function runClaude(config, args, executable = "claude") {
1120
1520
  CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1",
1121
1521
  CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: "1",
1122
1522
  };
1123
- if (maxContext > 0 && !(Number(process.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS) > 0)) {
1124
- env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(maxContext);
1523
+ if (compactWindow !== undefined && !(Number(process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW) > 0)) {
1524
+ env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(compactWindow);
1125
1525
  }
1526
+ const setSlot = (name, value) => {
1527
+ if (process.env[name] === undefined)
1528
+ env[name] = value;
1529
+ };
1530
+ if (slots) {
1531
+ // Route haiku/subagent tier traffic at catalog models instead of the
1532
+ // stock claude-haiku-* ids the gateway does not carry.
1533
+ setSlot("ANTHROPIC_DEFAULT_OPUS_MODEL", slots.opus);
1534
+ setSlot("ANTHROPIC_DEFAULT_SONNET_MODEL", slots.sonnet);
1535
+ setSlot("ANTHROPIC_DEFAULT_HAIKU_MODEL", slots.haiku);
1536
+ setSlot("ANTHROPIC_SMALL_FAST_MODEL", slots.haiku);
1537
+ }
1538
+ if (config.claude_models?.classifier)
1539
+ setSlot("CLAUDE_CODE_AUTO_MODE_MODEL", config.claude_models.classifier);
1126
1540
  delete env.ANTHROPIC_API_KEY;
1127
1541
  delete env.CLAUDE_CODE_USE_ANTHROPIC_AWS;
1128
1542
  delete env.CLAUDE_CODE_USE_BEDROCK;
@@ -1348,18 +1762,18 @@ const TARGETS = [
1348
1762
  },
1349
1763
  installed: (config) => {
1350
1764
  try {
1351
- return hasProviderTable(readFileSync(config.codex_config, "utf8"));
1765
+ return readFileSync(config.codex_config, "utf8").includes(MANAGED_MARKER);
1352
1766
  }
1353
1767
  catch {
1354
1768
  return false;
1355
1769
  }
1356
1770
  },
1357
- install: async (config, dryRun) => {
1771
+ install: async (config, dryRun, v2Off) => {
1358
1772
  if (dryRun) {
1359
1773
  console.log("would inject CPA catalog and loopback proxy into Codex");
1360
1774
  return;
1361
1775
  }
1362
- await inject(config);
1776
+ await inject(config, v2Off);
1363
1777
  },
1364
1778
  uninstall: async (config, dryRun) => {
1365
1779
  if (dryRun) {
@@ -1462,7 +1876,7 @@ export async function runInstall(config, requested, options) {
1462
1876
  console.log(`${target.id}: already installed; use --force to reinstall`);
1463
1877
  continue;
1464
1878
  }
1465
- await target.install(config, options.dryRun);
1879
+ await target.install(config, options.dryRun, options.v2Off);
1466
1880
  }
1467
1881
  return 0;
1468
1882
  }
@@ -1531,10 +1945,11 @@ function usage() {
1531
1945
  " cpac <inject|status|restore> [--config PATH]",
1532
1946
  " cpac proxy [--config PATH]",
1533
1947
  " cpac claude [--config PATH] [--] [claude args...]",
1948
+ " cpac claude-models [--opus [M]] [--sonnet [M]] [--haiku [M]] [--classifier [M]] [--reset] [--config PATH]",
1534
1949
  " cpac pi <install|uninstall|status> [--config PATH]",
1535
1950
  " cpac kimi <install|uninstall|status> [--config PATH]",
1536
1951
  " cpac detect [--json] [--home PATH]",
1537
- " cpac install [--target codex,claude,pi,kimi] [--all] [--dry-run] [--force] [--home PATH]",
1952
+ " cpac install [--target codex,claude,pi,kimi] [--all] [--dry-run] [--force] [--v2_off] [--v2_models] [--home PATH]",
1538
1953
  " cpac uninstall [--target codex,claude,pi,kimi] [--all] [--dry-run] [--home PATH]",
1539
1954
  " cpac upgrade [--check]",
1540
1955
  " cpac version",
@@ -1558,6 +1973,8 @@ function parseArgs(args) {
1558
1973
  dryRun: false,
1559
1974
  force: false,
1560
1975
  check: false,
1976
+ v2Off: false,
1977
+ v2Models: false,
1561
1978
  };
1562
1979
  for (let index = 1; index < args.length; index++) {
1563
1980
  const arg = args[index];
@@ -1592,6 +2009,12 @@ function parseArgs(args) {
1592
2009
  throw new CPACError("--config requires a path");
1593
2010
  parsed.configPath = resolve(expandUserPath(value));
1594
2011
  }
2012
+ else if (arg === "--v2_off" || arg === "--v2-off") {
2013
+ parsed.v2Off = true;
2014
+ }
2015
+ else if (arg === "--v2_models" || arg === "--v2-models") {
2016
+ parsed.v2Models = true;
2017
+ }
1595
2018
  else {
1596
2019
  throw new CPACError(`unknown option: ${arg}`);
1597
2020
  }
@@ -1611,6 +2034,62 @@ function parseArgs(args) {
1611
2034
  const action = args[index] || "status";
1612
2035
  return { command: args[0], configPath, action };
1613
2036
  }
2037
+ if (args[0] === "claude-models") {
2038
+ if (args.includes("-h") || args.includes("--help")) {
2039
+ console.log([
2040
+ "Usage: cpac claude-models [--opus [M]] [--sonnet [M]] [--haiku [M]] [--classifier [M]] [--reset] [--config PATH]",
2041
+ "",
2042
+ "Sets the model Claude Code uses per task tier. Saved to the config file",
2043
+ "and applied on every `cpac claude` launch:",
2044
+ " --opus heavy reasoning / planning fallback tier (ANTHROPIC_DEFAULT_OPUS_MODEL)",
2045
+ " --sonnet main conversation default tier (ANTHROPIC_DEFAULT_SONNET_MODEL)",
2046
+ " --haiku high-frequency small tasks: titles, summaries, background",
2047
+ " (ANTHROPIC_DEFAULT_HAIKU_MODEL + ANTHROPIC_SMALL_FAST_MODEL)",
2048
+ " --classifier auto-mode approval classifier (CLAUDE_CODE_AUTO_MODE_MODEL);",
2049
+ " unset follows Claude Code's built-in default",
2050
+ "",
2051
+ "A slot without a value opens an arrow-key picker over the CPA catalog;",
2052
+ "--reset clears all overrides back to catalog auto-selection.",
2053
+ ].join("\n"));
2054
+ return null;
2055
+ }
2056
+ let configPath = defaultConfigPath();
2057
+ const models = {};
2058
+ const pick = [];
2059
+ let reset = false;
2060
+ for (let index = 1; index < args.length; index += 1) {
2061
+ const arg = args[index];
2062
+ if (arg === "--config") {
2063
+ const value = args[++index];
2064
+ if (!value)
2065
+ throw new CPACError("--config requires a path");
2066
+ configPath = resolve(expandUserPath(value));
2067
+ }
2068
+ else if (arg === "--reset") {
2069
+ reset = true;
2070
+ }
2071
+ else if (arg === "--opus" ||
2072
+ arg === "--sonnet" ||
2073
+ arg === "--haiku" ||
2074
+ arg === "--classifier") {
2075
+ const slot = arg.slice(2);
2076
+ const value = args[index + 1];
2077
+ if (value !== undefined && !value.startsWith("-")) {
2078
+ index += 1;
2079
+ if (!value.trim())
2080
+ throw new CPACError(`${arg} requires a model id`);
2081
+ models[slot] = value.trim();
2082
+ }
2083
+ else {
2084
+ pick.push(slot);
2085
+ }
2086
+ }
2087
+ else {
2088
+ throw new CPACError(`unknown option: ${arg}`);
2089
+ }
2090
+ }
2091
+ return { command: "claude-models", configPath, models, pick, reset };
2092
+ }
1614
2093
  if (args[0] === "claude") {
1615
2094
  let configPath = defaultConfigPath();
1616
2095
  let index = 1;
@@ -1671,12 +2150,21 @@ export async function main(args = process.argv.slice(2)) {
1671
2150
  return await guide(config);
1672
2151
  if (parsed.command === "detect")
1673
2152
  return await runDetect(config, parsed.json);
1674
- if (parsed.command === "install")
1675
- return await runInstall(config, parsed.targets, {
2153
+ if (parsed.command === "install") {
2154
+ let installConfig = config;
2155
+ if (parsed.v2Models) {
2156
+ const picked = await pickSpawnModels(config);
2157
+ saveSpawnModels(parsed.configPath, picked);
2158
+ console.log(`spawn_models saved to ${parsed.configPath}: ${picked.join(", ")}`);
2159
+ installConfig = { ...config, spawn_models: picked };
2160
+ }
2161
+ return await runInstall(installConfig, parsed.targets, {
1676
2162
  all: parsed.all,
1677
2163
  dryRun: parsed.dryRun,
1678
2164
  force: parsed.force,
2165
+ v2Off: parsed.v2Off,
1679
2166
  });
2167
+ }
1680
2168
  if (parsed.command === "uninstall")
1681
2169
  return await runUninstall(config, parsed.targets, {
1682
2170
  all: parsed.all,
@@ -1690,6 +2178,8 @@ export async function main(args = process.argv.slice(2)) {
1690
2178
  }
1691
2179
  if (parsed.command === "claude")
1692
2180
  return await runClaude(config, parsed.args);
2181
+ if (parsed.command === "claude-models")
2182
+ return await runClaudeModels(parsed);
1693
2183
  if (parsed.command === "proxy")
1694
2184
  return await runProxy(config);
1695
2185
  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.9",
4
4
  "description": "Connect Codex and Claude Code to a remote CLIProxyAPI gateway",
5
5
  "type": "module",
6
6
  "bin": {