@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,268 @@
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 { catalogModelSlug } from "./parsing";
35
+ import type { CatalogModel } from "./parsing";
36
+
37
+ export const openAiApiCollisionWarnings = new Set<string>();
38
+
39
+ export const comboCatalogWarningSignatures = new Map<string, string>();
40
+
41
+ export function intersectStrings(values: readonly string[][]): string[] {
42
+ if (values.length === 0) return [];
43
+ const rest = values.slice(1).map(value => new Set(value));
44
+ return [...new Set(values[0])].filter(value => rest.every(set => set.has(value)));
45
+ }
46
+
47
+ export function effectiveComboDefault(
48
+ configured: string | null | undefined,
49
+ common: readonly string[],
50
+ ): string | undefined {
51
+ if (!configured) return undefined;
52
+ if (configured && common.includes(configured)) return configured;
53
+ const requestedRank = codexEffortRank(configured);
54
+ const ranked = common
55
+ .map(effort => ({ effort, rank: codexEffortRank(effort) }))
56
+ .filter(item => item.rank >= 0)
57
+ .sort((a, b) => a.rank - b.rank);
58
+ if (ranked.length === 0) return undefined;
59
+ const atOrBelow = ranked.filter(item => item.rank <= requestedRank);
60
+ return atOrBelow.at(-1)?.effort ?? ranked[0]!.effort;
61
+ }
62
+
63
+ export function deriveComboCatalogModel(
64
+ id: string,
65
+ combo: NormalizedComboConfig,
66
+ members: readonly CatalogModel[],
67
+ ): CatalogModel | null {
68
+ if (combo.targets.length === 0) return null;
69
+ if (new Set(combo.targets.map(targetKey)).size !== combo.targets.length) return null;
70
+ if (members.length !== combo.targets.length) return null;
71
+ if (!members.every((member, index) => (
72
+ `${member.provider}/${member.id}` === targetKey(combo.targets[index]!)
73
+ ))) return null;
74
+ const contexts = members.map(member => member.contextWindow);
75
+ if (contexts.some(value => typeof value !== "number" || value <= 0)) return null;
76
+
77
+ const inputModalities = intersectStrings(
78
+ members.map(member => member.inputModalities ?? ["text"]),
79
+ );
80
+ if (inputModalities.length === 0) return null;
81
+ const reasoningEfforts = intersectStrings(
82
+ members.map(member => member.reasoningEfforts ?? []),
83
+ );
84
+ const contextWindow = Math.min(...contexts as number[]);
85
+ const maxInputTokens = Math.min(
86
+ ...members.map(member => member.maxInputTokens ?? member.contextWindow!),
87
+ );
88
+ const defaultReasoningEffort = effectiveComboDefault(
89
+ combo.defaultEffort,
90
+ reasoningEfforts,
91
+ );
92
+
93
+ return {
94
+ provider: COMBO_NAMESPACE,
95
+ id,
96
+ owned_by: COMBO_NAMESPACE,
97
+ contextWindow,
98
+ maxInputTokens,
99
+ inputModalities,
100
+ reasoningEfforts,
101
+ ...(combo.alias ? { alias: combo.alias } : {}),
102
+ ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}),
103
+ ...(members.every(member => member.parallelToolCalls === true)
104
+ ? { parallelToolCalls: true }
105
+ : {}),
106
+ ...(members.some(member => member.supportsReasoningSummaries === false) ? { supportsReasoningSummaries: false } : {}),
107
+ };
108
+ }
109
+
110
+ export function safeCatalogWarningLabel(value: string): string {
111
+ return redactSecretString(value)
112
+ .replace(/[\u0000-\u001f\u007f]/g, "?")
113
+ .slice(0, 200);
114
+ }
115
+
116
+ export function comboCatalogWarningSignature(
117
+ combo: NormalizedComboConfig,
118
+ members: readonly CatalogModel[],
119
+ ): string {
120
+ const discovered = new Map<string, CatalogModel>(members.map(member => [
121
+ `${member.provider}/${member.id}`,
122
+ member,
123
+ ] as const));
124
+ return JSON.stringify(combo.targets.map(target => {
125
+ const key = targetKey(target);
126
+ const member = discovered.get(key);
127
+ return {
128
+ key,
129
+ contextWindow: member?.contextWindow ?? null,
130
+ maxInputTokens: member?.maxInputTokens ?? null,
131
+ inputModalities: [...new Set(member?.inputModalities ?? [])].sort(),
132
+ reasoningEfforts: [...new Set(member?.reasoningEfforts ?? [])].sort(),
133
+ parallelToolCalls: member?.parallelToolCalls === true,
134
+ supportsReasoningSummaries: member?.supportsReasoningSummaries !== false,
135
+ };
136
+ }).sort((a, b) => a.key.localeCompare(b.key)));
137
+ }
138
+
139
+ export function warnUncataloguedComboOnce(
140
+ id: string,
141
+ combo: NormalizedComboConfig,
142
+ members: readonly CatalogModel[],
143
+ ): void {
144
+ const signature = comboCatalogWarningSignature(combo, members);
145
+ if (comboCatalogWarningSignatures.get(id) === signature) return;
146
+ comboCatalogWarningSignatures.set(id, signature);
147
+ const targets = combo.targets
148
+ .map(target => safeCatalogWarningLabel(targetKey(target)))
149
+ .sort((a, b) => a.localeCompare(b));
150
+ console.warn(
151
+ `[opencodex] Combo "${safeCatalogWarningLabel(id)}" is omitted from the catalog because member capabilities are incomplete: ${targets.join(", ")}.`,
152
+ );
153
+ }
154
+
155
+ export function exactComboCatalogSlugs(
156
+ config: Pick<OcxConfig, "combos" | "disabledModels">,
157
+ ): Set<string> {
158
+ const disabled = new Set(config.disabledModels ?? []);
159
+ return new Set(listComboIds(config).flatMap(id => {
160
+ const alias = typeof config.combos?.[id]?.alias === "string"
161
+ ? config.combos[id]!.alias!.trim()
162
+ : "";
163
+ const canonical = comboModelId(id);
164
+ const publicSlug = alias || canonical;
165
+ return disabled.has(publicSlug) || disabled.has(canonical) ? [] : [publicSlug];
166
+ }));
167
+ }
168
+
169
+ export function normalizedOpenAiApiSignature(model: CatalogModel): string {
170
+ const normalized = {
171
+ provider: model.provider,
172
+ id: model.id,
173
+ contextWindow: model.contextWindow ?? null,
174
+ maxInputTokens: model.maxInputTokens ?? null,
175
+ inputModalities: [...new Set(model.inputModalities ?? [])].sort(),
176
+ reasoningEfforts: [...new Set(model.reasoningEfforts ?? [])].sort(),
177
+ ownedBy: model.owned_by ?? null,
178
+ };
179
+ return JSON.stringify(normalized);
180
+ }
181
+
182
+ export function resetOpenAiApiCatalogWarningStateForTests(): void {
183
+ openAiApiCollisionWarnings.clear();
184
+ }
185
+
186
+ export const slugAliasCollisionWarnings = new Set<string>();
187
+
188
+ export const comboMasqueradeCollisionWarnings = new Set<string>();
189
+
190
+ export function warnComboMasqueradeCollisionOnce(slug: string): void {
191
+ if (comboMasqueradeCollisionWarnings.has(slug)) return;
192
+ comboMasqueradeCollisionWarnings.add(slug);
193
+ console.warn(
194
+ `[opencodex] combo alias collision on "${safeCatalogWarningLabel(slug)}": the combo wins and the shadowed provider model is omitted from the catalog.`,
195
+ );
196
+ }
197
+
198
+ export function resolveSlugAliasCollisions(goModels: CatalogModel[]): Set<CatalogModel> {
199
+ const skipped = new Set<CatalogModel>();
200
+ const winnerByAlias = new Map<string, CatalogModel>();
201
+ for (const m of goModels) {
202
+ // Combo aliases have their own collision policy below: they always shadow provider rows.
203
+ if (m.provider === COMBO_NAMESPACE) continue;
204
+ const key = catalogModelSlug(m);
205
+ const winner = winnerByAlias.get(key);
206
+ if (!winner) {
207
+ winnerByAlias.set(key, m);
208
+ continue;
209
+ }
210
+ const winnerIsPlainAlias = !winner.id.includes("/");
211
+ const currentIsPlainAlias = !m.id.includes("/");
212
+ if (currentIsPlainAlias && !winnerIsPlainAlias) {
213
+ skipped.add(winner);
214
+ winnerByAlias.set(key, m);
215
+ } else {
216
+ skipped.add(m);
217
+ }
218
+ if (!slugAliasCollisionWarnings.has(key)) {
219
+ slugAliasCollisionWarnings.add(key);
220
+ console.warn(
221
+ `[opencodex] slug alias collision on "${key}": multiple native ids encode to the same Codex-facing slug; `
222
+ + "the plain-hyphen native id is cataloged, the slash id remains callable via its raw selector.",
223
+ );
224
+ }
225
+ }
226
+ return skipped;
227
+ }
228
+
229
+ export function uniqueCatalogModelsForPublicList(goModels: CatalogModel[]): CatalogModel[] {
230
+ const collisionSkipped = resolveSlugAliasCollisions(goModels);
231
+ const comboPublicSlugs = new Set(goModels
232
+ .filter(model => model.provider === COMBO_NAMESPACE)
233
+ .map(catalogModelSlug));
234
+ const seen = new Set<string>();
235
+ const out: CatalogModel[] = [];
236
+ for (const model of goModels) {
237
+ if (collisionSkipped.has(model)) continue;
238
+ const slug = catalogModelSlug(model);
239
+ if (model.provider !== COMBO_NAMESPACE && comboPublicSlugs.has(slug)) {
240
+ warnComboMasqueradeCollisionOnce(slug);
241
+ continue;
242
+ }
243
+ if (seen.has(slug)) continue;
244
+ seen.add(slug);
245
+ out.push(model);
246
+ }
247
+ return out;
248
+ }
249
+
250
+ export function uniqueCatalogModelsForRawPublicList(goModels: CatalogModel[]): CatalogModel[] {
251
+ const publicId = (model: CatalogModel): string => model.alias ?? `${model.provider}/${model.id}`;
252
+ const comboPublicIds = new Set(goModels
253
+ .filter(model => model.provider === COMBO_NAMESPACE)
254
+ .map(publicId));
255
+ const seen = new Set<string>();
256
+ const out: CatalogModel[] = [];
257
+ for (const model of goModels) {
258
+ const id = publicId(model);
259
+ if (model.provider !== COMBO_NAMESPACE && comboPublicIds.has(id)) {
260
+ warnComboMasqueradeCollisionOnce(id);
261
+ continue;
262
+ }
263
+ if (seen.has(id)) continue;
264
+ seen.add(id);
265
+ out.push(model);
266
+ }
267
+ return out;
268
+ }
@@ -0,0 +1,188 @@
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, catalogBackupPathFor, findNativeTemplate, isDefaultCatalogPath, legacyCatalogBackupPath, parseCatalogJson, readCatalog, readCatalogBackup, readCodexCatalogPath } from "./parsing";
35
+ import type { RawCatalog, RawEntry } from "./parsing";
36
+
37
+ export const BUNDLED_CATALOG_CACHE_MS = 60_000;
38
+
39
+ export let bundledCatalogCache: { expiresAt: number; value: RawCatalog | null } | null = null;
40
+
41
+ /** Test-only: clear the bundled-catalog cache (owned here; sync.ts calls this instead of assigning the import). */
42
+ export function resetBundledCatalogCacheForTests(): void {
43
+ bundledCatalogCache = null;
44
+ }
45
+
46
+ export type ExecFile = (
47
+ file: string,
48
+ args: string[],
49
+ options: {
50
+ encoding: "utf8";
51
+ stdio: ["ignore", "pipe", "ignore"];
52
+ timeout: number;
53
+ windowsHide: boolean;
54
+ shell?: boolean;
55
+ },
56
+ ) => string;
57
+
58
+ export interface BundledCatalogDeps {
59
+ commandCandidates?: () => string[];
60
+ execFileSync?: ExecFile;
61
+ }
62
+
63
+ export function unique(values: string[]): string[] {
64
+ return [...new Set(values.filter(Boolean))];
65
+ }
66
+
67
+ export function codexCommandCandidates(): string[] {
68
+ const envPath = process.env.CODEX_CLI_PATH?.trim();
69
+ const candidates = envPath ? [envPath] : [];
70
+ candidates.push(...codexShimCommandCandidates());
71
+ if (process.platform === "win32") {
72
+ for (const dir of (process.env.PATH ?? "").split(delimiter).filter(Boolean)) {
73
+ candidates.push(join(dir, "codex.exe"), join(dir, "codex.cmd"));
74
+ }
75
+ }
76
+ candidates.push("codex");
77
+ return unique(candidates);
78
+ }
79
+
80
+ export function isSpawnableCodexCandidate(path: string, platform: NodeJS.Platform = process.platform): boolean {
81
+ if (platform !== "win32") return true;
82
+ return /\.(cmd|bat|exe|com)$/i.test(path);
83
+ }
84
+
85
+ export function codexShimCommandCandidates(): string[] {
86
+ try {
87
+ const state = JSON.parse(readFileSync(join(getConfigDir(), "codex-shim.json"), "utf8")) as {
88
+ wrapperPath?: unknown;
89
+ originalPath?: unknown;
90
+ backupPath?: unknown;
91
+ wrappers?: Array<{ wrapperPath?: unknown; originalPath?: unknown; backupPath?: unknown }>;
92
+ };
93
+ const files = Array.isArray(state.wrappers) && state.wrappers.length > 0 ? state.wrappers : [state];
94
+ const out: string[] = [];
95
+ for (const file of files) {
96
+ for (const value of [file.backupPath, file.originalPath, file.wrapperPath]) {
97
+ if (typeof value !== "string" || value.length === 0) continue;
98
+ if (!isSpawnableCodexCandidate(value)) continue;
99
+ out.push(value);
100
+ }
101
+ }
102
+ return out;
103
+ } catch {
104
+ return [];
105
+ }
106
+ }
107
+
108
+ export function codexExecInvocation(
109
+ command: string,
110
+ platform: NodeJS.Platform = process.platform,
111
+ ): { file: string; shell: boolean } {
112
+ if (platform === "win32" && /\.(cmd|bat)$/i.test(command)) {
113
+ return { file: `"${command.replace(/"/g, "")}"`, shell: true };
114
+ }
115
+ return { file: command, shell: false };
116
+ }
117
+
118
+ export function runCodexDebugModels(command: string, execFile: ExecFile): string {
119
+ const args = ["debug", "models", "--bundled"];
120
+ const invocation = codexExecInvocation(command);
121
+ return execFile(invocation.file, args, {
122
+ encoding: "utf8" as const,
123
+ stdio: ["ignore", "pipe", "ignore"] as ["ignore", "pipe", "ignore"],
124
+ timeout: 10_000,
125
+ windowsHide: true,
126
+ shell: invocation.shell,
127
+ });
128
+ }
129
+
130
+ export function loadBundledCodexCatalog(deps: BundledCatalogDeps = {}): RawCatalog | null {
131
+ const useCache = !deps.commandCandidates && !deps.execFileSync;
132
+ if (useCache && bundledCatalogCache && bundledCatalogCache.expiresAt > Date.now()) {
133
+ return bundledCatalogCache.value;
134
+ }
135
+ const candidates = deps.commandCandidates?.() ?? codexCommandCandidates();
136
+ const execFile = deps.execFileSync ?? (execFileSync as unknown as ExecFile);
137
+ for (const command of candidates) {
138
+ try {
139
+ const catalog = parseCatalogJson(runCodexDebugModels(command, execFile));
140
+ if (catalog && findNativeTemplate(catalog)) {
141
+ if (useCache) bundledCatalogCache = { expiresAt: Date.now() + BUNDLED_CATALOG_CACHE_MS, value: catalog };
142
+ return catalog;
143
+ }
144
+ } catch { /* try next candidate */ }
145
+ }
146
+ if (useCache) bundledCatalogCache = { expiresAt: Date.now() + BUNDLED_CATALOG_CACHE_MS, value: null };
147
+ return null;
148
+ }
149
+
150
+ export function materializeBundledCodexCatalog(path: string, deps: BundledCatalogDeps = {}): RawCatalog | null {
151
+ const catalog = loadBundledCodexCatalog(deps);
152
+ if (!catalog) return null;
153
+ try {
154
+ mkdirSync(dirname(path), { recursive: true });
155
+ atomicWriteFile(path, JSON.stringify(catalog, null, 2) + "\n");
156
+ } catch {
157
+ return null;
158
+ }
159
+ return catalog;
160
+ }
161
+
162
+ export function loadCatalogForSync(path: string): RawCatalog | null {
163
+ const bundled = isDefaultCatalogPath(path) ? loadBundledCodexCatalog() : null;
164
+ if (bundled) return JSON.parse(JSON.stringify(bundled)) as RawCatalog;
165
+ const catalog = readCatalog(path);
166
+ if (catalog && findNativeTemplate(catalog)) return catalog;
167
+ return readCatalog(catalogBackupPathFor(path))
168
+ ?? (isDefaultCatalogPath(path) ? readCatalog(legacyCatalogBackupPath()) : null)
169
+ ?? readCatalog(activeCodexModelsCachePath())
170
+ ?? materializeBundledCodexCatalog(path)
171
+ ?? catalog;
172
+ }
173
+
174
+ export function readCurrentCatalogOrCache(): RawCatalog | null {
175
+ const path = readCodexCatalogPath();
176
+ return (isDefaultCatalogPath(path) ? loadBundledCodexCatalog() : null)
177
+ ?? readCatalog(path)
178
+ ?? readCatalog(activeCodexModelsCachePath());
179
+ }
180
+
181
+ export function loadCatalogTemplate(): RawEntry | null {
182
+ const catalogPath = readCodexCatalogPath();
183
+ const native = findNativeTemplate(readCatalog(catalogPath))
184
+ ?? findNativeTemplate(readCatalogBackup(catalogPath))
185
+ ?? findNativeTemplate(readCatalog(activeCodexModelsCachePath()))
186
+ ?? findNativeTemplate(loadBundledCodexCatalog());
187
+ return native ? JSON.parse(JSON.stringify(native)) : null;
188
+ }