@luchenglong/cli-bridge 0.2.7 → 0.2.9
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/CHANGELOG.md +97 -0
- package/PROVIDER-CONTRACT.md +53 -4
- package/README.md +12 -11
- package/docs/HANDOVER.md +9 -0
- package/docs/audits/2026-10-18-preset-0.2.1-contract-fix.md +160 -0
- package/docs/audits/README.md +15 -0
- package/dynamic/agy.host.js +1 -1
- package/dynamic/claude.host.js +1801 -0
- package/dynamic/claude.host.template.mjs +108 -0
- package/dynamic/codebuddy.host.js +1 -1
- package/dynamic/codex.host.js +1955 -0
- package/dynamic/codex.host.template.mjs +137 -0
- package/dynamic/mimo.host.js +1 -1
- package/dynamic/opencode.host.js +1 -1
- package/home-plugin/cli-bridge-indicator/lib/client.js +600 -606
- package/home-plugin/cli-bridge-indicator/lib/preset-declarations.mjs +87 -9
- package/mcp/cli-bridge-mcp.mjs +6 -4
- package/mcp/providers/claude.mjs +231 -0
- package/mcp/providers/codex.mjs +239 -0
- package/package.json +10 -6
- package/preset/agy-first/agent.cordis.yml +0 -5
- package/preset/agy-first/plugin-list.mjs +4 -1
- package/preset/claude-first/agent.cordis.yml +136 -0
- package/preset/claude-first/cli-bridge.mjs +131 -0
- package/preset/claude-first/plugin-list.mjs +28 -0
- package/preset/claude-first/preset.yml +3 -0
- package/preset/codebuddy-first/agent.cordis.yml +0 -5
- package/preset/codex-first/agent.cordis.yml +136 -0
- package/preset/codex-first/cli-bridge.mjs +160 -0
- package/preset/codex-first/plugin-list.mjs +28 -0
- package/preset/codex-first/preset.yml +3 -0
- package/preset/mimo-first/agent.cordis.yml +1 -7
- package/preset/mimo-first/plugin-list.mjs +18 -12
- package/preset/mimo-member/plugin-list.mjs +49 -0
- package/preset/opencode-first/agent.cordis.yml +0 -5
- package/preset/opencode-first/plugin-list.mjs +4 -2
- package/preset/standard-base.mjs +18 -6
- package/providers/claude-exe.mjs +112 -0
- package/providers/claude-models.mjs +334 -0
- package/providers/claude-policy.mjs +37 -0
- package/providers/claude.mjs +508 -0
- package/providers/codex-cli.mjs +471 -0
- package/providers/codex-models.mjs +107 -0
- package/providers/codex.mjs +544 -0
- package/providers/registry.mjs +25 -1
- package/scripts/build.mjs +1 -2
- package/scripts/verify.mjs +8 -2
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
// providers/codex-cli.mjs — codex 事实层:配置发现 / 可执行探测 / 三层模型清单。
|
|
2
|
+
//
|
|
3
|
+
// 从 providers/codex.mjs 拆出(规则 §26:主文件必须留在 600 物理行内)。这里只放
|
|
4
|
+
// 与「本机 codex 装成什么样」有关的纯逻辑:~/.codex 布局、config.toml 最小 TOML 解析、
|
|
5
|
+
// 可执行候选、三层模型清单(实时 HTTP / 本地目录 / 内置定义)。不含状态机、不含 argv。
|
|
6
|
+
//
|
|
7
|
+
// 沙箱纪律(与 providers/*.mjs 同):本文件会被 scripts/build.mjs 经模板的 EXTRA 标记
|
|
8
|
+
// 内联进同一个 dynamic 沙箱作用域 —— 因为 codex.mjs 主文件已超红线,必须走片文件:
|
|
9
|
+
// 1) 不得 import 任何模块(import 行会被剥掉,函数体留下但依赖丢失 → 运行期炸);
|
|
10
|
+
// Node 内建一律经 globalThis.process.getBuiltinModule + typeof 守卫。
|
|
11
|
+
// 2) 顶层标识符一律 codex 前缀,禁止与 bridge-kit / core/scheduling.mjs / codex.mjs
|
|
12
|
+
// 撞名(同一作用域重复声明 = 构建期 Identifier already declared)。
|
|
13
|
+
// 3) 不得出现 import.meta 字面量。
|
|
14
|
+
// 4) ⚠️ 注释里绝不写出占位标记字面量:build 做全局文本替换,注释里出现同样会被
|
|
15
|
+
// 替换成正文代码,把注释从中间劈开 → 生成物语法崩。
|
|
16
|
+
//
|
|
17
|
+
// 实测事实(codex-cli 0.98.0,本机 Windows):
|
|
18
|
+
// • 没有 `codex models` 子命令;`codex debug` 只有 app-server。故「实时模型清单」
|
|
19
|
+
// 只能走 HTTP(config.toml 里 model_providers.<active>.base_url + bearer token)。
|
|
20
|
+
// • 用无效 model id 探测不会吐名录(只报 "xxx is not a valid model ID")——
|
|
21
|
+
// 与 codebuddy/claude 的 400 名录机制不同,别照抄那套。
|
|
22
|
+
// • GET {base_url}/models 实测 200,返回 { data: [ {id,name,pricing,context_length,…} ] }。
|
|
23
|
+
// • ~/.codex/{model_catalog_json} 与 ~/.codex/models.json 都是 { models: [ {slug,…} ] }。
|
|
24
|
+
|
|
25
|
+
// ── 宿主能力(env / fs / path)───────────────────────────────────────────────
|
|
26
|
+
// keystone 的 settings 面板只用 createProvider(id,{env}) 构造描述符(不 attach、不注入
|
|
27
|
+
// nodeFs),所以这里必须能自己摸到 Node 内建;摸不到(dynamic 沙箱)时优雅缺席,
|
|
28
|
+
// 由调用方给出「本形态没有 fs」的明确错误,绝不静默返回空列表。
|
|
29
|
+
let codexHostEnv = {}
|
|
30
|
+
let codexHostFs = null
|
|
31
|
+
let codexHostPath = null
|
|
32
|
+
|
|
33
|
+
export function codexSetHost(envObj, fsMod, pathMod) {
|
|
34
|
+
if (envObj && typeof envObj === 'object') codexHostEnv = envObj
|
|
35
|
+
if (fsMod && typeof fsMod.readFileSync === 'function') codexHostFs = fsMod
|
|
36
|
+
if (pathMod && typeof pathMod.join === 'function') codexHostPath = pathMod
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function codexProcess() {
|
|
40
|
+
try {
|
|
41
|
+
const p = globalThis.process
|
|
42
|
+
return p && typeof p === 'object' ? p : null
|
|
43
|
+
} catch (e) { return null }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function codexBuiltin(name) {
|
|
47
|
+
try {
|
|
48
|
+
const p = codexProcess()
|
|
49
|
+
return (p && typeof p.getBuiltinModule === 'function') ? p.getBuiltinModule(name) : null
|
|
50
|
+
} catch (e) { return null }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function codexFs() {
|
|
54
|
+
if (codexHostFs) return codexHostFs
|
|
55
|
+
const nf = codexBuiltin('node:fs')
|
|
56
|
+
return nf && typeof nf.readFileSync === 'function' ? nf : null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function codexPathJoin() {
|
|
60
|
+
const parts = Array.prototype.slice.call(arguments).filter((x) => x !== undefined && x !== null && x !== '')
|
|
61
|
+
if (!parts.length) return ''
|
|
62
|
+
const mod = codexHostPath || codexBuiltin('node:path')
|
|
63
|
+
if (mod && typeof mod.join === 'function') return mod.join.apply(mod, parts)
|
|
64
|
+
return parts.map((x) => String(x)).join('/')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function codexReadText(p) {
|
|
68
|
+
if (!p) return null
|
|
69
|
+
const fs = codexFs()
|
|
70
|
+
if (!fs) return null
|
|
71
|
+
try { return fs.readFileSync(p, 'utf8') } catch (e) { return null }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** CODEX_HOME 覆盖 ~/.codex(CLI 自身语义)。 */
|
|
75
|
+
export function codexHomeDir(env) {
|
|
76
|
+
const e = env || codexHostEnv || {}
|
|
77
|
+
if (e.CODEX_HOME) return String(e.CODEX_HOME)
|
|
78
|
+
const home = e.USERPROFILE || e.HOME || ''
|
|
79
|
+
return home ? codexPathJoin(home, '.codex') : ''
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── config.toml 最小解析 ─────────────────────────────────────────────────────
|
|
83
|
+
// 只需要字符串标量与表头:本模块读的键是 model / model_provider / model_catalog_json,
|
|
84
|
+
// 以及 [model_providers.<name>] 下的 base_url / experimental_bearer_token。
|
|
85
|
+
// 数组(notify = [...])、布尔、数字一律跳过 —— 宁可少读,也不猜错类型。
|
|
86
|
+
export function codexParseToml(text) {
|
|
87
|
+
const top = Object.create(null)
|
|
88
|
+
const tables = Object.create(null)
|
|
89
|
+
let cur = top
|
|
90
|
+
for (const raw of String(text || '').split(/\r?\n/)) {
|
|
91
|
+
const line = raw.trim()
|
|
92
|
+
if (!line || line.charAt(0) === '#') continue
|
|
93
|
+
const hdr = /^\[([^\]]+)\]$/.exec(line)
|
|
94
|
+
if (hdr) {
|
|
95
|
+
const name = hdr[1].trim()
|
|
96
|
+
if (!tables[name]) tables[name] = Object.create(null)
|
|
97
|
+
cur = tables[name]
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
const kv = /^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/.exec(line)
|
|
101
|
+
if (!kv) continue
|
|
102
|
+
const rawVal = kv[2].trim()
|
|
103
|
+
let str = null
|
|
104
|
+
if (/^"(?:[^"\\]|\\.)*"$/.test(rawVal)) str = rawVal.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\')
|
|
105
|
+
else if (/^'[^']*'$/.test(rawVal)) str = rawVal.slice(1, -1)
|
|
106
|
+
else if (/^[A-Za-z0-9_.:/@-]+$/.test(rawVal) && rawVal !== 'true' && rawVal !== 'false' && !/^-?[0-9]+(\.[0-9]+)?$/.test(rawVal)) str = rawVal
|
|
107
|
+
if (str === null) continue
|
|
108
|
+
cur[kv[1]] = str
|
|
109
|
+
}
|
|
110
|
+
return { top, tables }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 汇总本机 codex 的生效配置。
|
|
115
|
+
* @returns {{ home, configPath, configFound, providerName, baseUrl, token, model,
|
|
116
|
+
* catalogFile, authPath, authFound, authBaseUrl, authApiKey }}
|
|
117
|
+
*/
|
|
118
|
+
export function codexReadConfig(env) {
|
|
119
|
+
const e = env || codexHostEnv || {}
|
|
120
|
+
const home = codexHomeDir(e)
|
|
121
|
+
const configPath = home ? codexPathJoin(home, 'config.toml') : ''
|
|
122
|
+
const configText = codexReadText(configPath)
|
|
123
|
+
const parsed = configText ? codexParseToml(configText) : { top: Object.create(null), tables: Object.create(null) }
|
|
124
|
+
const providerName = typeof parsed.top.model_provider === 'string' ? parsed.top.model_provider : ''
|
|
125
|
+
const block = providerName && parsed.tables['model_providers.' + providerName]
|
|
126
|
+
? parsed.tables['model_providers.' + providerName]
|
|
127
|
+
: Object.create(null)
|
|
128
|
+
const authPath = home ? codexPathJoin(home, 'auth.json') : ''
|
|
129
|
+
const authText = codexReadText(authPath)
|
|
130
|
+
let auth = null
|
|
131
|
+
try { auth = authText ? JSON.parse(authText) : null } catch (err) { auth = null }
|
|
132
|
+
const authBaseUrl = auth && typeof auth.OPENAI_BASE_URL === 'string' ? auth.OPENAI_BASE_URL : ''
|
|
133
|
+
const authApiKey = auth && typeof auth.OPENAI_API_KEY === 'string' ? auth.OPENAI_API_KEY : ''
|
|
134
|
+
const baseUrl = typeof block.base_url === 'string' && block.base_url ? block.base_url : authBaseUrl
|
|
135
|
+
const token = typeof block.experimental_bearer_token === 'string' && block.experimental_bearer_token
|
|
136
|
+
? block.experimental_bearer_token
|
|
137
|
+
: authApiKey
|
|
138
|
+
return {
|
|
139
|
+
home,
|
|
140
|
+
configPath,
|
|
141
|
+
configFound: configText !== null,
|
|
142
|
+
providerName,
|
|
143
|
+
baseUrl: String(baseUrl || ''),
|
|
144
|
+
token: String(token || ''),
|
|
145
|
+
model: typeof parsed.top.model === 'string' ? parsed.top.model : '',
|
|
146
|
+
catalogFile: typeof parsed.top.model_catalog_json === 'string' ? parsed.top.model_catalog_json : '',
|
|
147
|
+
authPath,
|
|
148
|
+
authFound: auth !== null,
|
|
149
|
+
authBaseUrl: String(authBaseUrl || ''),
|
|
150
|
+
authApiKey: String(authApiKey || '')
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 实时模型清单的端点(base_url + /models)。缺 base_url 时明确失败,不猜端点。 */
|
|
155
|
+
export function codexModelsEndpoint(env) {
|
|
156
|
+
const e = env || codexHostEnv || {}
|
|
157
|
+
const cfg = codexReadConfig(env)
|
|
158
|
+
// 显式覆盖(诊断/测试/临时换端点用):CODEX_BRIDGE_MODELS_URL 既可是完整 URL,
|
|
159
|
+
// 也可是 base_url(自动补 /models);token 同理可覆盖。
|
|
160
|
+
const override = String(e.CODEX_BRIDGE_MODELS_URL || '').trim()
|
|
161
|
+
const base = String(override || cfg.baseUrl || '').trim()
|
|
162
|
+
const token = String(e.CODEX_BRIDGE_MODELS_TOKEN || cfg.token || '')
|
|
163
|
+
if (!base) {
|
|
164
|
+
return {
|
|
165
|
+
ok: false, cfg, url: '', token: '', baseUrl: '', providerName: cfg.providerName,
|
|
166
|
+
error: 'no base_url configured: ' + (cfg.configFound
|
|
167
|
+
? 'config.toml has no model_providers.' + (cfg.providerName || '<none>') + '.base_url and auth.json has no OPENAI_BASE_URL'
|
|
168
|
+
: 'config.toml not found at ' + (cfg.configPath || '(unknown)')) +
|
|
169
|
+
'. Codex CLI has no `models` subcommand, so a live list can only come from the provider HTTP API (/models).'
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const url = /\/models\/*$/.test(base) ? base : base.replace(/\/+$/, '') + '/models'
|
|
173
|
+
return {
|
|
174
|
+
ok: true,
|
|
175
|
+
cfg,
|
|
176
|
+
baseUrl: base,
|
|
177
|
+
url,
|
|
178
|
+
token,
|
|
179
|
+
providerName: cfg.providerName
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── 可执行文件 ───────────────────────────────────────────────────────────────
|
|
184
|
+
/** 候选顺序:面板 binPath(调用方传入)> CODEX_BIN > CODEX_CLI > CODEX_CLI_PATH > PATH 名。 */
|
|
185
|
+
export function codexExeCandidates(env) {
|
|
186
|
+
const e = env || codexHostEnv || {}
|
|
187
|
+
const out = []
|
|
188
|
+
const push = (v) => { const s = String(v || '').trim(); if (s && out.indexOf(s) < 0) out.push(s) }
|
|
189
|
+
push(e.CODEX_BIN)
|
|
190
|
+
push(e.CODEX_CLI)
|
|
191
|
+
push(e.CODEX_CLI_PATH)
|
|
192
|
+
push('codex')
|
|
193
|
+
return out
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** .mjs/.cjs/.js 目标经 node 包装(测试夹具与自定义入口都走这条)。 */
|
|
197
|
+
export function codexResolveExePrefix(env, binPath) {
|
|
198
|
+
const bin = String(binPath || '').trim() || codexExeCandidates(env)[0]
|
|
199
|
+
if (/\.(mjs|cjs|js)$/i.test(bin)) {
|
|
200
|
+
const p = codexProcess()
|
|
201
|
+
return [p && p.execPath ? p.execPath : 'node', String(bin)]
|
|
202
|
+
}
|
|
203
|
+
return [String(bin)]
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Windows npm 全局安装的 `codex.cmd` 不能直接 spawn(CreateProcess 不解析 .cmd/.bat;
|
|
208
|
+
* 走 shell 又会让含空格/引号的 prompt 被二次解释 —— 正是本桥刻意避免的形态)。
|
|
209
|
+
* 与其抛出难懂 EINVAL,不如在解析阶段给一条可行动的错误。
|
|
210
|
+
* @returns {string|null} 拒绝原因(null = 可以用)
|
|
211
|
+
*/
|
|
212
|
+
export function codexShimRejectReason(binPath) {
|
|
213
|
+
const bin = String(binPath || '').trim()
|
|
214
|
+
if (!/\.(cmd|bat)$/i.test(bin)) return null
|
|
215
|
+
return 'codex executable "' + bin + '" is a Windows .cmd/.bat shim, which cannot be spawned directly without a shell ' +
|
|
216
|
+
'(and a shell would re-interpret the prompt). Point binPath / CODEX_BIN at the real codex.exe instead — ' +
|
|
217
|
+
'for an npm global install that is under %APPDATA%\\npm\\node_modules\\@openai\\codex\\vendor\\<platform>\\codex.exe.'
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── 模型清单:三层来源(顺序即权威性)────────────────────────────────────────
|
|
221
|
+
// A live —— GET {base_url}/models(服务端裁决,唯一权威)
|
|
222
|
+
// B catalog—— config.toml 的 model_catalog_json(codex 自用的模型目录)
|
|
223
|
+
// C builtin—— ~/.codex/models.json(CLI 内置定义)
|
|
224
|
+
// 红线:B/C 绝不允许冒充 A。清单项与结果对象都带 source,调用方据此展示。
|
|
225
|
+
export function codexNormalizeModelItem(raw, source) {
|
|
226
|
+
const r = raw && typeof raw === 'object' ? raw : null
|
|
227
|
+
if (!r) return null
|
|
228
|
+
const id = String(r.id || r.slug || r.model || r.name || '').trim()
|
|
229
|
+
if (!id) return null
|
|
230
|
+
let free = false
|
|
231
|
+
if (r.pricing && typeof r.pricing === 'object') {
|
|
232
|
+
const p = r.pricing.prompt
|
|
233
|
+
if (p === 0 || p === '0' || p === '0.0') free = true
|
|
234
|
+
}
|
|
235
|
+
if (!free && /(?::free$|-free$)/i.test(id)) free = true
|
|
236
|
+
const ctxNum = Number(r.context_window || r.context_length || r.max_context_window || 0)
|
|
237
|
+
return {
|
|
238
|
+
id,
|
|
239
|
+
name: String(r.display_name || r.name || id),
|
|
240
|
+
source,
|
|
241
|
+
free,
|
|
242
|
+
contextWindow: Number.isFinite(ctxNum) && ctxNum > 0 ? ctxNum : null,
|
|
243
|
+
visibility: typeof r.visibility === 'string' ? r.visibility : null
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 解析 {data:[…]} 或 {models:[…]} 两种载荷。畸形载荷返回明确错误。 */
|
|
248
|
+
export function codexParseModelsPayload(text, source) {
|
|
249
|
+
let json = null
|
|
250
|
+
try { json = JSON.parse(String(text || '')) } catch (e) {
|
|
251
|
+
return { ok: false, models: [], error: 'models payload is not JSON: ' + String(text || '').slice(0, 160) }
|
|
252
|
+
}
|
|
253
|
+
const arr = json && Array.isArray(json.data) ? json.data
|
|
254
|
+
: (json && Array.isArray(json.models) ? json.models : null)
|
|
255
|
+
if (!arr) return { ok: false, models: [], error: 'models payload has neither .data nor .models array: ' + String(text || '').slice(0, 160) }
|
|
256
|
+
const models = []
|
|
257
|
+
const seen = Object.create(null)
|
|
258
|
+
let hidden = 0
|
|
259
|
+
for (const raw of arr) {
|
|
260
|
+
const item = codexNormalizeModelItem(raw, source)
|
|
261
|
+
if (!item || seen[item.id]) continue
|
|
262
|
+
if (item.visibility && item.visibility !== 'list') { hidden += 1; continue }
|
|
263
|
+
seen[item.id] = true
|
|
264
|
+
models.push(item)
|
|
265
|
+
}
|
|
266
|
+
return { ok: true, models, hidden, total: arr.length }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 进程内缓存(面板每次渲染都会来取,避免反复打网络 / 反复起子进程)。
|
|
270
|
+
const CODEX_MODEL_CACHE = { at: 0, url: '', payload: null }
|
|
271
|
+
const CODEX_MODEL_TTL_MS = 5 * 60 * 1000
|
|
272
|
+
|
|
273
|
+
function codexCacheNow() { return Date.now() }
|
|
274
|
+
|
|
275
|
+
/** 已缓存的实时清单(无则 null)。供同步面板路径做零阻塞快读。 */
|
|
276
|
+
export function codexCachedLiveModels(env, nowMs) {
|
|
277
|
+
const ep = codexModelsEndpoint(env)
|
|
278
|
+
if (!ep.ok) return null
|
|
279
|
+
const hit = CODEX_MODEL_CACHE
|
|
280
|
+
const at = Number(nowMs) || Date.now()
|
|
281
|
+
if (hit.payload && hit.url === ep.url && at - hit.at < CODEX_MODEL_TTL_MS) return hit.payload
|
|
282
|
+
return null
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function codexRememberLive(url, payload) {
|
|
286
|
+
CODEX_MODEL_CACHE.at = codexCacheNow()
|
|
287
|
+
CODEX_MODEL_CACHE.url = url
|
|
288
|
+
CODEX_MODEL_CACHE.payload = payload
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function codexFetchInit(token) {
|
|
292
|
+
const headers = { accept: 'application/json' }
|
|
293
|
+
if (token) headers.authorization = 'Bearer ' + token
|
|
294
|
+
return { method: 'GET', headers }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function codexDescribeFetchError(status, body) {
|
|
298
|
+
const head = String(body || '').replace(/\s+/g, ' ').trim().slice(0, 200)
|
|
299
|
+
if (status === 401 || status === 403) {
|
|
300
|
+
return 'HTTP ' + status + ' — the provider rejected the credential. Set model_providers.<name>.experimental_bearer_token (or auth.json OPENAI_API_KEY) for the endpoint you actually use. ' + head
|
|
301
|
+
}
|
|
302
|
+
return 'HTTP ' + status + (head ? ' — ' + head : '')
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** A 层(异步):GET {base_url}/models。deps.fetch 可注入(测试),否则用全局 fetch。 */
|
|
306
|
+
export async function codexLiveModels(env, opts) {
|
|
307
|
+
const o = opts || {}
|
|
308
|
+
const ep = codexModelsEndpoint(env)
|
|
309
|
+
if (!ep.ok) return { ok: false, source: 'live', models: [], error: ep.error }
|
|
310
|
+
const cached = codexCachedLiveModels(env, o.nowMs)
|
|
311
|
+
if (cached && !o.refresh) return cached
|
|
312
|
+
const f = o.fetch || ((typeof globalThis !== 'undefined' && typeof globalThis.fetch === 'function') ? globalThis.fetch : null)
|
|
313
|
+
if (!f) {
|
|
314
|
+
return { ok: false, source: 'live', models: [], url: ep.url, error: 'no fetch capability in this host form, so the live model list from ' + ep.url + ' cannot be read. Use source="catalog" (config.toml model_catalog_json) or source="builtin" (~/.codex/models.json) explicitly — they are local and are NOT authoritative.' }
|
|
315
|
+
}
|
|
316
|
+
let res
|
|
317
|
+
try { res = await f(ep.url, codexFetchInit(ep.token)) } catch (e) {
|
|
318
|
+
return { ok: false, source: 'live', models: [], url: ep.url, error: 'GET ' + ep.url + ' failed: ' + String(e && e.message || e) + '. Check network/proxy, or set CODEX_BRIDGE_MODELS_URL to the provider catalogue endpoint.' }
|
|
319
|
+
}
|
|
320
|
+
let body = ''
|
|
321
|
+
try { body = typeof res.text === 'function' ? await res.text() : JSON.stringify(res.json || {}) } catch (e) { body = '' }
|
|
322
|
+
const status = Number(res && res.status) || 0
|
|
323
|
+
if (!res || status < 200 || status >= 300) {
|
|
324
|
+
return { ok: false, source: 'live', models: [], url: ep.url, error: 'GET ' + ep.url + ' → ' + codexDescribeFetchError(status, body) }
|
|
325
|
+
}
|
|
326
|
+
const parsed = codexParseModelsPayload(body, 'live')
|
|
327
|
+
if (!parsed.ok) return { ok: false, source: 'live', models: [], url: ep.url, error: ep.url + ': ' + parsed.error }
|
|
328
|
+
if (!parsed.models.length) {
|
|
329
|
+
return { ok: false, source: 'live', models: [], url: ep.url, error: ep.url + ' returned 0 usable models (payload items=' + parsed.total + '). Refusing to report an empty list as if it were authoritative.' }
|
|
330
|
+
}
|
|
331
|
+
const payload = {
|
|
332
|
+
ok: true, source: 'live', models: parsed.models, count: parsed.models.length,
|
|
333
|
+
url: ep.url, baseUrl: ep.baseUrl, providerName: ep.providerName, fetchedAt: Date.now()
|
|
334
|
+
}
|
|
335
|
+
codexRememberLive(ep.url, payload)
|
|
336
|
+
return payload
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// 子进程探针脚本:spawnSync 里不能 await,所以让子进程自己 async fetch 再打印。
|
|
340
|
+
// token/url 只经 env 传递(绝不进命令行,避免凭据出现在进程参数里被别的进程看到)。
|
|
341
|
+
// 子进程只回吐原始 body,解析仍由上面的 parse 统一做(单一解析实现,避免漂移)。
|
|
342
|
+
const CODEX_PROBE_SCRIPT = [
|
|
343
|
+
'const url = process.env.CODEX_BRIDGE_PROBE_URL',
|
|
344
|
+
'const token = process.env.CODEX_BRIDGE_PROBE_TOKEN || ""',
|
|
345
|
+
'const ms = Number(process.env.CODEX_BRIDGE_PROBE_TIMEOUT || 8000)',
|
|
346
|
+
'const headers = { accept: "application/json" }',
|
|
347
|
+
'if (token) headers.authorization = "Bearer " + token',
|
|
348
|
+
'const ctrl = new AbortController()',
|
|
349
|
+
'const timer = setTimeout(() => { try { ctrl.abort() } catch (e) {} }, ms)',
|
|
350
|
+
'fetch(url, { headers, signal: ctrl.signal })',
|
|
351
|
+
' .then(async (r) => { const b = await r.text(); process.stdout.write(JSON.stringify({ status: r.status, body: b })) })',
|
|
352
|
+
' .catch((e) => { process.stdout.write(JSON.stringify({ status: 0, error: String((e && e.message) || e) })) })',
|
|
353
|
+
' .finally(() => clearTimeout(timer))'
|
|
354
|
+
].join('\n')
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* A 层(同步):设置面板是同步回调,而 HTTP 客户端没有同步 API —— 用一个 node 子进程
|
|
358
|
+
* 跑上面的探针脚本,spawnSync 拿回 body。结果进 5 分钟缓存(面板反复渲染不至于反复起进程)。
|
|
359
|
+
*/
|
|
360
|
+
export function codexLiveModelsSync(env, opts) {
|
|
361
|
+
const o = opts || {}
|
|
362
|
+
const ep = codexModelsEndpoint(env)
|
|
363
|
+
if (!ep.ok) return { ok: false, source: 'live', models: [], error: ep.error }
|
|
364
|
+
const cached = codexCachedLiveModels(env, o.nowMs)
|
|
365
|
+
if (cached) return cached
|
|
366
|
+
const proc = codexProcess()
|
|
367
|
+
const child = codexBuiltin('node:child_process')
|
|
368
|
+
if (!proc || !proc.execPath || !child || typeof child.spawnSync !== 'function') {
|
|
369
|
+
return { ok: false, source: 'live', models: [], url: ep.url, error: 'no spawnSync capability to read ' + ep.url + ' synchronously' }
|
|
370
|
+
}
|
|
371
|
+
const r = child.spawnSync(proc.execPath, ['-e', CODEX_PROBE_SCRIPT], {
|
|
372
|
+
encoding: 'utf8',
|
|
373
|
+
timeout: Math.max(2000, Math.min(Number(o.timeoutMs) || 9000, 30000)),
|
|
374
|
+
windowsHide: true,
|
|
375
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
376
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
377
|
+
env: Object.assign({}, proc.env, {
|
|
378
|
+
CODEX_BRIDGE_PROBE_URL: ep.url,
|
|
379
|
+
CODEX_BRIDGE_PROBE_TOKEN: ep.token,
|
|
380
|
+
CODEX_BRIDGE_PROBE_TIMEOUT: String(Math.max(2000, Math.min(Number(o.timeoutMs) || 9000, 30000)) - 500)
|
|
381
|
+
})
|
|
382
|
+
})
|
|
383
|
+
if (!r || r.error) {
|
|
384
|
+
return { ok: false, source: 'live', models: [], url: ep.url, error: 'probe spawn failed: ' + String((r && r.error && (r.error.code || r.error.message)) || 'unknown') }
|
|
385
|
+
}
|
|
386
|
+
let out = null
|
|
387
|
+
try { out = JSON.parse(String(r.stdout || '')) } catch (e) { out = null }
|
|
388
|
+
if (!out) {
|
|
389
|
+
return { ok: false, source: 'live', models: [], url: ep.url, error: 'probe produced no JSON (exit=' + r.status + '): ' + String(r.stderr || r.stdout || '').slice(0, 200) }
|
|
390
|
+
}
|
|
391
|
+
if (!out.status && out.error) return { ok: false, source: 'live', models: [], url: ep.url, error: 'GET ' + ep.url + ' failed: ' + String(out.error) }
|
|
392
|
+
if (out.status < 200 || out.status >= 300) {
|
|
393
|
+
return { ok: false, source: 'live', models: [], url: ep.url, error: 'GET ' + ep.url + ' → ' + codexDescribeFetchError(out.status, out.body) }
|
|
394
|
+
}
|
|
395
|
+
const parsed = codexParseModelsPayload(out.body, 'live')
|
|
396
|
+
if (!parsed.ok) return { ok: false, source: 'live', models: [], url: ep.url, error: ep.url + ': ' + parsed.error }
|
|
397
|
+
if (!parsed.models.length) {
|
|
398
|
+
return { ok: false, source: 'live', models: [], url: ep.url, error: ep.url + ' returned 0 usable models (payload items=' + parsed.total + '). Refusing to report an empty list as authoritative.' }
|
|
399
|
+
}
|
|
400
|
+
const payload = {
|
|
401
|
+
ok: true, source: 'live', models: parsed.models, count: parsed.models.length,
|
|
402
|
+
url: ep.url, baseUrl: ep.baseUrl, providerName: ep.providerName, fetchedAt: Date.now()
|
|
403
|
+
}
|
|
404
|
+
codexRememberLive(ep.url, payload)
|
|
405
|
+
return payload
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** B 层:config.toml 的 model_catalog_json(相对 CODEX_HOME)。 */
|
|
409
|
+
export function codexCatalogModels(env) {
|
|
410
|
+
const cfg = codexReadConfig(env)
|
|
411
|
+
const rel = cfg.catalogFile
|
|
412
|
+
if (!rel) {
|
|
413
|
+
return { ok: false, source: 'catalog', models: [], error: 'config.toml has no model_catalog_json key, so there is no local catalog to read (set it to a file name under ' + (cfg.home || 'CODEX_HOME') + ').' }
|
|
414
|
+
}
|
|
415
|
+
const p = /[\\/]/.test(rel) || /^[A-Za-z]:/.test(rel) ? rel : codexPathJoin(cfg.home, rel)
|
|
416
|
+
const text = codexReadText(p)
|
|
417
|
+
if (text === null) return { ok: false, source: 'catalog', models: [], file: p, error: 'catalog file not readable: ' + p }
|
|
418
|
+
const parsed = codexParseModelsPayload(text, 'catalog')
|
|
419
|
+
if (!parsed.ok) return { ok: false, source: 'catalog', models: [], file: p, error: p + ': ' + parsed.error }
|
|
420
|
+
if (!parsed.models.length) return { ok: false, source: 'catalog', models: [], file: p, error: p + ' contains 0 visible models' }
|
|
421
|
+
return { ok: true, source: 'catalog', models: parsed.models, count: parsed.models.length, file: p }
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** C 层:~/.codex/models.json(CLI 内置定义)。 */
|
|
425
|
+
export function codexBuiltinModels(env) {
|
|
426
|
+
const cfg = codexReadConfig(env)
|
|
427
|
+
if (!cfg.home) return { ok: false, source: 'builtin', models: [], error: 'CODEX_HOME/USERPROFILE unknown, cannot locate ~/.codex/models.json' }
|
|
428
|
+
const p = codexPathJoin(cfg.home, 'models.json')
|
|
429
|
+
const text = codexReadText(p)
|
|
430
|
+
if (text === null) return { ok: false, source: 'builtin', models: [], file: p, error: 'builtin model definitions not readable: ' + p }
|
|
431
|
+
const parsed = codexParseModelsPayload(text, 'builtin')
|
|
432
|
+
if (!parsed.ok) return { ok: false, source: 'builtin', models: [], file: p, error: p + ': ' + parsed.error }
|
|
433
|
+
if (!parsed.models.length) return { ok: false, source: 'builtin', models: [], file: p, error: p + ' contains 0 visible models' }
|
|
434
|
+
return { ok: true, source: 'builtin', models: parsed.models, count: parsed.models.length, file: p }
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* 设置面板下拉的候选池。
|
|
439
|
+
* 优先实时(缓存或同步探测);实时拿不到时**明确标注**地退到本地目录,
|
|
440
|
+
* 让用户至少有东西可选,同时 note 里说清「这不是服务端权威清单」。
|
|
441
|
+
*/
|
|
442
|
+
export function codexModelOptions(env, opts) {
|
|
443
|
+
const o = opts || {}
|
|
444
|
+
const live = o.skipLive ? null : (codexCachedLiveModels(env) || codexLiveModelsSync(env, { timeoutMs: o.timeoutMs }))
|
|
445
|
+
if (live && live.ok && live.models.length) {
|
|
446
|
+
return {
|
|
447
|
+
options: live.models.map((m) => ({ value: m.id, label: m.id + (m.free ? ' [free]' : '') + ' [实时]' })),
|
|
448
|
+
source: 'live', url: live.url, note: '实时清单来自 ' + live.url + '(' + live.count + ' 个,免费模型标 [free])。'
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
const cat = codexCatalogModels(env)
|
|
452
|
+
if (cat.ok) {
|
|
453
|
+
return {
|
|
454
|
+
options: cat.models.map((m) => ({ value: m.id, label: m.id + ' [本地目录]' })),
|
|
455
|
+
source: 'catalog', file: cat.file,
|
|
456
|
+
note: '实时清单不可用(' + String((live && live.error) || '未探测') + ')。以下 ' + cat.count + ' 项来自 config.toml 的 model_catalog_json,仅代表 codex 本地目录,不是服务端权威清单。'
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const builtin = codexBuiltinModels(env)
|
|
460
|
+
if (builtin.ok) {
|
|
461
|
+
return {
|
|
462
|
+
options: builtin.models.map((m) => ({ value: m.id, label: m.id + ' [内置定义]' })),
|
|
463
|
+
source: 'builtin', file: builtin.file,
|
|
464
|
+
note: '实时清单与本地目录都不可用(' + String((cat && cat.error) || '') + ')。以下来自 ~/.codex/models.json 内置定义,不是服务端权威清单。'
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return {
|
|
468
|
+
options: [], source: 'none',
|
|
469
|
+
note: '未能读取任何模型清单:' + String((live && live.error) || 'live unavailable') + ';可直接手动输入模型 id。'
|
|
470
|
+
}
|
|
471
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// providers/codex-models.mjs — codex_models 工具定义(纯逻辑)。
|
|
2
|
+
//
|
|
3
|
+
// 从 providers/codex.mjs 再拆出:两个文件都要留在 600 物理行红线内(规则 §26)。
|
|
4
|
+
// scripts/build.mjs 会把 providers/codex-*.mjs 全部内联进 dynamic 沙箱的同一作用域,
|
|
5
|
+
// 所以沙箱里 codexCl* 系列函数天然可见;ESM 形态走本文件的 import。
|
|
6
|
+
// ⚠️ 注释里绝不写出占位标记字面量(build 全局替换会劈开注释 → 生成物语法崩)。
|
|
7
|
+
//
|
|
8
|
+
// 红线(用户明确要求,本仓铁律):拿不到实时清单必须 ok:false + 可操作错误。
|
|
9
|
+
// 绝不把 catalog/builtin 冒充成 live,也绝不静默返回空数组。清单项必须带 source。
|
|
10
|
+
|
|
11
|
+
import { codexLiveModels, codexCatalogModels, codexBuiltinModels } from './codex-cli.mjs'
|
|
12
|
+
|
|
13
|
+
const MODELS_PARAMS = {
|
|
14
|
+
type: 'object',
|
|
15
|
+
additionalProperties: false,
|
|
16
|
+
required: [],
|
|
17
|
+
properties: {
|
|
18
|
+
source: {
|
|
19
|
+
type: 'string',
|
|
20
|
+
enum: ['live', 'catalog', 'builtin'],
|
|
21
|
+
description: 'Which layer to read. live = GET {base_url}/models from the provider API configured in ~/.codex/config.toml — the only authoritative list. catalog = the file named by config.toml model_catalog_json. builtin = ~/.codex/models.json. Local layers are codex-local metadata, NOT a claim about what the server will accept.'
|
|
22
|
+
},
|
|
23
|
+
filter: { type: 'string', description: 'Optional case-insensitive substring filter on model id/name (e.g. "minimax" or "free").' },
|
|
24
|
+
freeOnly: { type: 'boolean', description: 'Return only models that look free (:free suffix or pricing.prompt == 0).' },
|
|
25
|
+
limit: { type: 'integer', description: 'Max models to return (default 80, 1-1000). The result always reports the full match count.' },
|
|
26
|
+
refresh: { type: 'boolean', description: 'Bypass the 5-minute live cache and re-fetch.' }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const MODELS_DESCRIPTION = 'List the models the local codex CLI can actually use, with the source of every id made explicit. Default source=live performs GET {base_url}/models against the provider endpoint configured in ~/.codex/config.toml (base_url + experimental_bearer_token, else auth.json OPENAI_BASE_URL/OPENAI_API_KEY) — codex-cli has no `models` subcommand and probing with an invalid model id does NOT print a roster, so this HTTP call is the only authoritative source. If it cannot be read, this tool returns ok:false with an actionable error and never invents or silently downgrades the list; pass source="catalog"/"builtin" only when you deliberately want the local (non-authoritative) catalog. Use this before codex_run when the model id matters, and prefer free models (:free / pricing 0) for routine work.'
|
|
31
|
+
|
|
32
|
+
function renderModels(args, value) {
|
|
33
|
+
const v = value || {}
|
|
34
|
+
if (!v.ok) return [{ type: 'text', text: 'codex_models failed: ' + String(v.error || 'unknown') }]
|
|
35
|
+
const list = Array.isArray(v.models) ? v.models : []
|
|
36
|
+
const lines = ['codex models [' + (v.source || '?') + '] (' + (v.count != null ? v.count : list.length) + ' matched' + (v.truncated ? ', showing ' + list.length : '') + '):']
|
|
37
|
+
for (const m of list.slice(0, 60)) {
|
|
38
|
+
const bits = []
|
|
39
|
+
if (m.free) bits.push('free')
|
|
40
|
+
if (m.contextWindow) bits.push(Math.round(m.contextWindow / 1000) + 'k ctx')
|
|
41
|
+
lines.push(' ' + m.id + (bits.length ? ' [' + bits.join(',') + ']' : ''))
|
|
42
|
+
}
|
|
43
|
+
if (list.length > 60) lines.push(' … and ' + (list.length - 60) + ' more')
|
|
44
|
+
if (v.source === 'live') lines.push('source: live — ' + String(v.url || ''))
|
|
45
|
+
else lines.push('source: ' + String(v.source || '') + ' — ' + String(v.file || '') + ' (local, NOT authoritative for the server)')
|
|
46
|
+
if (v.note) lines.push('note: ' + v.note)
|
|
47
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function modelsErrorHint(source, extra) {
|
|
51
|
+
const base = String(extra || 'unknown')
|
|
52
|
+
if (source !== 'live') return base
|
|
53
|
+
return base + ' — pass source="catalog" or source="builtin" to inspect the local, non-authoritative lists, or fix the endpoint/credential above and retry.'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* codex_models 工具定义。
|
|
58
|
+
* @param {{env?:object, deps?:object}} ctx env 用来定位 CODEX_HOME;deps.fetch 可注入。
|
|
59
|
+
*/
|
|
60
|
+
export function codexModelsTool(ctx) {
|
|
61
|
+
const c = ctx || {}
|
|
62
|
+
const env = c.env || {}
|
|
63
|
+
const deps = c.deps || {}
|
|
64
|
+
return {
|
|
65
|
+
name: 'codex_models',
|
|
66
|
+
description: MODELS_DESCRIPTION,
|
|
67
|
+
inputSchema: MODELS_PARAMS,
|
|
68
|
+
render: renderModels,
|
|
69
|
+
async logic(args) {
|
|
70
|
+
const a = args || {}
|
|
71
|
+
const source = a.source || 'live'
|
|
72
|
+
let res
|
|
73
|
+
if (source === 'live') res = await codexLiveModels(env, { fetch: deps.fetch, refresh: a.refresh === true })
|
|
74
|
+
else if (source === 'catalog') res = codexCatalogModels(env)
|
|
75
|
+
else if (source === 'builtin') res = codexBuiltinModels(env)
|
|
76
|
+
else return { ok: false, source, models: [], error: 'unknown source ' + JSON.stringify(String(source)) + ' (use live|catalog|builtin)' }
|
|
77
|
+
|
|
78
|
+
if (!res.ok || !Array.isArray(res.models) || !res.models.length) {
|
|
79
|
+
return { ok: false, source, models: [], error: modelsErrorHint(source, res.error) }
|
|
80
|
+
}
|
|
81
|
+
let list = res.models
|
|
82
|
+
if (a.filter) {
|
|
83
|
+
const q = String(a.filter).toLowerCase()
|
|
84
|
+
list = list.filter((m) => String(m.id).toLowerCase().indexOf(q) >= 0 || String(m.name || '').toLowerCase().indexOf(q) >= 0)
|
|
85
|
+
}
|
|
86
|
+
if (a.freeOnly) list = list.filter((m) => m.free)
|
|
87
|
+
if (!list.length) {
|
|
88
|
+
return { ok: false, source, models: [], error: 'no models matched filter=' + JSON.stringify(String(a.filter || '')) + ' freeOnly=' + String(a.freeOnly === true) + ' on source=' + source + ' (that is an empty *filter result*, not an empty provider list)' }
|
|
89
|
+
}
|
|
90
|
+
const limit = Number.isFinite(Number(a.limit)) ? Math.max(1, Math.min(Math.floor(Number(a.limit)), 1000)) : 80
|
|
91
|
+
const matched = list.length
|
|
92
|
+
const truncated = matched > limit
|
|
93
|
+
const models = list.slice(0, limit)
|
|
94
|
+
const out = {
|
|
95
|
+
ok: true, source,
|
|
96
|
+
models, count: matched, truncated,
|
|
97
|
+
freeCount: res.models.filter((m) => m.free).length
|
|
98
|
+
}
|
|
99
|
+
if (res.url) out.url = res.url
|
|
100
|
+
if (res.file) out.file = res.file
|
|
101
|
+
if (res.providerName) out.providerName = res.providerName
|
|
102
|
+
if (res.fetchedAt) out.fetchedAt = res.fetchedAt
|
|
103
|
+
if (truncated) out.note = 'showing ' + limit + ' of ' + matched + ' matched models; raise limit or narrow with filter/freeOnly.'
|
|
104
|
+
return out
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|