@bitkyc08/opencodex 2.7.35 → 2.7.36

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 (80) hide show
  1. package/README.ja.md +1 -1
  2. package/README.ko.md +1 -1
  3. package/README.md +4 -2
  4. package/README.ru.md +1 -1
  5. package/README.zh-CN.md +1 -1
  6. package/bin/ocx.mjs +52 -0
  7. package/gui/dist/assets/index-BpX-hoSd.css +1 -0
  8. package/gui/dist/assets/index-ZmFopEYw.js +52 -0
  9. package/gui/dist/index.html +2 -2
  10. package/package.json +1 -1
  11. package/src/adapters/cursor/cursor-errors.ts +38 -1
  12. package/src/adapters/cursor/discovery.ts +1 -0
  13. package/src/adapters/cursor/effort-map.ts +1 -0
  14. package/src/adapters/cursor/live-models.ts +22 -5
  15. package/src/adapters/cursor/live-transport.ts +82 -7
  16. package/src/adapters/cursor/transport.ts +2 -0
  17. package/src/adapters/cursor.ts +5 -2
  18. package/src/adapters/openai-responses.ts +64 -1
  19. package/src/cli/doctor.ts +10 -0
  20. package/src/cli/help.ts +10 -0
  21. package/src/cli/index.ts +88 -9
  22. package/src/cli/internal-dispatch.ts +20 -0
  23. package/src/cli/status.ts +15 -4
  24. package/src/cli/tray-proxy.ts +52 -0
  25. package/src/codex/auth-api.ts +46 -5
  26. package/src/codex/autostart-health.ts +149 -0
  27. package/src/codex/catalog/aggregation.ts +268 -0
  28. package/src/codex/catalog/bundled.ts +188 -0
  29. package/src/codex/catalog/effort.ts +263 -0
  30. package/src/codex/catalog/metadata.ts +176 -0
  31. package/src/codex/catalog/parsing.ts +399 -0
  32. package/src/codex/catalog/provider-fetch.ts +609 -0
  33. package/src/codex/catalog/sync.ts +540 -0
  34. package/src/codex/catalog.ts +11 -2426
  35. package/src/codex/inject.ts +165 -3
  36. package/src/codex/shim.ts +141 -8
  37. package/src/codex/sync.ts +17 -2
  38. package/src/config.ts +23 -0
  39. package/src/lib/errors.ts +11 -0
  40. package/src/providers/antigravity-models.ts +33 -0
  41. package/src/providers/kiro-models.ts +2 -0
  42. package/src/providers/registry.ts +2 -2
  43. package/src/responses/state.ts +69 -6
  44. package/src/server/auth-cors.ts +3 -0
  45. package/src/server/management/agent-settings-routes.ts +536 -0
  46. package/src/server/management/combo-routes.ts +210 -0
  47. package/src/server/management/config-routes.ts +302 -0
  48. package/src/server/management/context.ts +21 -0
  49. package/src/server/management/logs-usage-routes.ts +176 -0
  50. package/src/server/management/model-routes.ts +253 -0
  51. package/src/server/management/oauth-account-routes.ts +301 -0
  52. package/src/server/management/provider-routes.ts +408 -0
  53. package/src/server/management/shared.ts +186 -0
  54. package/src/server/management-api.ts +23 -1806
  55. package/src/server/responses/collaboration.ts +300 -0
  56. package/src/server/responses/compact.ts +342 -0
  57. package/src/server/responses/core.ts +1498 -0
  58. package/src/server/responses/encrypted-payload.ts +231 -0
  59. package/src/server/responses/fetch-helpers.ts +157 -0
  60. package/src/server/responses.ts +9 -2172
  61. package/src/server/startup-action-control.ts +41 -0
  62. package/src/server/startup-health-cache.ts +100 -0
  63. package/src/server/windows-tray-control.ts +41 -0
  64. package/src/service.ts +171 -19
  65. package/src/tray/assets/opencodex-tray-offline.ico +0 -0
  66. package/src/tray/assets/opencodex-tray-online.ico +0 -0
  67. package/src/tray/assets/opencodex-tray-warning.ico +0 -0
  68. package/src/tray/assets/opencodex-tray.png +0 -0
  69. package/src/tray/windows-tray.ps1 +290 -0
  70. package/src/tray/windows.ts +628 -0
  71. package/src/types.ts +5 -0
  72. package/src/update/index.ts +43 -0
  73. package/src/update/job.ts +46 -0
  74. package/src/update/tray-update-plan.d.mts +18 -0
  75. package/src/update/tray-update-plan.mjs +38 -0
  76. package/src/usage/cost.ts +0 -0
  77. package/src/usage/expected-prices.ts +9 -2
  78. package/src/usage/summary.ts +42 -7
  79. package/gui/dist/assets/index-BunUANVE.js +0 -52
  80. package/gui/dist/assets/index-Sg-7L_oZ.css +0 -1
@@ -1,2426 +1,11 @@
1
- import { execFileSync } from "node:child_process";
2
- import { createHash } from "node:crypto";
3
- import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
4
- import { delimiter, dirname, join, resolve } from "node:path";
5
- import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "../config";
6
- import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "./paths";
7
- import { clearModelCache, DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelsFetchCoolingDown, markModelsFetchFailure, setCached } from "./model-cache";
8
- import { buildModelsRequest, resolveModelsAuthToken } from "../oauth";
9
- import type { OcxConfig, OcxProviderConfig } from "../types";
10
- import { modelInList } from "../types";
11
- import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../reasoning-effort";
12
- import { getJawcodeModelMetadata, getJawcodeModelMetadataCaseInsensitive, listJawcodeModelMetadata, resolveJawcodeProvider } from "../generated/jawcode-model-metadata";
13
- import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../providers/derive";
14
- import { getProviderRegistryEntry } from "../providers/registry";
15
- import { applyProviderContextCap, providerContextCap } from "../providers/context-cap";
16
- import { routedSlug, slugEquals, slugsEquivalent } from "../providers/slug-codec";
17
- import { CODEX_GPT5_IDENTITY_LINE } from "../adapters/identity";
18
- import { filterCursorConfiguredModelsByLiveDiscovery } from "../adapters/cursor/discovery";
19
- import { fetchCursorUsableModels } from "../adapters/cursor/live-models";
20
- import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
21
- import {
22
- COMBO_NAMESPACE,
23
- comboModelId,
24
- getCombo,
25
- listComboIds,
26
- targetKey,
27
- } from "../combos";
28
- import type { NormalizedComboConfig } from "../combos/types";
29
- import { providerDestinationResolvedError } from "../lib/destination-policy";
30
- import { redactSecretString } from "../lib/redact";
31
- import upstreamModelsSnapshot from "./data/upstream-models.json";
32
-
33
- const BUNDLED_CATALOG_CACHE_MS = 60_000;
34
- let bundledCatalogCache: { expiresAt: number; value: RawCatalog | null } | null = null;
35
-
36
- function legacyCatalogBackupPath(): string {
37
- return join(getConfigDir(), "catalog-backup.json");
38
- }
39
-
40
- function catalogBackupPathFor(catalogPath: string): string {
41
- const normalized = process.platform === "win32" ? resolve(catalogPath).toLowerCase() : resolve(catalogPath);
42
- const id = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
43
- return join(getConfigDir(), `catalog-backup-${id}.json`);
44
- }
45
-
46
- function samePath(a: string, b: string): boolean {
47
- const left = resolve(a);
48
- const right = resolve(b);
49
- return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
50
- }
51
-
52
- function activeCodexHome(): string | null {
53
- const raw = process.env.CODEX_HOME?.trim();
54
- if (!raw) return null;
55
- const path = resolve(expandUserPath(raw));
56
- try {
57
- return realpathSync.native(path);
58
- } catch {
59
- return path;
60
- }
61
- }
62
-
63
- function activeCodexConfigPath(): string {
64
- const home = activeCodexHome();
65
- return home ? join(home, "config.toml") : CODEX_CONFIG_PATH;
66
- }
67
-
68
- function activeDefaultCatalogPath(): string {
69
- const home = activeCodexHome();
70
- return home ? join(home, "opencodex-catalog.json") : DEFAULT_CATALOG_PATH;
71
- }
72
-
73
- function activeCodexModelsCachePath(): string {
74
- const home = activeCodexHome();
75
- return home ? join(home, "models_cache.json") : CODEX_MODELS_CACHE_PATH;
76
- }
77
-
78
- function resolveActiveCodexConfigPath(path: string): string {
79
- const home = activeCodexHome();
80
- return home ? resolve(home, path) : resolveCodexConfigPath(path);
81
- }
82
-
83
- function isDefaultCatalogPath(path: string): boolean {
84
- return samePath(path, activeDefaultCatalogPath());
85
- }
86
-
87
- /**
88
- * Native OpenAI / Codex models served via ChatGPT OAuth passthrough — FALLBACK only. The ChatGPT
89
- * backend has no `GET /models`, so the real set is read from the live Codex catalog via
90
- * nativeOpenAiSlugs(); this static list is used when no catalog is present, plus selected documented
91
- * Codex-native additions that may lag in a user's installed Codex catalog.
92
- */
93
- export const NATIVE_OPENAI_MODELS = [
94
- "gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark",
95
- "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna",
96
- ];
97
-
98
- const DOCUMENTED_NATIVE_OPENAI_ADDITIONS = [
99
- "gpt-5.3-codex-spark",
100
- "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna",
101
- ];
102
-
103
- /**
104
- * The ONLY native OpenAI/Codex slugs opencodex advertises. A user's installed Codex ships extra
105
- * native models in its live catalog (e.g. `gpt-5.2`, `gpt-5.3-codex`, `codex-auto-review`); those
106
- * are legacy/internal and must never surface in `/v1/models` or the subagent picker. Live-catalog
107
- * native slugs are filtered against this allowlist so only the supported set is exposed.
108
- */
109
- const SUPPORTED_NATIVE_OPENAI_SLUGS = new Set(NATIVE_OPENAI_MODELS);
110
-
111
- /**
112
- * True when a bare slug is an OpenAI/Codex-family native that opencodex does NOT support
113
- * (legacy/internal like `gpt-5.2`, `gpt-5.3-codex`, `codex-auto-review`). Used to drop these
114
- * from the ON-DISK catalog so the Codex file picker matches the live `/v1/models` filter,
115
- * WITHOUT removing genuine user-added natives (non gpt-/codex- slugs are preserved).
116
- */
117
- function isUnsupportedOpenAiNativeSlug(slug: string): boolean {
118
- if (slug.includes("/")) return false;
119
- if (SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug)) return false;
120
- return /^(?:gpt|codex)-/.test(slug);
121
- }
122
-
123
- const NATIVE_GPT56_CONTEXT_WINDOW = 372_000;
124
-
125
- const NATIVE_OPENAI_CONTEXT_OVERRIDES: Record<string, { contextWindow?: number; maxContextWindow?: number }> = {
126
- "gpt-5.5": { contextWindow: 272_000, maxContextWindow: 272_000 },
127
- "gpt-5.4": { contextWindow: 1_000_000, maxContextWindow: 1_000_000 },
128
- "gpt-5.3-codex-spark": { contextWindow: 100_000, maxContextWindow: 100_000 },
129
- "gpt-5.6-sol": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_CONTEXT_WINDOW },
130
- "gpt-5.6-terra": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_CONTEXT_WINDOW },
131
- "gpt-5.6-luna": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_CONTEXT_WINDOW },
132
- };
133
-
134
- /** Known context window for a supported native OpenAI slug (management API display). */
135
- export function nativeOpenAiContextWindow(slug: string): number | undefined {
136
- return NATIVE_OPENAI_CONTEXT_OVERRIDES[slug]?.contextWindow
137
- ?? (typeof UPSTREAM_NATIVE_ENTRIES.get(slug)?.context_window === "number"
138
- ? UPSTREAM_NATIVE_ENTRIES.get(slug)!.context_window as number
139
- : undefined);
140
- }
141
-
142
- /* ── Native-slug capability helpers for combo member resolution (issue #268) ── */
143
-
144
- /** Input modalities for a native OpenAI slug, defaulting to the GPT family baseline. */
145
- function nativeInputModalities(slug: string): string[] {
146
- const upstream = UPSTREAM_NATIVE_ENTRIES.get(slug);
147
- if (Array.isArray(upstream?.input_modalities) && upstream!.input_modalities!.length > 0) {
148
- return [...upstream!.input_modalities as string[]];
149
- }
150
- // gpt-5.3-codex-spark is not in the upstream snapshot; all supported natives are
151
- // text+image capable, so default to the family baseline rather than text-only.
152
- return ["text", "image"];
153
- }
154
-
155
- /** Reasoning effort ladder for a native OpenAI slug, mirroring the catalog emission path. */
156
- function nativeReasoningEfforts(slug: string): string[] {
157
- const upstream = UPSTREAM_NATIVE_ENTRIES.get(slug);
158
- const levels = Array.isArray(upstream?.supported_reasoning_levels)
159
- ? upstream!.supported_reasoning_levels as Array<{ effort?: string }>
160
- : [];
161
- if (levels.length > 0) {
162
- const efforts = levels.flatMap(l => typeof l.effort === "string" ? [l.effort] : []);
163
- // gpt-5.6 natives get max+ultra restored (ensureGpt56ReasoningLevels catalog path does
164
- // the same); older natives (gpt-5.5/5.4/5.4-mini/5.3-codex-spark) stop at xhigh per
165
- // upstream snapshot.
166
- if (isGpt56NativeSlug(slug)) {
167
- const set = new Set(efforts);
168
- for (const e of ["max", "ultra"]) set.add(e);
169
- return [...set];
170
- }
171
- return efforts;
172
- }
173
- // gpt-5.3-codex-spark is not in upstream snapshot — use the standard old-ladder default.
174
- return ["low", "medium", "high", "xhigh"];
175
- }
176
-
177
- /** Whether a native OpenAI slug supports parallel tool calls (per upstream snapshot). */
178
- function nativeParallelToolCalls(slug: string): boolean {
179
- return UPSTREAM_NATIVE_ENTRIES.get(slug)?.supports_parallel_tool_calls === true
180
- || false;
181
- }
182
-
183
- /** Quick check whether the config has any combo targets at all. */
184
- function hasComboTargets(config: { combos?: Record<string, { targets?: unknown[] }> }): boolean {
185
- const combos = config.combos;
186
- if (!combos) return false;
187
- return Object.values(combos).some(c => Array.isArray(c?.targets) && c!.targets!.length > 0);
188
- }
189
-
190
- /**
191
- * Bare (slash-free) entries of `disabledModels` — the native GPT half of the single
192
- * enable/disable choke point. Routed ids are always namespaced `provider/id`, so bare
193
- * slugs can never collide with them.
194
- */
195
- export function disabledNativeSlugs(config: Pick<OcxConfig, "disabledModels">): Set<string> {
196
- return new Set((config.disabledModels ?? []).filter(id => !id.includes("/")));
197
- }
198
-
199
- /**
200
- * Native slugs to expose on bare availability surfaces (the OpenAI list shape of
201
- * /v1/models): the advertised set minus config-disabled natives. Catalog-shaped
202
- * emissions keep disabled entries with `visibility: "hide"` instead (codex-rs hides
203
- * them from the picker itself), so sync/restore stays symmetric.
204
- */
205
- export function visibleNativeSlugs(config: Pick<OcxConfig, "disabledModels">): string[] {
206
- const disabled = disabledNativeSlugs(config);
207
- return nativeOpenAiSlugs().filter(slug => !disabled.has(slug));
208
- }
209
-
210
- /**
211
- * Native GPT rows for the management dashboard. Sourced from the STATIC supported set —
212
- * independent of catalog visibility flips, so a disabled model stays listed and can be
213
- * re-enabled from the GUI.
214
- */
215
- export function nativeModelRows(config: Pick<OcxConfig, "disabledModels">): Array<{ slug: string; disabled: boolean; contextWindow?: number }> {
216
- const disabled = disabledNativeSlugs(config);
217
- return NATIVE_OPENAI_MODELS.map(slug => {
218
- const contextWindow = nativeOpenAiContextWindow(slug);
219
- return { slug, disabled: disabled.has(slug), ...(contextWindow !== undefined ? { contextWindow } : {}) };
220
- });
221
- }
222
-
223
- /**
224
- * Central visibility flip for supported native entries in catalog-shaped output:
225
- * disabled -> "hide" (entry preserved for template/backup/restore), enabled -> "list".
226
- * Unsupported natives and routed entries are untouched.
227
- */
228
- export function applyNativeVisibility(entries: RawEntry[], disabledNative: Set<string>): RawEntry[] {
229
- for (const entry of entries) {
230
- const slug = typeof entry.slug === "string" ? entry.slug : "";
231
- if (!slug || slug.includes("/") || !SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug)) continue;
232
- entry.visibility = disabledNative.has(slug) ? "hide" : "list";
233
- }
234
- return entries;
235
- }
236
-
237
- /**
238
- * Pinned upstream models.json snapshot (openai/codex PR #31684, codex-rs/models-manager/models.json)
239
- * providing the REAL catalog entries for supported native slugs the installed Codex binary may
240
- * predate (gpt-5.6-sol/terra/luna). Restricted to supported gpt-5.6 slugs ONLY: for
241
- * gpt-5.5/5.4/5.4-mini the installed catalog's live entries are RICHER than this bundled
242
- * fallback (the snapshot ships gpt-5.5 with tool_mode null / use_responses_lite false /
243
- * comp_hash 2911), so substituting them would downgrade real entries. gpt-5.6 has no real
244
- * installed entry to downgrade — the alternative is gpt-5.5-template synthesis, which this
245
- * snapshot strictly improves on (exact ladders: luna has NO ultra; sol defaults to low).
246
- */
247
- const UPSTREAM_NATIVE_ENTRIES: Map<string, RawEntry> = new Map(
248
- ((upstreamModelsSnapshot as unknown as { models?: RawEntry[] }).models ?? [])
249
- .filter(m => typeof m.slug === "string"
250
- && SUPPORTED_NATIVE_OPENAI_SLUGS.has(m.slug as string)
251
- && (m.slug as string).startsWith("gpt-5.6-"))
252
- .map(m => [m.slug as string, m]),
253
- );
254
-
255
- /**
256
- * Deep clone of the pinned upstream entry for a native slug, adapted for ocx emission:
257
- * `minimal_client_version` is stripped (a pinned client-version gate would hide the model from
258
- * older installed clients; ocx targets whatever client is installed, matching the synthesis
259
- * path which never emits the field). `prefer_websockets` is left in place — the central
260
- * websocket overrides in buildCatalogEntries/mergeCatalogEntriesForSync gate it with
261
- * supports_websockets.
262
- */
263
- export function upstreamNativeEntry(slug: string): RawEntry | null {
264
- const entry = UPSTREAM_NATIVE_ENTRIES.get(slug);
265
- if (!entry) return null;
266
- const clone = JSON.parse(JSON.stringify(entry)) as RawEntry;
267
- delete clone.minimal_client_version;
268
- return clone;
269
- }
270
-
271
- /**
272
- * Mock-max wire clamp (devlog/260709_v2_gated_ultra): the catalog advertises `ultra`
273
- * on natives whose REAL upstream ladder stops below max (gpt-5.5/5.4/…); codex-rs
274
- * converts ultra -> max at its inference boundary, and the ChatGPT backend then
275
- * rejects `max` for those models ("Invalid value: 'max'"). Returns the model's
276
- * highest real effort when the requested top-tier effort (max/ultra) is not in the
277
- * native ladder; null when no clamp is needed (routed slugs, real-max natives,
278
- * ordinary efforts, unknown slugs).
279
- */
280
- export function nativeEffortClamp(slug: string, effort: string | undefined): string | null {
281
- if (!effort || (effort !== "max" && effort !== "ultra")) return null;
282
- if (slug.includes("/")) return null; // routed models map efforts in their adapters
283
- const entry = UPSTREAM_NATIVE_ENTRIES.get(slug);
284
- const levels = Array.isArray(entry?.supported_reasoning_levels)
285
- ? entry.supported_reasoning_levels as Array<{ effort?: string }>
286
- : [];
287
- if (levels.length === 0) {
288
- // Not snapshot-covered. gpt-5.6 natives have a REAL max rung (ensureGpt56ReasoningLevels
289
- // restores it even off-snapshot) -> never clamp. Every other bare native (gpt-5.5/5.4/
290
- // 5.4-mini/5.3-codex-spark and future old-ladder slugs) really stops at xhigh — the
291
- // ChatGPT backend error names exactly none..xhigh — so clamp the synthetic top tier.
292
- return isGpt56NativeSlug(slug) ? null : "xhigh";
293
- }
294
- const supported = levels.flatMap(l => typeof l.effort === "string" ? [l.effort] : []);
295
- if (supported.includes(effort)) return null;
296
- const rank = ["minimal", "low", "medium", "high", "xhigh", "max"];
297
- const highest = supported
298
- .filter(e => rank.includes(e))
299
- .sort((a, b) => rank.indexOf(a) - rank.indexOf(b))
300
- .at(-1);
301
- return highest ?? null;
302
- }
303
-
304
- /**
305
- * Request-time guard for `nativeEffortClamp`: only the canonical built-in OpenAI/Codex
306
- * forward provider should ever use the native mock-max repair. Bare third-party
307
- * `defaultModel` selectors are still routed models whose adapters own effort mapping,
308
- * and explicit `provider/model` requests are routed by construction.
309
- */
310
- export function shouldApplyNativeEffortClamp(
311
- providerName: string,
312
- provider: OcxProviderConfig,
313
- requestedModelId: string,
314
- ): boolean {
315
- return !requestedModelId.includes("/")
316
- && providerName === OPENAI_CODEX_PROVIDER_ID
317
- && isCanonicalOpenAiForwardProvider(provider);
318
- }
319
-
320
- /**
321
- * True when a preserved catalog entry for a snapshot-covered slug should be UPGRADED to the
322
- * pinned upstream entry. Discriminator: `display_name === slug` — both ocx synthesis and the
323
- * codex-rs model_info fallback stamp the bare slug as display name, while genuine upstream
324
- * entries always carry marketing names ("GPT-5.6-Sol"). Fallback-quality entries are
325
- * intentionally overwritten; a real newer catalog entry is preserved untouched.
326
- */
327
- function shouldUpgradeToUpstreamEntry(entry: RawEntry): boolean {
328
- return typeof entry.slug === "string"
329
- && UPSTREAM_NATIVE_ENTRIES.has(entry.slug)
330
- && entry.display_name === entry.slug;
331
- }
332
-
333
- /**
334
- * Reasoning efforts each requested slug advertises in the INJECTED on-disk catalog —
335
- * the exact list codex-rs validates spawn_agent `reasoning_effort` arguments against
336
- * (unsupported rungs are then clamped on the wire, nativeEffortClamp/adapters).
337
- * Slugs missing from the catalog are omitted from the result. Used by the delegation
338
- * prompt to advertise the featured sub-agent roster with honest effort ladders.
339
- */
340
- export function catalogModelEfforts(slugs: readonly string[]): Map<string, string[]> {
341
- const out = new Map<string, string[]>();
342
- if (slugs.length === 0) return out;
343
- const catalog = readCatalog(readCodexCatalogPath());
344
- if (!catalog) return out;
345
- for (const entry of catalog.models ?? []) {
346
- if (typeof entry.slug !== "string") continue;
347
- // Tolerate raw legacy config slugs (`provider/vendor/model`) against the
348
- // Codex-facing encoded catalog slug (`provider/vendor-model`).
349
- const callerSlug = slugs.find(s => slugsEquivalent(s, entry.slug as string));
350
- if (callerSlug === undefined) continue;
351
- const levels = Array.isArray(entry.supported_reasoning_levels)
352
- ? entry.supported_reasoning_levels as Array<{ effort?: string }>
353
- : [];
354
- const efforts = levels.flatMap(l => typeof l.effort === "string" ? [l.effort] : []);
355
- if (efforts.length > 0) out.set(callerSlug, efforts);
356
- }
357
- return out;
358
- }
359
-
360
- export const MAX_SPAWN_AGENT_MODEL_OVERRIDES = 5;
361
-
362
- export type SpawnAgentSurface = "v1" | "v2";
363
- export type SubagentRosterExclusionReason =
364
- | "missing_catalog_entry"
365
- | "picker_hidden"
366
- | "surface_incompatible"
367
- | "outside_display_limit";
368
-
369
- export interface EffectiveSubagentModel {
370
- model: string;
371
- efforts: string[];
372
- }
373
-
374
- export interface SubagentRosterExclusion {
375
- configured: string;
376
- reason: SubagentRosterExclusionReason;
377
- catalogModel?: string;
378
- }
379
-
380
- export interface EffectiveSubagentRoster {
381
- candidates: EffectiveSubagentModel[];
382
- advertised: EffectiveSubagentModel[];
383
- excluded: SubagentRosterExclusion[];
384
- }
385
-
386
- function catalogEntryEfforts(entry: RawEntry): string[] {
387
- const levels = Array.isArray(entry.supported_reasoning_levels)
388
- ? entry.supported_reasoning_levels as Array<{ effort?: string }>
389
- : [];
390
- return levels.flatMap(level => typeof level.effort === "string" ? [level.effort] : []);
391
- }
392
-
393
- function configuredCatalogEntry(entries: RawEntry[], configured: string): RawEntry | undefined {
394
- return entries.find(entry => entry.slug === configured)
395
- ?? entries.find(entry => typeof entry.slug === "string" && slugsEquivalent(configured, entry.slug));
396
- }
397
-
398
- /**
399
- * The effective sub-agent roster for a collaboration surface: Codex's picker-visible
400
- * (`visibility === "list"`), surface-compatible, priority-sorted first five catalog
401
- * entries (candidates), intersected with the configured subagentModels (advertised),
402
- * plus a reason for every configured entry that did not make the set (excluded).
403
- * Canonical `entry.slug` is returned; legacy raw aliases are matching inputs only.
404
- */
405
- export function effectiveSubagentRoster(
406
- configuredModels: readonly string[],
407
- surface: SpawnAgentSurface,
408
- ): EffectiveSubagentRoster {
409
- const configured = configuredModels
410
- .filter(model => model.trim().length > 0)
411
- .filter((model, index, all) =>
412
- !all.slice(0, index).some(previous => slugsEquivalent(previous, model))
413
- );
414
- const entries = readCatalog(readCodexCatalogPath())?.models ?? [];
415
- const ordered = entries
416
- .map((entry, index) => ({ entry, index }))
417
- .filter(({ entry }) => typeof entry.slug === "string")
418
- .filter(({ entry }) => entry.visibility === "list")
419
- .filter(({ entry }) => surface !== "v2" || entry.multi_agent_version === "v2")
420
- .sort((left, right) => {
421
- const leftPriority = typeof left.entry.priority === "number" && Number.isFinite(left.entry.priority)
422
- ? left.entry.priority : Number.MAX_SAFE_INTEGER;
423
- const rightPriority = typeof right.entry.priority === "number" && Number.isFinite(right.entry.priority)
424
- ? right.entry.priority : Number.MAX_SAFE_INTEGER;
425
- return leftPriority - rightPriority || left.index - right.index;
426
- })
427
- .slice(0, MAX_SPAWN_AGENT_MODEL_OVERRIDES);
428
-
429
- const candidates = ordered.map(({ entry }) => ({
430
- model: entry.slug as string,
431
- efforts: catalogEntryEfforts(entry),
432
- }));
433
- const advertised = candidates.filter(candidate =>
434
- configured.some(model => slugsEquivalent(model, candidate.model))
435
- );
436
- const excluded = configured.flatMap((model): SubagentRosterExclusion[] => {
437
- const entry = configuredCatalogEntry(entries, model);
438
- if (!entry) return [{ configured: model, reason: "missing_catalog_entry" }];
439
- const catalogModel = entry.slug as string;
440
- if (entry.visibility !== "list") {
441
- return [{ configured: model, catalogModel, reason: "picker_hidden" }];
442
- }
443
- if (surface === "v2" && entry.multi_agent_version !== "v2") {
444
- return [{ configured: model, catalogModel, reason: "surface_incompatible" }];
445
- }
446
- if (!candidates.some(candidate => candidate.model === catalogModel)) {
447
- return [{ configured: model, catalogModel, reason: "outside_display_limit" }];
448
- }
449
- return [];
450
- });
451
- return { candidates, advertised, excluded };
452
- }
453
-
454
- /**
455
- * The native (passthrough) OpenAI slugs to advertise — the LIVE Codex catalog's own bare slugs when
456
- * available, with documented Codex-native additions layered in, else the static fallback above.
457
- * Single source for the /v1/models native list and the subagent-default seed.
458
- */
459
- export function nativeOpenAiSlugs(): string[] {
460
- const live = listCatalogNativeSlugs();
461
- return live.length > 0 ? unique([...live, ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS]) : NATIVE_OPENAI_MODELS;
462
- }
463
-
464
- export interface CatalogModel {
465
- id: string;
466
- provider: string;
467
- /** Public Codex-facing slug override (used by combo aliases). */
468
- alias?: string;
469
- /**
470
- * Display-only Codex catalog `display_name` override. Relabels the picker row ONLY — it never
471
- * affects the routing slug, alias-collision order, native marketing-name precedence, or provider
472
- * behavior. When unset, the entry falls back to its Codex-facing slug (the historical behavior).
473
- * Native upstream entries (e.g. gpt-5.6-sol → "GPT-5.6-Sol") come from the pinned snapshot path
474
- * which carries no CatalogModel, so a configured displayName can never override a native name.
475
- */
476
- displayName?: string;
477
- owned_by?: string;
478
- reasoningEfforts?: string[];
479
- defaultReasoningEffort?: string;
480
- contextWindow?: number;
481
- maxInputTokens?: number;
482
- contextCap?: number;
483
- contextCapped?: boolean;
484
- inputModalities?: string[];
485
- /** Provider opted into parallel tool calls (OcxProviderConfig.parallelToolCalls). */
486
- parallelToolCalls?: boolean;
487
- /** Whether Codex may send Responses text.verbosity for this routed model. */
488
- supportsVerbosity?: boolean;
489
- }
490
-
491
- type RawEntry = Record<string, unknown>;
492
- type RawCatalog = { models?: RawEntry[]; [k: string]: unknown };
493
- const JAWCODE_CATALOG_AUGMENT_PROVIDERS = new Set(["opencode-go"]);
494
-
495
- /**
496
- * Exact provider/model pairs whose discovery endpoint advertises them but whose inference backend
497
- * rejects them. Apply this after live/static/metadata sources converge so no source can resurrect
498
- * an uncallable picker row. Remove an entry once authenticated inference proves it usable again.
499
- */
500
- const ROUTED_MODEL_COMPATIBILITY_EXCLUSIONS = new Set([
501
- // Issue #82: Zen Go /models advertises HY3, but Console Go rejects it as outside the lite list.
502
- "opencode-go/hy3-preview",
503
- ]);
504
-
505
- function isRoutedModelCompatibilityExcluded(slug: string): boolean {
506
- return ROUTED_MODEL_COMPATIBILITY_EXCLUSIONS.has(slug);
507
- }
508
-
509
- /**
510
- * Image/video GENERATION model families. opencodex routes chat/coding models into Codex; media-
511
- * generation models (Grok image/video, DALL·E, Imagen, Sora, Veo, …) are useless to a coding agent
512
- * and must never surface in the dashboard, /v1/models, or the routed catalog. The metadata has no
513
- * output-modality field, so we classify by id. Extend this list as providers add media models.
514
- */
515
- const MEDIA_GEN_FAMILIES = [
516
- "dall-e", "dalle", "imagen", "sora", "veo", "flux", "kling",
517
- "seedance", "hailuo", "stable-diffusion", "sdxl", "midjourney",
518
- ];
519
- const MEDIA_GEN_ID_RE = new RegExp(
520
- `(?:^|[/_-])(?:image|video)(?:[/_-]|$)|(?:^|[/_-])(?:${MEDIA_GEN_FAMILIES.join("|")})(?:[/_-]|$|\\d)`,
521
- "i",
522
- );
523
-
524
- /**
525
- * True when a model id denotes image/video GENERATION (so it should be hidden everywhere). Vision
526
- * *input* chat models — `grok-2-vision`, `qwen3-vl-*`, `gpt-4o`, `gemini-3-pro-preview` — are
527
- * intentionally NOT matched: they carry no `image`/`video` id segment and no generation-family token.
528
- */
529
- export function isMediaGenerationModelId(id: string): boolean {
530
- return MEDIA_GEN_ID_RE.test(id);
531
- }
532
-
533
- function shouldExposeRoutedModel(model: CatalogModel): boolean {
534
- if (isRoutedModelCompatibilityExcluded(`${model.provider}/${model.id}`)) return false;
535
- if (model.provider === "cursor" && model.id === "gemini-3-pro-image-preview") return true;
536
- return !isMediaGenerationModelId(model.id);
537
- }
538
-
539
- /** Resolve the `model_catalog_json` path from Codex config.toml, else the default. */
540
- export function readCodexCatalogPath(): string {
541
- try {
542
- const configPath = activeCodexConfigPath();
543
- if (existsSync(configPath)) {
544
- const toml = readFileSync(configPath, "utf-8");
545
- const path = readRootTomlString(toml, "model_catalog_json");
546
- if (path) return resolveActiveCodexConfigPath(path);
547
- }
548
- } catch { /* ignore */ }
549
- return activeDefaultCatalogPath();
550
- }
551
-
552
- function parseCatalogJson(raw: string): RawCatalog | null {
553
- try {
554
- const cat = JSON.parse(raw);
555
- return (cat && Array.isArray(cat.models)) ? cat : null;
556
- } catch { return null; }
557
- }
558
-
559
- function readCatalog(path: string): RawCatalog | null {
560
- try {
561
- if (!existsSync(path)) return null;
562
- return parseCatalogJson(readFileSync(path, "utf-8"));
563
- } catch { return null; }
564
- }
565
-
566
- function findNativeTemplate(catalog: RawCatalog | null): RawEntry | null {
567
- return catalog?.models?.find(
568
- m => typeof m.slug === "string" && !m.slug.includes("/") && "base_instructions" in m,
569
- ) ?? null;
570
- }
571
-
572
- function normalizeServiceTiers(entry: RawEntry): RawEntry {
573
- // Codex stores the user-facing config spelling as "fast", but the catalog/request
574
- // service tier id is "priority" in current codex-rs. Keep legacy catalogs working.
575
- if (entry.service_tier === "fast") entry.service_tier = "priority";
576
- if (Array.isArray(entry.service_tiers)) {
577
- entry.service_tiers = entry.service_tiers.map(tier => {
578
- if (tier && typeof tier === "object" && "id" in tier && tier.id === "fast") {
579
- return { ...tier, id: "priority" };
580
- }
581
- return tier;
582
- });
583
- }
584
- return entry;
585
- }
586
-
587
- function ensureAutoCompactTokenLimit(entry: RawEntry): RawEntry {
588
- if (
589
- typeof entry.context_window === "number"
590
- && entry.context_window > 0
591
- && typeof entry.auto_compact_token_limit !== "number"
592
- ) {
593
- entry.auto_compact_token_limit = Math.floor(entry.context_window * 0.9);
594
- }
595
- return entry;
596
- }
597
-
598
- function isNativeOpenAiEntry(entry: RawEntry): boolean {
599
- return typeof entry.slug === "string" && !entry.slug.includes("/");
600
- }
601
-
602
- function applyNativeOpenAiContextOverride(entry: RawEntry): void {
603
- if (!isNativeOpenAiEntry(entry)) return;
604
- const override = NATIVE_OPENAI_CONTEXT_OVERRIDES[entry.slug as string];
605
- if (!override) return;
606
- if (typeof override.contextWindow === "number") {
607
- entry.context_window = override.contextWindow;
608
- entry.auto_compact_token_limit = Math.floor(override.contextWindow * 0.9);
609
- }
610
- if (typeof override.maxContextWindow === "number") {
611
- entry.max_context_window = override.maxContextWindow;
612
- }
613
- }
614
-
615
- function ensureStrictCatalogFields(
616
- entry: RawEntry,
617
- options: { preserveExactInputModalities?: boolean; isRouted?: boolean } = {},
618
- ): RawEntry {
619
- if (typeof entry.supports_reasoning_summaries !== "boolean") entry.supports_reasoning_summaries = true;
620
- if (typeof entry.default_reasoning_summary !== "string") entry.default_reasoning_summary = "none";
621
- if (typeof entry.support_verbosity !== "boolean") entry.support_verbosity = true;
622
- if (typeof entry.default_verbosity !== "string") entry.default_verbosity = "low";
623
- if (typeof entry.apply_patch_tool_type !== "string") entry.apply_patch_tool_type = "freeform";
624
- if (!entry.truncation_policy || typeof entry.truncation_policy !== "object" || Array.isArray(entry.truncation_policy)) {
625
- entry.truncation_policy = { mode: "tokens", limit: 10000 };
626
- }
627
- if (typeof entry.supports_parallel_tool_calls !== "boolean") entry.supports_parallel_tool_calls = true;
628
- if (typeof entry.supports_image_detail_original !== "boolean") entry.supports_image_detail_original = false;
629
- if (!Array.isArray(entry.experimental_supported_tools)) entry.experimental_supported_tools = [];
630
- if (!Array.isArray(entry.input_modalities) && !options.preserveExactInputModalities) {
631
- entry.input_modalities = ["text"];
632
- }
633
- const contextWindow = typeof entry.context_window === "number" && entry.context_window > 0 ? entry.context_window : 128000;
634
- entry.context_window = contextWindow;
635
- if (
636
- typeof entry.max_context_window !== "number"
637
- || entry.max_context_window <= 0
638
- || ((options.isRouted === true || !isNativeOpenAiEntry(entry)) && entry.max_context_window > contextWindow)
639
- ) {
640
- entry.max_context_window = contextWindow;
641
- }
642
- if (typeof entry.effective_context_window_percent !== "number") entry.effective_context_window_percent = 95;
643
- if (typeof entry.comp_hash !== "string") entry.comp_hash = "opencodex";
644
- return ensureAutoCompactTokenLimit(entry);
645
- }
646
-
647
- /** Multi-agent surface mode — see OcxConfig.multiAgentMode. */
648
- export type MultiAgentMode = "v1" | "default" | "v2";
649
-
650
- /**
651
- * Apply the 3-state multi-agent surface override to catalog entries.
652
- * - "v1": force multi_agent_version = "v1" on ALL entries (override upstream pins)
653
- * - "default": RESTORE upstream pins — clear stale forced values so entries that were
654
- * previously forced to v1/v2 revert to their natural state (upstream-pinned natives
655
- * get their snapshot pin, others get null so the codex feature flag decides)
656
- * - "v2": force multi_agent_version = "v2" on ALL entries (override upstream pins)
657
- */
658
- function applyMultiAgentMode(entries: RawEntry[], mode: MultiAgentMode): RawEntry[] {
659
- if (mode === "default") {
660
- // Restore upstream defaults: clear any stale forced multi_agent_version and
661
- // re-apply upstream pins from the snapshot for native entries that have one.
662
- for (const entry of entries) {
663
- const slug = typeof entry.slug === "string" ? entry.slug : "";
664
- const upstream = UPSTREAM_NATIVE_ENTRIES.get(slug);
665
- const upstreamPin = upstream?.multi_agent_version;
666
- if (typeof upstreamPin === "string") {
667
- entry.multi_agent_version = upstreamPin;
668
- } else {
669
- delete entry.multi_agent_version;
670
- }
671
- }
672
- return entries;
673
- }
674
- for (const entry of entries) {
675
- entry.multi_agent_version = mode;
676
- }
677
- return entries;
678
- }
679
-
680
- export function normalizeRoutedCatalogEntry(entry: RawEntry, parallelToolCalls = false): RawEntry {
681
- delete entry.model_messages;
682
- delete entry.tool_mode;
683
- delete entry.multi_agent_version;
684
- delete entry.use_responses_lite;
685
- delete entry.supports_websockets;
686
- delete entry.additional_speed_tiers;
687
- delete entry.service_tier;
688
- delete entry.service_tiers;
689
- delete entry.default_service_tier;
690
- const isCursorEntry = typeof entry.slug === "string" && entry.slug.startsWith("cursor/");
691
- // Routed providers use opencodex sidecars and client-executed tool discovery. The sidecar
692
- // runs through native gpt-5.4-mini, so image search is available and verbalized for text-only
693
- // models. EXCEPT cursor: its runTurn transport bypasses the web-search plan entirely and
694
- // rejects server search queries — advertising the tool would make models call into a void.
695
- if (isCursorEntry) {
696
- delete entry.web_search_tool_type;
697
- entry.supports_search_tool = false;
698
- } else {
699
- entry.web_search_tool_type = "text_and_image";
700
- entry.supports_search_tool = true;
701
- }
702
- // Cursor's transport already serializes overlapping tool calls into atomic Responses tool events.
703
- // Advertising parallel calls lets Codex send the same native capability bit it sends for OpenAI.
704
- // Opt-in providers (OcxProviderConfig.parallelToolCalls, e.g. xAI) advertise it too: the
705
- // openai-chat adapter stops forcing parallel_tool_calls:false and the buffered stream parser
706
- // assembles multi-call turns (devlog/_plan/260709_parallel_tool_calls).
707
- entry.supports_parallel_tool_calls = isCursorEntry || parallelToolCalls === true;
708
- return ensureStrictCatalogFields(entry, { isRouted: true });
709
- }
710
-
711
- // provider + NATIVE model id are passed separately: the Codex-facing slug may carry an
712
- // encoded alias (`provider/vendor-model`) that must never reach the metadata lookup,
713
- // whose keys are native ids (openrouter `anthropic/...`, nvidia `moonshotai/...`).
714
- function applyJawcodeCatalogMetadata(entry: RawEntry, provider: string, modelId: string, contextCap?: number): void {
715
- const jawcodeProvider = resolveJawcodeProvider(provider);
716
- if (!jawcodeProvider) return;
717
- const meta = getJawcodeModelMetadata(jawcodeProvider, modelId)
718
- ?? (shouldCaseFoldMetadataModelId(provider) ? getJawcodeModelMetadataCaseInsensitive(jawcodeProvider, modelId) : undefined);
719
- if (!meta) return;
720
- if (typeof meta.contextWindow === "number" && meta.contextWindow > 0) {
721
- const contextWindow = applyProviderContextCap(meta.contextWindow, contextCap) ?? meta.contextWindow;
722
- entry.context_window = contextWindow;
723
- entry.max_context_window = contextWindow;
724
- entry.auto_compact_token_limit = Math.floor(contextWindow * 0.9);
725
- }
726
- if (Array.isArray(meta.input) && meta.input.length > 0) {
727
- entry.input_modalities = meta.input;
728
- }
729
- }
730
-
731
- type ExecFile = (
732
- file: string,
733
- args: string[],
734
- options: {
735
- encoding: "utf8";
736
- stdio: ["ignore", "pipe", "ignore"];
737
- timeout: number;
738
- windowsHide: boolean;
739
- shell?: boolean;
740
- },
741
- ) => string;
742
-
743
- interface BundledCatalogDeps {
744
- commandCandidates?: () => string[];
745
- execFileSync?: ExecFile;
746
- }
747
-
748
- function unique(values: string[]): string[] {
749
- return [...new Set(values.filter(Boolean))];
750
- }
751
-
752
- function codexCommandCandidates(): string[] {
753
- const envPath = process.env.CODEX_CLI_PATH?.trim();
754
- const candidates = envPath ? [envPath] : [];
755
- candidates.push(...codexShimCommandCandidates());
756
- if (process.platform === "win32") {
757
- for (const dir of (process.env.PATH ?? "").split(delimiter).filter(Boolean)) {
758
- candidates.push(join(dir, "codex.exe"), join(dir, "codex.cmd"));
759
- }
760
- }
761
- candidates.push("codex");
762
- return unique(candidates);
763
- }
764
-
765
- /**
766
- * Windows probe guard: only PE/batch launchers can be spawned as processes. Anything
767
- * else pulled from the shim state (the extensionless Git-Bash sh backup
768
- * `codex.opencodex-real`, `.ps1` scripts) risks falling through to the cmd/ShellExecute
769
- * document-association path — Windows then OPENS the file in the user's editor
770
- * (e.g. VS Code) on every `codex` launch instead of executing it.
771
- */
772
- export function isSpawnableCodexCandidate(path: string, platform: NodeJS.Platform = process.platform): boolean {
773
- if (platform !== "win32") return true;
774
- return /\.(cmd|bat|exe|com)$/i.test(path);
775
- }
776
-
777
- function codexShimCommandCandidates(): string[] {
778
- try {
779
- const state = JSON.parse(readFileSync(join(getConfigDir(), "codex-shim.json"), "utf8")) as {
780
- wrapperPath?: unknown;
781
- originalPath?: unknown;
782
- backupPath?: unknown;
783
- wrappers?: Array<{ wrapperPath?: unknown; originalPath?: unknown; backupPath?: unknown }>;
784
- };
785
- const files = Array.isArray(state.wrappers) && state.wrappers.length > 0 ? state.wrappers : [state];
786
- const out: string[] = [];
787
- for (const file of files) {
788
- for (const value of [file.backupPath, file.originalPath, file.wrapperPath]) {
789
- if (typeof value !== "string" || value.length === 0) continue;
790
- if (!isSpawnableCodexCandidate(value)) continue;
791
- out.push(value);
792
- }
793
- }
794
- return out;
795
- } catch {
796
- return [];
797
- }
798
- }
799
-
800
- /**
801
- * `.cmd`/`.bat` launchers (npm's `codex.cmd`) cannot be spawned shell-less — Node ≥18.20
802
- * and Bun refuse with EINVAL (CVE-2024-27980 hardening), which the probe loop silently
803
- * swallowed, so npm-only Codex installs never loaded the bundled catalog on Windows.
804
- * Route those through the shell (repo convention — see src/update/index.ts, bin/ocx.mjs) and
805
- * pre-quote the path: shell:true joins file+args verbatim, so an unquoted path with
806
- * spaces (`C:\Users\John Doe\...`) would split. Windows paths cannot contain `"`.
807
- */
808
- export function codexExecInvocation(
809
- command: string,
810
- platform: NodeJS.Platform = process.platform,
811
- ): { file: string; shell: boolean } {
812
- if (platform === "win32" && /\.(cmd|bat)$/i.test(command)) {
813
- return { file: `"${command.replace(/"/g, "")}"`, shell: true };
814
- }
815
- return { file: command, shell: false };
816
- }
817
-
818
- function runCodexDebugModels(command: string, execFile: ExecFile): string {
819
- const args = ["debug", "models", "--bundled"];
820
- const invocation = codexExecInvocation(command);
821
- return execFile(invocation.file, args, {
822
- encoding: "utf8" as const,
823
- stdio: ["ignore", "pipe", "ignore"] as ["ignore", "pipe", "ignore"],
824
- timeout: 10_000,
825
- windowsHide: true,
826
- shell: invocation.shell,
827
- });
828
- }
829
-
830
- export function loadBundledCodexCatalog(deps: BundledCatalogDeps = {}): RawCatalog | null {
831
- const useCache = !deps.commandCandidates && !deps.execFileSync;
832
- if (useCache && bundledCatalogCache && bundledCatalogCache.expiresAt > Date.now()) {
833
- return bundledCatalogCache.value;
834
- }
835
- const candidates = deps.commandCandidates?.() ?? codexCommandCandidates();
836
- const execFile = deps.execFileSync ?? (execFileSync as unknown as ExecFile);
837
- for (const command of candidates) {
838
- try {
839
- const catalog = parseCatalogJson(runCodexDebugModels(command, execFile));
840
- if (catalog && findNativeTemplate(catalog)) {
841
- if (useCache) bundledCatalogCache = { expiresAt: Date.now() + BUNDLED_CATALOG_CACHE_MS, value: catalog };
842
- return catalog;
843
- }
844
- } catch { /* try next candidate */ }
845
- }
846
- if (useCache) bundledCatalogCache = { expiresAt: Date.now() + BUNDLED_CATALOG_CACHE_MS, value: null };
847
- return null;
848
- }
849
-
850
- export function materializeBundledCodexCatalog(path: string, deps: BundledCatalogDeps = {}): RawCatalog | null {
851
- const catalog = loadBundledCodexCatalog(deps);
852
- if (!catalog) return null;
853
- try {
854
- mkdirSync(dirname(path), { recursive: true });
855
- atomicWriteFile(path, JSON.stringify(catalog, null, 2) + "\n");
856
- } catch {
857
- return null;
858
- }
859
- return catalog;
860
- }
861
-
862
- function loadCatalogForSync(path: string): RawCatalog | null {
863
- const bundled = isDefaultCatalogPath(path) ? loadBundledCodexCatalog() : null;
864
- if (bundled) return JSON.parse(JSON.stringify(bundled)) as RawCatalog;
865
- const catalog = readCatalog(path);
866
- if (catalog && findNativeTemplate(catalog)) return catalog;
867
- return readCatalog(catalogBackupPathFor(path))
868
- ?? (isDefaultCatalogPath(path) ? readCatalog(legacyCatalogBackupPath()) : null)
869
- ?? readCatalog(activeCodexModelsCachePath())
870
- ?? materializeBundledCodexCatalog(path)
871
- ?? catalog;
872
- }
873
-
874
- function readCurrentCatalogOrCache(): RawCatalog | null {
875
- const path = readCodexCatalogPath();
876
- return (isDefaultCatalogPath(path) ? loadBundledCodexCatalog() : null)
877
- ?? readCatalog(path)
878
- ?? readCatalog(activeCodexModelsCachePath());
879
- }
880
-
881
- /**
882
- * A full native entry from the on-disk catalog, used as a clone template so injected
883
- * entries carry EVERY field Codex's strict parser requires (e.g. `base_instructions`).
884
- * Returns a deep copy, or null if no catalog/native entry exists.
885
- */
886
- export function loadCatalogTemplate(): RawEntry | null {
887
- const catalogPath = readCodexCatalogPath();
888
- const native = findNativeTemplate(readCatalog(catalogPath))
889
- ?? findNativeTemplate(readCatalogBackup(catalogPath))
890
- ?? findNativeTemplate(readCatalog(activeCodexModelsCachePath()))
891
- ?? findNativeTemplate(loadBundledCodexCatalog());
892
- return native ? JSON.parse(JSON.stringify(native)) : null;
893
- }
894
-
895
- /**
896
- * Codex accepts its native labels plus model-defined effort strings such as `max` in current builds.
897
- * Provider-specific aliases still map at request time by src/reasoning-effort.ts.
898
- */
899
- // Routed models default to the low..max ladder: upstream bundled catalogs advertise no "ultra"
900
- // either — but opencodex exposes ultra universally so routed models can use the auto-delegation
901
- // mode (codex-rs converts ultra → max on the wire before any provider request).
902
- const ROUTED_REASONING_LEVELS = [...CODEX_REASONING_LEVELS];
903
-
904
- function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel): void {
905
- if (!model) return;
906
- // This marker survives strict catalog normalization and lets sync distinguish a stale
907
- // bare combo alias from a genuine native model row.
908
- if (model.provider === COMBO_NAMESPACE) entry.owned_by = model.owned_by ?? COMBO_NAMESPACE;
909
- // displayName is DISPLAY-ONLY: it relabels the picker row but never touches the routing
910
- // slug, alias, or provider. deriveEntry already stamped the slug as display_name; a
911
- // configured displayName overrides just the label. The `/` separator is rejected at every
912
- // input boundary (CLI `ocx models add`, management API), so the catalog trusts its source.
913
- // Combos carry no displayName, and natives never reach here (no CatalogModel), so genuine
914
- // upstream marketing names and combo alias labels are preserved untouched.
915
- const displayName = typeof model.displayName === "string" ? model.displayName.trim() : "";
916
- if (displayName) entry.display_name = displayName;
917
- if (typeof model.contextWindow === "number" && model.contextWindow > 0) {
918
- entry.context_window = model.contextWindow;
919
- entry.max_context_window = model.contextWindow;
920
- entry.auto_compact_token_limit = Math.min(
921
- Math.floor(model.contextWindow * 0.9),
922
- model.maxInputTokens ?? Number.POSITIVE_INFINITY,
923
- );
924
- }
925
- if (Array.isArray(model.inputModalities) && model.inputModalities.length > 0) {
926
- entry.input_modalities = model.inputModalities;
927
- }
928
- if (typeof model.supportsVerbosity === "boolean") {
929
- entry.support_verbosity = model.supportsVerbosity;
930
- }
931
- }
932
-
933
- function applyReasoningLevels(
934
- entry: RawEntry,
935
- effortsOverride?: string[],
936
- defaultOverride?: string,
937
- preserveExact = false,
938
- ): void {
939
- let efforts = sanitizeCodexReasoningEfforts(effortsOverride) ?? ROUTED_REASONING_LEVELS.map(l => l.effort);
940
- // Mock top tiers (user decision 260709): every reasoning-capable model advertises `max`
941
- // even when the provider ladder stops lower — subagent spawns pass `max` DIRECTLY
942
- // (no ultra->max client conversion) and codex-rs validates it by catalog membership,
943
- // so a missing max rung hard-fails spawn_agent effort overrides. The wire stays honest:
944
- // routed adapters clamp via clampToSupportedCodexEffort and natives via
945
- // nativeEffortClamp (max -> the model's real top rung).
946
- if (!preserveExact && efforts.length > 0) {
947
- const additions: string[] = [];
948
- if (!efforts.includes("max")) additions.push("max");
949
- if (!efforts.includes("ultra")) additions.push("ultra");
950
- if (additions.length > 0) efforts = sanitizeCodexReasoningEfforts([...efforts, ...additions]) ?? efforts;
951
- }
952
- const byEffort = new Map(
953
- (Array.isArray(entry.supported_reasoning_levels) ? entry.supported_reasoning_levels : [])
954
- .map((l: { effort?: string }) => [l.effort, l]),
955
- );
956
- entry.supported_reasoning_levels = efforts.map(effort => {
957
- const native = byEffort.get(effort);
958
- if (native) return native;
959
- // Description lookup uses the FULL ladder so an opt-in effort outside the routed default
960
- // (e.g. "ultra") still renders its canonical description.
961
- return CODEX_REASONING_LEVELS.find(l => l.effort === effort) ?? { effort, description: `${effort} reasoning` };
962
- });
963
- if (efforts.length === 0) {
964
- delete entry.default_reasoning_level;
965
- return;
966
- }
967
- entry.default_reasoning_level = defaultOverride && efforts.includes(defaultOverride)
968
- ? defaultOverride
969
- : efforts.includes("medium") ? "medium" : efforts.includes("high") ? "high" : efforts[0];
970
- }
971
-
972
- function isGpt56NativeSlug(slug: string): boolean {
973
- return !slug.includes("/") && slug.startsWith("gpt-5.6-");
974
- }
975
-
976
- /**
977
- * Fallback ladder fix for a gpt-5.6 native slug NOT covered by the upstream snapshot (a future
978
- * variant the snapshot predates): entries cloned from an older template (gpt-5.5) stop at xhigh,
979
- * so append max+ultra in upstream rank order when absent. Snapshot-covered slugs never reach
980
- * this — deriveEntry returns their real entry first.
981
- */
982
- function ensureGpt56ReasoningLevels(entry: RawEntry): void {
983
- const levels = Array.isArray(entry.supported_reasoning_levels)
984
- ? entry.supported_reasoning_levels as Array<{ effort?: string }>
985
- : [];
986
- const out = [...levels];
987
- // max is a real native rung on the 5.6 family — always restored; ultra always advertised.
988
- for (const effort of ["max", "ultra"]) {
989
- if (out.some(level => level.effort === effort)) continue;
990
- out.push(CODEX_REASONING_LEVELS.find(level => level.effort === effort)
991
- ?? { effort, description: `${effort} reasoning` });
992
- }
993
- entry.supported_reasoning_levels = out;
994
- }
995
-
996
- /**
997
- * Ensure the mock top tiers on a native model's advertised ladder: `max` and `ultra`
998
- * are always advertised (subagent spawns pass max directly and codex-rs validates by
999
- * catalog membership — the ocx wire clamp routes it to the model's real top rung).
1000
- */
1001
- function ensureUltraReasoningLevel(entry: RawEntry): void {
1002
- const levels = Array.isArray(entry.supported_reasoning_levels)
1003
- ? entry.supported_reasoning_levels as Array<{ effort?: string }>
1004
- : [];
1005
- if (levels.length === 0) return;
1006
- const wanted = ["max", "ultra"];
1007
- for (const effort of wanted) {
1008
- if (levels.some(level => level.effort === effort)) continue;
1009
- levels.push(
1010
- CODEX_REASONING_LEVELS.find(level => level.effort === effort)
1011
- ?? { effort, description: `${effort} reasoning` },
1012
- );
1013
- }
1014
- entry.supported_reasoning_levels = levels;
1015
- }
1016
-
1017
- /** Reasoning-effort labels accepted by the installed Codex binary's bundled catalog. */
1018
- export function codexSupportedReasoningEfforts(deps: BundledCatalogDeps = {}): Set<string> | null {
1019
- const bundled = loadBundledCodexCatalog(deps);
1020
- if (!bundled) return null;
1021
- const efforts = new Set<string>();
1022
- for (const model of bundled.models ?? []) {
1023
- if (typeof model.slug !== "string" || model.slug.includes("/")) continue;
1024
- const levels = Array.isArray(model.supported_reasoning_levels) ? model.supported_reasoning_levels : [];
1025
- for (const level of levels) {
1026
- const effort = (level as { effort?: unknown })?.effort;
1027
- if (typeof effort === "string") efforts.add(effort);
1028
- }
1029
- if (typeof model.default_reasoning_level === "string") efforts.add(model.default_reasoning_level);
1030
- }
1031
- return efforts.size > 0 ? efforts : null;
1032
- }
1033
-
1034
- /** Highest surviving rung at or below the original default, with a conservative empty fallback. */
1035
- export function clampedDefaultEffort(original: string, surviving: readonly string[]): string {
1036
- if (surviving.length === 0) return "medium";
1037
- const ranked = [...surviving]
1038
- .map(effort => ({ effort, rank: codexEffortRank(effort) }))
1039
- .sort((a, b) => a.rank - b.rank);
1040
- const originalRank = codexEffortRank(original);
1041
- const atOrBelow = ranked.filter(item => item.rank >= 0 && item.rank <= originalRank);
1042
- return (atOrBelow.at(-1) ?? ranked[0]!).effort;
1043
- }
1044
-
1045
- /** Remove reasoning efforts the installed Codex binary cannot deserialize from one entry. */
1046
- export function clampEntryToCodexSupportedEfforts(entry: RawEntry, supported: Set<string> | null): void {
1047
- if (!supported) return;
1048
- const levels = Array.isArray(entry.supported_reasoning_levels)
1049
- ? entry.supported_reasoning_levels as Array<{ effort?: string }>
1050
- : null;
1051
- if (levels && levels.length > 0) {
1052
- const kept = levels.filter(level => typeof level?.effort === "string" && supported.has(level.effort));
1053
- entry.supported_reasoning_levels = kept.length > 0
1054
- ? kept
1055
- : CODEX_REASONING_LEVELS
1056
- .filter(level => level.effort === "low" || level.effort === "medium" || level.effort === "high")
1057
- .map(level => ({ ...level }));
1058
- }
1059
- const currentDefault = entry.default_reasoning_level;
1060
- if (typeof currentDefault === "string" && !supported.has(currentDefault)) {
1061
- const surviving = (Array.isArray(entry.supported_reasoning_levels) ? entry.supported_reasoning_levels : [])
1062
- .flatMap(level => typeof (level as { effort?: string })?.effort === "string"
1063
- ? [(level as { effort: string }).effort]
1064
- : []);
1065
- entry.default_reasoning_level = clampedDefaultEffort(currentDefault, surviving);
1066
- }
1067
- }
1068
-
1069
- /** Clamp every catalog entry to the reasoning ladder accepted by the installed Codex binary. */
1070
- export function clampCatalogModelsToCodexSupport(models: RawEntry[], deps: BundledCatalogDeps = {}): RawEntry[] {
1071
- const supported = codexSupportedReasoningEfforts(deps);
1072
- if (!supported) return models;
1073
- for (const entry of models) clampEntryToCodexSupportedEfforts(entry, supported);
1074
- return models;
1075
- }
1076
-
1077
- /**
1078
- * Native entry from the pinned upstream snapshot, finished for emission. Keeps the entry's
1079
- * OWN identity (display_name, description, priority, availability_nux — it is the model's own
1080
- * NUX, not another model's) instead of the caller's generic passthrough blurb. The caller's
1081
- * `priority` wins only when it is a deliberate override (featured rank / push-down), i.e. not
1082
- * the native default 9.
1083
- */
1084
- function finishUpstreamNativeEntry(clone: RawEntry, priority: number): RawEntry {
1085
- if (priority !== 9) clone.priority = priority;
1086
- applyNativeOpenAiContextOverride(clone);
1087
- // GPT-5.6 natives keep their exact upstream ladders (e.g. luna has max but no ultra).
1088
- // Older natives (gpt-5.5 / 5.4 / 5.4-mini / 5.3-codex-spark) get mock max + ultra
1089
- // (wire-clamped to xhigh). Ultra is always advertised regardless of v2 toggle.
1090
- if (!isGpt56NativeSlug(String(clone.slug ?? ""))) ensureUltraReasoningLevel(clone);
1091
- return ensureStrictCatalogFields(normalizeServiceTiers(clone));
1092
- }
1093
-
1094
- function isExactComboCatalogModel(
1095
- model: CatalogModel | undefined,
1096
- exactComboSlugs: ReadonlySet<string>,
1097
- ): boolean {
1098
- return model !== undefined && exactComboSlugs.has(catalogModelSlug(model));
1099
- }
1100
-
1101
- export function catalogModelSlug(model: CatalogModel): string {
1102
- return model.alias ?? routedSlug(model.provider, model.id);
1103
- }
1104
-
1105
- function deriveEntry(
1106
- template: RawEntry | null,
1107
- slug: string,
1108
- desc: string,
1109
- priority: number,
1110
- model?: CatalogModel,
1111
- exactComboSlugs: ReadonlySet<string> = new Set(),
1112
- ): RawEntry {
1113
- const preserveExact = isExactComboCatalogModel(model, exactComboSlugs);
1114
- const isRouted = model !== undefined;
1115
- if (!isRouted && !slug.includes("/")) {
1116
- // Supported native slug covered by the upstream snapshot: use the REAL entry (exact
1117
- // reasoning ladder — e.g. luna has no ultra — default effort, identity, model_messages)
1118
- // instead of cloning an older template.
1119
- const upstream = upstreamNativeEntry(slug);
1120
- if (upstream) return finishUpstreamNativeEntry(upstream, priority);
1121
- }
1122
- if (template) {
1123
- const e = JSON.parse(JSON.stringify(template)) as RawEntry;
1124
- e.slug = slug;
1125
- e.display_name = slug;
1126
- e.description = desc;
1127
- e.priority = priority;
1128
- e.visibility = "list";
1129
- if ("upgrade" in e) e.upgrade = null;
1130
- delete e.availability_nux; // don't replay another model's "now available" NUX
1131
- // Routed (namespaced) models inherit the gpt template — correct its OpenAI/GPT identity
1132
- // and advertise the reasoning ladder Codex accepts.
1133
- if (isRouted) {
1134
- // Native id for identity text + metadata lookups — the slug may be an encoded
1135
- // alias (`provider/vendor-model`); the model object carries the native id.
1136
- const modelName = model?.id ?? slug.slice(slug.indexOf("/") + 1);
1137
- if (typeof e.base_instructions === "string") {
1138
- // Proxy-neutral: keep the GPT-5/OpenAI disclaimer but never advertise the opencodex proxy
1139
- // (leaking that into base_instructions is a non-first-party signature → ToS risk).
1140
- e.base_instructions = e.base_instructions.replace(
1141
- CODEX_GPT5_IDENTITY_LINE,
1142
- `You are a coding agent powered by the ${modelName} model. Do not claim to be GPT-5 or made by OpenAI.`,
1143
- );
1144
- }
1145
- applyReasoningLevels(e, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact);
1146
- normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true);
1147
- if (model) applyJawcodeCatalogMetadata(e, model.provider, model.id, model.contextCap);
1148
- applyCatalogModelMetadata(e, model);
1149
- } else {
1150
- applyNativeOpenAiContextOverride(e);
1151
- if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(e);
1152
- else ensureUltraReasoningLevel(e);
1153
- // Non-5.6 natives (5.5, 5.4, 5.4-mini, spark) do not support responses-lite;
1154
- // the template may carry the flag from a 5.6 entry — strip it so codex-rs does
1155
- // not inject reasoning.context: "all_turns" for models that reject it.
1156
- if (!isGpt56NativeSlug(slug)) {
1157
- // Spark NEEDS use_responses_lite: true — it controls the tool delivery format
1158
- // (AdditionalTools in input vs top-level tools). The reasoning params that
1159
- // use_responses_lite triggers (context: "all_turns", summary) are stripped
1160
- // separately in the passthrough adapter (stripUnsupportedReasoningParams).
1161
- if (!slug.includes("codex-spark")) delete e.use_responses_lite;
1162
- delete e.supports_websockets;
1163
- }
1164
- }
1165
- return ensureStrictCatalogFields(normalizeServiceTiers(e), {
1166
- preserveExactInputModalities: preserveExact,
1167
- isRouted,
1168
- });
1169
- }
1170
- // Fallback when no template is available (best-effort; strict parser may need more).
1171
- const entry: RawEntry = {
1172
- slug, display_name: slug, description: desc,
1173
- shell_type: "shell_command", visibility: "list", supported_in_api: true,
1174
- priority, base_instructions: "You are a helpful coding assistant.",
1175
- ...(isRouted ? { web_search_tool_type: "text_and_image", supports_search_tool: true } : {}),
1176
- };
1177
- if (isRouted) {
1178
- applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact);
1179
- }
1180
- else {
1181
- applyReasoningLevels(entry, isGpt56NativeSlug(slug) ? undefined : ["low", "medium", "high", "xhigh"]);
1182
- if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(entry);
1183
- }
1184
- if (model && isRouted) applyJawcodeCatalogMetadata(entry, model.provider, model.id, model.contextCap);
1185
- applyCatalogModelMetadata(entry, model);
1186
- if (!isRouted) applyNativeOpenAiContextOverride(entry);
1187
- return ensureStrictCatalogFields(normalizeServiceTiers(entry), {
1188
- preserveExactInputModalities: preserveExact,
1189
- isRouted,
1190
- });
1191
- }
1192
-
1193
- /**
1194
- * Single source of truth for Codex-catalog-shaped entries, reused by both the on-disk
1195
- * catalog sync and the proxy `/v1/models?client_version` branch.
1196
- * Native gpt slugs stay bare; routed models are namespaced `<provider>/<model>`.
1197
- */
1198
- export function buildCatalogEntries(
1199
- template: RawEntry | null,
1200
- gptSlugs: string[],
1201
- goModels: CatalogModel[],
1202
- featured?: string[],
1203
- wsEnabled = false,
1204
- multiAgentMode: MultiAgentMode = "default",
1205
- exactComboSlugs: ReadonlySet<string> = new Set(),
1206
- ): RawEntry[] {
1207
- // Codex's models-manager sorts by `priority` ASC and advertises the first 5 picker-visible
1208
- // models to spawn_agent (sort_by_key(priority) + MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT=5). Catalog
1209
- // ARRAY order is discarded — so "featuring" a model = giving it the LOWEST priority (0..N-1) so
1210
- // it sorts to the front. This works for native gpt slugs AND routed slugs alike.
1211
- const rank = new Map((featured ?? []).map((slug, i) => [slug, i] as const));
1212
- const out: RawEntry[] = [];
1213
- const collisionSkipped = resolveSlugAliasCollisions(goModels);
1214
- const comboPublicSlugs = new Set(goModels
1215
- .filter(model => model.provider === COMBO_NAMESPACE)
1216
- .map(catalogModelSlug));
1217
- for (const slug of gptSlugs) {
1218
- const e = deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9);
1219
- if (rank.has(slug)) e.priority = rank.get(slug)!;
1220
- out.push(e);
1221
- }
1222
- for (const m of goModels) {
1223
- if (collisionSkipped.has(m)) continue;
1224
- const slug = catalogModelSlug(m);
1225
- if (m.provider !== COMBO_NAMESPACE && comboPublicSlugs.has(slug)) {
1226
- warnComboMasqueradeCollisionOnce(slug);
1227
- continue;
1228
- }
1229
- // Provider rows use the one-slash slug codec; combo aliases intentionally override that
1230
- // public slug and may be bare.
1231
- const e = deriveEntry(
1232
- template,
1233
- slug,
1234
- `Routed via opencodex → ${m.provider} (${m.owned_by ?? m.provider}).`,
1235
- 5,
1236
- m,
1237
- exactComboSlugs,
1238
- );
1239
- // Featured picks may be stored raw (legacy) or encoded — honor both.
1240
- const rankHit = rank.get(slug) ?? rank.get(`${m.provider}/${m.id}`);
1241
- if (rankHit !== undefined) e.priority = rankHit;
1242
- out.push(e);
1243
- }
1244
- // Central capability override (phase 120.4): the advertised flag must match the implemented WS
1245
- // endpoint. Overrides both the routed strip (normalizeRoutedCatalogEntry) and any native template
1246
- // leak (deriveEntry clones the template as-is for native slugs).
1247
- for (const entry of out) {
1248
- if (wsEnabled) entry.supports_websockets = true;
1249
- else {
1250
- delete entry.supports_websockets;
1251
- // Snapshot-backed native entries carry prefer_websockets: never advertise a preference
1252
- // for an endpoint ocx has disabled.
1253
- delete entry.prefer_websockets;
1254
- }
1255
- }
1256
- return applyMultiAgentMode(out, multiAgentMode);
1257
- }
1258
-
1259
- /** Bare picker-visible native slugs in the live Codex catalog (drives the subagent picker UI). */
1260
- export function listCatalogNativeSlugs(): string[] {
1261
- const cat = readCurrentCatalogOrCache();
1262
- const live = filterSupportedNativeSlugs(cat?.models ?? []);
1263
- // Ensure documented additions (e.g. gpt-5.3-codex-spark) appear even when the bundled catalog
1264
- // predates the slug — mirrors nativeOpenAiSlugs() which already merges them for /v1/models.
1265
- return unique([...live, ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS]);
1266
- }
1267
-
1268
- /**
1269
- * Keep only picker-visible, bare (non-routed) native slugs that opencodex actually supports.
1270
- * A user's installed Codex may list legacy/internal natives (`gpt-5.2`, `gpt-5.3-codex`,
1271
- * `codex-auto-review`, …); the allowlist drops them so `/v1/models` and the subagent picker
1272
- * never advertise an unsupported native. Exported for regression coverage.
1273
- */
1274
- export function filterSupportedNativeSlugs(models: RawEntry[]): string[] {
1275
- return models
1276
- .filter(m => typeof m.slug === "string" && !(m.slug as string).includes("/") && m.visibility === "list" && SUPPORTED_NATIVE_OPENAI_SLUGS.has(m.slug as string))
1277
- .map(m => m.slug as string);
1278
- }
1279
-
1280
- /**
1281
- * Native-model priority baseline read from the PRISTINE backup, so featuring stays reversible:
1282
- * a featured native gets its low rank, and un-featuring restores its original catalog priority
1283
- * (rather than the modified value left in the live catalog by a previous sync).
1284
- */
1285
- function readCatalogBackup(catalogPath: string): RawCatalog | null {
1286
- return readCatalog(catalogBackupPathFor(catalogPath))
1287
- ?? (isDefaultCatalogPath(catalogPath) ? readCatalog(legacyCatalogBackupPath()) : null);
1288
- }
1289
-
1290
- function catalogHasRoutedEntries(catalog: RawCatalog | null): boolean {
1291
- return (catalog?.models ?? []).some(m => typeof m.slug === "string" && m.slug.includes("/"));
1292
- }
1293
-
1294
- function writePristineCatalogBackup(backupPath: string, catalogPath: string, catalog: RawCatalog): void {
1295
- if (existsSync(backupPath)) return;
1296
- const onDisk = readCatalog(catalogPath);
1297
- if (onDisk && !catalogHasRoutedEntries(onDisk)) {
1298
- copyFileSync(catalogPath, backupPath);
1299
- return;
1300
- }
1301
- if (!catalogHasRoutedEntries(catalog)) {
1302
- atomicWriteFile(backupPath, JSON.stringify(catalog, null, 2) + "\n");
1303
- }
1304
- }
1305
-
1306
- function ensureCatalogBackup(catalogPath: string, catalog: RawCatalog): void {
1307
- const dir = getConfigDir();
1308
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1309
- writePristineCatalogBackup(catalogBackupPathFor(catalogPath), catalogPath, catalog);
1310
- if (isDefaultCatalogPath(catalogPath)) writePristineCatalogBackup(legacyCatalogBackupPath(), catalogPath, catalog);
1311
- }
1312
-
1313
- function readNativeBaseline(catalogPath: string): Map<string, number> {
1314
- const backup = readCatalogBackup(catalogPath);
1315
- const out = new Map<string, number>();
1316
- for (const e of backup?.models ?? []) {
1317
- if (typeof e.slug === "string" && !e.slug.includes("/") && typeof e.priority === "number") {
1318
- out.set(e.slug, e.priority);
1319
- }
1320
- }
1321
- return out;
1322
- }
1323
-
1324
-
1325
- type ProviderModelsApiItem = {
1326
- id: string;
1327
- owned_by?: string;
1328
- context_length?: number;
1329
- max_model_len?: number;
1330
- metadata?: {
1331
- capabilities?: Record<string, unknown>;
1332
- limits?: Record<string, unknown>;
1333
- };
1334
- };
1335
-
1336
- function isProviderModelsApiItems(value: unknown): value is ProviderModelsApiItem[] {
1337
- return Array.isArray(value) && value.every(item =>
1338
- item !== null
1339
- && typeof item === "object"
1340
- && !Array.isArray(item)
1341
- && typeof (item as { id?: unknown }).id === "string"
1342
- && (item as { id: string }).id.trim().length > 0
1343
- );
1344
- }
1345
-
1346
- function configuredContextWindow(prov: OcxProviderConfig, id: string): number | undefined {
1347
- const configured = modelRecordValue(prov.modelContextWindows, id) ?? prov.contextWindow;
1348
- return typeof configured === "number" && configured > 0 ? configured : undefined;
1349
- }
1350
-
1351
- function configuredInputModalities(prov: OcxProviderConfig, id: string): string[] | undefined {
1352
- const modalities = modelRecordValue(prov.modelInputModalities, id);
1353
- return Array.isArray(modalities) && modalities.length > 0 ? [...modalities] : undefined;
1354
- }
1355
-
1356
- function configuredMaxInputTokens(prov: OcxProviderConfig, id: string): number | undefined {
1357
- const configured = modelRecordValue(prov.modelMaxInputTokens, id);
1358
- return typeof configured === "number" && configured > 0 ? configured : undefined;
1359
- }
1360
-
1361
- export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, model: CatalogModel, providerCap?: number): CatalogModel {
1362
- void name;
1363
- const configuredCap = configuredContextWindow(prov, model.id);
1364
- const configuredMaxInput = configuredMaxInputTokens(prov, model.id);
1365
- let inputModalities = configuredInputModalities(prov, model.id);
1366
- // Vision-sidecar coverage: `noVisionModels` marks models whose images the PROXY describes
1367
- // (src/vision/index.ts). The catalog must still advertise image input for them — the Codex app
1368
- // gates attachments client-side on input_modalities, and a text-only entry would block images
1369
- // before the sidecar ever runs ("This model does not support image inputs").
1370
- if (modelInList(prov.noVisionModels, model.id)) {
1371
- const base = inputModalities ?? model.inputModalities ?? ["text"];
1372
- inputModalities = base.includes("image") ? [...base] : [...base, "image"];
1373
- }
1374
- const reasoningEfforts = configuredReasoningEfforts(prov, model.id);
1375
- const defaultReasoningEffort = modelRecordValue(prov.modelDefaultReasoningEfforts, model.id) ?? model.defaultReasoningEffort;
1376
- const hinted = {
1377
- ...model,
1378
- ...(configuredCap !== undefined
1379
- ? {
1380
- contextWindow: typeof model.contextWindow === "number" && model.contextWindow > 0
1381
- ? Math.min(model.contextWindow, configuredCap)
1382
- : configuredCap,
1383
- }
1384
- : {}),
1385
- ...(inputModalities ? { inputModalities } : {}),
1386
- ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
1387
- ...(configuredMaxInput !== undefined
1388
- ? {
1389
- maxInputTokens: typeof model.maxInputTokens === "number" && model.maxInputTokens > 0
1390
- ? Math.min(model.maxInputTokens, configuredMaxInput)
1391
- : configuredMaxInput,
1392
- }
1393
- : {}),
1394
- ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}),
1395
- ...(prov.adapter === "kiro" ? { supportsVerbosity: false } : {}),
1396
- // Default-on for openai-chat providers (explicit false opts out); other adapters
1397
- // advertise only on explicit opt-in.
1398
- ...(prov.parallelToolCalls === true || (prov.adapter === "openai-chat" && prov.parallelToolCalls !== false)
1399
- ? { parallelToolCalls: true }
1400
- : {}),
1401
- };
1402
- const capped = applyProviderContextCap(hinted.contextWindow, providerCap);
1403
- if (providerCap !== undefined && capped !== hinted.contextWindow) {
1404
- return { ...hinted, contextWindow: capped, contextCap: providerCap, contextCapped: true };
1405
- }
1406
- return providerCap !== undefined ? { ...hinted, contextCap: providerCap, contextCapped: false } : hinted;
1407
- }
1408
-
1409
- function catalogHintsFromProviderConfig(name: string, prov: OcxProviderConfig, id: string, contextCap?: number): Partial<CatalogModel> {
1410
- const hinted = applyProviderConfigHints(name, prov, { id, provider: name }, contextCap);
1411
- const { provider: _provider, id: _id, ...hints } = hinted;
1412
- return hints;
1413
- }
1414
-
1415
- function applyConfigHintsToCachedModels(name: string, prov: OcxProviderConfig, models: CatalogModel[], contextCap?: number): CatalogModel[] {
1416
- return models.map(model => applyProviderConfigHints(name, prov, model, contextCap));
1417
- }
1418
-
1419
- /**
1420
- * TRUE when `liveId` is a dated release of the configured alias `configuredId`:
1421
- * `<configuredId>-YYYYMMDD` (Anthropic's convention for superseded-but-callable models).
1422
- */
1423
- export function isDatedVariantId(liveId: string, configuredId: string): boolean {
1424
- if (!liveId.startsWith(`${configuredId}-`)) return false;
1425
- return /^\d{8}$/.test(liveId.slice(configuredId.length + 1));
1426
- }
1427
-
1428
- // Same-signature dedupe: Codex polls /v1/models frequently, and an unchanged drop list
1429
- // repeated on every poll is pure noise. Warn once per provider until the id set changes.
1430
- const lastDropWarnSignature = new Map<string, string>();
1431
- // These managed providers intentionally carry compatibility fallback ids while treating their
1432
- // authenticated live catalogs as canonical. A non-empty live response already hides stale ids;
1433
- // repeating that expected reconciliation on every startup only adds noise.
1434
- const QUIET_AUTHORITATIVE_CATALOG_PROVIDERS = new Set(["kimi", "xai"]);
1435
- // Direct OAuth chat-completions probes on 260718 confirmed these account-scoped ids still work
1436
- // even though the providers omit them from `/models`. Preserve only the proven compatibility
1437
- // ids; unknown configured ids and xAI's chat-incompatible multi-agent model remain hidden.
1438
- const CALLABLE_CONFIGURED_COMPATIBILITY_MODELS: Readonly<Record<string, ReadonlySet<string>>> = {
1439
- kimi: new Set([
1440
- "k3[1m]",
1441
- "kimi-k2.7-code",
1442
- "kimi-k2.7-code-highspeed",
1443
- "kimi-k2.6",
1444
- "kimi-k2.5",
1445
- ]),
1446
- xai: new Set([
1447
- "grok-4.3",
1448
- "grok-4.20-0309-reasoning",
1449
- "grok-4.20-0309-non-reasoning",
1450
- "grok-build-0.1",
1451
- "grok-composer-2.5-fast",
1452
- ]),
1453
- };
1454
- function warnDroppedConfiguredIdsOnce(name: string, droppedConfiguredIds: string[]): void {
1455
- const signature = [...droppedConfiguredIds].sort().join(",");
1456
- if (lastDropWarnSignature.get(name) === signature) return;
1457
- lastDropWarnSignature.set(name, signature);
1458
- console.warn(
1459
- `[opencodex] Provider model discovery for "${name}" omitted configured model ids; dropping them from the authoritative live catalog: ${droppedConfiguredIds.join(", ")}.`,
1460
- );
1461
- }
1462
-
1463
- function isGlm52ModelId(id: string): boolean {
1464
- const normalized = id.toLowerCase();
1465
- return normalized === "glm-5.2" || normalized === "glm-5.2[1m]";
1466
- }
1467
-
1468
- function catalogHintsFromModelsApiItem(providerName: string, item: ProviderModelsApiItem): Partial<CatalogModel> {
1469
- const capabilities = item.metadata?.capabilities;
1470
- const limits = item.metadata?.limits;
1471
- const contextWindow =
1472
- typeof limits?.max_context_length === "number" ? limits.max_context_length
1473
- : typeof item.context_length === "number" ? item.context_length
1474
- : typeof item.max_model_len === "number" ? item.max_model_len
1475
- : undefined;
1476
- const reasoningEfforts = capabilities && typeof capabilities.reasoning_effort === "boolean"
1477
- ? (capabilities.reasoning_effort
1478
- ? ((providerName === "neuralwatt" || providerName === "zai") && isGlm52ModelId(item.id)
1479
- ? ["low", "medium", "high", "xhigh", "max"]
1480
- : ["low", "medium", "high", "xhigh"])
1481
- : [])
1482
- : undefined;
1483
- const inputModalities = capabilities && typeof capabilities.vision === "boolean"
1484
- ? (capabilities.vision ? ["text", "image"] : ["text"])
1485
- : undefined;
1486
- return {
1487
- ...(contextWindow && contextWindow > 0 ? { contextWindow } : {}),
1488
- ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
1489
- ...(inputModalities ? { inputModalities } : {}),
1490
- };
1491
- }
1492
-
1493
- /**
1494
- * Fetch a provider's `/models` (openai-chat style) with a TTL cache + stale fallback. Skips
1495
- * forward-auth providers. Fresh cache → no network; schema-valid live fetch → cache the
1496
- * authoritative result; fetch failure or malformed data → last-known-good cache (so a provider
1497
- * blip doesn't drop its models), else the static config list. This is the per-provider half of
1498
- * jawcode's "always latest" resolver.
1499
- */
1500
- async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs: number, contextCap?: number): Promise<CatalogModel[]> {
1501
- if (prov.authMode === "forward") return []; // ChatGPT backend has no /models
1502
- const apiKey = await resolveModelsAuthToken(name, prov);
1503
- const seedVertexDefault = prov.adapter === "google"
1504
- && prov.googleMode === "vertex"
1505
- && (prov.models?.length ?? 0) === 0
1506
- && Boolean(prov.defaultModel);
1507
- const configuredIds = seedVertexDefault && prov.defaultModel ? [prov.defaultModel] : (prov.models ?? []);
1508
- const configured: CatalogModel[] = configuredIds.map(id => ({
1509
- id,
1510
- provider: name,
1511
- ...catalogHintsFromProviderConfig(name, prov, id, contextCap),
1512
- }));
1513
- // A configured default is a real callable selector and must remain discoverable when a
1514
- // compatible provider's live /models request fails (issue #308). Keep this separate from the
1515
- // explicit static list: `liveModels: false` + empty `models[]` intentionally publishes zero
1516
- // rows, while a failed live discovery may degrade to the default selector.
1517
- const failedDiscoveryConfigured = configured.length > 0 || !prov.defaultModel || prov.adapter !== "anthropic"
1518
- ? configured
1519
- : [{
1520
- id: prov.defaultModel,
1521
- provider: name,
1522
- ...catalogHintsFromProviderConfig(name, prov, prov.defaultModel, contextCap),
1523
- }];
1524
- const vertexDefaultSeed = seedVertexDefault ? configured[0] : undefined;
1525
- const withVertexDefaultSeed = (models: CatalogModel[]): CatalogModel[] => (
1526
- vertexDefaultSeed && !models.some(model => model.id === vertexDefaultSeed.id)
1527
- ? [...models, vertexDefaultSeed]
1528
- : models
1529
- );
1530
- if (prov.adapter === "cursor") {
1531
- if (prov.liveModels === false || !apiKey) return configured;
1532
- // Cursor uses a bespoke GetUsableModels RPC (not /models), returning the full effort-suffixed
1533
- // variants this PLAN can use. Keep the base-model UX (the request builder appends the effort
1534
- // suffix) but filter the static seed to the bases the account actually has — so models not on the
1535
- // plan (e.g. claude-fable-5) drop out instead of failing ERROR_BAD_MODEL_NAME. Fall back to the seed.
1536
- const cachedCursor = getFreshCached(name, ttlMs);
1537
- if (cachedCursor) return applyConfigHintsToCachedModels(name, prov, cachedCursor);
1538
- const liveResult = await fetchCursorUsableModels({ apiKey, baseUrl: prov.baseUrl });
1539
- if (liveResult.ok) {
1540
- const available = filterCursorConfiguredModelsByLiveDiscovery(configured, liveResult.models);
1541
- const result = available.length > 0 ? available : configured;
1542
- setCached(name, result);
1543
- return result;
1544
- }
1545
- console.warn(
1546
- `[opencodex] Cursor model discovery for "${name}" failed [${liveResult.error}]${liveResult.detail ? `: ${liveResult.detail}` : ""}; using stale/static catalog degradation.`,
1547
- );
1548
- const staleCursor = getStaleCached(name);
1549
- return staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor) : configured;
1550
- }
1551
- if (prov.authMode === "oauth" && !apiKey) {
1552
- // No usable token (logged out, or account marked needsReauth). Still surface the
1553
- // configured static catalog so the GUI Models tab / rail counts are not empty —
1554
- // matching Cursor's !apiKey → configured degradation and fetch-failure fallback.
1555
- return configured;
1556
- }
1557
- if (prov.liveModels === false) {
1558
- return configured;
1559
- }
1560
- const fresh = getFreshCached(name, ttlMs);
1561
- if (fresh) return withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, fresh, contextCap)); // dedups Codex's frequent /v1/models polling within the TTL
1562
- if (isModelsFetchCoolingDown(name)) {
1563
- // A recently-failed provider (unreachable API, missing proxy, bad key) must not re-pay the
1564
- // fetch timeout on every catalog poll — the dashboard polls this path per page load.
1565
- const stale = getStaleCached(name);
1566
- return stale ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) : failedDiscoveryConfigured;
1567
- }
1568
- const { url, headers } = buildModelsRequest(prov, apiKey, name);
1569
- const urlClass = new URL(url).hostname.endsWith("aiplatform.googleapis.com")
1570
- ? "vertex-aiplatform"
1571
- : "provider-models";
1572
- const failedDiscoveryFallback = (): { models: CatalogModel[]; fallback: "stale" | "configured" } => {
1573
- markModelsFetchFailure(name);
1574
- const stale = getStaleCached(name);
1575
- return {
1576
- models: stale
1577
- ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap))
1578
- : failedDiscoveryConfigured,
1579
- fallback: stale ? "stale" : "configured",
1580
- };
1581
- };
1582
- try {
1583
- const destinationError = await providerDestinationResolvedError(name, {
1584
- baseUrl: url,
1585
- allowPrivateNetwork: prov.allowPrivateNetwork,
1586
- });
1587
- if (destinationError) {
1588
- const { models, fallback } = failedDiscoveryFallback();
1589
- console.warn(
1590
- `[opencodex] Provider model discovery for "${name}" was blocked by destination policy: ${destinationError} [urlClass=${urlClass}, fallback=${fallback}].`,
1591
- );
1592
- return models;
1593
- }
1594
-
1595
- const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) });
1596
- if (!res.ok) {
1597
- const { models, fallback } = failedDiscoveryFallback();
1598
- console.warn(
1599
- `[opencodex] Provider model discovery for "${name}" failed with HTTP ${res.status} [urlClass=${urlClass}, fallback=${fallback}].`,
1600
- );
1601
- return models;
1602
- }
1603
-
1604
- const contentType = (
1605
- res.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() || "missing"
1606
- ).slice(0, 80);
1607
- const body = await res.text();
1608
- let json: unknown;
1609
- try {
1610
- json = JSON.parse(body) as unknown;
1611
- } catch {
1612
- const { models, fallback } = failedDiscoveryFallback();
1613
- const diagnostic = contentType === "application/json" || contentType.endsWith("+json")
1614
- ? "returned invalid JSON in a 2xx response"
1615
- : "returned a non-JSON 2xx response";
1616
- console.warn(
1617
- `[opencodex] Provider model discovery for "${name}" ${diagnostic} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
1618
- );
1619
- return models;
1620
- }
1621
- const data = json !== null && typeof json === "object" && !Array.isArray(json)
1622
- ? (json as { data?: unknown }).data
1623
- : undefined;
1624
- if (!isProviderModelsApiItems(data)) {
1625
- const { models, fallback } = failedDiscoveryFallback();
1626
- console.warn(
1627
- `[opencodex] Provider model discovery for "${name}" returned malformed 2xx data [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
1628
- );
1629
- return models;
1630
- }
1631
- const items = data;
1632
- const live = items.map(m => applyProviderConfigHints(name, prov, {
1633
- id: m.id,
1634
- provider: name,
1635
- owned_by: m.owned_by,
1636
- ...catalogHintsFromModelsApiItem(name, m),
1637
- }, contextCap))
1638
- .filter(m => shouldExposeProviderModel(name, m.id));
1639
- const liveIds = new Set(live.map(m => m.id));
1640
- // Dated-release aliases (Anthropic pattern): older models may appear in the live catalog
1641
- // ONLY under their dated id (claude-haiku-4-5-20251001) while the config names the
1642
- // API-valid alias (claude-haiku-4-5). Such aliases are real, callable models — keep them
1643
- // in the authoritative catalog (alias id, hints from the dated live entry) instead of
1644
- // dropping them and warning on every poll.
1645
- const droppedConfiguredIds: string[] = [];
1646
- for (const m of configured) {
1647
- if (liveIds.has(m.id)) continue;
1648
- const dated = live.find(l => isDatedVariantId(l.id, m.id));
1649
- if (dated) {
1650
- // Reapply config hints so alias-keyed overrides (modelContextWindows etc.) win.
1651
- live.push(applyProviderConfigHints(name, prov, { ...dated, id: m.id }, contextCap));
1652
- } else if (seedVertexDefault || shouldRetainConfiguredProviderModel(name, m.id)) {
1653
- live.push(m);
1654
- } else {
1655
- droppedConfiguredIds.push(m.id);
1656
- }
1657
- }
1658
- if (live.length === 0 && name !== OPENAI_API_PROVIDER_ID) {
1659
- console.warn(
1660
- `[opencodex] Provider model discovery for "${name}" returned an authoritative empty catalog; ${droppedConfiguredIds.length > 0 ? `dropping configured model ids: ${droppedConfiguredIds.join(", ")}` : "no models will be exposed"}.`,
1661
- );
1662
- } else if (droppedConfiguredIds.length > 0
1663
- && name !== OPENAI_API_PROVIDER_ID
1664
- && !QUIET_AUTHORITATIVE_CATALOG_PROVIDERS.has(name)) {
1665
- warnDroppedConfiguredIdsOnce(name, droppedConfiguredIds);
1666
- }
1667
- setCached(name, live);
1668
- return live;
1669
- } catch (error) {
1670
- const { models, fallback } = failedDiscoveryFallback();
1671
- console.warn(
1672
- `[opencodex] Provider model discovery for "${name}" threw ${error instanceof Error ? error.name : "unknown"} [urlClass=${urlClass}, fallback=${fallback}].`,
1673
- );
1674
- return models;
1675
- }
1676
- }
1677
-
1678
- function shouldExposeProviderModel(providerName: string, modelId: string): boolean {
1679
- if (providerName === "opencode-free") return modelId === "big-pickle" || modelId.endsWith("-free");
1680
- return true;
1681
- }
1682
-
1683
- function shouldRetainConfiguredProviderModel(providerName: string, modelId: string): boolean {
1684
- if (CALLABLE_CONFIGURED_COMPATIBILITY_MODELS[providerName]?.has(modelId)) return true;
1685
- if (providerName === "opencode-free") return modelId === "big-pickle" || modelId.endsWith("-free");
1686
- return false;
1687
- }
1688
-
1689
- /**
1690
- * Narrow a raw routed-model list to what Codex's catalog / clients should see: drop the
1691
- * `disabledModels` blocklist AND, for any provider with a non-empty `selectedModels` allowlist, keep
1692
- * only those ids. This is the single choke point applied at every CATALOG emission point (on-disk
1693
- * sync + /v1/models); the admin `/api/models` list stays unfiltered so the picker can show the full
1694
- * set. Live discovery is unaffected — this only decides what ships. See issue_052.
1695
- */
1696
- export function filterCatalogVisibleModels(
1697
- models: CatalogModel[],
1698
- config: Pick<OcxConfig, "disabledModels" | "providers">,
1699
- ): CatalogModel[] {
1700
- const disabled = new Set(config.disabledModels ?? []);
1701
- const allowByProvider = new Map<string, Set<string>>();
1702
- for (const [name, prov] of Object.entries(config.providers)) {
1703
- const sel = prov.selectedModels;
1704
- if (Array.isArray(sel) && sel.length > 0) allowByProvider.set(name, new Set(sel));
1705
- }
1706
- return models.filter(m => {
1707
- // disabledModels may be stored raw (canonical) or encoded (legacy UI writes).
1708
- for (const stored of disabled) {
1709
- // Combo management stores the public alias, while canonical `combo/<id>` references
1710
- // remain valid for backward compatibility through slugEquals below.
1711
- if (m.alias !== undefined && stored === catalogModelSlug(m)) return false;
1712
- if (slugEquals(stored, m.provider, m.id)) return false;
1713
- }
1714
- const allow = allowByProvider.get(m.provider);
1715
- return !allow || allow.has(m.id);
1716
- });
1717
- }
1718
-
1719
- /**
1720
- * Gather routed (non-forward) provider models across the config — the single source of truth for
1721
- * the live model list, used by both the on-disk catalog sync and the proxy's /api/* + /v1/models
1722
- * endpoints. Providers are fetched in parallel; the result is sorted (provider, then id) for a
1723
- * stable listing. TTL comes from `config.modelCacheTtlMs` (default 5 min).
1724
- */
1725
- export async function gatherRoutedModels(config: OcxConfig): Promise<CatalogModel[]> {
1726
- const ttlMs = config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS;
1727
- // Persisted provider entries can predate newer registry fields (noVisionModels,
1728
- // modelInputModalities, ...). The ROUTER merges registry seeds at request time
1729
- // (routedProviderConfig), so the proxy behaves correctly — the catalog listing must see the
1730
- // same merged view or its advertisements drift from actual proxy behavior (e.g. a
1731
- // vision-sidecar model advertised text-only, blocking image attachments app-side).
1732
- // Enrich a CLONE: hydrated defaults must never leak into the persisted config.
1733
- const activeProviders = Object.entries(config.providers)
1734
- .filter(([, prov]) => prov.disabled !== true)
1735
- .map(([name, prov]): [string, OcxProviderConfig] => {
1736
- const enriched = { ...prov };
1737
- enrichProviderFromRegistry(name, enriched);
1738
- return [name, enriched];
1739
- });
1740
- const lists = await Promise.all(
1741
- activeProviders.map(([name, prov]) => fetchProviderModels(name, prov, ttlMs, providerContextCap(config, name))),
1742
- );
1743
- const apiAugmented = augmentRoutedModelsWithRegistryOpenAiApiRows(lists.flat(), config);
1744
- const all = augmentRoutedModelsWithJawcodeMetadata(apiAugmented, activeProviders.map(([name]) => name), config.providers, config)
1745
- // Drop image/video generation models (e.g. Grok image/video) by default. Cursor's static catalog
1746
- // intentionally mirrors Cursor's public model table, including Gemini image preview, so the
1747
- // exposure decision goes through shouldExposeRoutedModel (single choke point).
1748
- .filter(shouldExposeRoutedModel);
1749
- const memberByKey = new Map(all.map(model => [`${model.provider}/${model.id}`, model]));
1750
- // [Decision Log]
1751
- // - 목적과 의도: 콤보 타겟에 native OpenAI(Codex login) 모델이 포함될 때 카탈로그에서
1752
- // 누락되는 버그(issue #268)를 수정. "openai" provider는 forward-auth(Codex login
1753
- // passthrough)이므로 fetchProviderModels가 항상 []를 반환하고, native slugs는
1754
- // 별도 정적 경로(nativeOpenAiSlugs)로만 노출됨. 따라서 memberByKey에
1755
- // openai/<slug> 키가 존재하지 않아 콤보가 조용히 drop됨.
1756
- // - 기존 구현 및 제약 조건: memberByKey는 routed provider /models fetch 결과로만 구성.
1757
- // - 검토한 주요 대안: (A) native slugs를 all 배열에 직접 push — /v1/models와 온디스크
1758
- // 카탈로그에서 native 모델이 중복 노출되는 부작용 발생. (B) memberByKey에만 synthetic
1759
- // CatalogModel을 주입 — 콤보 멤버 해석에만 사용하고 all에는 추가하지 않으므로 기존
1760
- // 노출 경로에 영향 없음.
1761
- // - 선택한 방식: (B) — synthetic entries를 memberByKey에만 주입.
1762
- // - 다른 대안 대신 이 방식을 선택한 이유: 기존 native 모델 노출 경로(/v1/models, 온디스크
1763
- // 카탈로그 sync, management API)를 전혀 변경하지 않고 콤보 resolution만 수선하기 때문.
1764
- // - 장점, 단점 및 영향: 장점 — 최소 수정, 기존 경로 무변경. 단점 — synthetic entries의
1765
- // capability 데이터가 static/upstream snapshot 기반이므로, 사용자가 커스텀 config
1766
- // 힌트(modelContextWindows 등)로 native 모델의 context window를 오버라이드한 경우
1767
- // 반영되지 않음. 하지만 nativeOpenAiContextWindow가 이미 config 오버라이드를
1768
- // 우선시하므로 실제 충돌 가능성은 낮음.
1769
- if (!hasComboTargets(config)) {
1770
- // Skip the native slug injection entirely when no combos are configured — avoids
1771
- // calling nativeOpenAiSlugs() (which reads the live Codex catalog from disk) for
1772
- // configs that will never need it.
1773
- } else {
1774
- const disabled = disabledNativeSlugs(config);
1775
- for (const slug of nativeOpenAiSlugs()) {
1776
- if (disabled.has(slug)) continue;
1777
- const contextWindow = nativeOpenAiContextWindow(slug);
1778
- if (contextWindow === undefined) continue;
1779
- const synthetic: CatalogModel = {
1780
- provider: "openai",
1781
- id: slug,
1782
- owned_by: "openai",
1783
- contextWindow,
1784
- maxInputTokens: contextWindow,
1785
- inputModalities: nativeInputModalities(slug),
1786
- reasoningEfforts: nativeReasoningEfforts(slug),
1787
- ...(nativeParallelToolCalls(slug) ? { parallelToolCalls: true } : {}),
1788
- };
1789
- const key = `openai/${slug}`;
1790
- // Only inject when not already present from a routed provider (an API-key
1791
- // "openai" provider could shadow the native one).
1792
- if (!memberByKey.has(key)) memberByKey.set(key, synthetic);
1793
- }
1794
- }
1795
- for (const id of listComboIds(config)) {
1796
- const combo = getCombo(config, id);
1797
- if (!combo) continue;
1798
- const members = combo.targets
1799
- .map(target => memberByKey.get(targetKey(target)))
1800
- .filter((member): member is CatalogModel => member !== undefined);
1801
- const derived = deriveComboCatalogModel(id, combo, members);
1802
- if (derived) all.push(derived);
1803
- else warnUncataloguedComboOnce(id, combo, members);
1804
- }
1805
- all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
1806
- const customModels = (config.customModels ?? []).map(cm => ({
1807
- id: cm.modelId,
1808
- provider: cm.provider,
1809
- // Display-only label: never feeds routing (customModels are keyed by routedSlug below).
1810
- ...(cm.displayName ? { displayName: cm.displayName } : {}),
1811
- ...(cm.contextWindow ? { contextWindow: cm.contextWindow } : {}),
1812
- ...(cm.inputModalities ? { inputModalities: cm.inputModalities } : {}),
1813
- }));
1814
- // Custom rows override discovered rows that encode to the same Codex-facing slug.
1815
- const customKeys = new Set(customModels.map(c => routedSlug(c.provider, c.id)));
1816
- const deduped = all.filter(m => !customKeys.has(routedSlug(m.provider, m.id)));
1817
- return [...deduped, ...customModels];
1818
- }
1819
-
1820
- const openAiApiCollisionWarnings = new Set<string>();
1821
- const comboCatalogWarningSignatures = new Map<string, string>();
1822
-
1823
- function intersectStrings(values: readonly string[][]): string[] {
1824
- if (values.length === 0) return [];
1825
- const rest = values.slice(1).map(value => new Set(value));
1826
- return [...new Set(values[0])].filter(value => rest.every(set => set.has(value)));
1827
- }
1828
-
1829
- function effectiveComboDefault(
1830
- configured: string | null | undefined,
1831
- common: readonly string[],
1832
- ): string | undefined {
1833
- if (!configured) return undefined;
1834
- if (configured && common.includes(configured)) return configured;
1835
- const requestedRank = codexEffortRank(configured);
1836
- const ranked = common
1837
- .map(effort => ({ effort, rank: codexEffortRank(effort) }))
1838
- .filter(item => item.rank >= 0)
1839
- .sort((a, b) => a.rank - b.rank);
1840
- if (ranked.length === 0) return undefined;
1841
- const atOrBelow = ranked.filter(item => item.rank <= requestedRank);
1842
- return atOrBelow.at(-1)?.effort ?? ranked[0]!.effort;
1843
- }
1844
-
1845
- export function deriveComboCatalogModel(
1846
- id: string,
1847
- combo: NormalizedComboConfig,
1848
- members: readonly CatalogModel[],
1849
- ): CatalogModel | null {
1850
- if (combo.targets.length === 0) return null;
1851
- if (new Set(combo.targets.map(targetKey)).size !== combo.targets.length) return null;
1852
- if (members.length !== combo.targets.length) return null;
1853
- if (!members.every((member, index) => (
1854
- `${member.provider}/${member.id}` === targetKey(combo.targets[index]!)
1855
- ))) return null;
1856
- const contexts = members.map(member => member.contextWindow);
1857
- if (contexts.some(value => typeof value !== "number" || value <= 0)) return null;
1858
-
1859
- const inputModalities = intersectStrings(
1860
- members.map(member => member.inputModalities ?? ["text"]),
1861
- );
1862
- if (inputModalities.length === 0) return null;
1863
- const reasoningEfforts = intersectStrings(
1864
- members.map(member => member.reasoningEfforts ?? []),
1865
- );
1866
- const contextWindow = Math.min(...contexts as number[]);
1867
- const maxInputTokens = Math.min(
1868
- ...members.map(member => member.maxInputTokens ?? member.contextWindow!),
1869
- );
1870
- const defaultReasoningEffort = effectiveComboDefault(
1871
- combo.defaultEffort,
1872
- reasoningEfforts,
1873
- );
1874
-
1875
- return {
1876
- provider: COMBO_NAMESPACE,
1877
- id,
1878
- owned_by: COMBO_NAMESPACE,
1879
- contextWindow,
1880
- maxInputTokens,
1881
- inputModalities,
1882
- reasoningEfforts,
1883
- ...(combo.alias ? { alias: combo.alias } : {}),
1884
- ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}),
1885
- ...(members.every(member => member.parallelToolCalls === true)
1886
- ? { parallelToolCalls: true }
1887
- : {}),
1888
- };
1889
- }
1890
-
1891
- function safeCatalogWarningLabel(value: string): string {
1892
- return redactSecretString(value)
1893
- .replace(/[\u0000-\u001f\u007f]/g, "?")
1894
- .slice(0, 200);
1895
- }
1896
-
1897
- function comboCatalogWarningSignature(
1898
- combo: NormalizedComboConfig,
1899
- members: readonly CatalogModel[],
1900
- ): string {
1901
- const discovered = new Map<string, CatalogModel>(members.map(member => [
1902
- `${member.provider}/${member.id}`,
1903
- member,
1904
- ] as const));
1905
- return JSON.stringify(combo.targets.map(target => {
1906
- const key = targetKey(target);
1907
- const member = discovered.get(key);
1908
- return {
1909
- key,
1910
- contextWindow: member?.contextWindow ?? null,
1911
- maxInputTokens: member?.maxInputTokens ?? null,
1912
- inputModalities: [...new Set(member?.inputModalities ?? [])].sort(),
1913
- reasoningEfforts: [...new Set(member?.reasoningEfforts ?? [])].sort(),
1914
- parallelToolCalls: member?.parallelToolCalls === true,
1915
- };
1916
- }).sort((a, b) => a.key.localeCompare(b.key)));
1917
- }
1918
-
1919
- function warnUncataloguedComboOnce(
1920
- id: string,
1921
- combo: NormalizedComboConfig,
1922
- members: readonly CatalogModel[],
1923
- ): void {
1924
- const signature = comboCatalogWarningSignature(combo, members);
1925
- if (comboCatalogWarningSignatures.get(id) === signature) return;
1926
- comboCatalogWarningSignatures.set(id, signature);
1927
- const targets = combo.targets
1928
- .map(target => safeCatalogWarningLabel(targetKey(target)))
1929
- .sort((a, b) => a.localeCompare(b));
1930
- console.warn(
1931
- `[opencodex] Combo "${safeCatalogWarningLabel(id)}" is omitted from the catalog because member capabilities are incomplete: ${targets.join(", ")}.`,
1932
- );
1933
- }
1934
-
1935
- export function exactComboCatalogSlugs(
1936
- config: Pick<OcxConfig, "combos" | "disabledModels">,
1937
- ): Set<string> {
1938
- const disabled = new Set(config.disabledModels ?? []);
1939
- return new Set(listComboIds(config).flatMap(id => {
1940
- const alias = typeof config.combos?.[id]?.alias === "string"
1941
- ? config.combos[id]!.alias!.trim()
1942
- : "";
1943
- const canonical = comboModelId(id);
1944
- const publicSlug = alias || canonical;
1945
- return disabled.has(publicSlug) || disabled.has(canonical) ? [] : [publicSlug];
1946
- }));
1947
- }
1948
-
1949
- function normalizedOpenAiApiSignature(model: CatalogModel): string {
1950
- const normalized = {
1951
- provider: model.provider,
1952
- id: model.id,
1953
- contextWindow: model.contextWindow ?? null,
1954
- maxInputTokens: model.maxInputTokens ?? null,
1955
- inputModalities: [...new Set(model.inputModalities ?? [])].sort(),
1956
- reasoningEfforts: [...new Set(model.reasoningEfforts ?? [])].sort(),
1957
- ownedBy: model.owned_by ?? null,
1958
- };
1959
- return JSON.stringify(normalized);
1960
- }
1961
-
1962
- export function resetOpenAiApiCatalogWarningStateForTests(): void {
1963
- openAiApiCollisionWarnings.clear();
1964
- }
1965
-
1966
- /**
1967
- * Encode-collision guard (slug-codec): two DISTINCT native ids of one provider mapping
1968
- * to the same Codex-facing alias (`a/b` vs `a-b`) cannot be decoded bijectively and must
1969
- * not emit duplicate catalog slugs. The plain-hyphen native id wins (matching decode
1970
- * precedence: exact native match first); the loser is dropped from the catalog — it stays
1971
- * callable via its raw full-slash selector — and we warn once per provider+alias.
1972
- */
1973
- const slugAliasCollisionWarnings = new Set<string>();
1974
- const comboMasqueradeCollisionWarnings = new Set<string>();
1975
-
1976
- function warnComboMasqueradeCollisionOnce(slug: string): void {
1977
- if (comboMasqueradeCollisionWarnings.has(slug)) return;
1978
- comboMasqueradeCollisionWarnings.add(slug);
1979
- console.warn(
1980
- `[opencodex] combo alias collision on "${safeCatalogWarningLabel(slug)}": the combo wins and the shadowed provider model is omitted from the catalog.`,
1981
- );
1982
- }
1983
-
1984
- function resolveSlugAliasCollisions(goModels: CatalogModel[]): Set<CatalogModel> {
1985
- const skipped = new Set<CatalogModel>();
1986
- const winnerByAlias = new Map<string, CatalogModel>();
1987
- for (const m of goModels) {
1988
- // Combo aliases have their own collision policy below: they always shadow provider rows.
1989
- if (m.provider === COMBO_NAMESPACE) continue;
1990
- const key = catalogModelSlug(m);
1991
- const winner = winnerByAlias.get(key);
1992
- if (!winner) {
1993
- winnerByAlias.set(key, m);
1994
- continue;
1995
- }
1996
- const winnerIsPlainAlias = !winner.id.includes("/");
1997
- const currentIsPlainAlias = !m.id.includes("/");
1998
- if (currentIsPlainAlias && !winnerIsPlainAlias) {
1999
- skipped.add(winner);
2000
- winnerByAlias.set(key, m);
2001
- } else {
2002
- skipped.add(m);
2003
- }
2004
- if (!slugAliasCollisionWarnings.has(key)) {
2005
- slugAliasCollisionWarnings.add(key);
2006
- console.warn(
2007
- `[opencodex] slug alias collision on "${key}": multiple native ids encode to the same Codex-facing slug; `
2008
- + "the plain-hyphen native id is cataloged, the slash id remains callable via its raw selector.",
2009
- );
2010
- }
2011
- }
2012
- return skipped;
2013
- }
2014
-
2015
- /** Apply Codex-facing encoded-slug collision policy to management and picker rows. */
2016
- export function uniqueCatalogModelsForPublicList(goModels: CatalogModel[]): CatalogModel[] {
2017
- const collisionSkipped = resolveSlugAliasCollisions(goModels);
2018
- const comboPublicSlugs = new Set(goModels
2019
- .filter(model => model.provider === COMBO_NAMESPACE)
2020
- .map(catalogModelSlug));
2021
- const seen = new Set<string>();
2022
- const out: CatalogModel[] = [];
2023
- for (const model of goModels) {
2024
- if (collisionSkipped.has(model)) continue;
2025
- const slug = catalogModelSlug(model);
2026
- if (model.provider !== COMBO_NAMESPACE && comboPublicSlugs.has(slug)) {
2027
- warnComboMasqueradeCollisionOnce(slug);
2028
- continue;
2029
- }
2030
- if (seen.has(slug)) continue;
2031
- seen.add(slug);
2032
- out.push(model);
2033
- }
2034
- return out;
2035
- }
2036
-
2037
- /**
2038
- * Deduplicate the ordinary OpenAI `/v1/models` list by the exact id emitted by that
2039
- * endpoint. Raw selectors that merely encode to the same Codex slug remain independently
2040
- * callable and visible; an exact combo alias collision still resolves in favor of the combo.
2041
- */
2042
- export function uniqueCatalogModelsForRawPublicList(goModels: CatalogModel[]): CatalogModel[] {
2043
- const publicId = (model: CatalogModel): string => model.alias ?? `${model.provider}/${model.id}`;
2044
- const comboPublicIds = new Set(goModels
2045
- .filter(model => model.provider === COMBO_NAMESPACE)
2046
- .map(publicId));
2047
- const seen = new Set<string>();
2048
- const out: CatalogModel[] = [];
2049
- for (const model of goModels) {
2050
- const id = publicId(model);
2051
- if (model.provider !== COMBO_NAMESPACE && comboPublicIds.has(id)) {
2052
- warnComboMasqueradeCollisionOnce(id);
2053
- continue;
2054
- }
2055
- if (seen.has(id)) continue;
2056
- seen.add(id);
2057
- out.push(model);
2058
- }
2059
- return out;
2060
- }
2061
-
2062
- /** Test-only reset for every process-global catalog cache/warning owner. */
2063
- export function resetCatalogRuntimeStateForTests(): void {
2064
- bundledCatalogCache = null;
2065
- lastDropWarnSignature.clear();
2066
- openAiApiCollisionWarnings.clear();
2067
- comboCatalogWarningSignatures.clear();
2068
- slugAliasCollisionWarnings.clear();
2069
- comboMasqueradeCollisionWarnings.clear();
2070
- clearModelCache();
2071
- }
2072
-
2073
- export function augmentRoutedModelsWithRegistryOpenAiApiRows(
2074
- models: CatalogModel[],
2075
- config: OcxConfig,
2076
- ): CatalogModel[] {
2077
- const configured = config.providers[OPENAI_API_PROVIDER_ID];
2078
- if (!configured || configured.disabled === true) return models;
2079
- const entry = getProviderRegistryEntry(OPENAI_API_PROVIDER_ID);
2080
- if (!entry?.models) return models;
2081
-
2082
- const existingById = new Map(
2083
- models.filter(model => model.provider === OPENAI_API_PROVIDER_ID).map(model => [model.id, model]),
2084
- );
2085
- const trustedRows = entry.models.map((id): CatalogModel => {
2086
- const officialContext = entry.modelContextWindows?.[id];
2087
- const officialMaxInput = entry.modelMaxInputTokens?.[id];
2088
- const userContext = configured.modelContextWindows?.[id] ?? configured.contextWindow;
2089
- const userMaxInput = configured.modelMaxInputTokens?.[id];
2090
- const providerCap = providerContextCap(config, OPENAI_API_PROVIDER_ID);
2091
- const contextWindow = typeof officialContext === "number"
2092
- ? Math.min(officialContext, userContext ?? officialContext, providerCap ?? officialContext)
2093
- : undefined;
2094
- const maxInputTokens = typeof officialMaxInput === "number"
2095
- ? Math.min(officialMaxInput, userMaxInput ?? officialMaxInput)
2096
- : undefined;
2097
- return {
2098
- provider: OPENAI_API_PROVIDER_ID,
2099
- id,
2100
- owned_by: OPENAI_API_PROVIDER_ID,
2101
- ...(contextWindow ? { contextWindow } : {}),
2102
- ...(maxInputTokens ? { maxInputTokens } : {}),
2103
- ...(entry.modelInputModalities?.[id] ? { inputModalities: [...entry.modelInputModalities[id]!] } : {}),
2104
- ...(entry.modelReasoningEfforts?.[id] ? { reasoningEfforts: [...entry.modelReasoningEfforts[id]!] } : {}),
2105
- };
2106
- });
2107
-
2108
- for (const trusted of trustedRows) {
2109
- const live = existingById.get(trusted.id);
2110
- if (!live) continue;
2111
- const liveSignature = normalizedOpenAiApiSignature(live);
2112
- const trustedSignature = normalizedOpenAiApiSignature(trusted);
2113
- if (liveSignature === trustedSignature) continue;
2114
- const warningKey = `${trusted.provider}/${trusted.id}\n${liveSignature}\n${trustedSignature}`;
2115
- if (openAiApiCollisionWarnings.has(warningKey)) continue;
2116
- openAiApiCollisionWarnings.add(warningKey);
2117
- console.warn(`[opencodex] replacing conflicting live OpenAI API metadata for ${trusted.provider}/${trusted.id} with trusted registry metadata`);
2118
- }
2119
-
2120
- return [
2121
- ...models.filter(model => model.provider !== OPENAI_API_PROVIDER_ID),
2122
- ...trustedRows,
2123
- ];
2124
- }
2125
-
2126
- export function augmentRoutedModelsWithJawcodeMetadata(
2127
- models: CatalogModel[],
2128
- providerNames: string[],
2129
- providers?: Record<string, OcxProviderConfig>,
2130
- caps?: Pick<OcxConfig, "providerContextCaps">,
2131
- ): CatalogModel[] {
2132
- const out = [...models];
2133
- const seen = new Set(out.map(m => `${m.provider}/${m.id}`));
2134
- for (const provider of providerNames) {
2135
- if (!JAWCODE_CATALOG_AUGMENT_PROVIDERS.has(provider)) continue;
2136
- if (providers?.[provider]?.liveModels === false) continue;
2137
- const jawcodeProvider = resolveJawcodeProvider(provider);
2138
- if (!jawcodeProvider) continue;
2139
- for (const meta of listJawcodeModelMetadata(jawcodeProvider)) {
2140
- const key = `${provider}/${meta.id}`;
2141
- if (seen.has(key)) continue;
2142
- seen.add(key);
2143
- const contextCap = caps ? providerContextCap(caps, provider) : undefined;
2144
- const model: CatalogModel = {
2145
- provider,
2146
- id: meta.id,
2147
- owned_by: provider,
2148
- ...(typeof meta.contextWindow === "number" && meta.contextWindow > 0 ? { contextWindow: meta.contextWindow } : {}),
2149
- ...(Array.isArray(meta.input) && meta.input.length > 0 ? { inputModalities: [...meta.input] } : {}),
2150
- };
2151
- out.push({
2152
- ...model,
2153
- ...(providers?.[provider] ? applyProviderConfigHints(provider, providers[provider], model, contextCap) : {}),
2154
- });
2155
- }
2156
- }
2157
- return out;
2158
- }
2159
-
2160
- /**
2161
- * Reorder routed models so the configured subagent picks come FIRST (in the chosen order).
2162
- * Codex's spawn_agent advertises only the first 5 routed catalog entries, so putting the chosen
2163
- * ones first makes exactly them appear as overrides. Non-featured keep their relative order (stable
2164
- * sort) and stay visibility:"list" — so they remain in the main /model picker and callable by name.
2165
- */
2166
- export function orderForSubagents(goModels: CatalogModel[], featured?: string[]): CatalogModel[] {
2167
- if (!featured || featured.length === 0) return goModels;
2168
- const rank = new Map(featured.map((id, i) => [id, i]));
2169
- // Featured picks may be stored raw (legacy) or encoded — match both forms.
2170
- const rankOf = (m: CatalogModel) =>
2171
- (m.alias ? rank.get(m.alias) : undefined)
2172
- ?? rank.get(`${m.provider}/${m.id}`)
2173
- ?? rank.get(routedSlug(m.provider, m.id))
2174
- ?? Number.MAX_SAFE_INTEGER;
2175
- return [...goModels].sort((a, b) => {
2176
- return rankOf(a) - rankOf(b);
2177
- });
2178
- }
2179
-
2180
- export function mergeCatalogEntriesForSync(
2181
- catalogModels: RawEntry[],
2182
- routedEntries: RawEntry[],
2183
- baseline: Map<string, number>,
2184
- featured: string[],
2185
- wsEnabled: boolean,
2186
- goIds: Set<string> = new Set(),
2187
- template: RawEntry | null = null,
2188
- disabledNative: Set<string> = new Set(),
2189
- gatheredProviderNames: Set<string> = new Set(routedEntries.flatMap(entry => {
2190
- const slug = typeof entry.slug === "string" ? entry.slug : "";
2191
- const slash = slug.indexOf("/");
2192
- return slash > 0 ? [slug.slice(0, slash)] : [];
2193
- })),
2194
- multiAgentMode: MultiAgentMode = "default",
2195
- exactComboSlugs: ReadonlySet<string> = new Set(),
2196
- hasPhysicalComboProvider = false,
2197
- ): RawEntry[] {
2198
- const rank = new Map(featured.map((slug, i) => [slug, i] as const));
2199
- const native = catalogModels
2200
- .filter(m => typeof m.slug === "string"
2201
- && !(m.slug as string).includes("/")
2202
- && m.owned_by !== COMBO_NAMESPACE
2203
- && !goIds.has(m.slug as string)
2204
- && !isUnsupportedOpenAiNativeSlug(m.slug as string))
2205
- .map(m => {
2206
- const slug = m.slug as string;
2207
- // Featured models rank first (rank order); non-featured natives are pushed below the featured
2208
- // block when any model is featured, else keep their pristine baseline priority.
2209
- const baselinePriority = baseline.get(slug) ?? (m.priority as number);
2210
- const priority = rank.has(slug)
2211
- ? rank.get(slug)!
2212
- : featured.length > 0
2213
- ? Math.max(typeof baselinePriority === "number" ? baselinePriority : 9, featured.length + 100)
2214
- : baselinePriority;
2215
- // Fallback-quality entries (ocx synthesis / codex-rs model_info fallback: display_name
2216
- // stamped with the bare slug) are upgraded to the pinned upstream snapshot entry so a
2217
- // previously synthesized ladder (e.g. luna advertising ultra) self-heals on sync. A
2218
- // genuine catalog entry (real display name) is preserved untouched.
2219
- if (shouldUpgradeToUpstreamEntry(m)) {
2220
- const upstream = upstreamNativeEntry(slug)!;
2221
- const upgradePriority = rank.has(slug)
2222
- ? rank.get(slug)!
2223
- : featured.length > 0
2224
- ? Math.max(typeof upstream.priority === "number" ? upstream.priority : 9, featured.length + 100)
2225
- : typeof upstream.priority === "number" ? upstream.priority : priority;
2226
- const finished = finishUpstreamNativeEntry(upstream, 9);
2227
- finished.priority = upgradePriority;
2228
- return finished;
2229
- }
2230
- const preserved = normalizeServiceTiers({ ...m, priority });
2231
- // Older natives kept from disk still need the mock top tiers (max + ultra always
2232
- // for subagent max spawns; wire-clamped to the model's real top rung).
2233
- if (!isGpt56NativeSlug(slug)) ensureUltraReasoningLevel(preserved);
2234
- return preserved;
2235
- });
2236
-
2237
- // Backfill any native OpenAI slug that the on-disk catalog is missing (e.g. gpt-5.5), so a
2238
- // routed provider exposing the same id can never delete the native OpenAI/Codex base row.
2239
- const nativeSlugs = new Set(native.flatMap(m => typeof m.slug === "string" ? [m.slug] : []));
2240
- for (const slug of nativeOpenAiSlugs()) {
2241
- if (nativeSlugs.has(slug)) continue;
2242
- nativeSlugs.add(slug);
2243
- const priority = rank.has(slug)
2244
- ? rank.get(slug)!
2245
- : featured.length > 0
2246
- ? featured.length + 100
2247
- : 9;
2248
- native.push(deriveEntry(template ? JSON.parse(JSON.stringify(template)) : null, slug, "OpenAI native model (Codex OAuth passthrough).", priority));
2249
- }
2250
-
2251
- const freshSlugs = new Set(
2252
- routedEntries.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []),
2253
- );
2254
- let finalRoutedEntries = routedEntries;
2255
- const preservingExistingRouted = routedEntries.length === 0
2256
- && catalogModels.some(m => typeof m.slug === "string" && (m.slug as string).includes("/"));
2257
- if (preservingExistingRouted) {
2258
- finalRoutedEntries = catalogModels.filter(m => typeof m.slug === "string" && (m.slug as string).includes("/"));
2259
- } else {
2260
- const preservedForeignRouted = catalogModels.filter(m => {
2261
- if (typeof m.slug !== "string" || !m.slug.includes("/")) return false;
2262
- const provider = m.slug.slice(0, m.slug.indexOf("/"));
2263
- return !gatheredProviderNames.has(provider) && !freshSlugs.has(m.slug);
2264
- });
2265
- finalRoutedEntries = [...routedEntries, ...preservedForeignRouted];
2266
- }
2267
- if (!hasPhysicalComboProvider) {
2268
- finalRoutedEntries = finalRoutedEntries.filter(entry => {
2269
- const slug = typeof entry.slug === "string" ? entry.slug : "";
2270
- const comboOwned = slug.startsWith(`${COMBO_NAMESPACE}/`) || entry.owned_by === COMBO_NAMESPACE;
2271
- return !comboOwned || freshSlugs.has(slug);
2272
- });
2273
- }
2274
- finalRoutedEntries = finalRoutedEntries.filter(entry => {
2275
- const slug = typeof entry.slug === "string" ? entry.slug : "";
2276
- return !exactComboSlugs.has(slug)
2277
- || (Array.isArray(entry.input_modalities) && entry.input_modalities.length > 0);
2278
- });
2279
- // Reapply final catalog policy to rows preserved from disk. Those rows bypass
2280
- // gatherRoutedModels, so filtering only the freshly gathered list can resurrect an excluded id.
2281
- finalRoutedEntries = finalRoutedEntries.filter(entry =>
2282
- typeof entry.slug !== "string" || !isRoutedModelCompatibilityExcluded(entry.slug)
2283
- );
2284
- if (preservingExistingRouted) {
2285
- console.warn(`[opencodex] catalog sync: routed model fetch returned empty; preserving ${finalRoutedEntries.length} existing routed entr${finalRoutedEntries.length === 1 ? "y" : "ies"} on disk.`);
2286
- }
2287
-
2288
- const mergedEntries = [...native, ...finalRoutedEntries].map(m => {
2289
- const normalized = normalizeServiceTiers(m);
2290
- applyNativeOpenAiContextOverride(normalized);
2291
- const exactCombo = typeof m.slug === "string" && exactComboSlugs.has(m.slug);
2292
- const e = ensureStrictCatalogFields(normalized, {
2293
- preserveExactInputModalities: exactCombo,
2294
- isRouted: finalRoutedEntries.includes(m),
2295
- });
2296
- // Mock-max universality (260709): preserved routed entries from disk may predate
2297
- // the max rung — ensure it here so subagent max spawns validate on every
2298
- // reasoning-capable entry. max only: 5.6 exact ladders (luna: no ultra) stay intact.
2299
- if (!exactCombo) {
2300
- const levels = Array.isArray(e.supported_reasoning_levels)
2301
- ? e.supported_reasoning_levels as Array<{ effort?: string }>
2302
- : [];
2303
- if (levels.length > 0 && !levels.some(level => level.effort === "max")) {
2304
- levels.push(CODEX_REASONING_LEVELS.find(level => level.effort === "max")
2305
- ?? { effort: "max", description: "Maximum reasoning depth for the hardest problems" });
2306
- e.supported_reasoning_levels = levels;
2307
- }
2308
- }
2309
- if (wsEnabled) e.supports_websockets = true;
2310
- else {
2311
- delete e.supports_websockets;
2312
- // Match buildCatalogEntries: never advertise a websocket preference while WS is off.
2313
- delete e.prefer_websockets;
2314
- }
2315
- return e;
2316
- });
2317
- // Native enable/disable (single choke point: bare slugs in `disabledModels`). Runs as the
2318
- // LAST pass so the upstream-upgrade branch above can never clobber a hide flag back to list.
2319
- return applyMultiAgentMode(applyNativeVisibility(mergedEntries, disabledNative), multiAgentMode);
2320
- }
2321
-
2322
- /**
2323
- * Merge namespaced routed-model entries into the on-disk Codex catalog.
2324
- * Idempotent + non-destructive:
2325
- * - native entries (slug without "/") are preserved untouched,
2326
- * - previously injected entries (slug containing "/") are dropped and re-added,
2327
- * - each injected entry is CLONED from a native template so it has all required fields,
2328
- * - the catalog is backed up to ~/.opencodex/catalog-backup.json before writing.
2329
- * No-op if the catalog file does not exist.
2330
- */
2331
- export async function syncCatalogModels(config: OcxConfig): Promise<{ added: number; path: string }> {
2332
- const catalogPath = readCodexCatalogPath();
2333
- const catalog = loadCatalogForSync(catalogPath);
2334
- if (!catalog) return { added: 0, path: catalogPath };
2335
-
2336
- const template = findNativeTemplate(catalog);
2337
-
2338
- const goModels = await gatherRoutedModels(config);
2339
- try {
2340
- // Once-only: preserve the PRISTINE pre-opencodex catalog as the native-priority baseline
2341
- // (later syncs would otherwise overwrite it with featured-modified priorities).
2342
- ensureCatalogBackup(catalogPath, catalog);
2343
- } catch { /* backup best-effort */ }
2344
-
2345
- // Hide disabled models from Codex, then feature the chosen subagent models (native OR routed)
2346
- // by giving them the lowest priority — see buildCatalogEntries for why priority, not array order.
2347
- const enabledGo = filterCatalogVisibleModels(goModels, config);
2348
- const featured = config.subagentModels ?? [];
2349
- const orderedGoModels = orderForSubagents(enabledGo, featured); // stable tie-break among equal priorities
2350
- const multiAgentMode: MultiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default";
2351
- const exactComboSlugs = exactComboCatalogSlugs(config);
2352
- const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE);
2353
- const goEntries = buildCatalogEntries(template ? JSON.parse(JSON.stringify(template)) : null, [], orderedGoModels, featured, websocketsEnabled(config), multiAgentMode, exactComboSlugs);
2354
- // Keep genuine native entries (gpt-*, codex-*) with their real per-model fields and append
2355
- // routed providers as namespaced slugs. Cursor and other adopted providers can expose model ids
2356
- // like `gpt-5.5`; those must not delete the native OpenAI/Codex base row.
2357
- const baseline = readNativeBaseline(catalogPath);
2358
- const goIds = new Set(enabledGo.map(m => m.id));
2359
- const gatheredProviderNames = new Set(
2360
- Object.entries(config.providers ?? {})
2361
- .filter(([, prov]) => prov.disabled !== true)
2362
- .map(([name]) => name),
2363
- );
2364
- // Central WS capability override on the FINAL on-disk catalog (the file Codex reads). Applies to
2365
- // native AND routed so the advertised flag matches the implemented endpoint (phase 120.4) and a
2366
- // native template can never leak supports_websockets while the flag is off.
2367
- const wsEnabled = websocketsEnabled(config);
2368
- catalog.models = mergeCatalogEntriesForSync(catalog.models ?? [], goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider);
2369
- clampCatalogModelsToCodexSupport(catalog.models);
2370
-
2371
- atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n");
2372
- return { added: goEntries.length, path: catalogPath };
2373
- }
2374
-
2375
- /**
2376
- * Restore the Codex catalog to native-only by dropping every opencodex-injected
2377
- * "<provider>/<model>" entry (those route through the proxy). Native gpt/codex slugs (no "/")
2378
- * are kept, so plain `codex` works when the proxy is stopped. Idempotent; no-op if nothing injected.
2379
- */
2380
- export function restoreCodexCatalog(): { removed: number; kept: number; path: string } {
2381
- const catalogPath = readCodexCatalogPath();
2382
- const catalog = readCatalog(catalogPath);
2383
- if (!catalog || !Array.isArray(catalog.models)) return { removed: 0, kept: 0, path: catalogPath };
2384
- const backup = readCatalogBackup(catalogPath);
2385
- if (backup && Array.isArray(backup.models)) {
2386
- const removed = (catalog.models ?? []).filter(m => typeof m.slug === "string" && m.slug.includes("/")).length;
2387
- const backupSlugs = new Set(backup.models.flatMap(m => typeof m.slug === "string" ? [m.slug] : []));
2388
- const userNativeAdditions = (catalog.models ?? []).filter(m =>
2389
- typeof m.slug === "string" && !m.slug.includes("/") && !backupSlugs.has(m.slug)
2390
- );
2391
- const restored = {
2392
- ...backup,
2393
- models: [...backup.models, ...userNativeAdditions],
2394
- };
2395
- atomicWriteFile(catalogPath, JSON.stringify(restored, null, 2) + "\n");
2396
- return { removed, kept: restored.models.length, path: catalogPath };
2397
- }
2398
- const before = catalog.models.length;
2399
- const native = catalog.models.filter(m => !(typeof m.slug === "string" && m.slug.includes("/")));
2400
- const removed = before - native.length;
2401
- if (removed > 0) {
2402
- catalog.models = native;
2403
- atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n");
2404
- }
2405
- return { removed, kept: native.length, path: catalogPath };
2406
- }
2407
-
2408
- /**
2409
- * Refresh Codex's models cache ($CODEX_HOME/models_cache.json) from the active catalog.
2410
- * Codex caches the model list for 5 min (DEFAULT_MODEL_CACHE_TTL); copying the injected catalog
2411
- * makes catalog edits (enable/disable, subagent reorder) apply on the next turn instead of waiting.
2412
- */
2413
- export function invalidateCodexModelsCache(): void {
2414
- try {
2415
- const catalogPath = readCodexCatalogPath();
2416
- if (!existsSync(catalogPath)) return;
2417
- const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
2418
- const models = catalog.models ?? catalog;
2419
- const wrapper = {
2420
- fetched_at: "2000-01-01T00:00:00Z",
2421
- client_version: "0.0.0",
2422
- models,
2423
- };
2424
- atomicWriteFile(activeCodexModelsCachePath(), JSON.stringify(wrapper, null, 2) + "\n");
2425
- } catch { /* best-effort */ }
2426
- }
1
+ // AUTO-SPLIT facade: original catalog.ts body moved into ./catalog/* modules.
2
+ // Public surface preserved exactly; importers keep using "src/codex/catalog".
3
+ export { isMediaGenerationModelId, readCodexCatalogPath, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing";
4
+ export type { CatalogModel, MultiAgentMode } from "./catalog/parsing";
5
+ export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs } from "./catalog/metadata";
6
+ export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled";
7
+ export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort";
8
+ export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithJawcodeMetadata } from "./catalog/provider-fetch";
9
+ export { deriveComboCatalogModel, exactComboCatalogSlugs, resetOpenAiApiCatalogWarningStateForTests, uniqueCatalogModelsForPublicList, uniqueCatalogModelsForRawPublicList } from "./catalog/aggregation";
10
+ export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, effectiveSubagentRoster, buildCatalogEntries, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache } from "./catalog/sync";
11
+ export type { SpawnAgentSurface, SubagentRosterExclusionReason, EffectiveSubagentModel, SubagentRosterExclusion, EffectiveSubagentRoster } from "./catalog/sync";