cabletidy 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CableTidy contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,230 @@
1
+ # CableTidy
2
+
3
+ CableTidy 是一个不依赖桌面 GUI 的本地 coding CLI gateway。它在本机运行一个 daemon 和一个 Web 管理台,为 Claude Code、Codex 以及其他编程 CLI 提供各自原生的本地接入服务。
4
+
5
+ CableTidy 的核心不是“提供一个统一 OpenAI API”,而是把复杂度留在本地控制面:
6
+
7
+ - 管理多个上游代理、账号、协议和能力差异。
8
+ - 为不同 CLI 暴露多个 Virtual Provider,每个服务使用目标 CLI 熟悉的 wire protocol。
9
+ - 默认透传客户端请求的模型名;按需配置模型改名、能力和 context window,reasoning effort 由 CLI 请求选择。
10
+ - 由内部 Route 把一个本地 Virtual Provider 连接到对应的上游和模型绑定。
11
+ - 让 Codex 或 Claude Code 连接本地 endpoint,并保留客户端选择的模型名。
12
+
13
+ 项目使用 Node.js 实现,使用 `toml-eslint-parser` 按语法树增量修改 Codex 配置。
14
+
15
+ ## 模型透传与可选设置
16
+
17
+ 每份配置默认将客户端请求的 `model` 原样发送到唯一上游,无需注册模型或填写同名映射。未配置的模型、工具和输入类型由上游判断是否支持;协议兼容不代表上游支持所有模型。
18
+
19
+ 需要改名或覆盖上下文等参数时,才添加模型设置。上游模型 ID 可留空,留空时继续使用请求中的模型名。设置只在当前配置内生效,不会影响其他配置,也不会阻止未配置的模型请求。请求未携带模型且没有显式默认模型时,返回 `model_required`。
20
+
21
+ Codex 的可选模型设置目前只支持与本机官方 GPT 目录明确对应的模型,允许上游使用不同的模型名称:
22
+
23
+ - 客户端默认保留对应的官方模型名,例如 `gpt-5.5`;CableTidy 将它映射为上游要求的模型名,不要求用户另外设置别名。
24
+ - 模型提示词和行为配置沿用对应的 Codex 官方模型定义,不用其他 GPT 模型冒充,也不提供自研的通用中性提示词。
25
+ - 模型身份对应不代表上游能力完整;仍需确认实际支持的工具、输入类型和上下文限制。
26
+ - 不对应 Codex 官方 GPT 模型的接入与适配推迟到后续版本,当前不提供相关配置向导或兼容性承诺。
27
+
28
+ 可选设置中的模型选择来自本机 `codex debug models --bundled`,不维护静态模型名白名单。新增或修改 Codex 模型设置、预览和应用含模型设置的配置时,会检查官方目录匹配;纯透传配置不依赖该目录。旧配置可继续加载和运行,不会自动改名或删除。目录匹配不等于上游模型身份认证,仍需依据上游接入说明确认,连通性测试不能证明模型身份或完整兼容性。
29
+
30
+ 仅模型名称不同且元数据一致时,不生成额外目录。显式覆盖上下文窗口或输入类型时,CableTidy 生成 `model_catalog_json` 并保留对应官方模型的完整指令、模板变量、reasoning 选项和工具配置。当前不实现逐模型 compact 策略同步,旧 compact 数据保留但不生效。
31
+
32
+ ## 安装与运行
33
+
34
+ 推荐 Node.js 24,也支持 Node.js 22.13 及以上的 22.x 版本;完整版本约束见 `package.json`。目前验证平台为 Linux,macOS 和 Windows 尚待验证。
35
+
36
+ ### 从 npm 官方源安装
37
+
38
+ ```bash
39
+ npm install -g cabletidy --registry=https://registry.npmjs.org/
40
+ cabletidy --version
41
+ cabletidy start
42
+ ```
43
+
44
+ 也可以不做全局安装,直接运行:
45
+
46
+ ```bash
47
+ npm exec --yes --registry=https://registry.npmjs.org/ --package=cabletidy -- cabletidy start
48
+ ```
49
+
50
+ ### 从本地安装包使用
51
+
52
+ 拿到 `.tgz` 安装包后,无需 clone 仓库即可安装:
53
+
54
+ ```bash
55
+ npm install -g /absolute/path/cabletidy-0.1.0.tgz
56
+ cabletidy --version
57
+ cabletidy start
58
+ ```
59
+
60
+ 也可以不做全局安装,直接运行同一个安装包:
61
+
62
+ ```bash
63
+ npm exec --yes --package=/absolute/path/cabletidy-0.1.0.tgz -- cabletidy start
64
+ ```
65
+
66
+ 安装包仍依赖本机 Node.js;npm 会下载尚未缓存的运行时依赖,因此 `.tgz` 本身不代表完全离线安装。安装过程不会启动服务或修改客户端配置。
67
+
68
+ ### 启动、升级与卸载
69
+
70
+ `cabletidy start` 在前台运行,打印管理台地址;按 Ctrl+C 或收到 SIGTERM 后停止接受新连接,等待已开始的请求、子进程和配置写入完成,再清理 `runtime.json` 并退出,不设置退出倒计时。等待期间再次按 Ctrl+C 会强制退出,退出码为 130;此时可能保留过期的 `runtime.json`,状态检查会检测服务是否仍在线。当前不包含后台常驻或开机自启。
71
+
72
+ 在另一个终端可以运行:
73
+
74
+ ```bash
75
+ cabletidy --help
76
+ cabletidy status
77
+ ```
78
+
79
+ `status` 同时显示 daemon 是否在线、管理台 URL、配置版本和当前配置套装列表;每套配置包含 ID、名称、目标 CLI、本地接入 URL 和启用状态,不再需要单独的 URL 或 Web 状态命令。
80
+
81
+ 升级时先停止服务,再安装最新版本并重新启动:
82
+
83
+ ```bash
84
+ npm install -g cabletidy@latest --registry=https://registry.npmjs.org/
85
+ cabletidy start
86
+ ```
87
+
88
+ 从本地安装包升级时,将安装目标换成新版本 `.tgz`。卸载使用 `npm uninstall -g cabletidy`,不会删除 `~/.cabletidy` 或撤销已应用的客户端配置;彻底停用前应先把客户端切换到其他接入。
89
+
90
+ ### 从源码运行与打包
91
+
92
+ ```bash
93
+ npm ci
94
+ npm start
95
+
96
+ # 生成可安装的 cabletidy-0.1.0.tgz。
97
+ npm pack
98
+ ```
99
+
100
+ `npm start` 与 `cabletidy start` 使用相同的启动和退出逻辑。源码用户也可以通过 `node bin/cabletidy.mjs --help` 调用完整 CLI。
101
+
102
+ 验证源码和安装产物:
103
+
104
+ ```bash
105
+ npm test
106
+ npm run test:package
107
+ ```
108
+
109
+ 安装冒烟测试会在临时目录打包、安装、启动和卸载,验证安装后的 CLI、Web 资源及用户数据保留;需要能够获取 npm 依赖。Linux 上还验证 SIGINT / SIGTERM 和端口冲突。分发方案的取舍、维护者发布流程与后续服务管理设计见 [安装与分发决策](https://github.com/ZY-WANG-0304/cabletidy/blob/main/docs/installation-research.md)。
110
+
111
+ 使用 Codex 可选模型设置时,本机需要可执行支持 `debug models --bundled` 的 Codex CLI,daemon 的 `PATH` 必须包含它。目录读取失败不会阻止纯透传配置的创建、代理启动、预览或应用,但会阻止新增模型设置或应用包含模型设置的 Codex 接入;管理台可在更新 Codex 后刷新模型列表。已在 Codex 0.154.0 验证目录加载和实际请求。
112
+
113
+ 管理台默认只监听本机回环地址。启动后可从 `cabletidy status` 输出中取得地址。
114
+
115
+ 默认数据目录为 `~/.cabletidy`。如需启动一个临时实例:
116
+
117
+ ```bash
118
+ CABLETIDY_HOME="$PWD/.cabletidy-dev" cabletidy start
119
+ ```
120
+
121
+ 源码运行时,将命令末尾的 `cabletidy start` 换成 `npm start`。
122
+
123
+ Web 管理台与所有 Virtual Provider 共用 `127.0.0.1:43100`。每份配置通过 `/<配置ID>/v1/...` 接入,例如 `http://127.0.0.1:43100/codex-main/v1/responses`;管理接口仍使用 `/api/v1/...`。
124
+
125
+ ## Web 配置管理
126
+
127
+ 管理台直接显示配置列表,首次使用与后续使用采用相同的操作流程,不设置介绍页或配置向导。点击“新建配置”填写:
128
+
129
+ - 配置名称(可选);留空时按 `Codex / <上游主机名>` 自动生成展示名称。
130
+ - 上游地址和 API Key。
131
+ - 模型设置(可选):需要改名时选择 Codex 官方模型并填写上游真实模型名;默认无需添加。
132
+
133
+ 点击“创建配置”后,服务端自动校验、保存并使配置生效,成功后进入详情页。本地服务入口和路由自动生成。配置 ID 按配置名称规范化生成,Virtual Provider ID 为 `cabletidy_<配置ID>`,并直接用作 Codex 的 `model_provider`;URL 路径使用不带此前缀的配置 ID。留空名称时,自动生成的名称也会参与 ID 规范化;如果多个配置使用同一上游主机且都留空名称,需要为后续配置填写不同名称。
134
+
135
+ 列表显示 CLI、模型设置数量(无设置时显示“直接透传”)、本地地址和本地服务状态。列表与详情页的状态根据 Virtual Provider 的运行情况显示“已启动”“已暂停”或“未启动”,不受上游连通性测试结果影响。详情页依次展示唯一上游的连接设置、可选模型设置和客户端接入;每行模型设置包含客户端模型及可选的上游模型 ID,底部接入区域集中展示本地地址、Virtual Provider ID 和服务启停、预览、应用操作。“保存上游”和“保存模型设置”分别直接保存对应修改;模型设置可全部删除,恢复直接透传。失败时保留输入并在当前表单显示错误。“诊断”页面提供模型解析测试和事件记录,解析使用已保存的配置。
136
+
137
+ 仅在存在实际未保存修改时,切换页面、返回列表或关闭页面会提示确认;改回原值后不再拦截。点击“刷新模型列表”同步更新沿用官方定义的元数据和最新限制,保留手动覆盖值与其他表单输入。
138
+
139
+ “应用到 Codex”保持独立,只有主动点击时才修改 Codex 配置文件。测试连通性和配置预览使用已保存的配置。
140
+
141
+ 按需在 Codex 套装详情页中添加模型设置;仅覆盖上下文时无需填写上游模型 ID。模型能力与上下文默认折叠,元数据默认“沿用官方定义”;
142
+ 展开并选择“覆盖上游限制”后可设置 context window 和图片输入,已有覆盖值或待确认的旧策略会自动展开。不提供固定 reasoning effort 或未实现的 compact 控件。
143
+ 旧窗口和压缩配置会提示待确认,不会因升级自动同步到 Codex。不同套装可使用同一个官方模型名,映射在各自 Virtual Provider 内隔离。
144
+
145
+ Daybreak Blue 和 Red 保留可选,位于模型列表末尾的“安全专项模型”分组,分别标记“需授权”和“需专项授权”。选中后显示简短用途提示,不自动禁用或更改已有映射;目录中的隐藏标记不等同于授权状态,实际可用性由上游支持和账户权限决定。
146
+
147
+ 每份配置只连接一个上游,配置内所有模型都映射到该上游;Codex 接入使用 Responses 协议。需要另一个上游时创建另一份配置,不支持在同一配置内添加备用上游或自动切换。多上游配置会被校验拒绝,上游失败直接返回错误。
148
+ 首次配置不再填充统一的百万上下文或 850K 压缩阈值,也不要求用户填写模型别名或内部 ID。
149
+
150
+ 创建或保存时,服务端执行配置校验、版本冲突检查和运行时更新。校验或更新失败不会覆盖已生效配置;版本冲突时刷新管理页会合并其他窗口新增的模型和不冲突的字段修改,保留本地编辑。同一字段冲突、修改与删除冲突或无法安全合并的结构变更会暂停该表单的保存,并保留当前输入;可先复制需要保留的内容,再点击“加载最新配置”确认放弃该表单的本地修改后重新编辑。上游 secret 保存在本地 `secrets.json`,Web GET 和预览不会回显明文。
151
+
152
+ 上游认证只使用 `secretRef` 对应的已保存密钥,不读取认证环境变量。旧 `envKey` 和 Codex 上游认证元数据在加载或保存配置时丢弃,`env://` 引用也不再读取环境变量;曾仅使用环境变量配置密钥时,需要在管理台填写并保存 API Key。
153
+
154
+ ## Codex 接入
155
+
156
+ 当前 Codex 接入生成一个本地 `config.toml` managed block,例如:
157
+
158
+ ```toml
159
+ model_provider = "cabletidy_xxx"
160
+
161
+ [model_providers.cabletidy_xxx]
162
+ name = "CableTidy / local"
163
+ base_url = "http://127.0.0.1:43100/xxx/v1"
164
+ wire_api = "responses"
165
+ requires_openai_auth = false
166
+ ```
167
+
168
+ 新配置不强制指定 `model`,应用时保留 Codex 已有的模型选择。旧配置中的显式 `defaultModel` 仍受支持。
169
+
170
+ 配置与 Virtual Provider 一对一。例如填写配置名称 `my-relay`,配置 ID、Virtual Provider ID 和 Codex `model_provider` 分别为 `my-relay`、`cabletidy_my-relay` 和 `cabletidy_my-relay`。名称包含大写或空格时仍会规范化为同样的配置 ID。
171
+ 配置名称会转换为小写,并将空格等字符转换为 `-`;无法生成有效标识时使用原配置 ID。规范化后重名的配置,以及占用管理接口保留路径 `api` 的配置,会被拒绝。
172
+ 旧配置加载时自动对齐内部 ID 和引用,保留模型映射和上游密钥,并移除 Virtual Provider 的独立 `listenHost` / `listenPort` 及 `daemon.proxyPortRange`。共用监听地址由 `web.listenHost` / `web.port` 决定,修改它需要重启 daemon。
173
+ 升级后重启 CableTidy,在详情页重新“应用到 Codex”,将旧的独立端口地址更新为配置路径。暂停一份配置只暂停对应入口;重命名配置会改变路径,需要再次应用客户端配置。
174
+ 上游连接的内部 ID 只用于 CableTidy 关联模型映射和路由,不参与 Codex provider 命名。
175
+ CableTidy 不会在 daemon 启动时改写已有 Codex `config.toml`;只有用户主动应用配置时才会写入新的 provider。
176
+
177
+ Codex Native Provider 的 MVP 接口契约是:
178
+
179
+ ```text
180
+ GET /v1/models 返回当前配置中显式设置的模型
181
+ POST /v1/responses 必须:普通和 stream Responses 请求
182
+ ```
183
+
184
+ `/models` 和 `/responses` 仍作为兼容别名保留,但不能替代标准的
185
+ `/v1/models` 和 `/v1/responses`。`GET /v1/models` 返回的是 CableTidy
186
+ 维护的 client model ID,不是上游真实模型 ID。它只列出显式模型设置,不是请求白名单,也不自动发现上游模型;纯透传配置返回空列表,客户端仍可请求上游支持的模型。
187
+
188
+ CableTidy 生成的 `base_url` 永远指向本地 Virtual Provider。Codex 不需要知道:
189
+
190
+ - 上游真实地址或上游 API Key。
191
+ - 上游模型名称和模型映射。
192
+ - 内部 Route 和上游健康状态;必要的模型能力元数据由 CableTidy 管理并通过客户端适配提供。
193
+ - 外部教程、profile ID 或 profile 文件。
194
+ - CableTidy 的内部 Model Profile 和 Upstream Model Binding。
195
+
196
+ Web 管理台的“应用”操作会在 `~/.codex/config.toml` 中增量维护 CableTidy 的根级选择和 provider 条目,不修改其他 `model_providers`,并创建备份。
197
+
198
+ 启用元数据覆盖后,“应用”还会写入 `model-catalogs/cabletidy-<hash>.json`,将根级 `model_catalog_json` 指向其绝对路径,并暂时移除会覆盖逐模型窗口的根级 `model_context_window`。原目录引用和窗口保存在 `.cabletidy-model-catalog.json`,所有模型恢复官方定义、再次应用时会恢复它们。原目录文件不被覆盖,其其他条目保留在生成目录中。
199
+
200
+ Codex 的模型目录是当前配置级别的,不按 provider 隔离。手动切换其他 provider 不会自动撤销目录覆盖;应先恢复官方定义并应用。已有 `model_instructions_file`、`developer_instructions`、reasoning effort、compact 阈值和其他用户设置保持不变,预览会提示影响模型行为的覆盖项。生成目录后重启 Codex;更新 Codex 版本后刷新模型列表并重新应用。
201
+
202
+ Virtual Provider 只监听 `127.0.0.1`、`localhost` 或 `::1`,不生成或校验本地 API Key。
203
+ Codex 配置不包含 `env_key`,也不需要设置认证环境变量。例如,可以直接请求:
204
+
205
+ ```bash
206
+ curl http://127.0.0.1:43100/codex-main/v1/models
207
+ ```
208
+
209
+ 升级后重启 CableTidy,再在套装详情页点击“应用 Codex 配置”,即可移除该 provider
210
+ 旧的 `env_key`。旧 CableTidy 配置中的 `localAuth` 和 `codex.localEnvKey` 在加载时忽略,
211
+ 旧 secrets store 中的本地密钥不再使用;上游密钥仍用于 daemon 出站认证。
212
+ Claude Code 适配器会自动注入固定的 `cabletidy-local` 占位值以满足客户端认证检查,
213
+ 它不是密钥,本地服务不会校验这个值。Generic CLI 只生成本地地址和模型环境变量。
214
+
215
+ ## 其他 CLI
216
+
217
+ Claude Code 使用 Anthropic Messages Virtual Provider,目标配置通过管理台预览。未来的 Gemini CLI、OpenCode 和其他编程 CLI 可以增加各自的 Target Adapter;它们不需要被强行改成 OpenAI 配置。
218
+
219
+ ## CLI
220
+
221
+ ```bash
222
+ node src/cli.mjs status
223
+ npm test
224
+ ```
225
+
226
+ 配置文件和 secrets 使用原子写入。管理台和 Virtual Provider 都只绑定 loopback,不需要本地访问 token、API Key 或 session 有效期。上游 API Key 由 CableTidy 单独保存和使用。
227
+
228
+ ## 许可证
229
+
230
+ 本项目采用 [MIT License](LICENSE)。
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "../src/cli.mjs";
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "cabletidy",
3
+ "version": "0.1.0",
4
+ "description": "A local multi-target coding CLI gateway and provider manager.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/ZY-WANG-0304/cabletidy.git"
9
+ },
10
+ "homepage": "https://github.com/ZY-WANG-0304/cabletidy#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/ZY-WANG-0304/cabletidy/issues"
13
+ },
14
+ "publishConfig": {
15
+ "registry": "https://registry.npmjs.org/",
16
+ "access": "public"
17
+ },
18
+ "type": "module",
19
+ "bin": {
20
+ "cabletidy": "bin/cabletidy.mjs"
21
+ },
22
+ "files": [
23
+ "bin/",
24
+ "src/",
25
+ "web/"
26
+ ],
27
+ "engines": {
28
+ "node": "^22.13.0 || >=24"
29
+ },
30
+ "scripts": {
31
+ "start": "node src/server.mjs",
32
+ "dev": "node --watch src/server.mjs",
33
+ "test": "node --test test/*.test.mjs",
34
+ "test:package": "node --test scripts/package-smoke.mjs",
35
+ "prepublishOnly": "npm test && npm run test:package"
36
+ },
37
+ "dependencies": {
38
+ "toml-eslint-parser": "^1.0.3"
39
+ }
40
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,141 @@
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
+ });
@@ -0,0 +1,227 @@
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
+ }