@yhong91/cpac 0.1.3 → 0.1.4

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
@@ -101,16 +101,20 @@ cpac claude --model claude-sonnet-4-5
101
101
  cpac claude -- -p "检查当前项目"
102
102
  ```
103
103
 
104
- `cpac claude` 会为 Claude Code 子进程设置:
104
+ `cpac claude` 会在本地起一个临时 loopback 代理,并为 Claude Code 子进程设置:
105
105
 
106
106
  ```text
107
- ANTHROPIC_BASE_URL=<CPA 地址>
107
+ ANTHROPIC_BASE_URL=http://127.0.0.1:<临时端口>
108
108
  ANTHROPIC_AUTH_TOKEN=<CPA_API_KEY>
109
+ CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1
110
+ CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST=1
109
111
  ```
110
112
 
111
113
  同时清除可能覆盖网关选择的 `ANTHROPIC_API_KEY` 和 Claude Code 云 provider 环境变量。它不会修改 `~/.claude/`,并原样返回 Claude Code 的退出码。
112
114
 
113
- > CPA 服务端必须支持 Claude Code 使用的 Anthropic Messages API(`/v1/messages`)。Claude 路径直接连接远端 CPA;CPAC 不负责协议转换。
115
+ 代理开启 Claude Code gateway model discovery:`/model` 选择器会列出 CPA 目录里的全部模型。claude 系模型直接使用原 id;其它模型以 `claude-cpac--<模型名>` 别名出现(例如 `claude-cpac--gpt-5.6-sol`),请求发出时由代理还原为真实模型名。
116
+
117
+ > CPA 服务端必须支持 Claude Code 使用的 Anthropic Messages API(`/v1/messages`)。
114
118
 
115
119
  ### Codex
116
120
 
package/dist/cpac.js CHANGED
@@ -507,6 +507,134 @@ export async function createLoopbackProxy(cpaUrl, apiKey, proxyId, port) {
507
507
  }
508
508
  return { server, port: address.port };
509
509
  }
510
+ const CLAUDE_ALIAS_PREFIX = "claude-cpac--";
511
+ function catalogModelRows(document) {
512
+ if (!objectValue(document))
513
+ return undefined;
514
+ const source = Array.isArray(document.models)
515
+ ? document.models
516
+ : Array.isArray(document.data)
517
+ ? document.data
518
+ : undefined;
519
+ if (!source)
520
+ return undefined;
521
+ return source.filter(objectValue);
522
+ }
523
+ function catalogModelId(model) {
524
+ if (typeof model.slug === "string" && model.slug.trim())
525
+ return model.slug.trim();
526
+ if (typeof model.id === "string" && model.id.trim())
527
+ return model.id.trim();
528
+ return undefined;
529
+ }
530
+ async function claudeModelList(cpaUrl, apiKey, response) {
531
+ let rows;
532
+ try {
533
+ const catalog = await fetch(`${apiBase(cpaUrl)}/models?client_version=1`, {
534
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
535
+ signal: AbortSignal.timeout(20_000),
536
+ });
537
+ if (catalog.ok)
538
+ rows = catalogModelRows(await catalog.json());
539
+ }
540
+ catch {
541
+ rows = undefined;
542
+ }
543
+ if (!rows) {
544
+ response.writeHead(502, { "content-type": "application/json" });
545
+ response.end(JSON.stringify({ error: "CPA catalog request failed" }));
546
+ return;
547
+ }
548
+ // 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 = [];
551
+ for (const model of rows) {
552
+ const id = catalogModelId(model);
553
+ if (!id)
554
+ 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()
560
+ ? model.display_name
561
+ : id,
562
+ });
563
+ }
564
+ response.writeHead(200, { "content-type": "application/json" });
565
+ response.end(JSON.stringify({
566
+ data,
567
+ has_more: false,
568
+ first_id: data.length > 0 ? data[0].id : null,
569
+ last_id: data.length > 0 ? data[data.length - 1].id : null,
570
+ }));
571
+ }
572
+ export async function createClaudeProxy(cpaUrl, apiKey) {
573
+ const server = createServer((request, response) => {
574
+ const requestUrl = request.url ?? "/";
575
+ if (request.method === "GET" && requestUrl.startsWith("/v1/models")) {
576
+ void claudeModelList(cpaUrl, apiKey, response);
577
+ return;
578
+ }
579
+ let target;
580
+ try {
581
+ target = upstreamUrl(cpaUrl, requestUrl);
582
+ }
583
+ catch {
584
+ response.writeHead(400).end("invalid request URL");
585
+ return;
586
+ }
587
+ const chunks = [];
588
+ request.on("data", (chunk) => chunks.push(chunk));
589
+ request.once("error", () => response.destroy());
590
+ request.once("end", () => {
591
+ let body = Buffer.concat(chunks);
592
+ if (body.length > 0) {
593
+ try {
594
+ const parsed = JSON.parse(body.toString("utf8"));
595
+ if (objectValue(parsed) &&
596
+ typeof parsed.model === "string" &&
597
+ parsed.model.startsWith(CLAUDE_ALIAS_PREFIX)) {
598
+ parsed.model = parsed.model.slice(CLAUDE_ALIAS_PREFIX.length);
599
+ body = Buffer.from(JSON.stringify(parsed));
600
+ }
601
+ }
602
+ catch {
603
+ // not JSON; forward unchanged
604
+ }
605
+ }
606
+ const headers = proxyHeaders(request.headers, apiKey);
607
+ headers["content-length"] = String(body.length);
608
+ const send = target.protocol === "https:" ? httpsRequest : httpRequest;
609
+ const upstream = send(target, { method: request.method, headers }, (upstreamResponse) => {
610
+ response.writeHead(upstreamResponse.statusCode ?? 502, responseHeaders(upstreamResponse.headers));
611
+ upstreamResponse.pipe(response);
612
+ });
613
+ upstream.once("error", () => {
614
+ if (!response.headersSent) {
615
+ response.writeHead(502, { "content-type": "application/json" });
616
+ }
617
+ if (!response.writableEnded)
618
+ response.end(JSON.stringify({ error: "CPA upstream request failed" }));
619
+ });
620
+ upstream.end(body);
621
+ });
622
+ });
623
+ server.on("clientError", (_error, socket) => socket.destroy());
624
+ await new Promise((resolveListen, rejectListen) => {
625
+ server.once("error", rejectListen);
626
+ server.listen(0, "127.0.0.1", () => {
627
+ server.off("error", rejectListen);
628
+ resolveListen();
629
+ });
630
+ });
631
+ const address = server.address();
632
+ if (!address || typeof address === "string") {
633
+ server.close();
634
+ throw new CPACError("cannot determine Claude proxy port");
635
+ }
636
+ return { server, port: address.port };
637
+ }
510
638
  async function runProxyChild(port) {
511
639
  const cpaUrl = process.env.CPAC_PROXY_UPSTREAM;
512
640
  const apiKey = process.env.CPAC_PROXY_API_KEY;
@@ -895,9 +1023,6 @@ export async function status(config) {
895
1023
  return 2;
896
1024
  return 0;
897
1025
  }
898
- function claudeBaseUrl(cpaUrl) {
899
- return cpaUrl.replace(/\/v1$/, "");
900
- }
901
1026
  async function promptSecret(name) {
902
1027
  if (!process.stdin.isTTY || !process.stderr.isTTY) {
903
1028
  throw new CPACError(`environment variable ${name} is not set; run: export ${name}="..."`);
@@ -965,10 +1090,17 @@ async function guide(config) {
965
1090
  }
966
1091
  export async function runClaude(config, args, executable = "claude") {
967
1092
  const apiKey = await resolveApiKey(config.api_key_env);
1093
+ const proxy = await createClaudeProxy(config.cpa_url, apiKey);
1094
+ const stopProxy = () => {
1095
+ proxy.server.closeAllConnections?.();
1096
+ proxy.server.close();
1097
+ };
968
1098
  const env = {
969
1099
  ...process.env,
970
- ANTHROPIC_BASE_URL: claudeBaseUrl(config.cpa_url),
1100
+ ANTHROPIC_BASE_URL: `http://127.0.0.1:${proxy.port}`,
971
1101
  ANTHROPIC_AUTH_TOKEN: apiKey,
1102
+ CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1",
1103
+ CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: "1",
972
1104
  };
973
1105
  delete env.ANTHROPIC_API_KEY;
974
1106
  delete env.CLAUDE_CODE_USE_ANTHROPIC_AWS;
@@ -978,11 +1110,15 @@ export async function runClaude(config, args, executable = "claude") {
978
1110
  return await new Promise((resolve, reject) => {
979
1111
  const child = spawn(executable, args, { env, stdio: "inherit" });
980
1112
  child.once("error", (error) => {
1113
+ stopProxy();
981
1114
  reject(new CPACError(error instanceof Error && "code" in error && error.code === "ENOENT"
982
1115
  ? `${executable} not found`
983
1116
  : `cannot start ${executable}: ${error.message}`));
984
1117
  });
985
- child.once("close", (code) => resolve(code ?? 1));
1118
+ child.once("close", (code) => {
1119
+ stopProxy();
1120
+ resolve(code ?? 1);
1121
+ });
986
1122
  });
987
1123
  }
988
1124
  export async function runProxy(config) {
@@ -40,20 +40,44 @@ function intField(value) {
40
40
 
41
41
  export default async function (pi) {
42
42
  const apiKey = (process.env.CPA_API_KEY || "").trim();
43
- if (!apiKey) return;
43
+ if (!apiKey) {
44
+ console.error("[cpac] CPA_API_KEY not set; skipping CPA providers");
45
+ return;
46
+ }
44
47
  let payload;
45
48
  try {
46
49
  const res = await fetch(CPA + "/v1/models?client_version=1", {
47
50
  headers: { Authorization: "Bearer " + apiKey },
48
51
  signal: AbortSignal.timeout(10000),
49
52
  });
50
- if (!res.ok) return;
53
+ if (!res.ok) {
54
+ console.error("[cpac] CPA models request failed: HTTP " + res.status);
55
+ return;
56
+ }
51
57
  payload = await res.json();
52
- } catch { return; }
53
- if (!payload || typeof payload !== "object" || !Array.isArray(payload.models)) return;
54
- const rows = payload.models.filter(function (m) {
55
- return m && typeof m === "object" && typeof m.slug === "string";
56
- });
58
+ } catch (error) {
59
+ console.error("[cpac] CPA models request failed: " + (error && error.message || error));
60
+ return;
61
+ }
62
+ const source = payload && Array.isArray(payload.models)
63
+ ? payload.models
64
+ : payload && Array.isArray(payload.data) ? payload.data : null;
65
+ if (!source) {
66
+ console.error("[cpac] CPA models response has no models list");
67
+ return;
68
+ }
69
+ const rows = [];
70
+ for (const m of source) {
71
+ if (!m || typeof m !== "object") continue;
72
+ const slug = typeof m.slug === "string" && m.slug.trim()
73
+ ? m.slug.trim()
74
+ : typeof m.id === "string" && m.id.trim() ? m.id.trim() : null;
75
+ if (slug) rows.push({ slug: slug, display_name: m.display_name, supported_reasoning_levels: m.supported_reasoning_levels, context_window: m.context_window });
76
+ }
77
+ if (rows.length === 0) {
78
+ console.error("[cpac] CPA models response has no usable models");
79
+ return;
80
+ }
57
81
  const groups = new Map();
58
82
  for (const m of rows) {
59
83
  const name = groupName(vendorFor(m.slug));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yhong91/cpac",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Connect Codex and Claude Code to a remote CLIProxyAPI gateway",
5
5
  "type": "module",
6
6
  "bin": {