@luchenglong/cli-bridge 0.2.8 → 0.2.10

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/PROVIDER-CONTRACT.md +87 -5
  3. package/README.md +12 -11
  4. package/docs/HANDOVER.md +109 -15
  5. package/dynamic/agy.host.js +1 -1
  6. package/dynamic/claude.host.js +1858 -0
  7. package/dynamic/claude.host.template.mjs +108 -0
  8. package/dynamic/codebuddy.host.js +1427 -1379
  9. package/dynamic/codex.host.js +1930 -0
  10. package/dynamic/codex.host.template.mjs +137 -0
  11. package/dynamic/mimo.host.js +721 -515
  12. package/dynamic/mimo.host.template.mjs +2 -0
  13. package/dynamic/opencode.host.js +741 -585
  14. package/dynamic/opencode.host.template.mjs +2 -0
  15. package/home-plugin/cli-bridge-indicator/lib/client.js +74 -90
  16. package/home-plugin/cli-bridge-indicator/lib/index.mjs +48 -0
  17. package/home-plugin/cli-bridge-indicator/lib/preset-declarations.mjs +2 -3
  18. package/mcp/cli-bridge-mcp.mjs +4 -2
  19. package/mcp/providers/claude.mjs +231 -0
  20. package/mcp/providers/codebuddy.mjs +427 -427
  21. package/mcp/providers/codex.mjs +239 -0
  22. package/package.json +6 -2
  23. package/preset/claude-first/agent.cordis.yml +136 -0
  24. package/preset/claude-first/cli-bridge.mjs +131 -0
  25. package/preset/claude-first/plugin-list.mjs +28 -0
  26. package/preset/claude-first/preset.yml +3 -0
  27. package/preset/codex-first/agent.cordis.yml +136 -0
  28. package/preset/codex-first/cli-bridge.mjs +160 -0
  29. package/preset/codex-first/plugin-list.mjs +28 -0
  30. package/preset/codex-first/preset.yml +3 -0
  31. package/providers/claude-exe.mjs +112 -0
  32. package/providers/claude-models.mjs +338 -0
  33. package/providers/claude-policy.mjs +37 -0
  34. package/providers/claude.mjs +561 -0
  35. package/providers/codebuddy-catalog.mjs +367 -599
  36. package/providers/codebuddy-consts.mjs +72 -72
  37. package/providers/codebuddy-models-source.mjs +254 -0
  38. package/providers/codebuddy-models-tool.mjs +106 -106
  39. package/providers/codebuddy-schemas.mjs +46 -46
  40. package/providers/codebuddy.mjs +600 -578
  41. package/providers/codex-cli.mjs +405 -0
  42. package/providers/codex-models.mjs +107 -0
  43. package/providers/codex.mjs +585 -0
  44. package/providers/mimo-pure.mjs +270 -111
  45. package/providers/mimo.mjs +453 -517
  46. package/providers/opencode-exe.mjs +190 -63
  47. package/providers/opencode.mjs +553 -587
  48. package/providers/registry.mjs +25 -1
  49. package/scripts/build.mjs +261 -262
  50. package/scripts/verify.mjs +8 -2
@@ -0,0 +1,405 @@
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, signal) {
292
+ const headers = { accept: 'application/json' }
293
+ if (token) headers.authorization = 'Bearer ' + token
294
+ const init = { method: 'GET', headers }
295
+ // 面板「拉取可用模型」是 HTTP 请求上的同步等待:端点卡住时必须有上界,
296
+ // 否则设置页会一直转圈(无 signal 能力的宿主就照旧直接请求)。
297
+ if (signal) init.signal = signal
298
+ return init
299
+ }
300
+
301
+ function codexDescribeFetchError(status, body) {
302
+ const head = String(body || '').replace(/\s+/g, ' ').trim().slice(0, 200)
303
+ if (status === 401 || status === 403) {
304
+ 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
305
+ }
306
+ return 'HTTP ' + status + (head ? ' — ' + head : '')
307
+ }
308
+
309
+ /** A 层(异步):GET {base_url}/models。deps.fetch 可注入(测试),否则用全局 fetch。 */
310
+ export async function codexLiveModels(env, opts) {
311
+ const o = opts || {}
312
+ const ep = codexModelsEndpoint(env)
313
+ if (!ep.ok) return { ok: false, source: 'live', models: [], error: ep.error }
314
+ const cached = codexCachedLiveModels(env, o.nowMs)
315
+ if (cached && !o.refresh) return cached
316
+ const f = o.fetch || ((typeof globalThis !== 'undefined' && typeof globalThis.fetch === 'function') ? globalThis.fetch : null)
317
+ if (!f) {
318
+ 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.' }
319
+ }
320
+ let res
321
+ // 取清单是设置面板的一次同步等待(点按钮 → HTTP → 这里):必须有上界。
322
+ // 无 AbortController 的宿主退化为不带上界(照旧请求),不静默改变成功语义。
323
+ const timeoutMs = Math.max(2000, Math.min(Number(o.timeoutMs) || 20000, 60000))
324
+ const ctrl = (typeof AbortController === 'function') ? new AbortController() : null
325
+ const timer = ctrl ? setTimeout(() => { try { ctrl.abort() } catch (err) { } }, timeoutMs) : null
326
+ try { res = await f(ep.url, codexFetchInit(ep.token, ctrl ? ctrl.signal : null)) } catch (e) {
327
+ return { ok: false, source: 'live', models: [], url: ep.url, error: 'GET ' + ep.url + ' failed (timeout ' + timeoutMs + 'ms): ' + String(e && e.message || e) + '. Check network/proxy, or set CODEX_BRIDGE_MODELS_URL to the provider catalogue endpoint.' }
328
+ } finally {
329
+ if (timer) clearTimeout(timer)
330
+ }
331
+ let body = ''
332
+ try { body = typeof res.text === 'function' ? await res.text() : JSON.stringify(res.json || {}) } catch (e) { body = '' }
333
+ const status = Number(res && res.status) || 0
334
+ if (!res || status < 200 || status >= 300) {
335
+ return { ok: false, source: 'live', models: [], url: ep.url, error: 'GET ' + ep.url + ' → ' + codexDescribeFetchError(status, body) }
336
+ }
337
+ const parsed = codexParseModelsPayload(body, 'live')
338
+ if (!parsed.ok) return { ok: false, source: 'live', models: [], url: ep.url, error: ep.url + ': ' + parsed.error }
339
+ if (!parsed.models.length) {
340
+ 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.' }
341
+ }
342
+ const payload = {
343
+ ok: true, source: 'live', models: parsed.models, count: parsed.models.length,
344
+ url: ep.url, baseUrl: ep.baseUrl, providerName: ep.providerName, fetchedAt: Date.now()
345
+ }
346
+ codexRememberLive(ep.url, payload)
347
+ return payload
348
+ }
349
+
350
+ // 子进程探针脚本已删除:面板不再自动探测(契约 §3.2 手动拉取)。live 清单只在
351
+ // settings.refreshModels()(异步、用户点按钮)与时序工具 codex_models 里读取。
352
+
353
+ /** B 层:config.toml 的 model_catalog_json(相对 CODEX_HOME)。 */
354
+ export function codexCatalogModels(env) {
355
+ const cfg = codexReadConfig(env)
356
+ const rel = cfg.catalogFile
357
+ if (!rel) {
358
+ 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') + ').' }
359
+ }
360
+ const p = /[\\/]/.test(rel) || /^[A-Za-z]:/.test(rel) ? rel : codexPathJoin(cfg.home, rel)
361
+ const text = codexReadText(p)
362
+ if (text === null) return { ok: false, source: 'catalog', models: [], file: p, error: 'catalog file not readable: ' + p }
363
+ const parsed = codexParseModelsPayload(text, 'catalog')
364
+ if (!parsed.ok) return { ok: false, source: 'catalog', models: [], file: p, error: p + ': ' + parsed.error }
365
+ if (!parsed.models.length) return { ok: false, source: 'catalog', models: [], file: p, error: p + ' contains 0 visible models' }
366
+ return { ok: true, source: 'catalog', models: parsed.models, count: parsed.models.length, file: p }
367
+ }
368
+
369
+ /** C 层:~/.codex/models.json(CLI 内置定义)。 */
370
+ export function codexBuiltinModels(env) {
371
+ const cfg = codexReadConfig(env)
372
+ if (!cfg.home) return { ok: false, source: 'builtin', models: [], error: 'CODEX_HOME/USERPROFILE unknown, cannot locate ~/.codex/models.json' }
373
+ const p = codexPathJoin(cfg.home, 'models.json')
374
+ const text = codexReadText(p)
375
+ if (text === null) return { ok: false, source: 'builtin', models: [], file: p, error: 'builtin model definitions not readable: ' + p }
376
+ const parsed = codexParseModelsPayload(text, 'builtin')
377
+ if (!parsed.ok) return { ok: false, source: 'builtin', models: [], file: p, error: p + ': ' + parsed.error }
378
+ if (!parsed.models.length) return { ok: false, source: 'builtin', models: [], file: p, error: p + ' contains 0 visible models' }
379
+ return { ok: true, source: 'builtin', models: parsed.models, count: parsed.models.length, file: p }
380
+ }
381
+
382
+ /**
383
+ * 面板下拉的候选池(契约 §3.2):**纯读缓存**,绝不 spawnSync、绝不发请求、绝不碰本地目录。
384
+ * 探测由 settings.refreshModels() 显式触发(用户点「拉取可用模型」),成功后写回同一份
385
+ * CODEX_MODEL_CACHE,这里下次渲染直接命中 —— 面板首屏因此不会被网络拖慢。
386
+ *
387
+ * 为什么不再回落到 catalog/builtin:那两层是本地元数据,不是服务端可用清单;把它们填进
388
+ * 这个下拉等于让用户以为「服务端就这些」(0.2.6~0.2.9 反复踩的坑)。宁可空着并说清
389
+ * 「点按钮拉取」,也不展示可能已经过时的非权威列表 —— 需要本地目录请用 codex_models
390
+ * 的 source="catalog"/"builtin" 显式查询(那里逐项标注来源)。
391
+ */
392
+ export function codexCachedModelOptions(env) {
393
+ const live = codexCachedLiveModels(env)
394
+ if (!live || !live.ok || !Array.isArray(live.models) || !live.models.length) {
395
+ return {
396
+ options: [], source: 'none', count: 0,
397
+ note: '还没拉取过(或上次没拉到):点「拉取可用模型」向端点取权威清单;空表示尚未拉取 / 端点或 token 未配好。可直接手动输入模型 id。'
398
+ }
399
+ }
400
+ return {
401
+ options: live.models.map((m) => ({ value: m.id, label: m.id + (m.free ? ' [free]' : '') })),
402
+ source: 'live', count: live.count, url: live.url, fetchedAt: live.fetchedAt,
403
+ note: '上次拉取到 ' + live.count + ' 个模型(源:' + live.url + ',免费标 [free])。点「拉取可用模型」重新获取。'
404
+ }
405
+ }
@@ -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
+ }