@yhong91/cpac 0.1.25 → 0.1.27

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,378 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { PROVIDER, STATE_FILES, cleanupStateFiles, fetchCatalog, liftContextWindows, originalBytes, proxyFingerprint, readState, reorderCatalog, stateBytes, stateProxy, } from "../config.js";
4
+ import { proxyIsHealthy, startProxyProcess, stopProxyProcess, } from "../proxy.js";
5
+ import { CPACError, atomicWrite, dominantEol, tomlString } from "../util.js";
6
+ const MODEL_PROVIDER_KEY = /^\s*(?:model_provider|"model_provider"|'model_provider')\s*=/;
7
+ const MODEL_CATALOG_KEY = /^\s*(?:model_catalog_json|"model_catalog_json"|'model_catalog_json')\s*=/;
8
+ const OPENAI_BASE_URL_KEY = /^\s*(?:openai_base_url|"openai_base_url"|'openai_base_url')\s*=/;
9
+ const ROOT_KEY = new RegExp(`(?:${MODEL_PROVIDER_KEY.source}|${MODEL_CATALOG_KEY.source}|${OPENAI_BASE_URL_KEY.source})`);
10
+ export const MANAGED_MARKER = "# CPAC managed; run CPAC 'restore' to restore the original file.";
11
+ function providerPatterns(provider = PROVIDER) {
12
+ const escaped = provider.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
13
+ const modelProviders = `(?:model_providers|"model_providers"|'model_providers')`;
14
+ const providerToken = `(?:${escaped}|"${escaped}"|'${escaped}')`;
15
+ return {
16
+ header: new RegExp(`^\\s*\\[\\s*${modelProviders}\\s*\\.\\s*${providerToken}\\s*\\]\\s*(?:#.*)?$`),
17
+ arrayHeader: new RegExp(`^\\s*\\[\\[\\s*${modelProviders}\\s*\\.\\s*${providerToken}\\s*\\]\\]\\s*(?:#.*)?$`),
18
+ parentHeader: new RegExp(`^\\s*\\[\\s*${modelProviders}\\s*\\]\\s*(?:#.*)?$`),
19
+ inlineKey: new RegExp(`^\\s*${providerToken}\\s*=`),
20
+ dottedKey: new RegExp(`^\\s*${modelProviders}\\s*\\.\\s*${providerToken}\\s*=`),
21
+ };
22
+ }
23
+ function providerHeader(provider = PROVIDER) {
24
+ return providerPatterns(provider).header;
25
+ }
26
+ export function hasProviderTable(content, provider = PROVIDER) {
27
+ const patterns = providerPatterns(provider);
28
+ let inRoot = true;
29
+ let inModelProviders = false;
30
+ for (const line of content.split(/\r?\n/)) {
31
+ if (patterns.header.test(line) || patterns.arrayHeader.test(line))
32
+ return true;
33
+ if (/^\s*\[/.test(line)) {
34
+ inRoot = false;
35
+ inModelProviders = patterns.parentHeader.test(line);
36
+ continue;
37
+ }
38
+ if ((inModelProviders && patterns.inlineKey.test(line)) ||
39
+ (inRoot && patterns.dottedKey.test(line)))
40
+ return true;
41
+ }
42
+ return false;
43
+ }
44
+ function stripManagedConfig(content) {
45
+ let text;
46
+ try {
47
+ text = new TextDecoder("utf-8", { fatal: true }).decode(content);
48
+ }
49
+ catch {
50
+ throw new CPACError("Codex config must be UTF-8");
51
+ }
52
+ const eol = dominantEol(text);
53
+ const header = providerHeader();
54
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
55
+ let inRoot = true;
56
+ let inProvider = false;
57
+ let managedRootKeys = 0;
58
+ const kept = [];
59
+ for (const line of lines) {
60
+ if (inProvider && /^\s*\[/.test(line))
61
+ inProvider = false;
62
+ if (!inProvider && header.test(line)) {
63
+ inProvider = true;
64
+ continue;
65
+ }
66
+ if (inProvider)
67
+ continue;
68
+ if (/^\s*\[/.test(line))
69
+ inRoot = false;
70
+ if (inRoot && line === MANAGED_MARKER) {
71
+ managedRootKeys = 2;
72
+ continue;
73
+ }
74
+ if (inRoot && managedRootKeys > 0 && ROOT_KEY.test(line)) {
75
+ managedRootKeys -= 1;
76
+ continue;
77
+ }
78
+ kept.push(line);
79
+ }
80
+ const output = kept.join("\n");
81
+ return Buffer.from(eol === "\n" ? output : output.replace(/\n/g, "\r\n"));
82
+ }
83
+ export function buildCodexConfig(original, proxyPort, catalogPath) {
84
+ let text;
85
+ try {
86
+ text = new TextDecoder("utf-8", { fatal: true }).decode(original);
87
+ }
88
+ catch {
89
+ throw new CPACError("Codex config must be UTF-8");
90
+ }
91
+ if (hasProviderTable(text)) {
92
+ throw new CPACError(`Codex config already contains [model_providers.${PROVIDER}]`);
93
+ }
94
+ const normalizedLines = text.replace(/\r\n/g, "\n").split("\n");
95
+ const firstTable = normalizedLines.findIndex((line) => /^\s*\[/.test(line));
96
+ const rootLines = normalizedLines.slice(0, firstTable === -1 ? undefined : firstTable);
97
+ const providerLine = rootLines.find((line) => MODEL_PROVIDER_KEY.test(line));
98
+ if (providerLine) {
99
+ const match = /=\s*["']([^"']+)["']/.exec(providerLine);
100
+ if (!match || match[1] !== "openai") {
101
+ throw new CPACError("Codex config selects an external model_provider; restore it to openai before injecting CPAC");
102
+ }
103
+ }
104
+ if (rootLines.some((line) => OPENAI_BASE_URL_KEY.test(line))) {
105
+ throw new CPACError("Codex config already contains openai_base_url; remove that user-owned override before injecting CPAC");
106
+ }
107
+ const eol = dominantEol(text);
108
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
109
+ let inRoot = true;
110
+ const kept = lines.filter((line) => {
111
+ if (/^\s*\[/.test(line))
112
+ inRoot = false;
113
+ return !(inRoot && MODEL_CATALOG_KEY.test(line));
114
+ });
115
+ let body = kept.join("\n");
116
+ if (body && !body.endsWith("\n"))
117
+ body += "\n";
118
+ const root = [
119
+ MANAGED_MARKER,
120
+ `model_catalog_json = ${tomlString(catalogPath)}`,
121
+ `openai_base_url = ${tomlString(`http://127.0.0.1:${proxyPort}/v1`)}`,
122
+ "",
123
+ ].join("\n");
124
+ const output = root + body;
125
+ return Buffer.from(eol === "\n" ? output : output.replace(/\n/g, "\r\n"));
126
+ }
127
+ // Codex multi-agent v2 (features.multi_agent_v2). Accepts the same TOML
128
+ // shapes codex-rs does: a [features.multi_agent_v2] table, an inline
129
+ // `multi_agent_v2 = ...` under [features], or dotted root keys.
130
+ const V2_TABLE_HEADER = /^\s*\[\s*(?:features|"features"|'features')\s*\.\s*(?:multi_agent_v2|"multi_agent_v2"|'multi_agent_v2')\s*\]\s*(?:#.*)?$/;
131
+ const FEATURES_TABLE_HEADER = /^\s*\[\s*(?:features|"features"|'features')\s*\]\s*(?:#.*)?$/;
132
+ const V2_KEY = /^\s*(?:multi_agent_v2|"multi_agent_v2"|'multi_agent_v2')\s*=/;
133
+ 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*=/;
134
+ function v2LineValue(line) {
135
+ const inline = line.match(/\{[^}]*\benabled\s*=\s*(true|false)/);
136
+ if (inline)
137
+ return inline[1] === "true";
138
+ const plain = line.match(/=\s*(true|false)(?![A-Za-z0-9_])/);
139
+ if (plain)
140
+ return plain[1] === "true";
141
+ return undefined;
142
+ }
143
+ export function multiAgentV2Enabled(content) {
144
+ let table = "root";
145
+ for (const line of content.replace(/\r\n/g, "\n").split("\n")) {
146
+ if (V2_TABLE_HEADER.test(line)) {
147
+ table = "v2";
148
+ continue;
149
+ }
150
+ if (FEATURES_TABLE_HEADER.test(line)) {
151
+ table = "features";
152
+ continue;
153
+ }
154
+ if (/^\s*\[/.test(line)) {
155
+ table = "other";
156
+ continue;
157
+ }
158
+ if (table === "v2" && /^\s*(?:enabled|"enabled"|'enabled')\s*=/.test(line)) {
159
+ return v2LineValue(line) ?? false;
160
+ }
161
+ if (table === "features" && V2_KEY.test(line))
162
+ return v2LineValue(line) ?? false;
163
+ if (table === "root" && V2_DOTTED_KEY.test(line))
164
+ return v2LineValue(line) ?? false;
165
+ }
166
+ return false;
167
+ }
168
+ function stripMultiAgentV2(content) {
169
+ const lines = content.replace(/\r\n/g, "\n").split("\n");
170
+ const kept = [];
171
+ let skipTable = false;
172
+ let table = "root";
173
+ for (const line of lines) {
174
+ if (/^\s*\[/.test(line)) {
175
+ skipTable = V2_TABLE_HEADER.test(line);
176
+ if (skipTable) {
177
+ table = "other";
178
+ continue;
179
+ }
180
+ table = FEATURES_TABLE_HEADER.test(line) ? "features" : "other";
181
+ }
182
+ if (skipTable)
183
+ continue;
184
+ if (table === "features" && V2_KEY.test(line))
185
+ continue;
186
+ if (table === "root" && V2_DOTTED_KEY.test(line))
187
+ continue;
188
+ kept.push(line);
189
+ }
190
+ return kept.join("\n");
191
+ }
192
+ function hasAgentsMaxThreads(content) {
193
+ let table = "root";
194
+ for (const line of content.replace(/\r\n/g, "\n").split("\n")) {
195
+ if (/^\s*\[/.test(line)) {
196
+ table = /^\s*\[\s*(?:agents|"agents"|'agents')\s*\]\s*(?:#.*)?$/.test(line)
197
+ ? "agents"
198
+ : "other";
199
+ continue;
200
+ }
201
+ if (table === "agents" && /^\s*max_threads\s*=/.test(line))
202
+ return true;
203
+ if (table === "root" &&
204
+ /^\s*(?:agents|"agents"|'agents')\s*\.\s*max_threads\s*=/.test(line))
205
+ return true;
206
+ }
207
+ return false;
208
+ }
209
+ export function withMultiAgentV2(content, enabled, eol) {
210
+ let body = stripMultiAgentV2(content);
211
+ if (body && !body.endsWith("\n"))
212
+ body += "\n";
213
+ const output = body + `\n[features.multi_agent_v2]\nenabled = ${enabled}\n`;
214
+ return eol === "\n" ? output : output.replace(/\n/g, "\r\n");
215
+ }
216
+ export async function inject(config, v2Off = false, maxContext = false) {
217
+ const apiKey = process.env[config.api_key_env]?.trim();
218
+ if (!apiKey)
219
+ throw new CPACError(`environment variable ${config.api_key_env} is not set`);
220
+ const state = readState(config.state_dir);
221
+ let existed;
222
+ let original;
223
+ let originalMode;
224
+ if (state) {
225
+ const backup = originalBytes(config.state_dir, state, config.codex_config);
226
+ try {
227
+ original = existsSync(config.codex_config)
228
+ ? stripManagedConfig(readFileSync(config.codex_config))
229
+ : backup;
230
+ }
231
+ catch (error) {
232
+ if (error instanceof CPACError)
233
+ throw error;
234
+ throw new CPACError(`cannot read Codex config: ${error instanceof Error ? error.message : String(error)}`);
235
+ }
236
+ existed = state.config_existed;
237
+ originalMode = state.config_mode;
238
+ }
239
+ else {
240
+ existed = existsSync(config.codex_config);
241
+ try {
242
+ original = existed ? readFileSync(config.codex_config) : new Uint8Array();
243
+ originalMode = existed ? statSync(config.codex_config).mode & 0o7777 : 0o600;
244
+ }
245
+ catch (error) {
246
+ throw new CPACError(`cannot read Codex config: ${error instanceof Error ? error.message : String(error)}`);
247
+ }
248
+ }
249
+ const catalog = await fetchCatalog(config.cpa_url, apiKey);
250
+ if (config.spawn_models?.length)
251
+ catalog.bytes = reorderCatalog(catalog.bytes, config.spawn_models);
252
+ if (maxContext)
253
+ catalog.bytes = liftContextWindows(catalog.bytes);
254
+ const stateDirExisted = existsSync(config.state_dir);
255
+ if (!state &&
256
+ STATE_FILES.some((name) => existsSync(join(config.state_dir, name)))) {
257
+ throw new CPACError("state_dir contains CPAC files without a valid state; refusing to overwrite them");
258
+ }
259
+ const fingerprint = proxyFingerprint(config, apiKey);
260
+ const previousProxy = state ? stateProxy(state) : null;
261
+ let proxy;
262
+ let startedProxy = false;
263
+ if (previousProxy &&
264
+ state?.proxy_fingerprint === fingerprint &&
265
+ (config.codex_proxy_port === 0 ||
266
+ config.codex_proxy_port === previousProxy.port) &&
267
+ (await proxyIsHealthy(previousProxy))) {
268
+ proxy = previousProxy;
269
+ }
270
+ else {
271
+ if (previousProxy)
272
+ await stopProxyProcess(previousProxy);
273
+ try {
274
+ proxy = await startProxyProcess(config, apiKey);
275
+ startedProxy = true;
276
+ }
277
+ catch (error) {
278
+ throw new CPACError(`${error instanceof Error ? error.message : String(error)}; choose another codex_proxy_port if the port is occupied`);
279
+ }
280
+ }
281
+ const catalogPath = join(config.state_dir, "codex-models.json");
282
+ let injected;
283
+ try {
284
+ injected = buildCodexConfig(original, proxy.port, catalogPath);
285
+ const injectedText = new TextDecoder("utf-8").decode(injected);
286
+ injected = Buffer.from(withMultiAgentV2(injectedText, !v2Off, dominantEol(injectedText)));
287
+ if (!v2Off && hasAgentsMaxThreads(injectedText)) {
288
+ console.warn("warning: [agents] max_threads is set; codex refuses to start with multi_agent_v2 enabled, remove it");
289
+ }
290
+ }
291
+ catch (error) {
292
+ if (startedProxy)
293
+ await stopProxyProcess(proxy);
294
+ throw error;
295
+ }
296
+ mkdirSync(config.state_dir, { recursive: true, mode: 0o700 });
297
+ if (!stateDirExisted)
298
+ chmodSync(config.state_dir, 0o700);
299
+ if (state) {
300
+ try {
301
+ atomicWrite(catalogPath, catalog.bytes);
302
+ atomicWrite(config.codex_config, injected, originalMode);
303
+ atomicWrite(join(config.state_dir, "state.json"), stateBytes(config, existed, originalMode, proxy, fingerprint));
304
+ }
305
+ catch (error) {
306
+ if (startedProxy)
307
+ await stopProxyProcess(proxy);
308
+ throw error;
309
+ }
310
+ }
311
+ else {
312
+ let configWritten = false;
313
+ try {
314
+ if (existed)
315
+ atomicWrite(join(config.state_dir, "config.toml.backup"), original);
316
+ atomicWrite(catalogPath, catalog.bytes);
317
+ atomicWrite(config.codex_config, injected, originalMode);
318
+ configWritten = true;
319
+ atomicWrite(join(config.state_dir, "state.json"), stateBytes(config, existed, originalMode, proxy, fingerprint));
320
+ }
321
+ catch (error) {
322
+ if (configWritten) {
323
+ try {
324
+ if (existed)
325
+ atomicWrite(config.codex_config, original, originalMode);
326
+ else
327
+ rmSync(config.codex_config, { force: true });
328
+ }
329
+ catch (rollbackError) {
330
+ throw new CPACError(`injection failed and config rollback failed; backup retained in ${config.state_dir}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
331
+ }
332
+ }
333
+ let cleanupError;
334
+ try {
335
+ cleanupStateFiles(config.state_dir);
336
+ }
337
+ catch (caught) {
338
+ cleanupError = caught;
339
+ }
340
+ if (startedProxy)
341
+ await stopProxyProcess(proxy);
342
+ if (cleanupError) {
343
+ throw new CPACError(`injection failed and state cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
344
+ }
345
+ throw error;
346
+ }
347
+ }
348
+ try {
349
+ rmSync(join(dirname(config.codex_config), "models_cache.json"), {
350
+ force: true,
351
+ });
352
+ }
353
+ catch {
354
+ console.warn("CPAC could not invalidate Codex models_cache.json; restart Codex App if its model list is stale.");
355
+ }
356
+ 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"})`);
357
+ console.log("Restart Codex App if its running app-server still shows the old model list.");
358
+ }
359
+ export async function restore(config) {
360
+ const state = readState(config.state_dir);
361
+ if (!state)
362
+ throw new CPACError("no active CPAC injection");
363
+ const original = originalBytes(config.state_dir, state, config.codex_config);
364
+ if (state.config_existed)
365
+ atomicWrite(config.codex_config, original, state.config_mode);
366
+ else
367
+ rmSync(config.codex_config, { force: true });
368
+ const proxy = stateProxy(state);
369
+ if (proxy)
370
+ await stopProxyProcess(proxy);
371
+ try {
372
+ cleanupStateFiles(config.state_dir);
373
+ }
374
+ catch (error) {
375
+ throw new CPACError(`config restored, but state cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
376
+ }
377
+ console.log(`Restored ${config.codex_config}`);
378
+ }
@@ -0,0 +1,109 @@
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, resolveApiKey, tomlString, } from "../util.js";
7
+ export function grokHome() {
8
+ const home = process.env.GROK_HOME?.trim() || join(homedir(), ".grok");
9
+ return expandUserPath(home);
10
+ }
11
+ export function grokConfigPath() {
12
+ return join(grokHome(), "config.toml");
13
+ }
14
+ const GROK_BLOCK_START = "# >>> CPAC Grok >>>";
15
+ const GROK_BLOCK_END = "# <<< CPAC Grok <<<";
16
+ const grokBlockRegex = new RegExp(`${GROK_BLOCK_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${GROK_BLOCK_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\n?`);
17
+ export function isGrokConfigInstalled() {
18
+ try {
19
+ const content = readFileSync(grokConfigPath(), "utf8");
20
+ return (grokBlockRegex.test(content) ||
21
+ /^\s*\[model\.(?:"cpac\/|'cpac\/|cpac-)/m.test(content));
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ function isCpacGrokTableHeader(line) {
28
+ const header = line.match(/^\[([^\]]+)\]\s*$/);
29
+ if (!header)
30
+ return false;
31
+ const name = header[1];
32
+ return /^model\.(?:"cpac\/|'cpac\/|cpac-)/.test(name);
33
+ }
34
+ function stripOrphanCpacGrokTables(content) {
35
+ const eol = content.includes("\r\n") ? "\r\n" : "\n";
36
+ const out = [];
37
+ let skipping = false;
38
+ for (const line of content.split(/\r?\n/)) {
39
+ if (/^\[[^\]]+\]\s*$/.test(line))
40
+ skipping = isCpacGrokTableHeader(line);
41
+ if (!skipping)
42
+ out.push(line);
43
+ }
44
+ return out.join(eol).replace(/(?:\r?\n){3,}/g, `${eol}${eol}`);
45
+ }
46
+ function writeGrokBlock(path, block) {
47
+ let content = existsSync(path) ? readFileSync(path, "utf8") : "";
48
+ content = content.replace(grokBlockRegex, "");
49
+ const orphan = content.indexOf(GROK_BLOCK_START);
50
+ if (orphan !== -1)
51
+ content = content.slice(0, orphan);
52
+ content = stripOrphanCpacGrokTables(content);
53
+ if (block) {
54
+ content = `${content}${content && !content.endsWith("\n") ? "\n" : ""}${block}\n`;
55
+ }
56
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
57
+ const mode = existsSync(path) ? statSync(path).mode & 0o7777 : 0o600;
58
+ atomicWrite(path, Buffer.from(content), mode);
59
+ }
60
+ export async function installGrokConfig(config) {
61
+ const apiKey = await resolveApiKey(config.api_key_env);
62
+ const catalog = await fetchCatalog(config.cpa_url, apiKey);
63
+ let document;
64
+ try {
65
+ document = JSON.parse(new TextDecoder("utf-8").decode(catalog.bytes));
66
+ }
67
+ catch {
68
+ throw new CPACError("invalid CPA catalog");
69
+ }
70
+ const rows = catalogModelRows(document) ?? [];
71
+ if (rows.length === 0)
72
+ throw new CPACError("CPA catalog contains no models");
73
+ const state = readState(config.state_dir);
74
+ const recorded = state ? stateProxy(state) : null;
75
+ const port = recorded?.port ?? config.codex_proxy_port;
76
+ if (!port) {
77
+ throw new CPACError("loopback proxy port unknown; run cpac inject first");
78
+ }
79
+ const lines = [GROK_BLOCK_START];
80
+ for (const row of rows) {
81
+ const slug = catalogModelId(row);
82
+ if (!slug)
83
+ continue;
84
+ const context = typeof row.context_window === "number" && row.context_window > 0
85
+ ? Math.floor(row.context_window)
86
+ : 200000;
87
+ const name = typeof row.display_name === "string" && row.display_name.trim()
88
+ ? `${row.display_name.trim()} (CPAC)`
89
+ : `CPAC ${slug}`;
90
+ lines.push(`[model."cpac/${slug}"]`, `model = ${tomlString(slug)}`, `base_url = "http://127.0.0.1:${port}/v1"`, 'api_backend = "responses"', '# Placeholder: the CPAC loopback proxy replaces it with the CPA key; run "cpac inject" to start the proxy.', 'api_key = "cpac-loopback"', `name = ${tomlString(name)}`, `context_window = ${context}`, "");
91
+ }
92
+ if (lines[lines.length - 1] === "")
93
+ lines.pop();
94
+ lines.push(GROK_BLOCK_END);
95
+ const target = grokConfigPath();
96
+ ensureCpacBackup(target);
97
+ writeGrokBlock(target, lines.join("\n"));
98
+ console.log(`Installed Grok Build provider config: ${target}`);
99
+ if (!recorded || !(await proxyIsHealthy(recorded))) {
100
+ console.log("Loopback proxy is not running; run: cpac inject");
101
+ }
102
+ }
103
+ export async function uninstallGrokConfig() {
104
+ const target = grokConfigPath();
105
+ if (!isGrokConfigInstalled())
106
+ throw new CPACError("Grok Build config is not installed");
107
+ writeGrokBlock(target, null);
108
+ console.log(`Removed Grok Build provider config: ${target}`);
109
+ }
@@ -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
+ }