@yhong91/cpac 0.1.26 → 0.1.28

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.
@@ -0,0 +1,175 @@
1
+ import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { catalogModelId, catalogModelRows, fetchCatalog, readState, stateProxy, } from "../config.js";
5
+ import { proxyIsHealthy } from "../proxy.js";
6
+ import { CPACError, atomicWrite, ensureCpacBackup, expandUserPath, objectValue, resolveApiKey, tomlString, tomlStringArray, } from "../util.js";
7
+ export function kimiConfigPath() {
8
+ const home = process.env.KIMI_CODE_HOME?.trim() || join(homedir(), ".kimi-code");
9
+ return join(expandUserPath(home), "config.toml");
10
+ }
11
+ const KIMI_BLOCK_START = "# >>> CPAC Kimi >>>";
12
+ const KIMI_BLOCK_END = "# <<< CPAC Kimi <<<";
13
+ const kimiBlockRegex = new RegExp(`${KIMI_BLOCK_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${KIMI_BLOCK_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\n?`);
14
+ export function isKimiConfigInstalled() {
15
+ try {
16
+ const content = readFileSync(kimiConfigPath(), "utf8");
17
+ return (kimiBlockRegex.test(content) || /^\s*\[providers\.cpac\]\s*$/m.test(content));
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ function isCpacTableHeader(line) {
24
+ const header = line.match(/^\[([^\]]+)\]\s*$/);
25
+ if (!header)
26
+ return false;
27
+ const name = header[1];
28
+ return (name === "providers.cpac" ||
29
+ name.startsWith("providers.cpac.") ||
30
+ /^models\.(?:"cpac\/|'cpac\/)/.test(name));
31
+ }
32
+ // Kimi may rewrite config.toml and drop our comment markers, leaving the
33
+ // [providers.cpac] / [models."cpac/..."] tables behind. A later install would
34
+ // then append a second copy and make the file invalid TOML.
35
+ function stripOrphanCpacTables(content) {
36
+ const eol = content.includes("\r\n") ? "\r\n" : "\n";
37
+ const out = [];
38
+ let skipping = false;
39
+ for (const line of content.split(/\r?\n/)) {
40
+ if (/^\[[^\]]+\]\s*$/.test(line))
41
+ skipping = isCpacTableHeader(line);
42
+ if (!skipping)
43
+ out.push(line);
44
+ }
45
+ return out.join(eol).replace(/(?:\r?\n){3,}/g, `${eol}${eol}`);
46
+ }
47
+ function writeKimiBlock(path, block) {
48
+ let content = existsSync(path) ? readFileSync(path, "utf8") : "";
49
+ content = content.replace(kimiBlockRegex, "");
50
+ // A truncated managed block (start marker without the end marker) leaves its
51
+ // tables behind; appending again would duplicate [providers.cpac], make the
52
+ // file invalid TOML, and block `kimi login`. The block is always appended
53
+ // last, so dropping from an orphaned start marker to EOF is safe.
54
+ const orphan = content.indexOf(KIMI_BLOCK_START);
55
+ if (orphan !== -1)
56
+ content = content.slice(0, orphan);
57
+ content = stripOrphanCpacTables(content);
58
+ if (block) {
59
+ content = `${content}${content && !content.endsWith("\n") ? "\n" : ""}${block}\n`;
60
+ }
61
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
62
+ const mode = existsSync(path) ? statSync(path).mode & 0o7777 : 0o600;
63
+ atomicWrite(path, Buffer.from(content), mode);
64
+ }
65
+ const KIMI_REASONING_EFFORTS = new Set([
66
+ "minimal",
67
+ "low",
68
+ "medium",
69
+ "high",
70
+ "xhigh",
71
+ "max",
72
+ "ultra",
73
+ ]);
74
+ function kimiSupportEfforts(row) {
75
+ const levels = row.supported_reasoning_levels ??
76
+ row.reasoning_effort_levels ??
77
+ row.reasoning_levels;
78
+ if (!Array.isArray(levels))
79
+ return [];
80
+ const seen = new Set();
81
+ const efforts = [];
82
+ for (const level of levels) {
83
+ const effort = typeof level === "string"
84
+ ? level.toLowerCase()
85
+ : objectValue(level) && typeof level.effort === "string"
86
+ ? level.effort.toLowerCase()
87
+ : undefined;
88
+ if (!effort || !KIMI_REASONING_EFFORTS.has(effort) || seen.has(effort))
89
+ continue;
90
+ seen.add(effort);
91
+ efforts.push(effort);
92
+ }
93
+ return efforts;
94
+ }
95
+ function kimiInputHasImage(row) {
96
+ const modalities = row.input_modalities;
97
+ if (!Array.isArray(modalities))
98
+ return true;
99
+ return modalities.some((value) => value === "image");
100
+ }
101
+ export async function installKimiConfig(config) {
102
+ const apiKey = await resolveApiKey(config.api_key_env);
103
+ const catalog = await fetchCatalog(config.cpa_url, apiKey);
104
+ let document;
105
+ try {
106
+ document = JSON.parse(new TextDecoder("utf-8").decode(catalog.bytes));
107
+ }
108
+ catch {
109
+ throw new CPACError("invalid CPA catalog");
110
+ }
111
+ const rows = catalogModelRows(document) ?? [];
112
+ if (rows.length === 0)
113
+ throw new CPACError("CPA catalog contains no models");
114
+ const state = readState(config.state_dir);
115
+ const recorded = state ? stateProxy(state) : null;
116
+ const port = recorded?.port ?? config.codex_proxy_port;
117
+ if (!port) {
118
+ throw new CPACError("loopback proxy port unknown; run cpac inject first");
119
+ }
120
+ const lines = [
121
+ KIMI_BLOCK_START,
122
+ "[providers.cpac]",
123
+ 'type = "openai"',
124
+ `base_url = "http://127.0.0.1:${port}/v1"`,
125
+ '# Placeholder: the CPAC loopback proxy replaces it with the CPA key; run "cpac inject" to start the proxy.',
126
+ 'api_key = "cpac-loopback"',
127
+ ];
128
+ for (const row of rows) {
129
+ const slug = catalogModelId(row);
130
+ if (!slug)
131
+ continue;
132
+ // ponytail: Kimi requires max_context_size; default 200000 when the catalog omits it.
133
+ const context = typeof row.context_window === "number" && row.context_window > 0
134
+ ? Math.floor(row.context_window)
135
+ : 200000;
136
+ const efforts = kimiSupportEfforts(row);
137
+ const capabilities = [
138
+ ...(efforts.length > 0 ? ["thinking"] : []),
139
+ "tool_use",
140
+ ...(kimiInputHasImage(row) ? ["image_in"] : []),
141
+ ];
142
+ lines.push("", `[models."cpac/${slug}"]`, 'provider = "cpac"', `model = ${tomlString(slug)}`, `max_context_size = ${context}`, `capabilities = ${tomlStringArray(capabilities)}`);
143
+ if (typeof row.display_name === "string" && row.display_name.trim()) {
144
+ lines.push(`display_name = ${tomlString(row.display_name)}`);
145
+ }
146
+ if (efforts.length > 0) {
147
+ lines.push(`support_efforts = ${tomlStringArray(efforts)}`);
148
+ const rawDefaultEffort = (typeof row.default_reasoning_level === "string" &&
149
+ row.default_reasoning_level) ||
150
+ (typeof row.default_reasoning_effort === "string" &&
151
+ row.default_reasoning_effort) ||
152
+ (typeof row.default_effort === "string" && row.default_effort);
153
+ const defaultEffort = rawDefaultEffort && efforts.includes(rawDefaultEffort.toLowerCase())
154
+ ? rawDefaultEffort.toLowerCase()
155
+ : undefined;
156
+ if (defaultEffort)
157
+ lines.push(`default_effort = ${tomlString(defaultEffort)}`);
158
+ }
159
+ }
160
+ lines.push(KIMI_BLOCK_END);
161
+ const target = kimiConfigPath();
162
+ ensureCpacBackup(target);
163
+ writeKimiBlock(target, lines.join("\n"));
164
+ console.log(`Installed Kimi Code provider config: ${target}`);
165
+ if (!recorded || !(await proxyIsHealthy(recorded))) {
166
+ console.log("Loopback proxy is not running; run: cpac inject");
167
+ }
168
+ }
169
+ export async function uninstallKimiConfig() {
170
+ const target = kimiConfigPath();
171
+ if (!isKimiConfigInstalled())
172
+ throw new CPACError("Kimi Code config is not installed");
173
+ writeKimiBlock(target, null);
174
+ console.log(`Removed Kimi Code provider config: ${target}`);
175
+ }
@@ -0,0 +1,59 @@
1
+ import { spawn } from "node:child_process";
2
+ import { apiBase, catalogModelId, fetchCatalog, } from "../config.js";
3
+ import { CPACError } from "../util.js";
4
+ const OPENCODE_OUTPUT_BUDGET = 32_000;
5
+ // Ephemeral takeover: inline a CPA provider via OPENCODE_CONFIG_CONTENT so no
6
+ // opencode config file is touched; the session ends with zero residue.
7
+ export async function runOpencode(config, args, executable = "opencode") {
8
+ const apiKey = process.env[config.api_key_env]?.trim();
9
+ if (!apiKey)
10
+ throw new CPACError(`environment variable ${config.api_key_env} is not set`);
11
+ const catalog = await fetchCatalog(config.cpa_url, apiKey);
12
+ let document;
13
+ try {
14
+ document = JSON.parse(new TextDecoder().decode(catalog.bytes));
15
+ }
16
+ catch {
17
+ throw new CPACError("invalid CPA catalog");
18
+ }
19
+ const models = {};
20
+ for (const row of document.models) {
21
+ const id = catalogModelId(row);
22
+ if (!id)
23
+ continue;
24
+ const window = typeof row.context_window === "number" && row.context_window > 0
25
+ ? Math.floor(row.context_window)
26
+ : 0;
27
+ models[id] =
28
+ window > 0
29
+ ? {
30
+ limit: {
31
+ context: window,
32
+ output: Math.min(OPENCODE_OUTPUT_BUDGET, window),
33
+ },
34
+ }
35
+ : {};
36
+ }
37
+ const content = JSON.stringify({
38
+ provider: {
39
+ cpac: {
40
+ npm: "@ai-sdk/openai-compatible",
41
+ options: { baseURL: apiBase(config.cpa_url), apiKey },
42
+ models,
43
+ },
44
+ },
45
+ });
46
+ const env = {
47
+ ...process.env,
48
+ OPENCODE_CONFIG_CONTENT: content,
49
+ };
50
+ return await new Promise((resolve, reject) => {
51
+ const child = spawn(executable, args, { env, stdio: "inherit" });
52
+ child.once("error", (error) => {
53
+ reject(new CPACError(error instanceof Error && "code" in error && error.code === "ENOENT"
54
+ ? `${executable} not found`
55
+ : `cannot start ${executable}: ${error.message}`));
56
+ });
57
+ child.once("close", (code) => resolve(code ?? 1));
58
+ });
59
+ }
@@ -0,0 +1,32 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { CPACError, atomicWrite, ensureCpacBackup, expandUserPath, } from "../util.js";
6
+ export function piExtensionsDir() {
7
+ return join(expandUserPath(process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent")), "extensions");
8
+ }
9
+ export function isPiExtensionInstalled() {
10
+ return existsSync(join(piExtensionsDir(), "cpac.ts"));
11
+ }
12
+ function piTemplatePath() {
13
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "pi-extension.template");
14
+ }
15
+ function piExtensionContent(cpaUrl) {
16
+ return readFileSync(piTemplatePath(), "utf8").replace("__CPA_URL__", cpaUrl);
17
+ }
18
+ export async function installPiExtension(config) {
19
+ const dir = piExtensionsDir();
20
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
21
+ const target = join(dir, "cpac.ts");
22
+ ensureCpacBackup(target);
23
+ atomicWrite(target, Buffer.from(piExtensionContent(config.cpa_url)), 0o644);
24
+ console.log(`Installed Pi extension: ${target}`);
25
+ }
26
+ export async function uninstallPiExtension() {
27
+ const target = join(piExtensionsDir(), "cpac.ts");
28
+ if (!existsSync(target))
29
+ throw new CPACError("Pi extension is not installed");
30
+ unlinkSync(target);
31
+ console.log(`Removed Pi extension: ${target}`);
32
+ }
package/dist/util.js CHANGED
@@ -1,4 +1,4 @@
1
- import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
1
+ import { chmodSync, closeSync, copyFileSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join, parse } from "node:path";
4
4
  import { createInterface } from "node:readline/promises";
@@ -9,6 +9,14 @@ export class CPACError extends Error {
9
9
  export function objectValue(value) {
10
10
  return typeof value === "object" && value !== null && !Array.isArray(value);
11
11
  }
12
+ export function ensureCpacBackup(target) {
13
+ if (!existsSync(target))
14
+ return;
15
+ const backup = `${target}.cpac-backup`;
16
+ if (existsSync(backup))
17
+ return;
18
+ copyFileSync(target, backup);
19
+ }
12
20
  export function expandUserPath(value) {
13
21
  if (value === "~")
14
22
  return homedir();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yhong91/cpac",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "description": "Connect Codex and Claude Code to a remote CLIProxyAPI gateway",
5
5
  "type": "module",
6
6
  "bin": {