@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
@@ -0,0 +1,540 @@
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
+
34
+ import { activeCodexModelsCachePath, applyJawcodeCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, catalogModelSlug, ensureCatalogBackup, ensureStrictCatalogFields, findNativeTemplate, isRoutedModelCompatibilityExcluded, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing";
35
+ import type { CatalogModel, MultiAgentMode, RawEntry } from "./parsing";
36
+ import { applyNativeVisibility, disabledNativeSlugs, isUnsupportedOpenAiNativeSlug, nativeOpenAiSlugs, shouldUpgradeToUpstreamEntry, upstreamNativeEntry } from "./metadata";
37
+ import { loadCatalogForSync, resetBundledCatalogCacheForTests } from "./bundled";
38
+ import { applyCatalogModelMetadata, applyReasoningLevels, catalogEntryEfforts, clampCatalogModelsToCodexSupport, ensureGpt56ReasoningLevels, ensureUltraReasoningLevel, isGpt56NativeSlug } from "./effort";
39
+ import { filterCatalogVisibleModels, gatherRoutedModels, lastDropWarnSignature } from "./provider-fetch";
40
+ import { comboCatalogWarningSignatures, comboMasqueradeCollisionWarnings, exactComboCatalogSlugs, openAiApiCollisionWarnings, resolveSlugAliasCollisions, slugAliasCollisionWarnings, warnComboMasqueradeCollisionOnce } from "./aggregation";
41
+
42
+ export const MAX_SPAWN_AGENT_MODEL_OVERRIDES = 5;
43
+
44
+ export type SpawnAgentSurface = "v1" | "v2";
45
+
46
+ export type SubagentRosterExclusionReason =
47
+ | "missing_catalog_entry"
48
+ | "picker_hidden"
49
+ | "surface_incompatible"
50
+ | "outside_display_limit";
51
+
52
+ export interface EffectiveSubagentModel {
53
+ model: string;
54
+ efforts: string[];
55
+ }
56
+
57
+ export interface SubagentRosterExclusion {
58
+ configured: string;
59
+ reason: SubagentRosterExclusionReason;
60
+ catalogModel?: string;
61
+ }
62
+
63
+ export interface EffectiveSubagentRoster {
64
+ candidates: EffectiveSubagentModel[];
65
+ advertised: EffectiveSubagentModel[];
66
+ excluded: SubagentRosterExclusion[];
67
+ }
68
+
69
+ export function configuredCatalogEntry(entries: RawEntry[], configured: string): RawEntry | undefined {
70
+ return entries.find(entry => entry.slug === configured)
71
+ ?? entries.find(entry => typeof entry.slug === "string" && slugsEquivalent(configured, entry.slug));
72
+ }
73
+
74
+ export function effectiveSubagentRoster(
75
+ configuredModels: readonly string[],
76
+ surface: SpawnAgentSurface,
77
+ ): EffectiveSubagentRoster {
78
+ const configured = configuredModels
79
+ .filter(model => model.trim().length > 0)
80
+ .filter((model, index, all) =>
81
+ !all.slice(0, index).some(previous => slugsEquivalent(previous, model))
82
+ );
83
+ const entries = readCatalog(readCodexCatalogPath())?.models ?? [];
84
+ const ordered = entries
85
+ .map((entry, index) => ({ entry, index }))
86
+ .filter(({ entry }) => typeof entry.slug === "string")
87
+ .filter(({ entry }) => entry.visibility === "list")
88
+ .filter(({ entry }) => surface !== "v2" || entry.multi_agent_version === "v2")
89
+ .sort((left, right) => {
90
+ const leftPriority = typeof left.entry.priority === "number" && Number.isFinite(left.entry.priority)
91
+ ? left.entry.priority : Number.MAX_SAFE_INTEGER;
92
+ const rightPriority = typeof right.entry.priority === "number" && Number.isFinite(right.entry.priority)
93
+ ? right.entry.priority : Number.MAX_SAFE_INTEGER;
94
+ return leftPriority - rightPriority || left.index - right.index;
95
+ })
96
+ .slice(0, MAX_SPAWN_AGENT_MODEL_OVERRIDES);
97
+
98
+ const candidates = ordered.map(({ entry }) => ({
99
+ model: entry.slug as string,
100
+ efforts: catalogEntryEfforts(entry),
101
+ }));
102
+ const advertised = candidates.filter(candidate =>
103
+ configured.some(model => slugsEquivalent(model, candidate.model))
104
+ );
105
+ const excluded = configured.flatMap((model): SubagentRosterExclusion[] => {
106
+ const entry = configuredCatalogEntry(entries, model);
107
+ if (!entry) return [{ configured: model, reason: "missing_catalog_entry" }];
108
+ const catalogModel = entry.slug as string;
109
+ if (entry.visibility !== "list") {
110
+ return [{ configured: model, catalogModel, reason: "picker_hidden" }];
111
+ }
112
+ if (surface === "v2" && entry.multi_agent_version !== "v2") {
113
+ return [{ configured: model, catalogModel, reason: "surface_incompatible" }];
114
+ }
115
+ if (!candidates.some(candidate => candidate.model === catalogModel)) {
116
+ return [{ configured: model, catalogModel, reason: "outside_display_limit" }];
117
+ }
118
+ return [];
119
+ });
120
+ return { candidates, advertised, excluded };
121
+ }
122
+
123
+ export function finishUpstreamNativeEntry(clone: RawEntry, priority: number): RawEntry {
124
+ if (priority !== 9) clone.priority = priority;
125
+ applyNativeOpenAiContextOverride(clone);
126
+ // GPT-5.6 natives keep their exact upstream ladders (e.g. luna has max but no ultra).
127
+ // Older natives (gpt-5.5 / 5.4 / 5.4-mini / 5.3-codex-spark) get mock max + ultra
128
+ // (wire-clamped to xhigh). Ultra is always advertised regardless of v2 toggle.
129
+ if (!isGpt56NativeSlug(String(clone.slug ?? ""))) ensureUltraReasoningLevel(clone);
130
+ return ensureStrictCatalogFields(normalizeServiceTiers(clone));
131
+ }
132
+
133
+ export function isExactComboCatalogModel(
134
+ model: CatalogModel | undefined,
135
+ exactComboSlugs: ReadonlySet<string>,
136
+ ): boolean {
137
+ return model !== undefined && exactComboSlugs.has(catalogModelSlug(model));
138
+ }
139
+
140
+ export function deriveEntry(
141
+ template: RawEntry | null,
142
+ slug: string,
143
+ desc: string,
144
+ priority: number,
145
+ model?: CatalogModel,
146
+ exactComboSlugs: ReadonlySet<string> = new Set(),
147
+ ): RawEntry {
148
+ const preserveExact = isExactComboCatalogModel(model, exactComboSlugs);
149
+ const isRouted = model !== undefined;
150
+ if (!isRouted && !slug.includes("/")) {
151
+ // Supported native slug covered by the upstream snapshot: use the REAL entry (exact
152
+ // reasoning ladder — e.g. luna has no ultra — default effort, identity, model_messages)
153
+ // instead of cloning an older template.
154
+ const upstream = upstreamNativeEntry(slug);
155
+ if (upstream) return finishUpstreamNativeEntry(upstream, priority);
156
+ }
157
+ if (template) {
158
+ const e = JSON.parse(JSON.stringify(template)) as RawEntry;
159
+ e.slug = slug;
160
+ e.display_name = slug;
161
+ e.description = desc;
162
+ e.priority = priority;
163
+ e.visibility = "list";
164
+ if ("upgrade" in e) e.upgrade = null;
165
+ delete e.availability_nux; // don't replay another model's "now available" NUX
166
+ // Routed (namespaced) models inherit the gpt template — correct its OpenAI/GPT identity
167
+ // and advertise the reasoning ladder Codex accepts.
168
+ if (isRouted) {
169
+ // Native id for identity text + metadata lookups — the slug may be an encoded
170
+ // alias (`provider/vendor-model`); the model object carries the native id.
171
+ const modelName = model?.id ?? slug.slice(slug.indexOf("/") + 1);
172
+ if (typeof e.base_instructions === "string") {
173
+ // Proxy-neutral: keep the GPT-5/OpenAI disclaimer but never advertise the opencodex proxy
174
+ // (leaking that into base_instructions is a non-first-party signature → ToS risk).
175
+ e.base_instructions = e.base_instructions.replace(
176
+ CODEX_GPT5_IDENTITY_LINE,
177
+ `You are a coding agent powered by the ${modelName} model. Do not claim to be GPT-5 or made by OpenAI.`,
178
+ );
179
+ }
180
+ applyReasoningLevels(e, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact);
181
+ normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true);
182
+ if (model) applyJawcodeCatalogMetadata(e, model.provider, model.id, model.contextCap);
183
+ applyCatalogModelMetadata(e, model);
184
+ } else {
185
+ applyNativeOpenAiContextOverride(e);
186
+ if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(e);
187
+ else ensureUltraReasoningLevel(e);
188
+ // Non-5.6 natives (5.5, 5.4, 5.4-mini, spark) do not support responses-lite;
189
+ // the template may carry the flag from a 5.6 entry — strip it so codex-rs does
190
+ // not inject reasoning.context: "all_turns" for models that reject it.
191
+ if (!isGpt56NativeSlug(slug)) {
192
+ // Spark NEEDS use_responses_lite: true — it controls the tool delivery format
193
+ // (AdditionalTools in input vs top-level tools). The reasoning params that
194
+ // use_responses_lite triggers (context: "all_turns", summary) are stripped
195
+ // separately in the passthrough adapter (stripUnsupportedReasoningParams).
196
+ if (!slug.includes("codex-spark")) delete e.use_responses_lite;
197
+ delete e.supports_websockets;
198
+ }
199
+ }
200
+ return ensureStrictCatalogFields(normalizeServiceTiers(e), {
201
+ preserveExactInputModalities: preserveExact,
202
+ isRouted,
203
+ });
204
+ }
205
+ // Fallback when no template is available (best-effort; strict parser may need more).
206
+ const entry: RawEntry = {
207
+ slug, display_name: slug, description: desc,
208
+ shell_type: "shell_command", visibility: "list", supported_in_api: true,
209
+ priority, base_instructions: "You are a helpful coding assistant.",
210
+ ...(isRouted ? { web_search_tool_type: "text_and_image", supports_search_tool: true } : {}),
211
+ };
212
+ if (isRouted) {
213
+ applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact);
214
+ }
215
+ else {
216
+ applyReasoningLevels(entry, isGpt56NativeSlug(slug) ? undefined : ["low", "medium", "high", "xhigh"]);
217
+ if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(entry);
218
+ }
219
+ if (model && isRouted) applyJawcodeCatalogMetadata(entry, model.provider, model.id, model.contextCap);
220
+ applyCatalogModelMetadata(entry, model);
221
+ if (!isRouted) applyNativeOpenAiContextOverride(entry);
222
+ return ensureStrictCatalogFields(normalizeServiceTiers(entry), {
223
+ preserveExactInputModalities: preserveExact,
224
+ isRouted,
225
+ });
226
+ }
227
+
228
+ export function buildCatalogEntries(
229
+ template: RawEntry | null,
230
+ gptSlugs: string[],
231
+ goModels: CatalogModel[],
232
+ featured?: string[],
233
+ wsEnabled = false,
234
+ multiAgentMode: MultiAgentMode = "default",
235
+ exactComboSlugs: ReadonlySet<string> = new Set(),
236
+ ): RawEntry[] {
237
+ // Codex's models-manager sorts by `priority` ASC and advertises the first 5 picker-visible
238
+ // models to spawn_agent (sort_by_key(priority) + MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT=5). Catalog
239
+ // ARRAY order is discarded — so "featuring" a model = giving it the LOWEST priority (0..N-1) so
240
+ // it sorts to the front. This works for native gpt slugs AND routed slugs alike.
241
+ const rank = new Map((featured ?? []).map((slug, i) => [slug, i] as const));
242
+ const out: RawEntry[] = [];
243
+ const collisionSkipped = resolveSlugAliasCollisions(goModels);
244
+ const comboPublicSlugs = new Set(goModels
245
+ .filter(model => model.provider === COMBO_NAMESPACE)
246
+ .map(catalogModelSlug));
247
+ for (const slug of gptSlugs) {
248
+ const e = deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9);
249
+ if (rank.has(slug)) e.priority = rank.get(slug)!;
250
+ out.push(e);
251
+ }
252
+ for (const m of goModels) {
253
+ if (collisionSkipped.has(m)) continue;
254
+ const slug = catalogModelSlug(m);
255
+ if (m.provider !== COMBO_NAMESPACE && comboPublicSlugs.has(slug)) {
256
+ warnComboMasqueradeCollisionOnce(slug);
257
+ continue;
258
+ }
259
+ // Provider rows use the one-slash slug codec; combo aliases intentionally override that
260
+ // public slug and may be bare.
261
+ const e = deriveEntry(
262
+ template,
263
+ slug,
264
+ `Routed via opencodex → ${m.provider} (${m.owned_by ?? m.provider}).`,
265
+ 5,
266
+ m,
267
+ exactComboSlugs,
268
+ );
269
+ // Featured picks may be stored raw (legacy) or encoded — honor both.
270
+ const rankHit = rank.get(slug) ?? rank.get(`${m.provider}/${m.id}`);
271
+ if (rankHit !== undefined) e.priority = rankHit;
272
+ out.push(e);
273
+ }
274
+ // Central capability override (phase 120.4): the advertised flag must match the implemented WS
275
+ // endpoint. Overrides both the routed strip (normalizeRoutedCatalogEntry) and any native template
276
+ // leak (deriveEntry clones the template as-is for native slugs).
277
+ for (const entry of out) {
278
+ if (wsEnabled) entry.supports_websockets = true;
279
+ else {
280
+ delete entry.supports_websockets;
281
+ // Snapshot-backed native entries carry prefer_websockets: never advertise a preference
282
+ // for an endpoint ocx has disabled.
283
+ delete entry.prefer_websockets;
284
+ }
285
+ }
286
+ return applyMultiAgentMode(out, multiAgentMode);
287
+ }
288
+
289
+ export function resetCatalogRuntimeStateForTests(): void {
290
+ resetBundledCatalogCacheForTests();
291
+ lastDropWarnSignature.clear();
292
+ openAiApiCollisionWarnings.clear();
293
+ comboCatalogWarningSignatures.clear();
294
+ slugAliasCollisionWarnings.clear();
295
+ comboMasqueradeCollisionWarnings.clear();
296
+ clearModelCache();
297
+ }
298
+
299
+ export function orderForSubagents(goModels: CatalogModel[], featured?: string[]): CatalogModel[] {
300
+ if (!featured || featured.length === 0) return goModels;
301
+ const rank = new Map(featured.map((id, i) => [id, i]));
302
+ // Featured picks may be stored raw (legacy) or encoded — match both forms.
303
+ const rankOf = (m: CatalogModel) =>
304
+ (m.alias ? rank.get(m.alias) : undefined)
305
+ ?? rank.get(`${m.provider}/${m.id}`)
306
+ ?? rank.get(routedSlug(m.provider, m.id))
307
+ ?? Number.MAX_SAFE_INTEGER;
308
+ return [...goModels].sort((a, b) => {
309
+ return rankOf(a) - rankOf(b);
310
+ });
311
+ }
312
+
313
+ export function mergeCatalogEntriesForSync(
314
+ catalogModels: RawEntry[],
315
+ routedEntries: RawEntry[],
316
+ baseline: Map<string, number>,
317
+ featured: string[],
318
+ wsEnabled: boolean,
319
+ goIds: Set<string> = new Set(),
320
+ template: RawEntry | null = null,
321
+ disabledNative: Set<string> = new Set(),
322
+ gatheredProviderNames: Set<string> = new Set(routedEntries.flatMap(entry => {
323
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
324
+ const slash = slug.indexOf("/");
325
+ return slash > 0 ? [slug.slice(0, slash)] : [];
326
+ })),
327
+ multiAgentMode: MultiAgentMode = "default",
328
+ exactComboSlugs: ReadonlySet<string> = new Set(),
329
+ hasPhysicalComboProvider = false,
330
+ ): RawEntry[] {
331
+ const rank = new Map(featured.map((slug, i) => [slug, i] as const));
332
+ const native = catalogModels
333
+ .filter(m => typeof m.slug === "string"
334
+ && !(m.slug as string).includes("/")
335
+ && m.owned_by !== COMBO_NAMESPACE
336
+ && !goIds.has(m.slug as string)
337
+ && !isUnsupportedOpenAiNativeSlug(m.slug as string))
338
+ .map(m => {
339
+ const slug = m.slug as string;
340
+ // Featured models rank first (rank order); non-featured natives are pushed below the featured
341
+ // block when any model is featured, else keep their pristine baseline priority.
342
+ const baselinePriority = baseline.get(slug) ?? (m.priority as number);
343
+ const priority = rank.has(slug)
344
+ ? rank.get(slug)!
345
+ : featured.length > 0
346
+ ? Math.max(typeof baselinePriority === "number" ? baselinePriority : 9, featured.length + 100)
347
+ : baselinePriority;
348
+ // Fallback-quality entries (ocx synthesis / codex-rs model_info fallback: display_name
349
+ // stamped with the bare slug) are upgraded to the pinned upstream snapshot entry so a
350
+ // previously synthesized ladder (e.g. luna advertising ultra) self-heals on sync. A
351
+ // genuine catalog entry (real display name) is preserved untouched.
352
+ if (shouldUpgradeToUpstreamEntry(m)) {
353
+ const upstream = upstreamNativeEntry(slug)!;
354
+ const upgradePriority = rank.has(slug)
355
+ ? rank.get(slug)!
356
+ : featured.length > 0
357
+ ? Math.max(typeof upstream.priority === "number" ? upstream.priority : 9, featured.length + 100)
358
+ : typeof upstream.priority === "number" ? upstream.priority : priority;
359
+ const finished = finishUpstreamNativeEntry(upstream, 9);
360
+ finished.priority = upgradePriority;
361
+ return finished;
362
+ }
363
+ const preserved = normalizeServiceTiers({ ...m, priority });
364
+ // Older natives kept from disk still need the mock top tiers (max + ultra always
365
+ // for subagent max spawns; wire-clamped to the model's real top rung).
366
+ if (!isGpt56NativeSlug(slug)) ensureUltraReasoningLevel(preserved);
367
+ return preserved;
368
+ });
369
+
370
+ // Backfill any native OpenAI slug that the on-disk catalog is missing (e.g. gpt-5.5), so a
371
+ // routed provider exposing the same id can never delete the native OpenAI/Codex base row.
372
+ const nativeSlugs = new Set(native.flatMap(m => typeof m.slug === "string" ? [m.slug] : []));
373
+ for (const slug of nativeOpenAiSlugs()) {
374
+ if (nativeSlugs.has(slug)) continue;
375
+ nativeSlugs.add(slug);
376
+ const priority = rank.has(slug)
377
+ ? rank.get(slug)!
378
+ : featured.length > 0
379
+ ? featured.length + 100
380
+ : 9;
381
+ native.push(deriveEntry(template ? JSON.parse(JSON.stringify(template)) : null, slug, "OpenAI native model (Codex OAuth passthrough).", priority));
382
+ }
383
+
384
+ const freshSlugs = new Set(
385
+ routedEntries.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []),
386
+ );
387
+ let finalRoutedEntries = routedEntries;
388
+ const preservingExistingRouted = routedEntries.length === 0
389
+ && catalogModels.some(m => typeof m.slug === "string" && (m.slug as string).includes("/"));
390
+ if (preservingExistingRouted) {
391
+ finalRoutedEntries = catalogModels.filter(m => typeof m.slug === "string" && (m.slug as string).includes("/"));
392
+ } else {
393
+ const preservedForeignRouted = catalogModels.filter(m => {
394
+ if (typeof m.slug !== "string" || !m.slug.includes("/")) return false;
395
+ const provider = m.slug.slice(0, m.slug.indexOf("/"));
396
+ return !gatheredProviderNames.has(provider) && !freshSlugs.has(m.slug);
397
+ });
398
+ finalRoutedEntries = [...routedEntries, ...preservedForeignRouted];
399
+ }
400
+ if (!hasPhysicalComboProvider) {
401
+ finalRoutedEntries = finalRoutedEntries.filter(entry => {
402
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
403
+ const comboOwned = slug.startsWith(`${COMBO_NAMESPACE}/`) || entry.owned_by === COMBO_NAMESPACE;
404
+ return !comboOwned || freshSlugs.has(slug);
405
+ });
406
+ }
407
+ finalRoutedEntries = finalRoutedEntries.filter(entry => {
408
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
409
+ return !exactComboSlugs.has(slug)
410
+ || (Array.isArray(entry.input_modalities) && entry.input_modalities.length > 0);
411
+ });
412
+ // Reapply final catalog policy to rows preserved from disk. Those rows bypass
413
+ // gatherRoutedModels, so filtering only the freshly gathered list can resurrect an excluded id.
414
+ finalRoutedEntries = finalRoutedEntries.filter(entry =>
415
+ typeof entry.slug !== "string" || !isRoutedModelCompatibilityExcluded(entry.slug)
416
+ );
417
+ if (preservingExistingRouted) {
418
+ console.warn(`[opencodex] catalog sync: routed model fetch returned empty; preserving ${finalRoutedEntries.length} existing routed entr${finalRoutedEntries.length === 1 ? "y" : "ies"} on disk.`);
419
+ }
420
+
421
+ const mergedEntries = [...native, ...finalRoutedEntries].map(m => {
422
+ const normalized = normalizeServiceTiers(m);
423
+ applyNativeOpenAiContextOverride(normalized);
424
+ const exactCombo = typeof m.slug === "string" && exactComboSlugs.has(m.slug);
425
+ const e = ensureStrictCatalogFields(normalized, {
426
+ preserveExactInputModalities: exactCombo,
427
+ isRouted: finalRoutedEntries.includes(m),
428
+ });
429
+ // Mock-max universality (260709): preserved routed entries from disk may predate
430
+ // the max rung — ensure it here so subagent max spawns validate on every
431
+ // reasoning-capable entry. max only: 5.6 exact ladders (luna: no ultra) stay intact.
432
+ if (!exactCombo) {
433
+ const levels = Array.isArray(e.supported_reasoning_levels)
434
+ ? e.supported_reasoning_levels as Array<{ effort?: string }>
435
+ : [];
436
+ if (levels.length > 0 && !levels.some(level => level.effort === "max")) {
437
+ levels.push(CODEX_REASONING_LEVELS.find(level => level.effort === "max")
438
+ ?? { effort: "max", description: "Maximum reasoning depth for the hardest problems" });
439
+ e.supported_reasoning_levels = levels;
440
+ }
441
+ }
442
+ if (wsEnabled) e.supports_websockets = true;
443
+ else {
444
+ delete e.supports_websockets;
445
+ // Match buildCatalogEntries: never advertise a websocket preference while WS is off.
446
+ delete e.prefer_websockets;
447
+ }
448
+ return e;
449
+ });
450
+ // Native enable/disable (single choke point: bare slugs in `disabledModels`). Runs as the
451
+ // LAST pass so the upstream-upgrade branch above can never clobber a hide flag back to list.
452
+ return applyMultiAgentMode(applyNativeVisibility(mergedEntries, disabledNative), multiAgentMode);
453
+ }
454
+
455
+ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: number; path: string }> {
456
+ const catalogPath = readCodexCatalogPath();
457
+ const catalog = loadCatalogForSync(catalogPath);
458
+ if (!catalog) return { added: 0, path: catalogPath };
459
+
460
+ const template = findNativeTemplate(catalog);
461
+
462
+ const goModels = await gatherRoutedModels(config);
463
+ try {
464
+ // Once-only: preserve the PRISTINE pre-opencodex catalog as the native-priority baseline
465
+ // (later syncs would otherwise overwrite it with featured-modified priorities).
466
+ ensureCatalogBackup(catalogPath, catalog);
467
+ } catch { /* backup best-effort */ }
468
+
469
+ // Hide disabled models from Codex, then feature the chosen subagent models (native OR routed)
470
+ // by giving them the lowest priority — see buildCatalogEntries for why priority, not array order.
471
+ const enabledGo = filterCatalogVisibleModels(goModels, config);
472
+ const featured = config.subagentModels ?? [];
473
+ const orderedGoModels = orderForSubagents(enabledGo, featured); // stable tie-break among equal priorities
474
+ const multiAgentMode: MultiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default";
475
+ const exactComboSlugs = exactComboCatalogSlugs(config);
476
+ const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE);
477
+ const goEntries = buildCatalogEntries(template ? JSON.parse(JSON.stringify(template)) : null, [], orderedGoModels, featured, websocketsEnabled(config), multiAgentMode, exactComboSlugs);
478
+ // Keep genuine native entries (gpt-*, codex-*) with their real per-model fields and append
479
+ // routed providers as namespaced slugs. Cursor and other adopted providers can expose model ids
480
+ // like `gpt-5.5`; those must not delete the native OpenAI/Codex base row.
481
+ const baseline = readNativeBaseline(catalogPath);
482
+ const goIds = new Set(enabledGo.map(m => m.id));
483
+ const gatheredProviderNames = new Set(
484
+ Object.entries(config.providers ?? {})
485
+ .filter(([, prov]) => prov.disabled !== true)
486
+ .map(([name]) => name),
487
+ );
488
+ // Central WS capability override on the FINAL on-disk catalog (the file Codex reads). Applies to
489
+ // native AND routed so the advertised flag matches the implemented endpoint (phase 120.4) and a
490
+ // native template can never leak supports_websockets while the flag is off.
491
+ const wsEnabled = websocketsEnabled(config);
492
+ catalog.models = mergeCatalogEntriesForSync(catalog.models ?? [], goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider);
493
+ clampCatalogModelsToCodexSupport(catalog.models);
494
+
495
+ atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n");
496
+ return { added: goEntries.length, path: catalogPath };
497
+ }
498
+
499
+ export function restoreCodexCatalog(): { removed: number; kept: number; path: string } {
500
+ const catalogPath = readCodexCatalogPath();
501
+ const catalog = readCatalog(catalogPath);
502
+ if (!catalog || !Array.isArray(catalog.models)) return { removed: 0, kept: 0, path: catalogPath };
503
+ const backup = readCatalogBackup(catalogPath);
504
+ if (backup && Array.isArray(backup.models)) {
505
+ const removed = (catalog.models ?? []).filter(m => typeof m.slug === "string" && m.slug.includes("/")).length;
506
+ const backupSlugs = new Set(backup.models.flatMap(m => typeof m.slug === "string" ? [m.slug] : []));
507
+ const userNativeAdditions = (catalog.models ?? []).filter(m =>
508
+ typeof m.slug === "string" && !m.slug.includes("/") && !backupSlugs.has(m.slug)
509
+ );
510
+ const restored = {
511
+ ...backup,
512
+ models: [...backup.models, ...userNativeAdditions],
513
+ };
514
+ atomicWriteFile(catalogPath, JSON.stringify(restored, null, 2) + "\n");
515
+ return { removed, kept: restored.models.length, path: catalogPath };
516
+ }
517
+ const before = catalog.models.length;
518
+ const native = catalog.models.filter(m => !(typeof m.slug === "string" && m.slug.includes("/")));
519
+ const removed = before - native.length;
520
+ if (removed > 0) {
521
+ catalog.models = native;
522
+ atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n");
523
+ }
524
+ return { removed, kept: native.length, path: catalogPath };
525
+ }
526
+
527
+ export function invalidateCodexModelsCache(): void {
528
+ try {
529
+ const catalogPath = readCodexCatalogPath();
530
+ if (!existsSync(catalogPath)) return;
531
+ const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
532
+ const models = catalog.models ?? catalog;
533
+ const wrapper = {
534
+ fetched_at: "2000-01-01T00:00:00Z",
535
+ client_version: "0.0.0",
536
+ models,
537
+ };
538
+ atomicWriteFile(activeCodexModelsCachePath(), JSON.stringify(wrapper, null, 2) + "\n");
539
+ } catch { /* best-effort */ }
540
+ }