@bitkyc08/opencodex 2.55.0 → 2.56.0

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 (167) hide show
  1. package/gui/dist/assets/{index-VuoiWj9J.js → index-D4zuyIxQ.js} +1 -1
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -1
  4. package/src/adapters/base.ts +21 -0
  5. package/src/adapters/cursor/transport-retry.ts +46 -1
  6. package/src/adapters/cursor.ts +4 -0
  7. package/src/adapters/kiro/adapter.ts +42 -1
  8. package/src/adapters/kiro-retry.ts +23 -4
  9. package/src/adapters/openai-chat/errors.ts +116 -0
  10. package/src/adapters/openai-chat/messages.ts +346 -0
  11. package/src/adapters/openai-chat/passthrough.ts +146 -0
  12. package/src/adapters/openai-chat/response-events.ts +117 -0
  13. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  14. package/src/adapters/openai-chat/tool-schema.ts +477 -0
  15. package/src/adapters/openai-chat/wire.ts +50 -0
  16. package/src/adapters/openai-chat.ts +33 -1445
  17. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  18. package/src/adapters/openai-responses/image-gen.ts +406 -0
  19. package/src/adapters/openai-responses/internal.ts +3 -0
  20. package/src/adapters/openai-responses/passthrough.ts +611 -0
  21. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  22. package/src/adapters/openai-responses/reasoning.ts +220 -0
  23. package/src/adapters/openai-responses/request-strips.ts +185 -0
  24. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  25. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  26. package/src/adapters/openai-responses/web-search.ts +156 -0
  27. package/src/adapters/openai-responses.ts +4 -2625
  28. package/src/bridge/errors.ts +34 -0
  29. package/src/bridge/internal.ts +174 -0
  30. package/src/bridge/response-json.ts +624 -0
  31. package/src/bridge/sse.ts +1444 -0
  32. package/src/bridge.ts +5 -2204
  33. package/src/chat/inbound.ts +12 -1
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-store.ts +71 -9
  36. package/src/codex/auth-api/account-list.ts +507 -0
  37. package/src/codex/auth-api/http.ts +32 -0
  38. package/src/codex/auth-api/login-flow.ts +554 -0
  39. package/src/codex/auth-api/login-state.ts +64 -0
  40. package/src/codex/auth-api/main-account-probe.ts +331 -0
  41. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  42. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  43. package/src/codex/auth-api/reset-credit-service.ts +422 -0
  44. package/src/codex/auth-api/routes.ts +425 -0
  45. package/src/codex/auth-api/runtime-config.ts +48 -0
  46. package/src/codex/auth-api.ts +27 -3118
  47. package/src/codex/auth-context.ts +95 -28
  48. package/src/codex/catalog/auto-review.ts +507 -0
  49. package/src/codex/catalog/build-entries.ts +981 -0
  50. package/src/codex/catalog/combo-member.ts +375 -0
  51. package/src/codex/catalog/derive-entry.ts +229 -0
  52. package/src/codex/catalog/effort.ts +0 -1
  53. package/src/codex/catalog/gated-native-warn.ts +63 -0
  54. package/src/codex/catalog/gather-capture.ts +533 -0
  55. package/src/codex/catalog/model-hints.ts +691 -0
  56. package/src/codex/catalog/model-visibility.ts +304 -0
  57. package/src/codex/catalog/provider-fetch.ts +52 -2942
  58. package/src/codex/catalog/provider-models.ts +685 -0
  59. package/src/codex/catalog/restore.ts +132 -0
  60. package/src/codex/catalog/retained-sync.ts +706 -0
  61. package/src/codex/catalog/routed-gather.ts +858 -0
  62. package/src/codex/catalog/subagent-roster.ts +176 -0
  63. package/src/codex/catalog/sync.ts +52 -2698
  64. package/src/codex/inject/config-toml.ts +563 -0
  65. package/src/codex/inject/remove.ts +192 -0
  66. package/src/codex/inject/restore.ts +540 -0
  67. package/src/codex/inject/routing-classify.ts +109 -0
  68. package/src/codex/inject/routing-target.ts +125 -0
  69. package/src/codex/inject.ts +81 -1436
  70. package/src/codex/lineage.ts +458 -0
  71. package/src/codex/pool-refresh-backoff.ts +152 -0
  72. package/src/codex/routing/active-account.ts +194 -0
  73. package/src/codex/routing/cooldown-math.ts +275 -0
  74. package/src/codex/routing/health-store.ts +402 -0
  75. package/src/codex/routing/probe-lease.ts +358 -0
  76. package/src/codex/routing/selection.ts +703 -0
  77. package/src/codex/routing/thread-affinity.ts +538 -0
  78. package/src/codex/routing.ts +353 -2234
  79. package/src/codex/shim-fingerprint.ts +223 -0
  80. package/src/codex/shim-inspect.ts +175 -0
  81. package/src/codex/shim-probe.ts +367 -0
  82. package/src/codex/shim-restore-lock.ts +169 -0
  83. package/src/codex/shim-state-file.ts +151 -0
  84. package/src/codex/shim-templates.ts +265 -0
  85. package/src/codex/shim.ts +48 -1268
  86. package/src/config/diagnostics.ts +705 -0
  87. package/src/config/feature-flags.ts +55 -0
  88. package/src/config/live-reconcile.ts +403 -0
  89. package/src/config/load-degrade.ts +880 -0
  90. package/src/config/mutation-lock.ts +244 -0
  91. package/src/config/openai-tier-backup.ts +268 -0
  92. package/src/config/persist-unlocked.ts +92 -0
  93. package/src/config/proxy-env.ts +188 -0
  94. package/src/config/salvage.ts +244 -0
  95. package/src/config/schema/config-schema.ts +640 -0
  96. package/src/config/schema/leaf-validators.ts +855 -0
  97. package/src/config/warn-memo.ts +28 -0
  98. package/src/config.ts +234 -4481
  99. package/src/generated/compatibility-version.json +539 -39
  100. package/src/lib/request-execution-budget.ts +69 -20
  101. package/src/lib/spend-reservation-ledger.ts +940 -0
  102. package/src/lib/upstream-retry.ts +55 -11
  103. package/src/lib/workflow-budget.ts +553 -30
  104. package/src/providers/quota/account-cache.ts +441 -0
  105. package/src/providers/quota/antigravity.ts +295 -0
  106. package/src/providers/quota/report-cache.ts +320 -0
  107. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  108. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  109. package/src/providers/quota.ts +324 -3079
  110. package/src/providers/registry/entries-core.ts +1221 -0
  111. package/src/providers/registry/entries-extended.ts +1204 -0
  112. package/src/providers/registry/model-seeds.ts +908 -0
  113. package/src/providers/registry/types.ts +352 -0
  114. package/src/providers/registry.ts +24 -3536
  115. package/src/responses/continuation-ownership.ts +29 -0
  116. package/src/responses/state/replay-fingerprint.ts +80 -0
  117. package/src/responses/state/snapshot-codec.ts +104 -0
  118. package/src/responses/state/spill-failure.ts +118 -0
  119. package/src/responses/state/spill-queue.ts +665 -0
  120. package/src/responses/state/temp-recovery.ts +257 -0
  121. package/src/responses/state.ts +82 -1143
  122. package/src/routing/identity-domains.ts +449 -0
  123. package/src/routing/probe-lease.ts +511 -0
  124. package/src/server/index/bounded-request.ts +88 -0
  125. package/src/server/index/live-sideband.ts +565 -0
  126. package/src/server/index/serve-options.ts +1766 -0
  127. package/src/server/index/startup-warnings.ts +213 -0
  128. package/src/server/index/websocket-handler.ts +335 -0
  129. package/src/server/index.ts +40 -2547
  130. package/src/server/management/route-registry.ts +26 -23
  131. package/src/server/management/shared.ts +8 -5
  132. package/src/server/management/workflow-budget-routes.ts +133 -0
  133. package/src/server/management-api.ts +12 -0
  134. package/src/server/request-log-conversation.ts +9 -7
  135. package/src/server/request-log.ts +245 -1
  136. package/src/server/responses/account-change-state.ts +233 -0
  137. package/src/server/responses/adapter-continuation.ts +514 -0
  138. package/src/server/responses/adapter-delivery.ts +214 -0
  139. package/src/server/responses/adapter-dispatch.ts +971 -0
  140. package/src/server/responses/compact.ts +59 -4
  141. package/src/server/responses/completion-policy.ts +33 -0
  142. package/src/server/responses/core-auth.ts +527 -0
  143. package/src/server/responses/core-codex-account.ts +859 -0
  144. package/src/server/responses/core-combo-failure.ts +210 -0
  145. package/src/server/responses/core-combo.ts +707 -0
  146. package/src/server/responses/core-errors.ts +152 -0
  147. package/src/server/responses/core-lifetime.ts +95 -0
  148. package/src/server/responses/core-normalize.ts +350 -0
  149. package/src/server/responses/core-opaque-recovery.ts +380 -0
  150. package/src/server/responses/core-options.ts +159 -0
  151. package/src/server/responses/core-replay.ts +225 -0
  152. package/src/server/responses/core.ts +192 -8893
  153. package/src/server/responses/passthrough-delivery.ts +856 -0
  154. package/src/server/responses/passthrough-dispatch.ts +1476 -0
  155. package/src/server/responses/passthrough-execution.ts +54 -0
  156. package/src/server/responses/request-prepare.ts +970 -0
  157. package/src/server/responses/request-send-budget.ts +164 -0
  158. package/src/server/responses/request-sidecar-auth.ts +149 -0
  159. package/src/server/responses/request-transport.ts +744 -0
  160. package/src/server/responses/response-effects.ts +157 -0
  161. package/src/server/responses/run-turn-execution.ts +448 -0
  162. package/src/server/responses/sidecar-execution.ts +469 -0
  163. package/src/server/responses-image-gen-repair.ts +1 -1
  164. package/src/server/workflow-refusal.ts +84 -0
  165. package/src/types/config.ts +30 -0
  166. package/src/usage/log.ts +146 -0
  167. package/src/usage/summary.ts +171 -21
@@ -0,0 +1,706 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { loadConfig, websocketsEnabled } from "../../config";
4
+ import { shouldSyncCodexOnStart } from "../desired-state";
5
+ import { legacyCustomModelCatalogSlugs } from "../custom-model-catalog-migration";
6
+ import { getCodexHome } from "../paths";
7
+ import type { OcxConfig } from "../../types";
8
+ import { pendingModelSelectionProviders } from "../../providers/initial-model-selection";
9
+ import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
10
+ import { providerCodexAccountMode } from "../../providers/registry";
11
+ import { COMBO_NAMESPACE } from "../../combos";
12
+ import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../account-namespaces";
13
+ import { MAIN_CODEX_ACCOUNT_ID } from "../main-account";
14
+ import {
15
+ availableAccountGatedNativeModels,
16
+ codexModelEntitlementStateForAccount,
17
+ isCodexModelEntitlementSnapshotCurrent,
18
+ resolveCodexModelEntitlements,
19
+ type CodexModelEntitlementSnapshot,
20
+ } from "../model-entitlements";
21
+ import { isAccountNeedsReauth } from "../account-runtime-state";
22
+ import { codexRuntimeStatePath } from "../runtime";
23
+ import {
24
+ activeCodexModelsCachePath,
25
+ catalogBackupPathFor,
26
+ catalogHasRoutedEntries,
27
+ findNativeTemplate,
28
+ findSupportedNativeTemplate,
29
+ isDefaultCatalogPath,
30
+ legacyCatalogBackupPath,
31
+ readCatalog,
32
+ readCatalogBackup,
33
+ readCodexCatalogPath,
34
+ readCodexCatalogPathForHome,
35
+ readNativeBaseline,
36
+ } from "./parsing";
37
+ import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing";
38
+ import {
39
+ accountBoundNativeOpenAiSlugsBySelector,
40
+ desktopAllowlistSuppressedNativeSlugs,
41
+ disabledNativeSlugs,
42
+ nativeContextLimits,
43
+ observedAccountBoundNativeEntries,
44
+ observedReserveCatalogSource,
45
+ shouldIncludeAccountBoundNativeOpenAi,
46
+ shouldIncludeNativeOpenAi,
47
+ upstreamNativeEntry,
48
+ } from "./metadata";
49
+ import { trustedAccountBoundNativeCatalogSlug } from "./account-models";
50
+ import { bundledCatalogCacheState, loadBundledCodexCatalog } from "./bundled";
51
+ import { isMultiAgentV2Enabled } from "../features";
52
+ import { clampCatalogModelsToCodexSupport } from "./effort";
53
+ import { filterCatalogVisibleModels, gatherRoutedModels, type CatalogGatherProviderModelOutcome } from "./provider-fetch";
54
+ import { exactComboCatalogSlugs, type ComboCatalogOmission } from "./aggregation";
55
+ import {
56
+ withCatalogWriteSerialization,
57
+ type CatalogWritePermit,
58
+ } from "../catalog-write-serialization";
59
+ import {
60
+ publishHashedCodexCatalogBackup,
61
+ publishLegacyCodexCatalogBackup,
62
+ replaceActiveCodexCatalog,
63
+ replaceCodexModelsCache,
64
+ } from "../internal/catalog-writer";
65
+ import { visibleCodexAccountSelectors } from "./account-models";
66
+ import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_OPENAI_MODELS, NATIVE_RESERVE_MODEL } from "./native-models";
67
+ import { createReserveCatalogProjection, RESERVE_LUNA_METADATA_SOURCE, RESERVE_SOURCE_CATALOG_FIELD } from "./reserve";
68
+ import {
69
+ CANONICAL_NATIVE_CATALOG_CONTENT_POLICY,
70
+ buildCatalogEntriesFromObservedState,
71
+ mergeCatalogEntriesFromObservedState,
72
+ mergeCatalogModelsWithNativeRecovery,
73
+ orderForSubagents,
74
+ } from "./build-entries";
75
+ import { finishUpstreamNativeEntry } from "./derive-entry";
76
+ import { finalizeAutoReviewModelOverride } from "./auto-review";
77
+ import { gatedNativeAccountLabel, gatedNativeReauthSuppressionReason, warnGatedNativeSuppressedOnce } from "./gated-native-warn";
78
+
79
+ interface RetainedCatalogSyncRead {
80
+ readonly catalogPath: string;
81
+ readonly catalog: RawCatalog;
82
+ readonly onDiskCatalog: RawCatalog | null;
83
+ readonly modelsCache: RawCatalog | null;
84
+ readonly evidence: string;
85
+ /**
86
+ * Process-local epochs, baselined AFTER our own gather rather than with the
87
+ * filesystem bytes above. See `retainedCatalogProcessEvidence`.
88
+ */
89
+ readonly processEvidence: string;
90
+ }
91
+
92
+ interface RetainedCatalogSyncResult {
93
+ added: number;
94
+ path: string;
95
+ catalogWritten: boolean;
96
+ comboOmissions: ComboCatalogOmission[];
97
+ /** Validated catalog commit (including identical bytes), or a refused refresh. */
98
+ refreshOutcome?: "committed" | "refused";
99
+ /** `desired_disabled` observed under K after the provider await; nothing was written. */
100
+ skippedReason?: "desired_disabled";
101
+ }
102
+
103
+ /**
104
+ * Catalog/cache commit overrides.
105
+ *
106
+ * An explicit `ocx sync` is also the refresh path for side profiles that consume
107
+ * the OpenCodex catalog without injection (for example a custom `model_provider`
108
+ * that routes to the proxy). In that mode the Codex integration toggle only
109
+ * governs config/history injection; the catalog and models cache may still be
110
+ * refreshed, so `allowWhenDesiredDisabled` lets the commit path ignore the OFF
111
+ * gate that otherwise protects a fully native home.
112
+ */
113
+ export interface CodexCatalogSyncOptions {
114
+ allowWhenDesiredDisabled?: boolean;
115
+ }
116
+
117
+ interface RetainedCatalogSyncWrite {
118
+ readonly config: OcxConfig;
119
+ readonly goModels: CatalogModel[];
120
+ readonly providerModelOutcomes: readonly CatalogGatherProviderModelOutcome[];
121
+ readonly comboOmissions: ComboCatalogOmission[];
122
+ readonly read: RetainedCatalogSyncRead;
123
+ readonly permit: CatalogWritePermit;
124
+ readonly owningCodexHome: string;
125
+ readonly modelEntitlements: CodexModelEntitlementSnapshot;
126
+ }
127
+
128
+ function optionalFileBytes(path: string): string | null {
129
+ try {
130
+ return readFileSync(path).toString("base64");
131
+ } catch (error) {
132
+ if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") return null;
133
+ throw error;
134
+ }
135
+ }
136
+
137
+ function loadCatalogForRetainedSync(path: string): RawCatalog | null {
138
+ const bundled = isDefaultCatalogPath(path) ? loadBundledCodexCatalog() : null;
139
+ if (bundled) return JSON.parse(JSON.stringify(bundled)) as RawCatalog;
140
+ const active = readCatalog(path);
141
+ // A valid configured custom file remains the content authority even when it has no bare native
142
+ // template. The null-template builder is deliberate; a stale backup must not replace active
143
+ // custom root metadata merely because the current file contains only routed rows.
144
+ if (active && (!isDefaultCatalogPath(path) || findNativeTemplate(active))) return active;
145
+ return readCatalog(catalogBackupPathFor(path))
146
+ ?? (isDefaultCatalogPath(path) ? readCatalog(legacyCatalogBackupPath()) : null)
147
+ ?? readCatalog(activeCodexModelsCachePath())
148
+ ?? active;
149
+ }
150
+
151
+ function retainedCatalogSyncEvidence(
152
+ config: OcxConfig,
153
+ catalogPath: string,
154
+ catalog: RawCatalog,
155
+ ): string {
156
+ return JSON.stringify({
157
+ config,
158
+ catalogPath,
159
+ catalog,
160
+ catalogBytes: optionalFileBytes(catalogPath),
161
+ hashedBackupBytes: optionalFileBytes(catalogBackupPathFor(catalogPath)),
162
+ legacyBackupBytes: isDefaultCatalogPath(catalogPath)
163
+ ? optionalFileBytes(legacyCatalogBackupPath()) : null,
164
+ modelsCacheBytes: optionalFileBytes(activeCodexModelsCachePath()),
165
+ // The persisted runtime selection is a pre-await filesystem input, not a
166
+ // process epoch: another PROCESS can move runtime authority by rewriting this
167
+ // file, and that move is invisible to our in-process memo. Recorded PRESENT or
168
+ // ABSENT, because its absence is what makes the resolver fall back.
169
+ runtimeStateBytes: optionalFileBytes(codexRuntimeStatePath()),
170
+ });
171
+ }
172
+
173
+ /**
174
+ * The bundled-template half of the same evidence, observed separately.
175
+ *
176
+ * The runtime process memo is deliberately NOT here, and that exclusion took three
177
+ * attempts to get honest. Gathering resolves the Codex runtime lazily and under its
178
+ * own cache key, so this path cannot pre-settle that memo: baselining it before the
179
+ * await always detected our own side effect and refused every write, and baselining
180
+ * it after the await captured a runtime that ANOTHER process had moved as though it
181
+ * were ours — a catalog prepared from R1 committing after authority reached R2.
182
+ *
183
+ * Runtime authority is covered where it is actually durable instead: the persisted
184
+ * `codex-runtime.json` bytes sit in the pre-await filesystem evidence, PRESENT or
185
+ * ABSENT, so a cross-process runtime move is caught. What is left uncovered, and is
186
+ * written down rather than papered over, is a same-process in-memory runtime swap
187
+ * that never touches that file — WP11 owns the lock that makes that case decidable.
188
+ */
189
+ function retainedCatalogProcessEvidence(): string {
190
+ return JSON.stringify({
191
+ bundledCatalogCache: bundledCatalogCacheState(),
192
+ });
193
+ }
194
+
195
+ /**
196
+ * Capture every local catalog input the retained sync path consults before its
197
+ * provider await. The exact evidence is compared after K acquisition; a newer
198
+ * catalog/backup/cache or target selection makes this attempt a no-write.
199
+ */
200
+ function readRetainedCatalogSync(config: OcxConfig): RetainedCatalogSyncRead | null {
201
+ const catalogPath = readCodexCatalogPath();
202
+ const catalog = loadCatalogForRetainedSync(catalogPath);
203
+ if (!catalog) return null;
204
+
205
+ // The bundled catalog is a reliable native template on the default path, but it is not the
206
+ // merge source. Preservation must inspect the file that this sync is about to overwrite;
207
+ // otherwise an empty/partial provider gather cannot see routed or user-native rows on disk.
208
+ const onDiskCatalog = readCatalog(catalogPath);
209
+ const modelsCache = readCatalog(activeCodexModelsCachePath());
210
+ const evidence = retainedCatalogSyncEvidence(config, catalogPath, catalog);
211
+ // `processEvidence` is filled in after the provider await, not here.
212
+ return { catalogPath, catalog, onDiskCatalog, modelsCache, evidence, processEvidence: "" };
213
+ }
214
+
215
+ function revalidateRetainedCatalogSync(
216
+ config: OcxConfig,
217
+ prepared: RetainedCatalogSyncRead,
218
+ ): RetainedCatalogSyncRead | null {
219
+ const catalogPath = readCodexCatalogPath();
220
+ if (catalogPath !== prepared.catalogPath) return null;
221
+ const evidence = retainedCatalogSyncEvidence(config, catalogPath, prepared.catalog);
222
+ if (evidence !== prepared.evidence) return null;
223
+ if (retainedCatalogProcessEvidence() !== prepared.processEvidence) return null;
224
+ return {
225
+ catalogPath,
226
+ catalog: JSON.parse(JSON.stringify(prepared.catalog)) as RawCatalog,
227
+ onDiskCatalog: readCatalog(catalogPath),
228
+ modelsCache: readCatalog(activeCodexModelsCachePath()),
229
+ evidence,
230
+ processEvidence: prepared.processEvidence,
231
+ };
232
+ }
233
+
234
+ /**
235
+ * Exact bytes currently on disk at `path`, or null when unreadable/absent.
236
+ *
237
+ * Deliberately a Buffer rather than a decoded string: `readFileSync(path, "utf8")`
238
+ * substitutes U+FFFD for every invalid byte, so a file holding a raw 0x80 decodes
239
+ * equal to prepared content holding a legitimately encoded U+FFFD. Comparing the
240
+ * decoded strings would then classify a malformed catalog as identical, skip the
241
+ * atomic repair write, and leave the corruption on disk while reporting
242
+ * `catalogWritten: false`.
243
+ */
244
+ function currentCatalogFileContent(path: string): Buffer | null {
245
+ try {
246
+ return readFileSync(path);
247
+ } catch {
248
+ return null;
249
+ }
250
+ }
251
+
252
+ function pristineCatalogBytes(read: RetainedCatalogSyncRead): string | null {
253
+ if (read.onDiskCatalog && !catalogHasRoutedEntries(read.onDiskCatalog)) {
254
+ try {
255
+ return readFileSync(read.catalogPath, "utf8");
256
+ } catch {
257
+ return null;
258
+ }
259
+ }
260
+ return catalogHasRoutedEntries(read.catalog)
261
+ ? null
262
+ : `${JSON.stringify(read.catalog, null, 2)}\n`;
263
+ }
264
+
265
+ function catalogModelsForMergeWithNativeRecovery(
266
+ catalogPath: string,
267
+ catalog: RawCatalog,
268
+ onDiskCatalog: RawCatalog | null,
269
+ ): RawEntry[] {
270
+ const primaryCatalogModels = onDiskCatalog?.models ?? catalog.models ?? [];
271
+ // Native-alias compatibility can omit disabled native rows from the effective catalog because
272
+ // Desktop's remote allowlist ignores `visibility: "hide"`. Keep current/pristine native recovery
273
+ // sources beside the on-disk rows so re-enabling a model restores its real metadata. Routed and
274
+ // user-authored rows still come only from the on-disk catalog.
275
+ return mergeCatalogModelsWithNativeRecovery(primaryCatalogModels, [
276
+ catalog.models ?? [],
277
+ readCatalogBackup(catalogPath)?.models ?? [],
278
+ ]);
279
+ }
280
+
281
+ function writeRetainedCatalogSync({
282
+ config,
283
+ goModels,
284
+ providerModelOutcomes,
285
+ comboOmissions,
286
+ read,
287
+ permit,
288
+ owningCodexHome,
289
+ modelEntitlements,
290
+ }: RetainedCatalogSyncWrite): RetainedCatalogSyncResult {
291
+ const { catalogPath, catalog, onDiskCatalog } = read;
292
+ const catalogModelsForMerge = catalogModelsForMergeWithNativeRecovery(
293
+ catalogPath,
294
+ catalog,
295
+ onDiskCatalog,
296
+ );
297
+ // Strict selector for template inheritance; the validity gate above keeps the broad one.
298
+ const template = findSupportedNativeTemplate(catalog);
299
+
300
+ try {
301
+ // Once-only: preserve the PRISTINE pre-opencodex catalog as the native-priority baseline
302
+ // (later syncs would otherwise overwrite it with featured-modified priorities).
303
+ const pristine = pristineCatalogBytes(read);
304
+ if (pristine !== null) {
305
+ publishHashedCodexCatalogBackup(permit, owningCodexHome, {
306
+ path: catalogBackupPathFor(catalogPath),
307
+ content: pristine,
308
+ });
309
+ if (isDefaultCatalogPath(catalogPath)) {
310
+ publishLegacyCodexCatalogBackup(permit, owningCodexHome, {
311
+ path: legacyCatalogBackupPath(),
312
+ content: pristine,
313
+ });
314
+ }
315
+ }
316
+ } catch { /* backup best-effort */ }
317
+
318
+ // Hide disabled models from Codex, then feature the chosen subagent models (native OR routed)
319
+ // by giving them the lowest priority — see buildCatalogEntries for why priority, not array order.
320
+ const enabledGo = filterCatalogVisibleModels(goModels, config);
321
+ const featured = config.subagentModels ?? [];
322
+ const orderedGoModels = orderForSubagents(enabledGo, featured); // stable tie-break among equal priorities
323
+ const modelPickerOrder = config.modelPickerOrder ?? [];
324
+ const multiAgentMode: MultiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default";
325
+ const exactComboSlugs = exactComboCatalogSlugs(config);
326
+ const bareEligibleAccountIds = providerCodexAccountMode(
327
+ OPENAI_CODEX_PROVIDER_ID,
328
+ config.providers[OPENAI_CODEX_PROVIDER_ID],
329
+ ) === "direct" ? new Set([MAIN_CODEX_ACCOUNT_ID]) : undefined;
330
+ const availableBareGatedNativeSlugs = availableAccountGatedNativeModels(
331
+ modelEntitlements,
332
+ bareEligibleAccountIds,
333
+ );
334
+ const availableAccountGatedNativeSlugs = availableAccountGatedNativeModels(modelEntitlements);
335
+ const availableBareNativeSlugs = NATIVE_OPENAI_MODELS.filter(slug => (
336
+ !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableBareGatedNativeSlugs.has(slug)
337
+ ));
338
+ const availableAccountNativeSlugs = NATIVE_OPENAI_MODELS.filter(slug => (
339
+ !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableAccountGatedNativeSlugs.has(slug)
340
+ ));
341
+ const unavailableGatedNativeSlugs = new Set([...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(slug => (
342
+ !availableBareGatedNativeSlugs.has(slug)
343
+ )));
344
+ // #4212: this set is the whole record of a model vanishing, and it is a set of strings that
345
+ // nothing downstream ever asks a question of. Explain it here, while the entitlement snapshot
346
+ // that produced it is still in scope, because after this point the model is simply absent and
347
+ // no later surface can tell "never entitled" apart from "the account broke this morning".
348
+ for (const slug of unavailableGatedNativeSlugs) {
349
+ const reason = gatedNativeReauthSuppressionReason({
350
+ snapshot: modelEntitlements,
351
+ slug,
352
+ eligibleAccountIds: bareEligibleAccountIds,
353
+ needsReauth: isAccountNeedsReauth,
354
+ label: accountId => gatedNativeAccountLabel(config, accountId),
355
+ });
356
+ if (reason) warnGatedNativeSuppressedOnce(slug, reason);
357
+ }
358
+ const suppressedBareNativeSlugs = new Set([
359
+ ...desktopAllowlistSuppressedNativeSlugs(config),
360
+ ...unavailableGatedNativeSlugs,
361
+ ]);
362
+ const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE);
363
+ const includeNativeOpenAi = shouldIncludeNativeOpenAi(config);
364
+ const includeAccountBoundNativeOpenAi = shouldIncludeAccountBoundNativeOpenAi(config);
365
+ // Both user levers. Passing only the cap here is what let a per-model window the dashboard
366
+ // had accepted get written back at full width in the on-disk catalog.
367
+ const openaiContextCap = nativeContextLimits(config);
368
+ const accountSelectors = includeAccountBoundNativeOpenAi
369
+ ? visibleCodexAccountSelectors(config)
370
+ : [];
371
+ const observedAccountNativeEntries = [
372
+ ...(read.modelsCache?.models ?? []),
373
+ ...(onDiskCatalog?.models ?? []).filter(entry =>
374
+ trustedAccountBoundNativeCatalogSlug(entry) !== undefined),
375
+ ];
376
+ const accountTargets = new Map(codexAccountNamespaceEntries(config));
377
+ const reserveMainSelectors = accountSelectors.filter(selector =>
378
+ isMainCodexAccountTarget(accountTargets.get(selector) ?? ""));
379
+ // The active file can own a bare source even when the bundled catalog is the build base.
380
+ // A previously clamped qualified projection must not shorten a retained genuine ladder.
381
+ const reserveObservations = [
382
+ ...(onDiskCatalog?.models ?? []),
383
+ ...(read.modelsCache?.models ?? []),
384
+ ...(catalog.models ?? []),
385
+ ];
386
+ const retainedReserve = onDiskCatalog?.[RESERVE_SOURCE_CATALOG_FIELD];
387
+ const retainedReserveSource = retainedReserve && typeof retainedReserve === "object" && !Array.isArray(retainedReserve)
388
+ ? observedReserveCatalogSource([retainedReserve as RawEntry], [])
389
+ : null;
390
+ const observedReserveSource = observedReserveCatalogSource(
391
+ // Cache invalidation carries historical bare observations alongside emitted models.
392
+ // Only unmarked observations are fresh enough to supersede the retained source.
393
+ reserveObservations.filter(entry => entry.slug === NATIVE_RESERVE_MODEL
394
+ && entry.opencodex_account_observed_native === undefined), reserveMainSelectors,
395
+ ) ?? retainedReserveSource ?? observedReserveCatalogSource(reserveObservations, reserveMainSelectors);
396
+ // This root is read only by OCX. Upstream ModelsResponse ignores unknown root fields.
397
+ // Retain before final runtime clamping: an omitted row must not turn into Luna next sync.
398
+ if (observedReserveSource) catalog[RESERVE_SOURCE_CATALOG_FIELD] = structuredClone(observedReserveSource);
399
+ else delete catalog[RESERVE_SOURCE_CATALOG_FIELD];
400
+ const lunaSource = upstreamNativeEntry(RESERVE_LUNA_METADATA_SOURCE);
401
+ const reserve = createReserveCatalogProjection(
402
+ config,
403
+ reserveMainSelectors,
404
+ observedReserveSource,
405
+ lunaSource ? finishUpstreamNativeEntry(lunaSource, 9, openaiContextCap) : null,
406
+ );
407
+ const accountNativeSlugsBySelector = accountSelectors.length > 0
408
+ ? new Map([...accountBoundNativeOpenAiSlugsBySelector(config, observedAccountNativeEntries)].map(([selector, slugs]) => {
409
+ const target = accountTargets.get(selector);
410
+ const accountId = target && isMainCodexAccountTarget(target) ? MAIN_CODEX_ACCOUNT_ID : target;
411
+ return [selector, slugs.filter(slug => (
412
+ !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug)
413
+ || (accountId !== undefined
414
+ && codexModelEntitlementStateForAccount(modelEntitlements, accountId, slug) === "granted")
415
+ ))] as const;
416
+ }))
417
+ : new Map<string, readonly string[]>();
418
+ const accountNativeSlugs = accountSelectors.length > 0
419
+ ? [...new Set([...accountNativeSlugsBySelector.values()].flatMap(slugs => [...slugs]))]
420
+ : [];
421
+ // Unknown account-native ids have no safe bare/global identity. They are only projected through
422
+ // the selector map above; the no-selector catalog remains the static native/API-key surface.
423
+ const observedNativeSlugs: string[] = [];
424
+ const wsEnabled = websocketsEnabled(config);
425
+ const multiAgentV2Enabled = isMultiAgentV2Enabled();
426
+ const goEntries = buildCatalogEntriesFromObservedState({
427
+ template: template ? JSON.parse(JSON.stringify(template)) : null,
428
+ gptSlugs: [],
429
+ goModels: orderedGoModels,
430
+ featured,
431
+ modelPickerOrder,
432
+ wsEnabled,
433
+ multiAgentMode,
434
+ exactComboSlugs,
435
+ accountSelectors,
436
+ suppressedBareNativeSlugs,
437
+ disabledNativeAccountSlugs: new Set(),
438
+ multiAgentV2Enabled,
439
+ openaiContextCap,
440
+ });
441
+ // Keep genuine native entries (gpt-*, codex-*) with their real per-model fields and append
442
+ // routed providers as namespaced slugs. Cursor and other adopted providers can expose model ids
443
+ // like `gpt-5.5`; those must not delete the native OpenAI/Codex base row.
444
+ const baselineCatalog = readCatalogBackup(catalogPath);
445
+ const baseline = readNativeBaseline(catalogPath);
446
+ const gatheredProviderNames = new Set(
447
+ Object.entries(config.providers ?? {})
448
+ .filter(([, prov]) => prov.disabled !== true)
449
+ .map(([name]) => name),
450
+ );
451
+ const degradedProviderNames = new Set(
452
+ providerModelOutcomes
453
+ .filter(outcome => outcome.state === "degraded")
454
+ .map(outcome => outcome.provider),
455
+ );
456
+ const selectedModelsByProvider = new Map<string, ReadonlySet<string>>(
457
+ Object.entries(config.providers ?? {}).flatMap(([name, provider]) => (
458
+ provider.disabled !== true
459
+ && Array.isArray(provider.selectedModels)
460
+ && provider.selectedModels.length > 0
461
+ ? [[name, new Set(provider.selectedModels)] as const]
462
+ : []
463
+ )),
464
+ );
465
+ // Central WS capability override on the FINAL on-disk catalog (the file Codex reads). Applies to
466
+ // native AND routed so the advertised flag matches the implemented endpoint (phase 120.4) and a
467
+ // native template can never leak supports_websockets while the flag is off.
468
+ // #636: when the user only configured non-OpenAI providers (e.g. kimi), do not advertise
469
+ // bare gpt-* rows that hard-404 via NoEnabledOpenAiProviderError. Keep natives when no
470
+ // providers are configured yet (fresh install / catalog bootstrap tests).
471
+ const accountBoundEntries = includeAccountBoundNativeOpenAi && accountSelectors.length > 0
472
+ ? buildCatalogEntriesFromObservedState({
473
+ template: template ? JSON.parse(JSON.stringify(template)) : null,
474
+ gptSlugs: availableAccountNativeSlugs,
475
+ goModels: [],
476
+ featured,
477
+ wsEnabled,
478
+ multiAgentMode,
479
+ exactComboSlugs,
480
+ accountSelectors,
481
+ suppressedBareNativeSlugs,
482
+ disabledNativeAccountSlugs: new Set([...disabledNativeSlugs(config)].filter(slug => suppressedBareNativeSlugs.has(slug))),
483
+ multiAgentV2Enabled,
484
+ keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true,
485
+ openaiContextCap,
486
+ accountNativeSlugs,
487
+ accountNativeSlugsBySelector,
488
+ reserve,
489
+ }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined)
490
+ : [];
491
+ catalog.models = mergeCatalogEntriesFromObservedState({
492
+ modelPickerOrder,
493
+ accountSelectors,
494
+ catalogModels: catalogModelsForMerge,
495
+ baselineCatalogModels: baselineCatalog?.models ?? [],
496
+ routedEntries: goEntries,
497
+ baseline,
498
+ featured,
499
+ wsEnabled,
500
+ template,
501
+ disabledModels: new Set(config.disabledModels ?? []),
502
+ selectedModelsByProvider,
503
+ gatheredProviderNames,
504
+ pendingProviderNames: pendingModelSelectionProviders(config),
505
+ degradedProviderNames,
506
+ legacyCustomModelSlugs: legacyCustomModelCatalogSlugs(config),
507
+ multiAgentMode,
508
+ multiAgentV2Enabled,
509
+ keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true,
510
+ exactComboSlugs,
511
+ hasPhysicalComboProvider,
512
+ includeNativeOpenAi,
513
+ accountBoundEntries,
514
+ suppressedBareNativeSlugs,
515
+ openaiContextCap,
516
+ nativeDisplayNames: config.providers[OPENAI_CODEX_PROVIDER_ID]?.modelDisplayNames,
517
+ policy: {
518
+ ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY,
519
+ nativeBackfillSlugs: [...availableBareNativeSlugs, ...observedNativeSlugs],
520
+ warningPolicy: "emit",
521
+ },
522
+ });
523
+ clampCatalogModelsToCodexSupport(catalog.models);
524
+ finalizeAutoReviewModelOverride(catalog.models, catalogModelsForMerge, config);
525
+
526
+ const added = goEntries.length + accountBoundEntries.length;
527
+ const content = `${JSON.stringify(catalog, null, 2)}\n`;
528
+ // A byte-identical rewrite is not a catalog change, but every mtime-keyed reader
529
+ // has to treat it as one. The app-server staleness classifier (#857) is the one
530
+ // that matters: it compares this file's mtime against each running Codex's start
531
+ // time, so an ordinary `ocx start` — or any dashboard action that re-syncs an
532
+ // unchanged model set — marked every already-running Codex as holding an outdated
533
+ // in-memory catalog. Since #1407 that verdict silences opencodex's own model
534
+ // guidance entirely (no preferred model, no roster) for the rest of that Codex's
535
+ // lifetime, so a configured injectionModel stops reaching the session even though
536
+ // nothing about the catalog changed. Skipping the no-op write keeps both the mtime
537
+ // and `catalogWritten` honest; `added` still reports the routed rows the catalog
538
+ // carries, because they are on disk either way.
539
+ const onDiskBytes = currentCatalogFileContent(catalogPath);
540
+ if (onDiskBytes !== null && onDiskBytes.equals(Buffer.from(content, "utf8"))) {
541
+ return { added, path: catalogPath, catalogWritten: false, comboOmissions };
542
+ }
543
+
544
+ replaceActiveCodexCatalog(permit, owningCodexHome, {
545
+ path: catalogPath,
546
+ content,
547
+ });
548
+ return {
549
+ added,
550
+ path: catalogPath,
551
+ catalogWritten: true,
552
+ comboOmissions,
553
+ };
554
+ }
555
+
556
+ export async function syncCatalogModels(
557
+ config: OcxConfig,
558
+ options?: CodexCatalogSyncOptions,
559
+ ): Promise<RetainedCatalogSyncResult> {
560
+ if (pendingModelSelectionProviders(config).size) {
561
+ const { resolvePendingInitialModelSelection } = await import("../../providers/initial-model-selection-runtime");
562
+ await resolvePendingInitialModelSelection(config);
563
+ }
564
+ const owningCodexHome = getCodexHome();
565
+ const preflightRead = readRetainedCatalogSync(config);
566
+ if (preflightRead === null) {
567
+ return {
568
+ added: 0,
569
+ path: readCodexCatalogPath(),
570
+ catalogWritten: false,
571
+ comboOmissions: [],
572
+ refreshOutcome: "refused",
573
+ };
574
+ }
575
+
576
+ const comboOmissions: ComboCatalogOmission[] = [];
577
+ const providerModelOutcomes: CatalogGatherProviderModelOutcome[] = [];
578
+ // Settle the bundled template, then baseline, and only then await. Reading it
579
+ // here makes the memo ours before anyone else can move it, so a bundled swap
580
+ // during the await is an outside change rather than our own side effect.
581
+ //
582
+ // The persisted runtime selection is covered by the filesystem evidence above
583
+ // rather than by a process epoch; see `retainedCatalogProcessEvidence` for why
584
+ // the in-memory runtime memo cannot be baselined honestly from this path.
585
+ loadBundledCodexCatalog();
586
+ const prepared: RetainedCatalogSyncRead = {
587
+ ...preflightRead,
588
+ evidence: retainedCatalogSyncEvidence(config, preflightRead.catalogPath, preflightRead.catalog),
589
+ processEvidence: retainedCatalogProcessEvidence(),
590
+ };
591
+ const [goModels, modelEntitlements] = await Promise.all([
592
+ gatherRoutedModels(config, {
593
+ comboOmissions,
594
+ providerModelOutcomes,
595
+ }),
596
+ resolveCodexModelEntitlements(config),
597
+ ]);
598
+ const committed = withCatalogWriteSerialization(owningCodexHome, permit => {
599
+ // Desired state can flip OFF during the provider await above. The catalog
600
+ // evidence revalidation below cannot see that — intent lives in our config,
601
+ // not in the catalog files — so the policy is re-read here, under K, right
602
+ // before the only write. A lost race becomes the discriminated skip instead
603
+ // of a routed catalog/cache surviving a completed disable. An explicit
604
+ // catalog-only sync opts out of that gate: the user asked for a refresh even
605
+ // when injection is OFF, and the toggle only protects config/history writes.
606
+ if (!shouldSyncCodexOnStart(loadConfig()) && options?.allowWhenDesiredDisabled !== true) {
607
+ return {
608
+ added: 0,
609
+ path: prepared.catalogPath,
610
+ catalogWritten: false,
611
+ comboOmissions,
612
+ skippedReason: "desired_disabled" as const,
613
+ };
614
+ }
615
+ const current = revalidateRetainedCatalogSync(config, prepared);
616
+ if (current === null) return null;
617
+ if (!isCodexModelEntitlementSnapshotCurrent(modelEntitlements)) return null;
618
+ return writeRetainedCatalogSync({
619
+ config,
620
+ goModels,
621
+ providerModelOutcomes,
622
+ comboOmissions,
623
+ read: current,
624
+ permit,
625
+ owningCodexHome,
626
+ modelEntitlements,
627
+ });
628
+ });
629
+ if (committed.kind === "completed" && committed.value !== null) {
630
+ return {
631
+ ...committed.value,
632
+ refreshOutcome: committed.value.skippedReason ? "refused" : "committed",
633
+ };
634
+ }
635
+ return {
636
+ added: 0,
637
+ path: prepared.catalogPath,
638
+ catalogWritten: false,
639
+ comboOmissions,
640
+ refreshOutcome: "refused",
641
+ };
642
+ }
643
+
644
+ export function invalidateCodexModelsCacheWithPermit(
645
+ permit: CatalogWritePermit,
646
+ owningCodexHome: string,
647
+ options?: CodexCatalogSyncOptions,
648
+ ): boolean {
649
+ try {
650
+ // This permit is a REACQUISITION: refreshCodexModelCatalog's commit released
651
+ // K before this rewrite runs, so the commit-path desired-state check cannot
652
+ // cover it. A disable landing in that gap must not be overwritten by a
653
+ // routed cache write — re-read intent under this permit, same as the commit.
654
+ // The catalog-only sync override applies here too so an explicit refresh
655
+ // keeps the cache consistent with the catalog it just wrote.
656
+ if (!shouldSyncCodexOnStart(loadConfig()) && options?.allowWhenDesiredDisabled !== true) return false;
657
+ const catalogPath = readCodexCatalogPathForHome(owningCodexHome);
658
+ if (!existsSync(catalogPath)) return false;
659
+ const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
660
+ const models = catalog.models ?? catalog;
661
+ const cachePath = join(owningCodexHome, "models_cache.json");
662
+ const currentCache = readCatalog(cachePath);
663
+ const existingSlugs = new Set(models.flatMap((entry: RawEntry) =>
664
+ typeof entry.slug === "string" ? [entry.slug] : []));
665
+ const currentConfig = loadConfig();
666
+ const mainSelectors = visibleCodexAccountSelectors(currentConfig).filter(selector => {
667
+ const target = new Map(codexAccountNamespaceEntries(currentConfig)).get(selector);
668
+ return isMainCodexAccountTarget(target ?? "");
669
+ });
670
+ const observedAccountModels = observedAccountBoundNativeEntries(currentCache?.models ?? [])
671
+ .filter(entry => {
672
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
673
+ return !existingSlugs.has(slug);
674
+ })
675
+ .map(entry => ({
676
+ ...entry,
677
+ // Keep the observation in Codex's cache without advertising a new bare picker row. The
678
+ // next OpenCodex catalog sync consumes this marker and creates only selector-qualified
679
+ // rows for the currently configured public account selectors.
680
+ visibility: "hide",
681
+ opencodex_account_observed_native: true,
682
+ opencodex_account_observed_selectors: mainSelectors,
683
+ }));
684
+ const wrapper = {
685
+ fetched_at: "2000-01-01T00:00:00Z",
686
+ client_version: "0.0.0",
687
+ models: [...models, ...observedAccountModels],
688
+ };
689
+ replaceCodexModelsCache(permit, owningCodexHome, {
690
+ path: cachePath,
691
+ content: `${JSON.stringify(wrapper, null, 2)}\n`,
692
+ });
693
+ return true;
694
+ } catch {
695
+ return false;
696
+ }
697
+ }
698
+
699
+ export function invalidateCodexModelsCache(options?: CodexCatalogSyncOptions): boolean {
700
+ const owningCodexHome = getCodexHome();
701
+ const outcome = withCatalogWriteSerialization(
702
+ owningCodexHome,
703
+ permit => invalidateCodexModelsCacheWithPermit(permit, owningCodexHome, options),
704
+ );
705
+ return outcome.kind === "completed" && outcome.value;
706
+ }