cabletidy 0.1.0 → 0.3.0-rc.2

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/src/cli.mjs DELETED
@@ -1,141 +0,0 @@
1
- import fs from "node:fs/promises";
2
-
3
- import {
4
- getPaths,
5
- loadConfig,
6
- readRuntimeInfo,
7
- } from "./config.mjs";
8
- import { configurationBaseUrl } from "../web/config-identity.js";
9
-
10
- const [, , command = "status", ...args] = process.argv;
11
-
12
- async function main() {
13
- if (["--help", "-h", "help"].includes(command)) {
14
- printHelp();
15
- return;
16
- }
17
-
18
- if (["--version", "-v"].includes(command)) {
19
- const metadata = JSON.parse(await fs.readFile(new URL("../package.json", import.meta.url), "utf8"));
20
- console.log(metadata.version);
21
- return;
22
- }
23
-
24
- if (command === "start") {
25
- if (args.length) throw new Error("用法: cabletidy start");
26
- const { startDaemon } = await import("./server.mjs");
27
- await startDaemon();
28
- return;
29
- }
30
-
31
- const paths = getPaths();
32
-
33
- if (command === "status") {
34
- const [config, runtime] = await Promise.all([loadConfig(paths), readRuntimeInfo(paths)]);
35
- const daemon = runtime ? await probeDaemon(runtime) : { online: false, reason: "runtime.json 不存在" };
36
- const web = runtime?.web || {
37
- host: config.web.listenHost,
38
- port: config.web.port,
39
- };
40
- print({
41
- configRevision: config.revision,
42
- runtime: runtime
43
- ? { ...runtime, web: { ...web, url: web.url || webUrl(web) }, status: daemon.online ? "online" : "offline", liveness: daemon.reason }
44
- : { web: { ...web, url: webUrl(web) }, status: "offline", liveness: daemon.reason },
45
- configurations: Object.entries(config.bindings).map(([id, binding]) => {
46
- const provider = config.virtualProviders[binding.virtualProvider];
47
- return {
48
- id,
49
- name: binding.name || id,
50
- target: binding.target,
51
- url: `${configurationBaseUrl(config, id)}/v1`,
52
- enabled: binding.enabled !== false && provider?.enabled !== false,
53
- };
54
- }),
55
- });
56
- return;
57
- }
58
-
59
- console.error(`未知命令: ${[command, ...args].join(" ")}`);
60
- printHelp();
61
- process.exitCode = 1;
62
- }
63
-
64
- function printHelp() {
65
- console.log(`CableTidy CLI
66
-
67
- Usage: cabletidy <command>
68
-
69
- Commands:
70
- start Start the daemon in the foreground
71
- --help, -h Show this help
72
- --version, -v Print the installed version
73
- status Show daemon status and management URL
74
-
75
- Data: CABLETIDY_HOME or ~/.cabletidy
76
- No command: show status
77
- `);
78
- }
79
-
80
- async function probeDaemon(runtime) {
81
- const pid = Number(runtime.pid);
82
- if (!Number.isInteger(pid) || pid <= 0) {
83
- return { online: false, reason: "CableTidy runtime.json 无有效 daemon PID,请重新执行 cabletidy start" };
84
- }
85
- try {
86
- process.kill(pid, 0);
87
- } catch (error) {
88
- return { online: false, reason: `CableTidy daemon 不在线(PID ${pid}):${error.message}` };
89
- }
90
-
91
- const web = runtime.web || {};
92
- const host = web.host || "127.0.0.1";
93
- const port = Number(web.port);
94
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
95
- return { online: false, reason: "CableTidy runtime.json 无有效 Web 端口,请重新执行 cabletidy start" };
96
- }
97
- const controller = new AbortController();
98
- const timeout = setTimeout(() => controller.abort(), 1500);
99
- try {
100
- const response = await fetch(`http://${formatHostForUrl(host)}:${port}/api/v1/runtime`, {
101
- signal: controller.signal,
102
- });
103
- // The Web console is loopback-only and has no management session. A 200
104
- // response with the CableTidy marker proves the daemon is alive.
105
- if (response.status !== 200 || response.headers.get("x-cabletidy") !== "cabletidy") {
106
- return {
107
- online: false,
108
- reason: `CableTidy Web runtime probe 返回异常响应(HTTP ${response.status})`,
109
- };
110
- }
111
- return { online: true, reason: `Web runtime probe 正常(PID ${pid})` };
112
- } catch (error) {
113
- if (error.name === "AbortError") {
114
- return { online: false, reason: "CableTidy daemon Web 管理台无响应,请重新执行 cabletidy start" };
115
- }
116
- return { online: false, reason: `CableTidy daemon Web 管理台不可达:${error.message}` };
117
- } finally {
118
- clearTimeout(timeout);
119
- }
120
- }
121
-
122
- function formatHostForUrl(host) {
123
- const value = String(host || "127.0.0.1");
124
- if (value.includes(":") && !value.startsWith("[")) return `[${value}]`;
125
- return value;
126
- }
127
-
128
- function webUrl(web = {}) {
129
- const host = formatHostForUrl(web.host || "127.0.0.1");
130
- const port = Number(web.port || 43100);
131
- return `http://${host}:${port}/`;
132
- }
133
-
134
- function print(value) {
135
- console.log(JSON.stringify(value, null, 2));
136
- }
137
-
138
- main().catch((error) => {
139
- console.error(error.stack || error.message);
140
- process.exitCode = 1;
141
- });
@@ -1,227 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { clientModelIdForProfile, effectiveCapabilities } from "./model-resolver.mjs";
3
-
4
- let cached;
5
- let pending;
6
- let activeCommand;
7
-
8
- async function executeCodex(args) {
9
- const timeout = AbortSignal.timeout(15_000);
10
- const command = spawn("codex", args, {
11
- windowsHide: true,
12
- stdio: ["ignore", "pipe", "pipe"],
13
- // Keep npm launcher descendants in a group we can stop on forced daemon exit.
14
- detached: process.platform !== "win32",
15
- });
16
- activeCommand = command;
17
- const closed = new Promise(resolve => {
18
- command.once("close", (code, signal) => resolve({ code, signal }));
19
- });
20
- let onTimeout;
21
- const completed = new Promise((resolve, reject) => {
22
- command.once("error", reject);
23
- command.once("exit", (code, signal) => {
24
- if (code !== 0) reject(new Error(`codex exited with ${signal || code}`));
25
- });
26
- // Descendants may hold the pipes open after the launcher has exited.
27
- onTimeout = () => reject(timeout.reason);
28
- timeout.addEventListener("abort", onTimeout, { once: true });
29
- closed.then(resolve);
30
- });
31
- const output = [readCommandOutput(command.stdout), readCommandOutput(command.stderr)];
32
- try {
33
- const [, stdout] = await Promise.all([completed, ...output]);
34
- return { stdout };
35
- } catch (error) {
36
- forceStopCodexCatalog();
37
- await Promise.allSettled([closed, ...output]);
38
- throw error;
39
- } finally {
40
- timeout.removeEventListener("abort", onTimeout);
41
- activeCommand = undefined;
42
- }
43
- }
44
-
45
- async function readCommandOutput(stream) {
46
- const chunks = [];
47
- let size = 0;
48
- for await (const chunk of stream) {
49
- size += chunk.length;
50
- if (size > 16 * 1024 * 1024) throw new Error("codex output exceeded 16 MiB");
51
- chunks.push(chunk);
52
- }
53
- return Buffer.concat(chunks).toString("utf8");
54
- }
55
-
56
- export function forceStopCodexCatalog() {
57
- if (!activeCommand?.pid) return;
58
- try {
59
- if (process.platform === "win32") activeCommand.kill("SIGKILL");
60
- else process.kill(-activeCommand.pid, "SIGKILL");
61
- } catch (error) {
62
- if (error.code !== "ESRCH") throw error;
63
- }
64
- }
65
-
66
- export function validateCatalog(value) {
67
- if (!value || !Array.isArray(value.models) || !value.models.length) {
68
- throw new Error("Codex 模型目录为空或格式不受支持,请更新 Codex 后重试");
69
- }
70
- const names = new Set();
71
- for (const model of value.models) {
72
- if (!model || typeof model.slug !== "string" || !model.slug || names.has(model.slug)) {
73
- throw new Error("Codex 模型目录包含无效或重复的模型 ID");
74
- }
75
- names.add(model.slug);
76
- }
77
- return value;
78
- }
79
-
80
- export async function loadCodexCatalog({ refresh = false } = {}) {
81
- if (!refresh && cached && Date.now() - cached.loadedAt < 60_000) return structuredClone(cached);
82
- if (!pending) {
83
- pending = (async () => {
84
- try {
85
- const version = (await executeCodex(["--version"])).stdout.trim();
86
- // Bundled metadata is independent of the user's catalog and never
87
- // needs their upstream credentials or a remote model request.
88
- const result = await executeCodex(["debug", "models", "--bundled"]);
89
- const catalog = validateCatalog(JSON.parse(result.stdout));
90
- cached = { version, catalog, loadedAt: Date.now() };
91
- return cached;
92
- } catch (error) {
93
- const reason = error.code === "ENOENT" ? "找不到 codex 命令" : "当前版本无法导出有效目录";
94
- throw new Error(`无法读取本机 Codex 官方模型目录:${reason}。请安装或更新 Codex,并确认 daemon 的 PATH 包含 codex。`, { cause: error });
95
- }
96
- })().finally(() => { pending = null; });
97
- }
98
- return structuredClone(await pending);
99
- }
100
-
101
- export function officialModels(snapshot) {
102
- return validateCatalog(snapshot.catalog).models.filter((model) =>
103
- /^gpt-/i.test(model.slug) && model.supported_in_api === true,
104
- );
105
- }
106
-
107
- export function defaultCapabilities(model) {
108
- return [
109
- "streaming", "tools", "parallel_tool_calls",
110
- ...(model.supported_reasoning_levels?.length ? ["reasoning"] : []),
111
- ...(model.input_modalities?.includes("image") ? ["vision"] : []),
112
- ];
113
- }
114
-
115
- export function publicCodexCatalog(snapshot) {
116
- return {
117
- available: true,
118
- version: snapshot.version,
119
- models: officialModels(snapshot).map((model) => ({
120
- id: model.slug,
121
- name: model.display_name || model.slug,
122
- hidden: model.visibility === "hide",
123
- contextWindow: model.context_window ?? null,
124
- maxContextWindow: model.max_context_window ?? model.context_window ?? null,
125
- inputModalities: model.input_modalities || ["text"],
126
- capabilities: defaultCapabilities(model),
127
- })),
128
- };
129
- }
130
-
131
- export function codexModelIds(config) {
132
- const ids = new Set();
133
- for (const binding of Object.values(config.bindings || {})) {
134
- if (binding?.target !== "codex") continue;
135
- for (const id of config.virtualProviders?.[binding.virtualProvider]?.allowedModels || []) ids.add(id);
136
- }
137
- return ids;
138
- }
139
-
140
- export function catalogEntryForProfile(snapshot, id, profile) {
141
- const name = clientModelIdForProfile(id, profile);
142
- const official = officialModels(snapshot).find((model) => model.slug === name);
143
- if (!official) {
144
- throw new Error(`模型 ${name} 未匹配本机 Codex 官方 GPT 目录。请选择对应的官方模型;若本机目录过旧,请更新 Codex。当前不支持非对应模型。`);
145
- }
146
- if (!official.base_instructions && !official.model_messages?.instructions_template) {
147
- throw new Error(`官方模型 ${name} 缺少指令定义,不能生成替代提示词`);
148
- }
149
- const entry = structuredClone(official);
150
- if (profile.codex?.metadataMode === "override") {
151
- if (profile.contextWindow !== undefined) {
152
- const maximum = official.max_context_window ?? official.context_window;
153
- if (!Number.isSafeInteger(profile.contextWindow) || profile.contextWindow < 1 ||
154
- !Number.isSafeInteger(maximum) || profile.contextWindow > maximum) {
155
- throw new Error(`${name} 的 context window 必须为 1 到 ${maximum || "官方已知上限"} 之间的整数`);
156
- }
157
- entry.context_window = profile.contextWindow;
158
- entry.max_context_window = profile.contextWindow;
159
- }
160
- if (profile.codex.inputModalities !== undefined) {
161
- const modalities = profile.codex.inputModalities;
162
- if (!Array.isArray(modalities) || !modalities.includes("text") ||
163
- new Set(modalities).size !== modalities.length ||
164
- modalities.some((item) => !["text", "image"].includes(item) || !official.input_modalities?.includes(item))) {
165
- throw new Error(`${name} 的输入类型只能是官方已支持类型的子集,且必须保留 text`);
166
- }
167
- entry.input_modalities = [...modalities];
168
- }
169
- }
170
- return { official, entry };
171
- }
172
-
173
- export function planCodexCatalog(config, provider, snapshot) {
174
- const catalog = structuredClone(validateCatalog(snapshot.catalog));
175
- const overrides = [];
176
- const warnings = [];
177
- const models = [];
178
- const route = config.routes?.[provider.route];
179
- if (route?.backends?.length !== 1) throw new Error("每份配置必须且只能连接一个 upstream");
180
- for (const id of provider.allowedModels || []) {
181
- const profile = config.models[id];
182
- if (!profile) throw new Error(`模型不存在: ${id}`);
183
- const { official, entry } = catalogEntryForProfile(snapshot, id, profile);
184
- const backends = (config.routes?.[provider.route]?.backends || []).filter((backend) =>
185
- backend.enabled !== false && config.upstreams?.[backend.upstream] && config.upstreams[backend.upstream].enabled !== false &&
186
- (!backend.models?.length || backend.models.includes(id)),
187
- );
188
- if (backends.length !== 1) throw new Error(`${entry.slug} 的 Codex MVP 接入必须对应一个有效上游`);
189
- if (config.upstreams[backends[0].upstream].protocol !== "openai.responses") {
190
- throw new Error(`${entry.slug} 的 Codex MVP 上游必须使用 Responses 协议,暂不支持跨协议转换`);
191
- }
192
- const binding = profile.upstreams?.[backends[0].upstream];
193
- if (Object.keys(profile.upstreams || {}).length !== 1) throw new Error(`${entry.slug} 必须且只能映射一个 upstream`);
194
- if (!binding) throw new Error(`${entry.slug} 缺少上游模型设置`);
195
- if (profile.codex && !effectiveCapabilities(profile, binding).has("vision") && entry.input_modalities?.includes("image")) {
196
- entry.input_modalities = entry.input_modalities.filter((item) => item !== "image");
197
- warnings.push(`${entry.slug} 的上游 vision 能力未启用,生成目录将禁用图片输入。`);
198
- }
199
- models.push(entry.slug);
200
- if (JSON.stringify(entry) !== JSON.stringify(official)) {
201
- catalog.models[catalog.models.findIndex((model) => model.slug === entry.slug)] = entry;
202
- overrides.push(entry.slug);
203
- }
204
- if (!profile.codex && (profile.contextWindow || profile.compact)) {
205
- warnings.push(`${entry.slug} 的旧 context window / compact 配置未同步;请在模型映射中确认元数据设置。`);
206
- }
207
- if (profile.compact) warnings.push(`${entry.slug} 的 compact 策略仍由 Codex 管理,CableTidy 未同步逐模型压缩配置。`);
208
- }
209
- return { sourceVersion: snapshot.version, catalog: overrides.length ? catalog : null, models, overrides, warnings };
210
- }
211
-
212
- export async function validateCodexChanges(config, previous, loadCatalog = loadCodexCatalog) {
213
- const oldIds = codexModelIds(previous);
214
- const changed = [...codexModelIds(config)].filter((id) =>
215
- !oldIds.has(id) || JSON.stringify(config.models[id]) !== JSON.stringify(previous.models?.[id]),
216
- );
217
- if (!changed.length) return [];
218
- let snapshot;
219
- try { snapshot = await loadCatalog(); }
220
- catch (error) { return [{ path: "models", message: error.message }]; }
221
- const errors = [];
222
- for (const id of changed) {
223
- try { catalogEntryForProfile(snapshot, id, config.models[id]); }
224
- catch (error) { errors.push({ path: `models.${id}`, message: error.message }); }
225
- }
226
- return errors;
227
- }
@@ -1,67 +0,0 @@
1
- import { getStaticTOMLValue, parseTOML } from "toml-eslint-parser";
2
-
3
- export function parseCodexConfig(text) {
4
- return parseTOML(text, { tomlVersion: "1.0.0" });
5
- }
6
-
7
- export function readCodexConfig(text) {
8
- return getStaticTOMLValue(parseCodexConfig(text));
9
- }
10
-
11
- function keyParts(node) {
12
- return node.key.keys.map((key) => key.type === "TOMLBare" ? key.name : key.value);
13
- }
14
-
15
- export function patchRootConfig(text, values) {
16
- const body = parseCodexConfig(text).body[0].body;
17
- const edits = [];
18
- const additions = [];
19
- for (const [key, value] of Object.entries(values)) {
20
- const node = body.find((item) => item.type === "TOMLKeyValue" && keyParts(item).length === 1 && keyParts(item)[0] === key);
21
- if (node) {
22
- edits.push({ start: value === undefined ? node.range[0] : node.value.range[0], end: node.range[1], text: value === undefined ? "" : JSON.stringify(value) });
23
- } else if (value !== undefined) additions.push(`${key} = ${JSON.stringify(value)}\n`);
24
- }
25
- for (const edit of edits.sort((a, b) => b.start - a.start)) {
26
- text = text.slice(0, edit.start) + edit.text + text.slice(edit.end);
27
- }
28
- const result = additions.join("") + text;
29
- parseCodexConfig(result);
30
- return result;
31
- }
32
-
33
- export function patchProviderConfig(text, providerId, providerContents) {
34
- const ast = parseCodexConfig(text);
35
- const edits = [];
36
- for (const node of ast.body[0].body) {
37
- if (node.type !== "TOMLTable") continue;
38
- const parts = node.resolvedKey || keyParts(node);
39
- if (parts[0] === "model_providers" && parts[1] === providerId) {
40
- edits.push({ start: node.range[0], end: node.range[1] });
41
- }
42
- }
43
- for (const edit of edits.sort((a, b) => b.start - a.start)) {
44
- text = text.slice(0, edit.start) + text.slice(edit.end);
45
- }
46
- // Remove only real marker comments, never matching text inside instructions.
47
- const markers = parseCodexConfig(text).comments.filter((comment) =>
48
- ["# >>> CABLETIDY MANAGED PROVIDER", "# <<< CABLETIDY MANAGED PROVIDER"].some((marker) =>
49
- text.slice(...comment.range).startsWith(`${marker} ${providerId} `),
50
- ),
51
- );
52
- for (const comment of markers.sort((a, b) => b.range[0] - a.range[0])) {
53
- text = text.slice(0, comment.range[0]) + text.slice(comment.range[1]);
54
- }
55
- const updatedAst = parseCodexConfig(text);
56
- const firstProvider = updatedAst.body[0].body.find((node) => {
57
- if (node.type !== "TOMLTable") return false;
58
- const parts = node.resolvedKey || keyParts(node);
59
- return parts[0] === "model_providers";
60
- });
61
- const providerBlock = `${providerContents.trimEnd()}\n\n`;
62
- const result = firstProvider
63
- ? `${text.slice(0, firstProvider.range[0]).trimEnd()}\n\n${providerBlock}${text.slice(firstProvider.range[0])}`
64
- : `${text.trimEnd()}\n\n${providerContents}`;
65
- parseCodexConfig(result);
66
- return result;
67
- }