@bitkyc08/opencodex 2.11.0 → 2.11.1

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 (92) hide show
  1. package/gui/dist/assets/{index-Bk-PN-70.css → index-BA1dgo4q.css} +1 -1
  2. package/gui/dist/assets/index-DDZpgzKk.js +70 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +6 -4
  5. package/src/adapters/anthropic.ts +10 -2
  6. package/src/adapters/cursor/native-exec-common.ts +6 -2
  7. package/src/adapters/google-antigravity-replay.ts +3 -2
  8. package/src/adapters/google-antigravity-wire.ts +38 -6
  9. package/src/adapters/google.ts +57 -10
  10. package/src/adapters/openai-chat.ts +19 -2
  11. package/src/claude/model-info.ts +1 -1
  12. package/src/cli/account-auth.ts +3 -1
  13. package/src/cli/account-catalog-refresh.ts +14 -0
  14. package/src/cli/account-extended.ts +15 -1
  15. package/src/cli/codex-shim-readiness.ts +69 -0
  16. package/src/cli/combo.ts +5 -0
  17. package/src/cli/config-command.ts +33 -4
  18. package/src/cli/doctor.ts +21 -0
  19. package/src/cli/export-command.ts +6 -6
  20. package/src/cli/help.ts +6 -6
  21. package/src/cli/index.ts +22 -5
  22. package/src/cli/models-runtime.ts +13 -1
  23. package/src/cli/provider.ts +7 -0
  24. package/src/clients/config-export.ts +67 -5
  25. package/src/codex/account-lifecycle.ts +99 -10
  26. package/src/codex/auth-api.ts +222 -34
  27. package/src/codex/catalog/account-models.ts +9 -4
  28. package/src/codex/catalog/aggregation.ts +41 -8
  29. package/src/codex/catalog/bundled.ts +54 -22
  30. package/src/codex/catalog/effort.ts +47 -20
  31. package/src/codex/catalog/kinds.ts +2 -0
  32. package/src/codex/catalog/metadata.ts +70 -18
  33. package/src/codex/catalog/native-models.ts +7 -0
  34. package/src/codex/catalog/parsing.ts +29 -11
  35. package/src/codex/catalog/provider-fetch.ts +335 -50
  36. package/src/codex/catalog/sync.ts +588 -126
  37. package/src/codex/catalog-refresh-status.ts +87 -0
  38. package/src/codex/catalog-write-serialization.ts +2 -1
  39. package/src/codex/catalog.ts +4 -3
  40. package/src/codex/convergence-types.ts +1 -1
  41. package/src/codex/convergence.ts +190 -52
  42. package/src/codex/custom-model-catalog-migration.ts +176 -0
  43. package/src/codex/features.ts +79 -1
  44. package/src/codex/history-job.ts +81 -1
  45. package/src/codex/history-lock.ts +2 -1
  46. package/src/codex/history-provider.ts +4 -3
  47. package/src/codex/inject.ts +56 -12
  48. package/src/codex/model-cache.ts +50 -10
  49. package/src/codex/transition-state.ts +10 -2
  50. package/src/codex/user-identity.ts +110 -2
  51. package/src/combos/index.ts +3 -0
  52. package/src/combos/types.ts +75 -9
  53. package/src/config.ts +26 -12
  54. package/src/generated/{jawcode-model-metadata.ts → model-metadata.ts} +12 -10
  55. package/src/integrations/registry.ts +7 -0
  56. package/src/integrations/writer.ts +1 -1
  57. package/src/lib/pinned-http.ts +40 -9
  58. package/src/lib/process-control.ts +4 -1
  59. package/src/lib/provider-outbound.ts +42 -9
  60. package/src/oauth/cursor.ts +25 -5
  61. package/src/oauth/index.ts +59 -45
  62. package/src/providers/antigravity-models.ts +115 -3
  63. package/src/providers/context-cap.ts +13 -5
  64. package/src/providers/model-discovery-limits.ts +16 -0
  65. package/src/providers/model-discovery.ts +14 -15
  66. package/src/providers/provider-id-rewrite.ts +29 -0
  67. package/src/providers/quota.ts +93 -19
  68. package/src/providers/registry.ts +16 -5
  69. package/src/providers/slug-codec.ts +13 -6
  70. package/src/reasoning-effort.ts +22 -0
  71. package/src/router.ts +0 -20
  72. package/src/routing/history/indexer.ts +54 -39
  73. package/src/routing/quota.ts +77 -56
  74. package/src/server/index.ts +32 -7
  75. package/src/server/management/combo-routes.ts +43 -19
  76. package/src/server/management/config-routes.ts +115 -20
  77. package/src/server/management/model-routes.ts +10 -7
  78. package/src/server/management/model-rows.ts +6 -2
  79. package/src/server/management/oauth-account-routes.ts +12 -0
  80. package/src/server/management/provider-routes.ts +114 -40
  81. package/src/server/management/routing-profile-routes.ts +0 -22
  82. package/src/server/management-api.ts +7 -25
  83. package/src/server/responses/core.ts +3 -3
  84. package/src/server/responses/policy-fallback.ts +152 -0
  85. package/src/server/responses.ts +3 -2
  86. package/src/types.ts +15 -2
  87. package/src/usage/cost.ts +0 -0
  88. package/src/vision/describe.ts +3 -1
  89. package/src/vision/index.ts +17 -6
  90. package/src/vision/reasoning.ts +55 -0
  91. package/src/web-search/parse.ts +15 -3
  92. package/gui/dist/assets/index-BynIEIV-.js +0 -70
@@ -0,0 +1,87 @@
1
+ import type { CatalogDisposition, CatalogNotice } from "./convergence-types";
2
+
3
+ const INVALID_CATALOG_DISPOSITION_FIELD = Symbol("invalid-catalog-disposition-field");
4
+
5
+ /** Read an own data property without invoking prototype getters or accessors. */
6
+ function ownDataProperty(value: object, key: PropertyKey): unknown {
7
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
8
+ return descriptor && "value" in descriptor
9
+ ? descriptor.value
10
+ : INVALID_CATALOG_DISPOSITION_FIELD;
11
+ }
12
+
13
+ /** Accept only the public catalog degradation codes. */
14
+ function isCatalogNotice(value: unknown): value is CatalogNotice {
15
+ return value === "provider-auth" || value === "provider-network" || value === "fallback";
16
+ }
17
+
18
+ /** Copy a bounded notice array without trusting accessors or a custom iterator. */
19
+ function normalizeCatalogNotices(value: unknown): CatalogNotice[] | null {
20
+ if (!Array.isArray(value)) return null;
21
+ const length = ownDataProperty(value, "length");
22
+ if (typeof length !== "number" || !Number.isInteger(length) || length < 0 || length > 3) {
23
+ return null;
24
+ }
25
+ const notices: CatalogNotice[] = [];
26
+ for (let index = 0; index < length; index += 1) {
27
+ const notice = ownDataProperty(value, String(index));
28
+ if (!isCatalogNotice(notice)) return null;
29
+ notices.push(notice);
30
+ }
31
+ return notices;
32
+ }
33
+
34
+ /**
35
+ * Rebuild a catalog disposition from a strict set of own data properties.
36
+ *
37
+ * Management callbacks are internal today, but this response is a privacy
38
+ * boundary. Avoid coercion, accessors, custom iterators, and `toJSON` hooks so a
39
+ * malformed callback cannot smuggle private provider or account detail into a
40
+ * management response.
41
+ */
42
+ export function normalizeCatalogDisposition(value: unknown): CatalogDisposition | null {
43
+ if (value === null || typeof value !== "object") return null;
44
+ try {
45
+ if (Array.isArray(value)) return null;
46
+ const status = ownDataProperty(value, "status");
47
+ if (status === "committed") {
48
+ const changed = ownDataProperty(value, "changed");
49
+ const degraded = ownDataProperty(value, "degraded");
50
+ const notices = normalizeCatalogNotices(ownDataProperty(value, "notices"));
51
+ if (typeof changed !== "boolean" || typeof degraded !== "boolean" || notices === null) {
52
+ return null;
53
+ }
54
+ return { status, changed, degraded, notices };
55
+ }
56
+ if (status === "skipped") {
57
+ const reason = ownDataProperty(value, "reason");
58
+ const retryable = ownDataProperty(value, "retryable");
59
+ if ((reason !== "not-requested"
60
+ && reason !== "catalog-unavailable"
61
+ && reason !== "busy"
62
+ && reason !== "stale"
63
+ && reason !== "refused")
64
+ || typeof retryable !== "boolean") return null;
65
+ return { status, reason, retryable };
66
+ }
67
+ if (status === "failed") {
68
+ const reason = ownDataProperty(value, "reason");
69
+ const phase = ownDataProperty(value, "phase");
70
+ const retryable = ownDataProperty(value, "retryable");
71
+ const partialWrite = ownDataProperty(value, "partialWrite");
72
+ if ((reason !== "provider-auth" && reason !== "provider-network" && reason !== "disk")
73
+ || (phase !== "gather" && phase !== "commit")
74
+ || typeof retryable !== "boolean"
75
+ || typeof partialWrite !== "boolean") return null;
76
+ return { status, reason, phase, retryable, partialWrite };
77
+ }
78
+ return null;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /** Whether a persisted mutation still needs a successful catalog commit. */
85
+ export function catalogRefreshIsPending(disposition: CatalogDisposition): boolean {
86
+ return disposition.status !== "committed";
87
+ }
@@ -32,6 +32,7 @@ import {
32
32
  CodexUserIdentityRefusal,
33
33
  resolveCodexCatalogSerializationDatabasePath,
34
34
  resolveEffectiveUserIdentity,
35
+ samePathIdentity,
35
36
  } from "./user-identity";
36
37
 
37
38
  /**
@@ -180,7 +181,7 @@ export function withCatalogWriteSerialization<T>(
180
181
  }
181
182
  const opened = lstatSync(databasePath);
182
183
  if (opened.isSymbolicLink() || !opened.isFile()
183
- || realpathSync.native(databasePath) !== databasePath) {
184
+ || !samePathIdentity(realpathSync.native(databasePath), databasePath)) {
184
185
  return { kind: "unavailable", reason: "unsafe-path" };
185
186
  }
186
187
 
@@ -2,12 +2,13 @@
2
2
  // Public surface preserved exactly; importers keep using "src/codex/catalog".
3
3
  export { isMediaGenerationModelId, shouldExposeRoutedModel, readCodexCatalogPath, readCatalog, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing";
4
4
  export type { CatalogModel, MultiAgentMode } from "./catalog/parsing";
5
- export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi } from "./catalog/metadata";
5
+ export { CODEX_NATIVE_ALIAS_CATALOG_KIND, NATIVE_OPENAI_MODELS, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, isNativeAliasCatalogEntry, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi } from "./catalog/metadata";
6
6
  export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled";
7
7
  export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort";
8
- export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithJawcodeMetadata } from "./catalog/provider-fetch";
8
+ export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithMetadata, resolveComboCatalogMember } from "./catalog/provider-fetch";
9
9
  export { deriveComboCatalogModel, exactComboCatalogSlugs, getLastComboCatalogOmissions, resetOpenAiApiCatalogWarningStateForTests, uniqueCatalogModelsForPublicList, uniqueCatalogModelsForRawPublicList, buildComboCatalogOmission, comboCatalogOmissionReason, summarizeComboCatalogOmissions } from "./catalog/aggregation";
10
10
  export type { ComboCatalogOmission, ComboCatalogOmissionReason } from "./catalog/aggregation";
11
- export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, effectiveSubagentRoster, buildCatalogEntries, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache } from "./catalog/sync";
11
+ export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, effectiveSubagentRoster, buildCatalogEntries, mergeCatalogEntriesFromObservedState, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache } from "./catalog/sync";
12
+ export type { ObservedCatalogMergeInput } from "./catalog/sync";
12
13
  export type { SpawnAgentSurface, SubagentRosterExclusionReason, EffectiveSubagentModel, SubagentRosterExclusion, EffectiveSubagentRoster } from "./catalog/sync";
13
14
  export { accountBoundNativeDisplayName, accountBoundNativeModelSlugs, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./catalog/account-models";
@@ -422,7 +422,7 @@ export interface CatalogProviderDiscoveryPolicySnapshot {
422
422
  readonly path: CatalogDiscoveryPolicyField<string | undefined>;
423
423
  readonly query: CatalogDiscoveryPolicyField<Readonly<Record<string, string>> | undefined>;
424
424
  }>;
425
- readonly finalMethod: "GET";
425
+ readonly finalMethod: "GET" | "POST";
426
426
  readonly finalUrl: string;
427
427
  readonly filter: CatalogDiscoveryPolicyField<ProviderModelDiscoveryFilter | undefined>;
428
428
  readonly maxResponseBytes: number;
@@ -2,16 +2,17 @@ import { join } from "node:path";
2
2
 
3
3
  import { getConfigDir, websocketsEnabled, withExpectedConfigGenerationSync } from "../config";
4
4
  import { COMBO_NAMESPACE } from "../combos";
5
- import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
6
5
  import { getAuthStorePath } from "../oauth/store";
7
6
  import type { OcxConfig } from "../types";
8
7
  import { captureCatalogAdmissionSnapshot } from "./catalog-admission";
8
+ import { legacyCustomModelCatalogSlugs } from "./custom-model-catalog-migration";
9
9
  import {
10
+ type CatalogGatherPathKind,
10
11
  type CatalogSourceForGather,
11
12
  bundledCatalogCacheState,
12
13
  resolveCatalogSourceForGather,
13
- } from "./catalog/bundled";
14
- import {
14
+ } from "./catalog/bundled";
15
+ import {
15
16
  acceptCatalogGatherSourcePath,
16
17
  captureAndSealCatalogHomeSelection,
17
18
  captureCatalogGatherTargetIdentity,
@@ -19,28 +20,49 @@ import {
19
20
  readCatalogGatherSource,
20
21
  sealCatalogGatherEvidenceSession,
21
22
  type CatalogFilesystemEvidenceSession,
22
- } from "./catalog/filesystem-evidence";
23
- import {
23
+ } from "./catalog/filesystem-evidence";
24
+ import {
24
25
  CatalogGatherBusyError,
25
26
  createCatalogGatherAuthorityIdentity,
26
27
  filterCatalogVisibleModels,
27
28
  gatherRoutedModelsForCatalogGather,
28
29
  type CatalogGatherProviderAuthOutcome,
29
- } from "./catalog/provider-fetch";
30
- import {
30
+ type CatalogGatherProviderModelOutcome,
31
+ } from "./catalog/provider-fetch";
32
+ import {
31
33
  catalogBackupPathFor,
32
34
  catalogHasRoutedEntries,
33
35
  findNativeTemplate,
34
36
  legacyCatalogBackupPath,
35
37
  parseCatalogJson,
36
38
  type RawCatalog,
37
- } from "./catalog/parsing";
38
- import {
39
- buildCatalogEntries,
39
+ type RawEntry,
40
+ } from "./catalog/parsing";
41
+ import {
42
+ buildCatalogEntriesFromObservedState,
43
+ CANONICAL_NATIVE_CATALOG_CONTENT_POLICY,
44
+ mergeCatalogEntriesFromObservedState,
45
+ mergeCatalogModelsWithNativeRecovery,
40
46
  orderForSubagents,
41
- } from "./catalog/sync";
42
- import { exactComboCatalogSlugs } from "./catalog/aggregation";
43
- import { disabledNativeSlugs } from "./catalog/metadata";
47
+ } from "./catalog/sync";
48
+ import { multiAgentV2EnabledFromConfigText } from "./features";
49
+ import { exactComboCatalogSlugs } from "./catalog/aggregation";
50
+ import {
51
+ isNativeAliasCatalogEntry,
52
+ disabledNativeSlugs,
53
+ desktopAllowlistSuppressedNativeSlugs,
54
+ NATIVE_OPENAI_MODELS,
55
+ shouldIncludeAccountBoundNativeOpenAi,
56
+ shouldIncludeNativeOpenAi,
57
+ } from "./catalog/metadata";
58
+ import {
59
+ trustedAccountBoundNativeCatalogSlug,
60
+ visibleCodexAccountSelectors,
61
+ } from "./catalog/account-models";
62
+ import {
63
+ clampCatalogModelsToObservedCodexSupport,
64
+ supportedCodexReasoningEffortsFromObservedCatalog,
65
+ } from "./catalog/effort";
44
66
  import { codexRuntimeStatePath, peekCodexRuntimeProcessCache } from "./runtime";
45
67
  import { withCatalogWriteSerialization } from "./catalog-write-serialization";
46
68
  import {
@@ -119,12 +141,23 @@ function catalogBytes(catalog: RawCatalog): string {
119
141
  return `${JSON.stringify(catalog, null, 2)}\n`;
120
142
  }
121
143
 
144
+ function nativePriorityBaseline(catalog: ReadonlyRawCatalogLike | null): Map<string, number> {
145
+ return new Map((catalog?.models ?? []).flatMap(entry => (
146
+ typeof entry.slug === "string"
147
+ && !entry.slug.includes("/")
148
+ && typeof entry.priority === "number"
149
+ ? [[entry.slug, entry.priority] as const]
150
+ : []
151
+ )));
152
+ }
153
+
122
154
  interface ReadonlyRawCatalogLike {
123
155
  readonly models?: readonly Readonly<Record<string, unknown>>[];
124
156
  }
125
157
 
126
158
  function hasRoutedEntries(catalog: ReadonlyRawCatalogLike): boolean {
127
- return (catalog.models ?? []).some(entry => typeof entry.slug === "string" && entry.slug.includes("/"));
159
+ return (catalog.models ?? []).some(entry => typeof entry.slug === "string"
160
+ && (entry.slug.includes("/") || isNativeAliasCatalogEntry(entry as RawEntry)));
128
161
  }
129
162
 
130
163
  function processEvidence(source: CatalogSourceForGather): CatalogProcessLocalEvidence {
@@ -137,7 +170,14 @@ function processEvidence(source: CatalogSourceForGather): CatalogProcessLocalEvi
137
170
  function bindGatherPaths(
138
171
  session: CatalogFilesystemEvidenceSession,
139
172
  snapshot: CatalogAdmissionSnapshot,
140
- ): Readonly<{ catalog: string; cache: string; keyedBackup: string; legacyBackup?: string }> {
173
+ ): Readonly<{
174
+ catalog: string;
175
+ cache: string;
176
+ keyedBackup: string;
177
+ legacyBackup?: string;
178
+ catalogKind: CatalogGatherPathKind;
179
+ multiAgentV2Enabled: boolean;
180
+ }> {
141
181
  const catalog = targetPath(snapshot.targets.catalog);
142
182
  const cache = targetPath(snapshot.targets.cache);
143
183
  const keyedBackup = targetPath(snapshot.targets.catalogBackups[0]!);
@@ -146,7 +186,7 @@ function bindGatherPaths(
146
186
  const configPath = snapshot.sourceEvidence.required["catalog-target-selection"].logicalPath;
147
187
 
148
188
  acceptCatalogGatherSourcePath(session, "catalog-target-selection", configPath);
149
- readCatalogGatherSource(session, "catalog-target-selection");
189
+ const configBytes = readCatalogGatherSource(session, "catalog-target-selection");
150
190
  acceptCatalogGatherSourcePath(session, "active-catalog-merge", catalog);
151
191
  acceptCatalogGatherSourcePath(session, "hashed-backup-fallback", keyedBackup);
152
192
  acceptCatalogGatherSourcePath(session, "legacy-backup-fallback", legacyBackup ?? legacyCatalogBackupPath());
@@ -154,7 +194,16 @@ function bindGatherPaths(
154
194
  acceptCatalogGatherSourcePath(session, "runtime-selection", codexRuntimeStatePath(getConfigDir()));
155
195
  acceptCatalogGatherSourcePath(session, "provider-auth-selection", getAuthStorePath());
156
196
  acceptCatalogGatherSourcePath(session, "native-catalog-selection", catalog);
157
- return { catalog, cache, keyedBackup, ...(legacyBackup ? { legacyBackup } : {}) };
197
+ return {
198
+ catalog,
199
+ cache,
200
+ keyedBackup,
201
+ ...(legacyBackup ? { legacyBackup } : {}),
202
+ catalogKind: legacyBackup ? "default" : "custom",
203
+ multiAgentV2Enabled: multiAgentV2EnabledFromConfigText(
204
+ configBytes === null ? null : Buffer.from(configBytes).toString("utf8"),
205
+ ),
206
+ };
158
207
  }
159
208
 
160
209
  function prepareCatalog(
@@ -162,6 +211,11 @@ function prepareCatalog(
162
211
  source: Extract<CatalogSourceForGather, { kind: "available" }>,
163
212
  active: RawCatalog | null,
164
213
  routedModels: Awaited<ReturnType<typeof gatherRoutedModelsForCatalogGather>>,
214
+ multiAgentV2Enabled: boolean,
215
+ baseline: ReadonlyMap<string, number>,
216
+ baselineCatalogModels: readonly Readonly<Record<string, unknown>>[],
217
+ degradedProviderNames: ReadonlySet<string>,
218
+ nativeRecoverySources: readonly (readonly RawEntry[])[] = [],
165
219
  ): RawCatalog {
166
220
  const catalog = JSON.parse(JSON.stringify(source.catalog)) as RawCatalog;
167
221
  const template = findNativeTemplate(catalog);
@@ -171,41 +225,87 @@ function prepareCatalog(
171
225
  const multiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2"
172
226
  ? config.multiAgentMode : "default";
173
227
  const exactComboSlugs = exactComboCatalogSlugs(config);
228
+ const suppressedBareNativeSlugs = desktopAllowlistSuppressedNativeSlugs(config);
174
229
  const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE);
175
230
  const enabledProviders = Object.entries(config.providers).filter(([, provider]) => provider.disabled !== true);
176
- const includeNativeOpenAi = enabledProviders.length === 0 || enabledProviders.some(([name, provider]) => (
177
- name === "openai" && isCanonicalOpenAiForwardProvider(provider)
178
- ));
179
- const disabledNative = disabledNativeSlugs(config);
180
- const nativeSlugs = includeNativeOpenAi
181
- ? [...new Set((active?.models ?? catalog.models ?? []).flatMap(entry => (
182
- typeof entry.slug === "string" && !entry.slug.includes("/") && !disabledNative.has(entry.slug)
183
- ? [entry.slug] : []
184
- )))]
231
+ const includeNativeOpenAi = shouldIncludeNativeOpenAi(config);
232
+ const accountSelectors = shouldIncludeAccountBoundNativeOpenAi(config)
233
+ ? visibleCodexAccountSelectors(config)
185
234
  : [];
186
- const entries = buildCatalogEntries(
187
- template ? JSON.parse(JSON.stringify(template)) : null,
188
- nativeSlugs, ordered, featured, websocketsEnabled(config), multiAgentMode, exactComboSlugs,
235
+ const disabledNative = disabledNativeSlugs(config);
236
+ const nativeCatalogModels = mergeCatalogModelsWithNativeRecovery(
237
+ active?.models ?? catalog.models ?? [],
238
+ [catalog.models ?? [], ...nativeRecoverySources],
189
239
  );
190
- if (entries.length === nativeSlugs.length) {
191
- const configuredProviders = new Set(enabledProviders.map(([name]) => name));
192
- const preserved = (active?.models ?? []).filter(entry => {
193
- if (typeof entry.slug !== "string" || !entry.slug.includes("/")) return false;
194
- const provider = entry.slug.slice(0, entry.slug.indexOf("/"));
195
- const description = typeof entry.description === "string" ? entry.description : "";
196
- return configuredProviders.has(provider) || !description.startsWith("Routed via opencodex → ");
197
- });
198
- entries.push(...preserved);
199
- }
200
- if (!hasPhysicalComboProvider) {
201
- const exact = exactComboSlugs;
202
- catalog.models = entries.filter(entry => (
203
- typeof entry.slug !== "string" || !exact.has(entry.slug)
204
- || (Array.isArray(entry.input_modalities) && entry.input_modalities.length > 0)
205
- ));
206
- } else {
207
- catalog.models = entries;
208
- }
240
+ const catalogModels = nativeCatalogModels;
241
+ const routedEntries = buildCatalogEntriesFromObservedState({
242
+ template: template ? JSON.parse(JSON.stringify(template)) : null,
243
+ gptSlugs: [],
244
+ goModels: ordered,
245
+ featured,
246
+ wsEnabled: websocketsEnabled(config),
247
+ multiAgentMode,
248
+ exactComboSlugs,
249
+ accountSelectors,
250
+ suppressedBareNativeSlugs,
251
+ disabledNativeAccountSlugs: new Set(),
252
+ multiAgentV2Enabled,
253
+ });
254
+ const accountBoundEntries = accountSelectors.length === 0
255
+ ? []
256
+ : buildCatalogEntriesFromObservedState({
257
+ template: template ? JSON.parse(JSON.stringify(template)) : null,
258
+ gptSlugs: NATIVE_OPENAI_MODELS,
259
+ goModels: [],
260
+ featured,
261
+ wsEnabled: websocketsEnabled(config),
262
+ multiAgentMode,
263
+ exactComboSlugs,
264
+ accountSelectors,
265
+ suppressedBareNativeSlugs,
266
+ disabledNativeAccountSlugs: new Set([...disabledNative].filter(slug => suppressedBareNativeSlugs.has(slug))),
267
+ multiAgentV2Enabled,
268
+ }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined);
269
+ const gatheredProviderNames = new Set(enabledProviders.map(([name]) => name));
270
+ const selectedModelsByProvider = new Map<string, ReadonlySet<string>>(
271
+ enabledProviders.flatMap(([name, provider]) => (
272
+ Array.isArray(provider.selectedModels) && provider.selectedModels.length > 0
273
+ ? [[name, new Set(provider.selectedModels)] as const]
274
+ : []
275
+ )),
276
+ );
277
+ const mergedModels = mergeCatalogEntriesFromObservedState({
278
+ catalogModels,
279
+ baselineCatalogModels,
280
+ routedEntries,
281
+ baseline,
282
+ featured,
283
+ wsEnabled: websocketsEnabled(config),
284
+ template,
285
+ disabledModels: new Set(config.disabledModels ?? []),
286
+ selectedModelsByProvider,
287
+ gatheredProviderNames,
288
+ degradedProviderNames,
289
+ legacyCustomModelSlugs: legacyCustomModelCatalogSlugs(config),
290
+ multiAgentMode,
291
+ multiAgentV2Enabled,
292
+ exactComboSlugs,
293
+ hasPhysicalComboProvider,
294
+ includeNativeOpenAi,
295
+ accountBoundEntries,
296
+ suppressedBareNativeSlugs,
297
+ policy: {
298
+ ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY,
299
+ warningPolicy: "suppress",
300
+ },
301
+ });
302
+ clampCatalogModelsToObservedCodexSupport(
303
+ mergedModels,
304
+ source.runtimeSupport.kind === "available"
305
+ ? supportedCodexReasoningEffortsFromObservedCatalog(source.runtimeSupport.catalog)
306
+ : null,
307
+ );
308
+ catalog.models = mergedModels;
209
309
  return catalog;
210
310
  }
211
311
 
@@ -220,7 +320,7 @@ export async function gatherCodexCatalogCandidate(
220
320
  return { kind: "disposition", disposition: { status: "skipped", reason: "stale", retryable: true } };
221
321
  }
222
322
  const paths = bindGatherPaths(session, snapshot);
223
- const source = resolveCatalogSourceForGather(session);
323
+ const source = resolveCatalogSourceForGather(session, paths.catalogKind);
224
324
  if (source.kind === "catalog-unavailable") {
225
325
  return { kind: "disposition", disposition: { status: "skipped", reason: "catalog-unavailable", retryable: false } };
226
326
  }
@@ -234,10 +334,12 @@ export async function gatherCodexCatalogCandidate(
234
334
  readCatalogGatherSource(session, "native-catalog-selection");
235
335
  }
236
336
  const authOutcomes: CatalogGatherProviderAuthOutcome[] = [];
337
+ const providerModelOutcomes: CatalogGatherProviderModelOutcome[] = [];
237
338
  const discoveryPolicies: CatalogProviderDiscoveryPolicySnapshot[] = [];
238
339
  providerGatherStarted = true;
239
340
  const routedModels = await gatherRoutedModelsForCatalogGather(snapshot.config, session, {
240
341
  providerAuthOutcomes: authOutcomes,
342
+ providerModelOutcomes,
241
343
  discoveryPolicySnapshots: discoveryPolicies,
242
344
  });
243
345
  const processLocal = processEvidence(source);
@@ -255,7 +357,34 @@ export async function gatherCodexCatalogCandidate(
255
357
  }
256
358
 
257
359
  const active = catalogFrom(activeBytes);
258
- const preparedCatalog = prepareCatalog(snapshot.config, source, active, routedModels);
360
+ // Retained sync restores native priorities from the once-only pristine backup. Convergence
361
+ // must use the same admitted evidence instead of treating its already-featured active catalog
362
+ // as the baseline, or feature A -> feature B -> none would retain stale priorities.
363
+ const backupCanAcceptPristineCatalog = keyedBackupBytes === null
364
+ || (paths.legacyBackup !== undefined && legacyBackupBytes === null);
365
+ const baselineCatalog = catalogFrom(keyedBackupBytes)
366
+ ?? catalogFrom(legacyBackupBytes)
367
+ ?? (backupCanAcceptPristineCatalog
368
+ ? (active && !catalogHasRoutedEntries(active)
369
+ ? active
370
+ : !hasRoutedEntries(source.catalog) ? source.catalog : null)
371
+ : null);
372
+ const preparedCatalog = prepareCatalog(
373
+ snapshot.config,
374
+ source,
375
+ active,
376
+ routedModels,
377
+ paths.multiAgentV2Enabled,
378
+ nativePriorityBaseline(baselineCatalog),
379
+ baselineCatalog?.models ?? [],
380
+ new Set(providerModelOutcomes
381
+ .filter(outcome => outcome.state === "degraded")
382
+ .map(outcome => outcome.provider)),
383
+ [
384
+ catalogFrom(keyedBackupBytes)?.models ?? [],
385
+ catalogFrom(legacyBackupBytes)?.models ?? [],
386
+ ],
387
+ );
259
388
  const preparedCatalogBytes = catalogBytes(preparedCatalog);
260
389
  const preparedCacheBytes = `${JSON.stringify({
261
390
  fetched_at: "2000-01-01T00:00:00Z",
@@ -266,8 +395,17 @@ export async function gatherCodexCatalogCandidate(
266
395
  ? Buffer.from(activeBytes!).toString("utf8")
267
396
  : !hasRoutedEntries(source.catalog) ? `${JSON.stringify(source.catalog, null, 2)}\n` : null;
268
397
  const notices = new Set<CatalogNotice>();
269
- if (source.source !== "bundled-catalog-template") notices.add("fallback");
270
- if (authOutcomes.some(outcome => outcome.state !== "available")) notices.add("provider-auth");
398
+ const sourceIsAuthoritative = paths.catalogKind === "default"
399
+ ? source.source === "bundled-catalog-template"
400
+ : source.source === "active-catalog-merge";
401
+ if (!sourceIsAuthoritative) notices.add("fallback");
402
+ const authDegradedProviders = new Set(authOutcomes
403
+ .filter(outcome => outcome.state !== "available")
404
+ .map(outcome => outcome.provider));
405
+ if (authDegradedProviders.size > 0) notices.add("provider-auth");
406
+ if (providerModelOutcomes.some(outcome => (
407
+ outcome.state === "degraded" && !authDegradedProviders.has(outcome.provider)
408
+ ))) notices.add("provider-network");
271
409
  const candidate = {} as CodexCatalogCandidate;
272
410
  candidateStates.set(candidate, {
273
411
  consumed: false,
@@ -0,0 +1,176 @@
1
+ import { routedSlug, slugEquivalenceKey } from "../providers/slug-codec";
2
+ import type { OcxConfig } from "../types";
3
+
4
+ const MIGRATION_FIELD = "customModelCatalogMigration";
5
+ const MIGRATION_VERSION = 1;
6
+
7
+ type ConfigRecord = Record<string, unknown>;
8
+
9
+ interface SupportedMigrationState {
10
+ readonly record: ConfigRecord;
11
+ readonly legacyOwnedSlugs: readonly string[];
12
+ }
13
+
14
+ type ParsedMigrationState =
15
+ | { readonly kind: "absent" }
16
+ | { readonly kind: "supported"; readonly state: SupportedMigrationState }
17
+ | { readonly kind: "unsupported"; readonly value: unknown };
18
+
19
+ type CustomModelSlugSet =
20
+ | { readonly kind: "valid"; readonly byKey: ReadonlyMap<string, string> }
21
+ | { readonly kind: "invalid" };
22
+
23
+ function configRecord(value: unknown): ConfigRecord | null {
24
+ return value !== null && typeof value === "object" && !Array.isArray(value)
25
+ ? value as ConfigRecord
26
+ : null;
27
+ }
28
+
29
+ function canonicalRoutedSlug(value: unknown): string | null {
30
+ if (typeof value !== "string") return null;
31
+ const slug = value.trim();
32
+ const slash = slug.indexOf("/");
33
+ if (slash <= 0 || slash === slug.length - 1) return null;
34
+ const provider = slug.slice(0, slash).trim();
35
+ const modelId = slug.slice(slash + 1).trim();
36
+ if (!provider || !modelId || provider.includes("/")) return null;
37
+ return routedSlug(provider, modelId);
38
+ }
39
+
40
+ function canonicalSlugMap(values: readonly unknown[]): ReadonlyMap<string, string> | null {
41
+ const byKey = new Map<string, string>();
42
+ for (const value of values) {
43
+ const slug = canonicalRoutedSlug(value);
44
+ if (slug === null) return null;
45
+ byKey.set(slugEquivalenceKey(slug), slug);
46
+ }
47
+ return byKey;
48
+ }
49
+
50
+ function parseMigrationState(config: unknown): ParsedMigrationState {
51
+ const record = configRecord(config);
52
+ if (!record || !Object.hasOwn(record, MIGRATION_FIELD)) return { kind: "absent" };
53
+ const value = record[MIGRATION_FIELD];
54
+ const state = configRecord(value);
55
+ if (!state || state.version !== MIGRATION_VERSION || !Array.isArray(state.legacyOwnedSlugs)) {
56
+ return { kind: "unsupported", value };
57
+ }
58
+ const slugs = canonicalSlugMap(state.legacyOwnedSlugs);
59
+ if (slugs === null) return { kind: "unsupported", value };
60
+ return {
61
+ kind: "supported",
62
+ state: {
63
+ record: state,
64
+ legacyOwnedSlugs: [...slugs.values()].sort(),
65
+ },
66
+ };
67
+ }
68
+
69
+ function customModelSlugs(config: unknown): CustomModelSlugSet {
70
+ const record = configRecord(config);
71
+ if (!record || record.customModels === undefined) {
72
+ return { kind: "valid", byKey: new Map() };
73
+ }
74
+ if (!Array.isArray(record.customModels)) return { kind: "invalid" };
75
+ const byKey = new Map<string, string>();
76
+ for (const value of record.customModels) {
77
+ const custom = configRecord(value);
78
+ if (!custom || typeof custom.provider !== "string" || typeof custom.modelId !== "string") {
79
+ return { kind: "invalid" };
80
+ }
81
+ const provider = custom.provider.trim();
82
+ const modelId = custom.modelId.trim();
83
+ if (!provider || !modelId || provider.includes("/")) return { kind: "invalid" };
84
+ const slug = routedSlug(provider, modelId);
85
+ byKey.set(slugEquivalenceKey(slug), slug);
86
+ }
87
+ return { kind: "valid", byKey };
88
+ }
89
+
90
+ function copyMigrationField(target: OcxConfig, source: OcxConfig): void {
91
+ const targetRecord = target as unknown as ConfigRecord;
92
+ const sourceRecord = source as unknown as ConfigRecord;
93
+ if (Object.hasOwn(sourceRecord, MIGRATION_FIELD)) {
94
+ targetRecord[MIGRATION_FIELD] = structuredClone(sourceRecord[MIGRATION_FIELD]);
95
+ } else {
96
+ delete targetRecord[MIGRATION_FIELD];
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Project one-time legacy custom-model ownership onto a whole-config write.
102
+ *
103
+ * The persisted pre-write config is the only authority for pre-marker ownership.
104
+ * Unknown future/malformed state is carried forward byte-for-value and grants no
105
+ * deletion authority; an older binary must never reinterpret or erase it.
106
+ */
107
+ export function projectCustomModelCatalogMigration(
108
+ persistedConfig: unknown,
109
+ candidateConfig: OcxConfig,
110
+ ): OcxConfig {
111
+ const persistedState = parseMigrationState(persistedConfig);
112
+ const candidateState = parseMigrationState(candidateConfig);
113
+ const projected = { ...candidateConfig } as OcxConfig;
114
+ const projectedRecord = projected as unknown as ConfigRecord;
115
+
116
+ if (persistedState.kind === "unsupported") {
117
+ projectedRecord[MIGRATION_FIELD] = structuredClone(persistedState.value);
118
+ return projected;
119
+ }
120
+ if (persistedState.kind === "absent" && candidateState.kind === "unsupported") {
121
+ projectedRecord[MIGRATION_FIELD] = structuredClone(candidateState.value);
122
+ return projected;
123
+ }
124
+
125
+ const legacyByKey = new Map<string, string>();
126
+ const addLegacy = (slugs: readonly string[]): void => {
127
+ for (const slug of slugs) legacyByKey.set(slugEquivalenceKey(slug), slug);
128
+ };
129
+ if (persistedState.kind === "supported") addLegacy(persistedState.state.legacyOwnedSlugs);
130
+ if (candidateState.kind === "supported") addLegacy(candidateState.state.legacyOwnedSlugs);
131
+
132
+ const persistedCustom = customModelSlugs(persistedConfig);
133
+ const candidateCustom = customModelSlugs(candidateConfig);
134
+ // Only an absent persisted version means these rows can predate the ownership marker.
135
+ // Never add candidate-only or post-version models: those are born with custom-model-v1.
136
+ if (persistedState.kind === "absent" && persistedCustom.kind === "valid") {
137
+ for (const [key, slug] of persistedCustom.byKey) legacyByKey.set(key, slug);
138
+ }
139
+
140
+ // Establish an empty v1 on the first custom-model add too: the cutover, not list
141
+ // non-emptiness, proves that candidate-only models can never be inferred as legacy.
142
+ // Unrelated saves for users who never configured custom models remain untouched.
143
+ const shouldVersion = persistedState.kind === "supported"
144
+ || candidateState.kind === "supported"
145
+ || (persistedState.kind === "absent"
146
+ && ((persistedCustom.kind === "valid" && persistedCustom.byKey.size > 0)
147
+ || (candidateCustom.kind === "valid" && candidateCustom.byKey.size > 0)));
148
+ if (!shouldVersion) {
149
+ delete projectedRecord[MIGRATION_FIELD];
150
+ return projected;
151
+ }
152
+
153
+ const priorRecord = persistedState.kind === "supported" ? persistedState.state.record : {};
154
+ const nextRecord = candidateState.kind === "supported" ? candidateState.state.record : {};
155
+ projectedRecord[MIGRATION_FIELD] = {
156
+ ...structuredClone(priorRecord),
157
+ ...structuredClone(nextRecord),
158
+ version: MIGRATION_VERSION,
159
+ legacyOwnedSlugs: [...legacyByKey.values()].sort(),
160
+ };
161
+ return projected;
162
+ }
163
+
164
+ /** Copy only the internal migration state after the projected write succeeds. */
165
+ export function adoptCustomModelCatalogMigration(
166
+ target: OcxConfig,
167
+ projected: OcxConfig,
168
+ ): void {
169
+ copyMigrationField(target, projected);
170
+ }
171
+
172
+ /** Canonical slugs that may classify old, unmarked OpenCodex custom rows. */
173
+ export function legacyCustomModelCatalogSlugs(config: OcxConfig): ReadonlySet<string> {
174
+ const state = parseMigrationState(config);
175
+ return new Set(state.kind === "supported" ? state.state.legacyOwnedSlugs : []);
176
+ }