@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
@@ -4,16 +4,17 @@ import { existsSync, readFileSync } from "node:fs";
4
4
  import { delimiter, dirname, join, resolve } from "node:path";
5
5
  import { expandUserPath, loadConfig, readConfigDiagnostics, websocketsEnabled } from "../../config";
6
6
  import { shouldSyncCodexOnStart } from "../desired-state";
7
+ import { legacyCustomModelCatalogSlugs } from "../custom-model-catalog-migration";
7
8
  import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, getCodexHome, readRootTomlString, resolveCodexConfigPath } from "../paths";
8
9
  import { clearModelCache, DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelsFetchCoolingDown, markModelsFetchFailure, setCached } from "../model-cache";
9
10
  import { buildModelsRequest, resolveModelsAuthToken } from "../../oauth";
10
11
  import type { OcxConfig, OcxProviderConfig } from "../../types";
11
12
  import { modelInList } from "../../types";
12
13
  import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort";
13
- import { getJawcodeModelMetadata, getJawcodeModelMetadataCaseInsensitive, listJawcodeModelMetadata, resolveJawcodeProvider } from "../../generated/jawcode-model-metadata";
14
+ import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata";
14
15
  import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive";
15
16
  import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap";
16
- import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec";
17
+ import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../../providers/slug-codec";
17
18
  import { identifyRoutedModel } from "../../adapters/identity";
18
19
  import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery";
19
20
  import { fetchCursorUsableModels } from "../../adapters/cursor/live-models";
@@ -30,9 +31,9 @@ import { redactSecretString } from "../../lib/redact";
30
31
  import upstreamModelsSnapshot from "../data/upstream-models.json";
31
32
 
32
33
 
33
- import { activeCodexModelsCachePath, applyJawcodeCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing";
34
+ import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing";
34
35
  import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing";
35
- import { applyNativeVisibility, disabledNativeSlugs, isUnsupportedOpenAiNativeSlug, nativeOpenAiSlugs, NATIVE_OPENAI_MODELS, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata";
36
+ import { applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata";
36
37
  import {
37
38
  bundledCatalogCacheState,
38
39
  loadBundledCodexCatalog,
@@ -40,8 +41,14 @@ import {
40
41
  } from "./bundled";
41
42
  import { isMultiAgentV2Enabled } from "../features";
42
43
  import { applyCatalogModelMetadata, applyReasoningLevels, catalogEntryEfforts, clampCatalogModelsToCodexSupport, ensureGpt56ReasoningLevels, ensureUltraReasoningLevel, isGpt56NativeSlug } from "./effort";
43
- import { clearGatherRoutedModelsInflight, filterCatalogVisibleModels, gatherRoutedModels, lastDropWarnSignature } from "./provider-fetch";
44
- import { accountSelectorShadowCollisionWarnings, clearLastComboCatalogOmissions, comboCatalogWarningSignatures, comboMasqueradeCollisionWarnings, exactComboCatalogSlugs, openAiApiCollisionWarnings, resolveSlugAliasCollisions, slugAliasCollisionWarnings, warnAccountSelectorShadowedProviderOnce, warnComboMasqueradeCollisionOnce } from "./aggregation";
44
+ import {
45
+ clearGatherRoutedModelsInflight,
46
+ filterCatalogVisibleModels,
47
+ gatherRoutedModels,
48
+ lastDropWarnSignature,
49
+ type CatalogGatherProviderModelOutcome,
50
+ } from "./provider-fetch";
51
+ import { accountSelectorShadowCollisionWarnings, clearLastComboCatalogOmissions, comboCatalogWarningSignatures, comboMasqueradeCollisionWarnings, comboUnrestorableShadowWarnings, exactComboCatalogSlugs, openAiApiCollisionWarnings, resolveSlugAliasCollisions, slugAliasCollisionWarnings, warnAccountSelectorShadowedProviderOnce, warnComboMasqueradeCollisionOnce, warnComboUnrestorableShadowOnce } from "./aggregation";
45
52
  import type { ComboCatalogOmission } from "./aggregation";
46
53
  import {
47
54
  withCatalogWriteSerialization,
@@ -197,7 +204,16 @@ export function isExactComboCatalogModel(
197
204
  model: CatalogModel | undefined,
198
205
  exactComboSlugs: ReadonlySet<string>,
199
206
  ): boolean {
200
- return model !== undefined && exactComboSlugs.has(catalogModelSlug(model));
207
+ return model?.provider === COMBO_NAMESPACE && exactComboSlugs.has(catalogModelSlug(model));
208
+ }
209
+
210
+ function isExactComboCatalogEntry(
211
+ entry: RawEntry,
212
+ exactComboSlugs: ReadonlySet<string>,
213
+ ): boolean {
214
+ return entry.owned_by === COMBO_NAMESPACE
215
+ && typeof entry.slug === "string"
216
+ && exactComboSlugs.has(entry.slug);
201
217
  }
202
218
 
203
219
  /**
@@ -266,8 +282,9 @@ export function deriveEntry(
266
282
  }
267
283
  applyReasoningLevels(e, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact);
268
284
  normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true);
269
- if (model) applyJawcodeCatalogMetadata(e, model.provider, model.id, model.contextCap);
285
+ if (model) applyCatalogMetadata(e, model.provider, model.id, model.contextCap);
270
286
  applyCatalogModelMetadata(e, model);
287
+ if (model?.catalogKind) e.opencodex_catalog_kind = model.catalogKind;
271
288
  } else {
272
289
  applyNativeOpenAiContextOverride(e);
273
290
  if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(e);
@@ -303,8 +320,9 @@ export function deriveEntry(
303
320
  applyReasoningLevels(entry, isGpt56NativeSlug(slug) ? undefined : ["low", "medium", "high", "xhigh"]);
304
321
  if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(entry);
305
322
  }
306
- if (model && isRouted) applyJawcodeCatalogMetadata(entry, model.provider, model.id, model.contextCap);
323
+ if (model && isRouted) applyCatalogMetadata(entry, model.provider, model.id, model.contextCap);
307
324
  applyCatalogModelMetadata(entry, model);
325
+ if (model?.catalogKind) entry.opencodex_catalog_kind = model.catalogKind;
308
326
  if (!isRouted) applyNativeOpenAiContextOverride(entry);
309
327
  return ensureStrictCatalogFields(normalizeServiceTiers(entry), {
310
328
  preserveExactInputModalities: preserveExact,
@@ -312,6 +330,21 @@ export function deriveEntry(
312
330
  });
313
331
  }
314
332
 
333
+ export interface ObservedCatalogEntryBuildInput {
334
+ readonly template: RawEntry | null;
335
+ readonly gptSlugs: readonly string[];
336
+ readonly goModels: readonly CatalogModel[];
337
+ readonly featured?: readonly string[];
338
+ readonly wsEnabled: boolean;
339
+ readonly multiAgentMode: MultiAgentMode;
340
+ readonly exactComboSlugs: ReadonlySet<string>;
341
+ readonly accountSelectors: readonly string[];
342
+ readonly suppressedBareNativeSlugs: ReadonlySet<string>;
343
+ readonly disabledNativeAccountSlugs: ReadonlySet<string>;
344
+ readonly multiAgentV2Enabled: boolean;
345
+ }
346
+
347
+ /** Build entries with the process-observed Codex feature state. */
315
348
  export function buildCatalogEntries(
316
349
  template: RawEntry | null,
317
350
  gptSlugs: string[],
@@ -321,7 +354,38 @@ export function buildCatalogEntries(
321
354
  multiAgentMode: MultiAgentMode = "default",
322
355
  exactComboSlugs: ReadonlySet<string> = new Set(),
323
356
  accountSelectors: readonly string[] = [],
357
+ suppressedBareNativeSlugs: ReadonlySet<string> = new Set(),
358
+ disabledNativeAccountSlugs: ReadonlySet<string> = new Set(),
324
359
  ): RawEntry[] {
360
+ return buildCatalogEntriesFromObservedState({
361
+ template,
362
+ gptSlugs,
363
+ goModels,
364
+ featured,
365
+ wsEnabled,
366
+ multiAgentMode,
367
+ exactComboSlugs,
368
+ accountSelectors,
369
+ suppressedBareNativeSlugs,
370
+ disabledNativeAccountSlugs,
371
+ multiAgentV2Enabled: isMultiAgentV2Enabled(),
372
+ });
373
+ }
374
+
375
+ /** Build entries solely from caller-observed inputs, with no feature-state filesystem read. */
376
+ export function buildCatalogEntriesFromObservedState({
377
+ template,
378
+ gptSlugs,
379
+ goModels,
380
+ featured,
381
+ wsEnabled,
382
+ multiAgentMode,
383
+ exactComboSlugs,
384
+ accountSelectors,
385
+ suppressedBareNativeSlugs,
386
+ disabledNativeAccountSlugs,
387
+ multiAgentV2Enabled,
388
+ }: ObservedCatalogEntryBuildInput): RawEntry[] {
325
389
  // Codex's models-manager sorts by `priority` ASC and advertises the first 5 picker-visible
326
390
  // models to spawn_agent (sort_by_key(priority) + MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT=5). Catalog
327
391
  // ARRAY order is discarded — so "featuring" a model = giving it the LOWEST priority (0..N-1) so
@@ -330,27 +394,70 @@ export function buildCatalogEntries(
330
394
  const priorityStride = Math.max(accountSelectors.length, 1);
331
395
  const out: RawEntry[] = [];
332
396
  const nativeEntries: RawEntry[] = [];
333
- const collisionSkipped = resolveSlugAliasCollisions(goModels);
397
+ const collisionSkipped = resolveSlugAliasCollisions([...goModels]);
398
+ const emittedNativeAliases = new Set<CatalogModel>();
399
+ const emittedNativeAliasSlugs = new Set<string>();
400
+ const nativeAliasesBySlug = new Map<string, CatalogModel>();
401
+ for (const model of goModels) {
402
+ if (model.provider !== COMBO_NAMESPACE
403
+ || model.nativeAlias !== true
404
+ || typeof model.alias !== "string"
405
+ || model.alias.includes("/")) continue;
406
+ if (nativeAliasesBySlug.has(model.alias)) {
407
+ collisionSkipped.add(model);
408
+ if (!slugAliasCollisionWarnings.has(model.alias)) {
409
+ slugAliasCollisionWarnings.add(model.alias);
410
+ console.warn(
411
+ `[opencodex] native combo alias collision on "${model.alias}": keeping the first configured combo and omitting later duplicates from the catalog.`,
412
+ );
413
+ }
414
+ continue;
415
+ }
416
+ nativeAliasesBySlug.set(model.alias, model);
417
+ }
334
418
  const comboPublicSlugs = new Set(goModels
335
419
  .filter(model => model.provider === COMBO_NAMESPACE)
336
420
  .map(catalogModelSlug));
337
421
  for (const slug of gptSlugs) {
338
- const e = deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9);
339
- if (rank.has(slug)) e.priority = rank.get(slug)!;
340
- out.push(e);
341
- nativeEntries.push(e);
422
+ const native = deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9);
423
+ if (rank.has(slug)) native.priority = rank.get(slug)!;
424
+ nativeEntries.push(native);
425
+ const nativeAlias = nativeAliasesBySlug.get(slug);
426
+ if (!nativeAlias || collisionSkipped.has(nativeAlias)) {
427
+ if (!suppressedBareNativeSlugs.has(slug)) out.push(native);
428
+ continue;
429
+ }
430
+ const routed = deriveEntry(
431
+ template,
432
+ slug,
433
+ `Routed via opencodex → ${nativeAlias.provider} (${nativeAlias.owned_by ?? nativeAlias.provider}).`,
434
+ 5,
435
+ nativeAlias,
436
+ exactComboSlugs,
437
+ );
438
+ routed.opencodex_catalog_kind = CODEX_NATIVE_ALIAS_CATALOG_KIND;
439
+ const rankHit = rank.get(slug) ?? rank.get(`${nativeAlias.provider}/${nativeAlias.id}`);
440
+ if (rankHit !== undefined) routed.priority = rankHit * priorityStride;
441
+ else if (accountSelectors.length > 0) routed.priority = 1_000 + (typeof routed.priority === "number" ? routed.priority : 5);
442
+ out.push(routed);
443
+ emittedNativeAliases.add(nativeAlias);
444
+ emittedNativeAliasSlugs.add(slug);
342
445
  }
343
446
  for (const [selectorIndex, selector] of accountSelectors.entries()) {
344
447
  for (const [nativeIndex, native] of nativeEntries.entries()) {
345
- const e = JSON.parse(JSON.stringify(native)) as RawEntry;
346
448
  const nativeSlug = String(native.slug);
449
+ if (disabledNativeAccountSlugs.has(nativeSlug)) continue;
450
+ const e = JSON.parse(JSON.stringify(native)) as RawEntry;
347
451
  const catalogSlug = `${selector}/${nativeSlug}`;
348
452
  e.slug = catalogSlug;
349
453
  e.display_name = accountBoundNativeDisplayName(selector, native);
350
454
  // Codex ignores this OpenCodex extension; preserve the native comp_hash unchanged.
351
455
  e.opencodex_catalog_kind = CODEX_ACCOUNT_BOUND_CATALOG_KIND;
352
456
  const exactRank = rank.get(catalogSlug);
353
- const inheritedRank = rank.get(nativeSlug);
457
+ // A bare featured id belongs to the compatibility combo once shadowed. Exact
458
+ // account-qualified picks still rank normally, but the account clone must not
459
+ // inherit the bare alias rank and consume another top spawn_agent slot.
460
+ const inheritedRank = emittedNativeAliasSlugs.has(nativeSlug) ? undefined : rank.get(nativeSlug);
354
461
  const featuredRank = exactRank ?? inheritedRank;
355
462
  e.priority = featuredRank !== undefined
356
463
  ? featuredRank * priorityStride + selectorIndex
@@ -360,7 +467,7 @@ export function buildCatalogEntries(
360
467
  }
361
468
  }
362
469
  for (const m of goModels) {
363
- if (collisionSkipped.has(m)) continue;
470
+ if (collisionSkipped.has(m) || emittedNativeAliases.has(m)) continue;
364
471
  const slug = catalogModelSlug(m);
365
472
  if (m.provider !== COMBO_NAMESPACE && comboPublicSlugs.has(slug)) {
366
473
  warnComboMasqueradeCollisionOnce(slug);
@@ -376,6 +483,9 @@ export function buildCatalogEntries(
376
483
  m,
377
484
  exactComboSlugs,
378
485
  );
486
+ if (m.provider === COMBO_NAMESPACE && m.nativeAlias === true && !slug.includes("/")) {
487
+ e.opencodex_catalog_kind = CODEX_NATIVE_ALIAS_CATALOG_KIND;
488
+ }
379
489
  // Featured picks may be stored raw (legacy) or encoded — honor both.
380
490
  const rankHit = rank.get(slug) ?? rank.get(`${m.provider}/${m.id}`);
381
491
  if (rankHit !== undefined) e.priority = rankHit * priorityStride;
@@ -397,7 +507,7 @@ export function buildCatalogEntries(
397
507
  delete entry.prefer_websockets;
398
508
  }
399
509
  }
400
- return applyMultiAgentMode(out, multiAgentMode, isMultiAgentV2Enabled());
510
+ return applyMultiAgentMode(out, multiAgentMode, multiAgentV2Enabled);
401
511
  }
402
512
 
403
513
  export function resetCatalogRuntimeStateForTests(): void {
@@ -407,9 +517,10 @@ export function resetCatalogRuntimeStateForTests(): void {
407
517
  comboCatalogWarningSignatures.clear();
408
518
  slugAliasCollisionWarnings.clear();
409
519
  comboMasqueradeCollisionWarnings.clear();
520
+ comboUnrestorableShadowWarnings.clear();
410
521
  accountSelectorShadowCollisionWarnings.clear();
411
522
  clearLastComboCatalogOmissions();
412
- clearModelCache();
523
+ clearModelCache(undefined, "eviction");
413
524
  clearGatherRoutedModelsInflight();
414
525
  }
415
526
 
@@ -437,95 +548,284 @@ export function orderForSubagents(goModels: CatalogModel[], featured?: string[])
437
548
  * row.
438
549
  */
439
550
  function isOcxAuthoredRoutedEntry(entry: RawEntry): boolean {
551
+ if (isNativeAliasCatalogEntry(entry)) return true;
440
552
  const desc = typeof entry.description === "string" ? entry.description : "";
441
553
  const slug = typeof entry.slug === "string" ? entry.slug : "";
442
554
  return slug.includes("/") && desc.startsWith("Routed via opencodex → ");
443
555
  }
444
556
 
445
- export function mergeCatalogEntriesForSync(
446
- catalogModels: RawEntry[],
447
- routedEntries: RawEntry[],
448
- baseline: Map<string, number>,
449
- featured: string[],
450
- wsEnabled: boolean,
451
- goIds: Set<string> = new Set(),
452
- template: RawEntry | null = null,
453
- disabledModels: ReadonlySet<string> = new Set(),
454
- gatheredProviderNames: Set<string> = new Set(routedEntries.flatMap(entry => {
455
- const slug = typeof entry.slug === "string" ? entry.slug : "";
456
- const slash = slug.indexOf("/");
457
- return slash > 0 ? [slug.slice(0, slash)] : [];
458
- })),
459
- multiAgentMode: MultiAgentMode = "default",
460
- exactComboSlugs: ReadonlySet<string> = new Set(),
461
- hasPhysicalComboProvider = false,
462
- includeNativeOpenAi = true,
463
- accountBoundEntries: readonly RawEntry[] = [],
557
+ function recoverableNativeSlug(entry: RawEntry): string | null {
558
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
559
+ return SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug)
560
+ && !isNativeAliasCatalogEntry(entry)
561
+ && entry.owned_by !== COMBO_NAMESPACE
562
+ ? slug
563
+ : null;
564
+ }
565
+
566
+ /** Append missing supported native rows from trusted catalog sources only. */
567
+ export function mergeCatalogModelsWithNativeRecovery(
568
+ primaryCatalogModels: readonly RawEntry[],
569
+ nativeRecoverySources: readonly (readonly RawEntry[])[],
464
570
  ): RawEntry[] {
571
+ const merged = [...primaryCatalogModels];
572
+ const recoveredNativeSlugs = new Set(primaryCatalogModels.flatMap(entry => {
573
+ const slug = recoverableNativeSlug(entry);
574
+ return slug === null ? [] : [slug];
575
+ }));
576
+ for (const source of nativeRecoverySources) {
577
+ for (const entry of source) {
578
+ const slug = recoverableNativeSlug(entry);
579
+ if (slug === null || recoveredNativeSlugs.has(slug)) continue;
580
+ merged.push(structuredClone(entry) as RawEntry);
581
+ recoveredNativeSlugs.add(slug);
582
+ }
583
+ }
584
+ return merged;
585
+ }
586
+
587
+ export interface ObservedCatalogMergePolicy {
588
+ /** Required observed/fixed set; the core merge never consults ambient catalog state. */
589
+ readonly nativeBackfillSlugs: readonly string[];
590
+ /** Whether unsupported OpenAI-family bare rows survive the merge. */
591
+ readonly unsupportedNativeEntries: "preserve" | "drop";
592
+ /** Whether merge-policy collision/preservation warnings belong to this caller's flow. */
593
+ readonly warningPolicy: "emit" | "suppress";
594
+ }
595
+
596
+ /** Content policy shared by every writer of the canonical Codex model catalog. */
597
+ export const CANONICAL_NATIVE_CATALOG_CONTENT_POLICY: Readonly<
598
+ Pick<ObservedCatalogMergePolicy, "nativeBackfillSlugs" | "unsupportedNativeEntries">
599
+ > = Object.freeze({
600
+ nativeBackfillSlugs: Object.freeze([...NATIVE_OPENAI_MODELS]),
601
+ unsupportedNativeEntries: "drop",
602
+ });
603
+
604
+ export interface ObservedCatalogMergeInput {
605
+ readonly catalogModels: readonly RawEntry[];
606
+ readonly baselineCatalogModels: readonly RawEntry[];
607
+ readonly routedEntries: readonly RawEntry[];
608
+ readonly baseline: ReadonlyMap<string, number>;
609
+ readonly featured: readonly string[];
610
+ readonly wsEnabled: boolean;
611
+ readonly template: RawEntry | null;
612
+ readonly disabledModels: ReadonlySet<string>;
613
+ readonly selectedModelsByProvider: ReadonlyMap<string, ReadonlySet<string>>;
614
+ readonly gatheredProviderNames: ReadonlySet<string>;
615
+ readonly degradedProviderNames: ReadonlySet<string>;
616
+ readonly legacyCustomModelSlugs: ReadonlySet<string>;
617
+ readonly multiAgentMode: MultiAgentMode;
618
+ readonly multiAgentV2Enabled: boolean;
619
+ readonly exactComboSlugs: ReadonlySet<string>;
620
+ readonly hasPhysicalComboProvider: boolean;
621
+ readonly includeNativeOpenAi: boolean;
622
+ readonly accountBoundEntries: readonly RawEntry[];
623
+ readonly suppressedBareNativeSlugs?: ReadonlySet<string>;
624
+ readonly policy: ObservedCatalogMergePolicy;
625
+ }
626
+
627
+ /**
628
+ * Deterministically merge one fully observed catalog state.
629
+ *
630
+ * Every non-catalog input is explicit so evidence-bound convergence cannot
631
+ * accidentally fall back to process-ambient catalog discovery or merge-policy warnings.
632
+ */
633
+ export function mergeCatalogEntriesFromObservedState({
634
+ catalogModels,
635
+ baselineCatalogModels,
636
+ routedEntries,
637
+ baseline,
638
+ featured,
639
+ wsEnabled,
640
+ template,
641
+ disabledModels,
642
+ selectedModelsByProvider,
643
+ gatheredProviderNames,
644
+ degradedProviderNames,
645
+ legacyCustomModelSlugs,
646
+ multiAgentMode,
647
+ multiAgentV2Enabled,
648
+ exactComboSlugs,
649
+ hasPhysicalComboProvider,
650
+ includeNativeOpenAi,
651
+ accountBoundEntries,
652
+ suppressedBareNativeSlugs = new Set(),
653
+ policy,
654
+ }: ObservedCatalogMergeInput): RawEntry[] {
655
+ // Raw catalog rows contain nested arrays/objects that normalization mutates. Detach every row at
656
+ // the observed-core boundary so callers can safely retain evidence objects or repeat the merge.
657
+ const detachedCatalogModels = catalogModels.map(entry => structuredClone(entry) as RawEntry);
658
+ const detachedBaselineCatalogModels = baselineCatalogModels
659
+ .map(entry => structuredClone(entry) as RawEntry);
660
+ const detachedRoutedEntries = routedEntries.map(entry => structuredClone(entry) as RawEntry);
661
+ const detachedAccountBoundEntries = accountBoundEntries
662
+ .map(entry => structuredClone(entry) as RawEntry);
663
+ const disabledModelKeys = new Set([...disabledModels].map(slugEquivalenceKey));
664
+ const legacyCustomModelKeys = new Set(
665
+ [...legacyCustomModelSlugs].map(slugEquivalenceKey),
666
+ );
667
+ const selectedModelKeysByProvider = new Map([...selectedModelsByProvider].map(([provider, models]) => (
668
+ [provider, new Set([...models].map(model => slugEquivalenceKey(routedSlug(provider, model))))] as const
669
+ )));
670
+ const freshAccountKeys = new Set(detachedAccountBoundEntries.flatMap(entry => (
671
+ typeof entry.slug === "string" ? [slugEquivalenceKey(entry.slug)] : []
672
+ )));
673
+ const wouldSurviveUnreplaced = (entry: RawEntry): boolean => {
674
+ if (entry.owned_by === COMBO_NAMESPACE
675
+ || trustedAccountBoundNativeCatalogSlug(entry) !== undefined
676
+ || entry.opencodex_catalog_kind === CODEX_CUSTOM_MODEL_CATALOG_KIND
677
+ || isOcxAuthoredRoutedEntry(entry)
678
+ || typeof entry.slug !== "string") return false;
679
+ const slug = entry.slug;
680
+ if (!slug.includes("/")) {
681
+ if (!includeNativeOpenAi || policy.nativeBackfillSlugs.includes(slug)) return false;
682
+ return policy.unsupportedNativeEntries === "preserve" || !isUnsupportedOpenAiNativeSlug(slug);
683
+ }
684
+ if (isRoutedModelCompatibilityExcluded(slug)) return false;
685
+ if (!hasPhysicalComboProvider && slug.startsWith(`${COMBO_NAMESPACE}/`)) return false;
686
+ const key = slugEquivalenceKey(slug);
687
+ if (freshAccountKeys.has(key)) return false;
688
+ if (disabledModelKeys.has(key)) return false;
689
+ const slash = slug.indexOf("/");
690
+ const provider = slug.slice(0, slash);
691
+ const selected = selectedModelKeysByProvider.get(provider);
692
+ if (selected !== undefined && !selected.has(key)) return false;
693
+ return !gatheredProviderNames.has(provider) || degradedProviderNames.has(provider);
694
+ };
695
+ const validRoutedEntries = detachedRoutedEntries.filter(entry => {
696
+ return !isExactComboCatalogEntry(entry, exactComboSlugs)
697
+ || (Array.isArray(entry.input_modalities) && entry.input_modalities.length > 0);
698
+ });
699
+ const restorableCatalogKeys = new Set(detachedBaselineCatalogModels.flatMap(entry => (
700
+ wouldSurviveUnreplaced(entry) && typeof entry.slug === "string"
701
+ ? [slugEquivalenceKey(entry.slug)]
702
+ : []
703
+ )));
704
+ const unrestorableCatalogKeys = new Set(detachedCatalogModels.flatMap(entry => {
705
+ if (!wouldSurviveUnreplaced(entry) || typeof entry.slug !== "string") return [];
706
+ const key = slugEquivalenceKey(entry.slug);
707
+ return restorableCatalogKeys.has(key) ? [] : [key];
708
+ }));
709
+ const admittedRoutedEntries = validRoutedEntries.filter(entry => {
710
+ if (!isExactComboCatalogEntry(entry, exactComboSlugs)) return true;
711
+ const slug = entry.slug as string;
712
+ const key = slugEquivalenceKey(slug);
713
+ if (!unrestorableCatalogKeys.has(key)) return true;
714
+ if (policy.warningPolicy === "emit") warnComboUnrestorableShadowOnce(slug);
715
+ return false;
716
+ });
717
+ // A fresh non-custom row authoritatively resolves a historically ambiguous slug as a normal
718
+ // provider model. Persist that classification so the durable deletion evidence cannot remove
719
+ // the legitimate row during a later degraded refresh.
720
+ for (const entry of admittedRoutedEntries) {
721
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
722
+ if (!slug
723
+ || entry.opencodex_catalog_kind !== undefined
724
+ || entry.owned_by === COMBO_NAMESPACE
725
+ || !isOcxAuthoredRoutedEntry(entry)
726
+ || !legacyCustomModelKeys.has(slugEquivalenceKey(slug))) continue;
727
+ entry.opencodex_catalog_kind = CODEX_PROVIDER_MODEL_CATALOG_KIND;
728
+ }
729
+ const freshExactComboEntries = new Set(admittedRoutedEntries.filter(entry => (
730
+ isExactComboCatalogEntry(entry, exactComboSlugs)
731
+ && typeof entry.description === "string"
732
+ && entry.description.startsWith(`Routed via opencodex → ${COMBO_NAMESPACE} (`)
733
+ )));
465
734
  const rank = new Map(featured.map((slug, i) => [slug, i] as const));
466
- const native = includeNativeOpenAi
467
- ? catalogModels
735
+ const freshEquivalentKeys = new Set(admittedRoutedEntries.flatMap(entry => (
736
+ typeof entry.slug === "string" ? [slugEquivalenceKey(entry.slug)] : []
737
+ )));
738
+ const freshEquivalent = (slug: string): boolean => (
739
+ freshEquivalentKeys.has(slugEquivalenceKey(slug))
740
+ );
741
+ const freshBareComboAliases = new Set(admittedRoutedEntries.flatMap(entry => (
742
+ typeof entry.slug === "string"
743
+ && !entry.slug.includes("/")
744
+ && entry.owned_by === COMBO_NAMESPACE
745
+ ? [entry.slug]
746
+ : []
747
+ )));
748
+ const staleComboKeys = new Set(detachedCatalogModels.flatMap(entry => (
749
+ typeof entry.slug === "string"
750
+ && entry.owned_by === COMBO_NAMESPACE
751
+ && !freshEquivalent(entry.slug)
752
+ ? [slugEquivalenceKey(entry.slug)]
753
+ : []
754
+ )));
755
+ const currentNonComboKeys = new Set(detachedCatalogModels.flatMap(entry => (
756
+ entry.owned_by !== COMBO_NAMESPACE && typeof entry.slug === "string"
757
+ ? [slugEquivalenceKey(entry.slug)]
758
+ : []
759
+ )));
760
+ const restoredComboShadows = detachedBaselineCatalogModels.filter(entry => {
761
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
762
+ if (!slug || entry.owned_by === COMBO_NAMESPACE) return false;
763
+ const key = slugEquivalenceKey(slug);
764
+ return staleComboKeys.has(key) && !currentNonComboKeys.has(key);
765
+ });
766
+ const catalogModelsForMerge = [...detachedCatalogModels, ...restoredComboShadows];
767
+ const nativePriority = (slug: string, fallback: unknown): number => {
768
+ const base = baseline.get(slug)
769
+ ?? (typeof fallback === "number" ? fallback : 9);
770
+ if (rank.has(slug)) return rank.get(slug)!;
771
+ return featured.length > 0 ? Math.max(base, featured.length + 100) : base;
772
+ };
773
+ const nativeSourceEntries = includeNativeOpenAi
774
+ ? catalogModelsForMerge
468
775
  .filter(m => typeof m.slug === "string"
469
776
  && !(m.slug as string).includes("/")
470
777
  && m.owned_by !== COMBO_NAMESPACE
471
- && !goIds.has(m.slug as string)
472
- && !isUnsupportedOpenAiNativeSlug(m.slug as string))
778
+ && (policy.unsupportedNativeEntries === "preserve"
779
+ || !isUnsupportedOpenAiNativeSlug(m.slug as string)))
473
780
  .map(m => {
474
781
  const slug = m.slug as string;
475
- // Featured models rank first (rank order); non-featured natives are pushed below the featured
476
- // block when any model is featured, else keep their pristine baseline priority.
477
- const baselinePriority = baseline.get(slug) ?? (m.priority as number);
478
- const priority = rank.has(slug)
479
- ? rank.get(slug)!
480
- : featured.length > 0
481
- ? Math.max(typeof baselinePriority === "number" ? baselinePriority : 9, featured.length + 100)
482
- : baselinePriority;
483
782
  // Fallback-quality entries (ocx synthesis / codex-rs model_info fallback: display_name
484
783
  // stamped with the bare slug) are upgraded to the pinned upstream snapshot entry so a
485
784
  // previously synthesized ladder (e.g. luna advertising ultra) self-heals on sync. A
486
785
  // genuine catalog entry (real display name) is preserved untouched.
487
786
  if (shouldUpgradeToUpstreamEntry(m)) {
488
787
  const upstream = upstreamNativeEntry(slug)!;
489
- const upgradePriority = rank.has(slug)
490
- ? rank.get(slug)!
491
- : featured.length > 0
492
- ? Math.max(typeof upstream.priority === "number" ? upstream.priority : 9, featured.length + 100)
493
- : typeof upstream.priority === "number" ? upstream.priority : priority;
494
788
  const finished = finishUpstreamNativeEntry(upstream, 9);
495
- finished.priority = upgradePriority;
789
+ finished.priority = nativePriority(slug, upstream.priority);
496
790
  return finished;
497
791
  }
498
- const preserved = normalizeServiceTiers({ ...m, priority });
792
+ const preserved = normalizeServiceTiers({ ...m, priority: nativePriority(slug, m.priority) });
499
793
  // Older natives kept from disk still need the mock top tiers (max + ultra always
500
794
  // for subagent max spawns; wire-clamped to the model's real top rung).
501
795
  if (!isGpt56NativeSlug(slug)) ensureUltraReasoningLevel(preserved);
502
796
  return preserved;
503
797
  })
504
798
  : [];
799
+ const native = nativeSourceEntries.filter(entry =>
800
+ typeof entry.slug !== "string"
801
+ || (!freshBareComboAliases.has(entry.slug) && !suppressedBareNativeSlugs.has(entry.slug))
802
+ );
505
803
 
506
804
  // Backfill any native OpenAI slug that the on-disk catalog is missing (e.g. gpt-5.5), so a
507
805
  // routed provider exposing the same id can never delete the native OpenAI/Codex base row.
508
806
  // Skip when no enabled canonical openai provider exists (#636) — bare gpt-* would 404.
509
807
  const nativeSlugs = new Set(native.flatMap(m => typeof m.slug === "string" ? [m.slug] : []));
510
808
  if (includeNativeOpenAi) {
511
- for (const slug of nativeOpenAiSlugs()) {
512
- if (nativeSlugs.has(slug)) continue;
513
- nativeSlugs.add(slug);
514
- const priority = rank.has(slug)
515
- ? rank.get(slug)!
516
- : featured.length > 0
517
- ? featured.length + 100
518
- : 9;
519
- native.push(deriveEntry(template ? JSON.parse(JSON.stringify(template)) : null, slug, "OpenAI native model (Codex OAuth passthrough).", priority));
520
- }
809
+ for (const slug of policy.nativeBackfillSlugs) {
810
+ if (nativeSlugs.has(slug) || freshBareComboAliases.has(slug) || suppressedBareNativeSlugs.has(slug)) continue;
811
+ nativeSlugs.add(slug);
812
+ const entry = deriveEntry(
813
+ template ? JSON.parse(JSON.stringify(template)) : null,
814
+ slug,
815
+ "OpenAI native model (Codex OAuth passthrough).",
816
+ nativePriority(slug, upstreamNativeEntry(slug)?.priority),
817
+ );
818
+ entry.priority = nativePriority(slug, upstreamNativeEntry(slug)?.priority);
819
+ native.push(entry);
820
+ }
521
821
  }
522
822
 
523
- const nativeBySlug = new Map(native.flatMap(entry =>
823
+ const nativeSourceBySlug = new Map([...nativeSourceEntries, ...native].flatMap(entry =>
524
824
  typeof entry.slug === "string" ? [[entry.slug, entry] as const] : []
525
825
  ));
526
- const alignedAccountBoundEntries = accountBoundEntries.map(entry => {
826
+ const alignedAccountBoundEntries = detachedAccountBoundEntries.map(entry => {
527
827
  const nativeSlug = trustedAccountBoundNativeCatalogSlug(entry);
528
- const source = nativeSlug === undefined ? undefined : nativeBySlug.get(nativeSlug);
828
+ const source = nativeSlug === undefined ? undefined : nativeSourceBySlug.get(nativeSlug);
529
829
  if (!source) return entry;
530
830
  const aligned = JSON.parse(JSON.stringify(source)) as RawEntry;
531
831
  aligned.slug = entry.slug;
@@ -537,46 +837,64 @@ export function mergeCatalogEntriesForSync(
537
837
  });
538
838
 
539
839
  const freshSlugs = new Set(
540
- routedEntries.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []),
840
+ admittedRoutedEntries.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []),
541
841
  );
542
- let finalRoutedEntries = routedEntries;
543
- const existingRoutedEntries = catalogModels.filter(m =>
842
+ const existingRoutedEntries = catalogModelsForMerge.filter(m =>
544
843
  typeof m.slug === "string"
545
- && m.slug.includes("/")
844
+ && (m.slug.includes("/") || isNativeAliasCatalogEntry(m))
546
845
  && trustedAccountBoundNativeCatalogSlug(m) === undefined
547
846
  );
548
- const preservingExistingRouted = routedEntries.length === 0
549
- && existingRoutedEntries.length > 0;
550
- if (preservingExistingRouted) {
551
- // #855: transient-fetch protection keeps existing rows, but rows OpenCodex
552
- // itself authored for a provider that is no longer configured are ghosts,
553
- // not protected foreign entries.
554
- finalRoutedEntries = existingRoutedEntries.filter(m => {
555
- const provider = (m.slug as string).slice(0, (m.slug as string).indexOf("/"));
556
- return !(isOcxAuthoredRoutedEntry(m) && !gatheredProviderNames.has(provider));
557
- });
558
- } else {
559
- const preservedForeignRouted = catalogModels.filter(m => {
560
- if (typeof m.slug !== "string" || !m.slug.includes("/")) return false;
561
- if (trustedAccountBoundNativeCatalogSlug(m) !== undefined) return false;
562
- const provider = m.slug.slice(0, m.slug.indexOf("/"));
563
- if (gatheredProviderNames.has(provider) || freshSlugs.has(m.slug)) return false;
564
- // #855: an OpenCodex-authored row whose provider was deleted is a ghost;
565
- // only genuinely foreign rows (Cursor, user tooling) are preserved.
566
- return !isOcxAuthoredRoutedEntry(m);
567
- });
568
- finalRoutedEntries = [...routedEntries, ...preservedForeignRouted];
569
- }
847
+ const preservedRoutedEntries = existingRoutedEntries.filter(entry => {
848
+ const slug = entry.slug as string;
849
+ if (freshEquivalent(slug)) return false;
850
+ if (isNativeAliasCatalogEntry(entry)) return exactComboSlugs.has(slug);
851
+ // Current custom rows are always regenerated from config, even while provider discovery is
852
+ // degraded. A marked row absent from the fresh projection is therefore an intentional delete.
853
+ if (entry.opencodex_catalog_kind === CODEX_CUSTOM_MODEL_CATALOG_KIND) return false;
854
+ // Before custom rows had a marker, a config deletion could otherwise be mistaken for a
855
+ // provider outage. Only explicit save-boundary evidence may classify an unmarked OpenCodex
856
+ // row; foreign and future-marked rows fail closed and remain preserved.
857
+ if (entry.opencodex_catalog_kind === undefined
858
+ && entry.owned_by !== COMBO_NAMESPACE
859
+ && isOcxAuthoredRoutedEntry(entry)
860
+ && legacyCustomModelKeys.has(slugEquivalenceKey(slug))) return false;
861
+ const provider = slug.slice(0, slug.indexOf("/"));
862
+ if (gatheredProviderNames.has(provider)) {
863
+ // A provider-local degraded observation preserves only that namespace. Authoritative empty
864
+ // catalogs and successful removals still delete stale rows even when another provider fails.
865
+ return degradedProviderNames.has(provider);
866
+ }
867
+ // Deleted/disabled providers cannot retain OpenCodex-authored ghosts. Foreign catalog rows
868
+ // remain outside provider ownership and survive unless a fresh row replaces their exact slug.
869
+ return !isOcxAuthoredRoutedEntry(entry);
870
+ });
871
+ let finalRoutedEntries = [...admittedRoutedEntries, ...preservedRoutedEntries];
872
+ finalRoutedEntries = finalRoutedEntries.filter(entry => {
873
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
874
+ if (!slug.includes("/")) return true;
875
+ if (disabledModelKeys.has(slugEquivalenceKey(slug))) return false;
876
+ // Provider allowlists own provider rows, not a current combo's public alias. Exempt only an
877
+ // identity from this gather's generated combo projection: provider discovery may supply a
878
+ // spoofed `owned_by`, and persisted combo-shaped rows are not fresh authority.
879
+ if (freshExactComboEntries.has(entry)) return true;
880
+ const slash = slug.indexOf("/");
881
+ const provider = slug.slice(0, slash);
882
+ const selected = selectedModelKeysByProvider.get(provider);
883
+ return selected === undefined || selected.has(slugEquivalenceKey(slug));
884
+ });
570
885
  if (!hasPhysicalComboProvider) {
571
886
  finalRoutedEntries = finalRoutedEntries.filter(entry => {
572
887
  const slug = typeof entry.slug === "string" ? entry.slug : "";
573
888
  const comboOwned = slug.startsWith(`${COMBO_NAMESPACE}/`) || entry.owned_by === COMBO_NAMESPACE;
574
- return !comboOwned || freshSlugs.has(slug);
889
+ const retainedNativeAlias = isNativeAliasCatalogEntry(entry) && exactComboSlugs.has(slug);
890
+ return !comboOwned || freshSlugs.has(slug) || retainedNativeAlias;
575
891
  });
576
892
  }
577
893
  finalRoutedEntries = finalRoutedEntries.filter(entry => {
578
894
  const slug = typeof entry.slug === "string" ? entry.slug : "";
579
- return !exactComboSlugs.has(slug)
895
+ const retainedNativeAlias = isNativeAliasCatalogEntry(entry) && exactComboSlugs.has(slug);
896
+ return retainedNativeAlias
897
+ || !isExactComboCatalogEntry(entry, exactComboSlugs)
580
898
  || (Array.isArray(entry.input_modalities) && entry.input_modalities.length > 0);
581
899
  });
582
900
  // Reapply final catalog policy to rows preserved from disk. Those rows bypass
@@ -589,21 +907,30 @@ export function mergeCatalogEntriesForSync(
589
907
  ));
590
908
  finalRoutedEntries = finalRoutedEntries.filter(entry => {
591
909
  if (typeof entry.slug !== "string" || !accountBoundSlugs.has(entry.slug)) return true;
592
- if (freshSlugs.has(entry.slug)) warnAccountSelectorShadowedProviderOnce(entry.slug);
910
+ if (freshSlugs.has(entry.slug) && policy.warningPolicy === "emit") {
911
+ warnAccountSelectorShadowedProviderOnce(entry.slug);
912
+ }
593
913
  return false;
594
914
  });
595
- if (preservingExistingRouted) {
596
- console.warn(`[opencodex] catalog sync: routed model fetch returned empty; preserving ${finalRoutedEntries.length} existing routed entr${finalRoutedEntries.length === 1 ? "y" : "ies"} on disk.`);
915
+ const finalRoutedEntrySet = new Set(finalRoutedEntries);
916
+ const degradedPreservedCount = preservedRoutedEntries.filter(entry => {
917
+ if (!finalRoutedEntrySet.has(entry)) return false;
918
+ const slug = entry.slug as string;
919
+ const provider = slug.slice(0, slug.indexOf("/"));
920
+ return gatheredProviderNames.has(provider) && degradedProviderNames.has(provider);
921
+ }).length;
922
+ if (degradedPreservedCount > 0 && policy.warningPolicy === "emit") {
923
+ console.warn(`[opencodex] catalog sync: provider discovery degraded; preserving ${degradedPreservedCount} existing routed entr${degradedPreservedCount === 1 ? "y" : "ies"} on disk.`);
597
924
  }
598
925
 
599
926
  const managedEntries = [...finalRoutedEntries, ...alignedAccountBoundEntries];
600
927
  const mergedEntries = [...native, ...managedEntries].map(m => {
601
928
  const normalized = normalizeServiceTiers(m);
602
- applyNativeOpenAiContextOverride(normalized);
603
- const exactCombo = typeof m.slug === "string" && exactComboSlugs.has(m.slug);
929
+ if (!isNativeAliasCatalogEntry(normalized)) applyNativeOpenAiContextOverride(normalized);
930
+ const exactCombo = isExactComboCatalogEntry(m, exactComboSlugs);
604
931
  const e = ensureStrictCatalogFields(normalized, {
605
932
  preserveExactInputModalities: exactCombo,
606
- isRouted: finalRoutedEntries.includes(m),
933
+ isRouted: finalRoutedEntrySet.has(m),
607
934
  });
608
935
  // Mock-max universality (260709): preserved routed entries from disk may predate
609
936
  // the max rung — ensure it here so subagent max spawns validate on every
@@ -629,11 +956,85 @@ export function mergeCatalogEntriesForSync(
629
956
  // Native enable/disable runs as the LAST pass so the upstream-upgrade branch above can never
630
957
  // clobber a hide flag back to list. Bare ids disable every account clone; qualified ids disable
631
958
  // only their generated account row.
632
- return applyMultiAgentMode(
959
+ const versionedEntries = applyMultiAgentMode(
633
960
  applyNativeVisibility(mergedEntries, disabledModels, alignedAccountBoundEntries.length > 0),
634
961
  multiAgentMode,
635
- isMultiAgentV2Enabled(),
962
+ multiAgentV2Enabled,
963
+ );
964
+ for (const entry of versionedEntries) {
965
+ const kind = entry.opencodex_catalog_kind;
966
+ if (trustedAccountBoundNativeCatalogSlug(entry) === undefined
967
+ && kind !== CODEX_CUSTOM_MODEL_CATALOG_KIND
968
+ && kind !== CODEX_PROVIDER_MODEL_CATALOG_KIND) continue;
969
+ // Canonicalize extension-field order after every normalizer. This keeps an unchanged catalog
970
+ // byte-idempotent whether an owned row was freshly built or retained from the prior pass.
971
+ delete entry.opencodex_catalog_kind;
972
+ entry.opencodex_catalog_kind = kind;
973
+ }
974
+ return versionedEntries;
975
+ }
976
+
977
+ /** Merge retained-sync rows using the process-observed Codex feature state. */
978
+ export function mergeCatalogEntriesForSync(
979
+ catalogModels: RawEntry[],
980
+ routedEntries: RawEntry[],
981
+ baseline: Map<string, number>,
982
+ featured: string[],
983
+ wsEnabled: boolean,
984
+ _goIds: Set<string> = new Set(),
985
+ template: RawEntry | null = null,
986
+ disabledModels: ReadonlySet<string> = new Set(),
987
+ gatheredProviderNames?: Set<string>,
988
+ multiAgentMode: MultiAgentMode = "default",
989
+ exactComboSlugs: ReadonlySet<string> = new Set(),
990
+ hasPhysicalComboProvider = false,
991
+ includeNativeOpenAi = true,
992
+ accountBoundEntries: readonly RawEntry[] = [],
993
+ legacyCustomModelSlugs: ReadonlySet<string> = new Set(),
994
+ suppressedBareNativeSlugs: ReadonlySet<string> = new Set(
995
+ routedEntries.flatMap(entry => (
996
+ isNativeAliasCatalogEntry(entry) && typeof entry.slug === "string" ? [entry.slug] : []
997
+ )),
998
+ ),
999
+ ): RawEntry[] {
1000
+ // Retained for source compatibility with the original helper contract. Raw provider ids must
1001
+ // not suppress same-named native rows; actual admitted combo entries own that decision now.
1002
+ void _goIds;
1003
+ const effectiveGatheredProviderNames = gatheredProviderNames ?? new Set(
1004
+ routedEntries.flatMap(entry => {
1005
+ // A slashed combo alias is not evidence that its public prefix is an authoritative provider
1006
+ // namespace. Treating it as one would let the combo replace an unrestorable foreign row.
1007
+ if (isExactComboCatalogEntry(entry, exactComboSlugs)) return [];
1008
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
1009
+ const slash = slug.indexOf("/");
1010
+ return slash > 0 ? [slug.slice(0, slash)] : [];
1011
+ }),
636
1012
  );
1013
+ return mergeCatalogEntriesFromObservedState({
1014
+ catalogModels,
1015
+ baselineCatalogModels: [],
1016
+ routedEntries,
1017
+ baseline,
1018
+ featured,
1019
+ wsEnabled,
1020
+ template,
1021
+ disabledModels,
1022
+ selectedModelsByProvider: new Map(),
1023
+ gatheredProviderNames: effectiveGatheredProviderNames,
1024
+ degradedProviderNames: new Set(),
1025
+ legacyCustomModelSlugs,
1026
+ multiAgentMode,
1027
+ multiAgentV2Enabled: isMultiAgentV2Enabled(),
1028
+ exactComboSlugs,
1029
+ hasPhysicalComboProvider,
1030
+ includeNativeOpenAi,
1031
+ accountBoundEntries,
1032
+ suppressedBareNativeSlugs,
1033
+ policy: {
1034
+ ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY,
1035
+ warningPolicy: "emit",
1036
+ },
1037
+ });
637
1038
  }
638
1039
 
639
1040
  interface RetainedCatalogSyncRead {
@@ -660,6 +1061,7 @@ interface RetainedCatalogSyncResult {
660
1061
  interface RetainedCatalogSyncWrite {
661
1062
  readonly config: OcxConfig;
662
1063
  readonly goModels: CatalogModel[];
1064
+ readonly providerModelOutcomes: readonly CatalogGatherProviderModelOutcome[];
663
1065
  readonly comboOmissions: ComboCatalogOmission[];
664
1066
  readonly read: RetainedCatalogSyncRead;
665
1067
  readonly permit: CatalogWritePermit;
@@ -679,7 +1081,10 @@ function loadCatalogForRetainedSync(path: string): RawCatalog | null {
679
1081
  const bundled = isDefaultCatalogPath(path) ? loadBundledCodexCatalog() : null;
680
1082
  if (bundled) return JSON.parse(JSON.stringify(bundled)) as RawCatalog;
681
1083
  const active = readCatalog(path);
682
- if (active && findNativeTemplate(active)) return active;
1084
+ // A valid configured custom file remains the content authority even when it has no bare native
1085
+ // template. The null-template builder is deliberate; a stale backup must not replace active
1086
+ // custom root metadata merely because the current file contains only routed rows.
1087
+ if (active && (!isDefaultCatalogPath(path) || findNativeTemplate(active))) return active;
683
1088
  return readCatalog(catalogBackupPathFor(path))
684
1089
  ?? (isDefaultCatalogPath(path) ? readCatalog(legacyCatalogBackupPath()) : null)
685
1090
  ?? readCatalog(activeCodexModelsCachePath())
@@ -780,16 +1185,37 @@ function pristineCatalogBytes(read: RetainedCatalogSyncRead): string | null {
780
1185
  : `${JSON.stringify(read.catalog, null, 2)}\n`;
781
1186
  }
782
1187
 
1188
+ function catalogModelsForMergeWithNativeRecovery(
1189
+ catalogPath: string,
1190
+ catalog: RawCatalog,
1191
+ onDiskCatalog: RawCatalog | null,
1192
+ ): RawEntry[] {
1193
+ const primaryCatalogModels = onDiskCatalog?.models ?? catalog.models ?? [];
1194
+ // Native-alias compatibility can omit disabled native rows from the effective catalog because
1195
+ // Desktop's remote allowlist ignores `visibility: "hide"`. Keep current/pristine native recovery
1196
+ // sources beside the on-disk rows so re-enabling a model restores its real metadata. Routed and
1197
+ // user-authored rows still come only from the on-disk catalog.
1198
+ return mergeCatalogModelsWithNativeRecovery(primaryCatalogModels, [
1199
+ catalog.models ?? [],
1200
+ readCatalogBackup(catalogPath)?.models ?? [],
1201
+ ]);
1202
+ }
1203
+
783
1204
  function writeRetainedCatalogSync({
784
1205
  config,
785
1206
  goModels,
1207
+ providerModelOutcomes,
786
1208
  comboOmissions,
787
1209
  read,
788
1210
  permit,
789
1211
  owningCodexHome,
790
1212
  }: RetainedCatalogSyncWrite): RetainedCatalogSyncResult {
791
1213
  const { catalogPath, catalog, onDiskCatalog } = read;
792
- const catalogModelsForMerge = onDiskCatalog?.models ?? catalog.models ?? [];
1214
+ const catalogModelsForMerge = catalogModelsForMergeWithNativeRecovery(
1215
+ catalogPath,
1216
+ catalog,
1217
+ onDiskCatalog,
1218
+ );
793
1219
  const template = findNativeTemplate(catalog);
794
1220
 
795
1221
  try {
@@ -817,6 +1243,7 @@ function writeRetainedCatalogSync({
817
1243
  const orderedGoModels = orderForSubagents(enabledGo, featured); // stable tie-break among equal priorities
818
1244
  const multiAgentMode: MultiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default";
819
1245
  const exactComboSlugs = exactComboCatalogSlugs(config);
1246
+ const suppressedBareNativeSlugs = desktopAllowlistSuppressedNativeSlugs(config);
820
1247
  const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE);
821
1248
  const includeNativeOpenAi = shouldIncludeNativeOpenAi(config);
822
1249
  const includeAccountBoundNativeOpenAi = shouldIncludeAccountBoundNativeOpenAi(config);
@@ -824,26 +1251,44 @@ function writeRetainedCatalogSync({
824
1251
  ? visibleCodexAccountSelectors(config)
825
1252
  : [];
826
1253
  const wsEnabled = websocketsEnabled(config);
827
- const goEntries = buildCatalogEntries(
828
- template ? JSON.parse(JSON.stringify(template)) : null,
829
- [],
830
- orderedGoModels,
1254
+ const multiAgentV2Enabled = isMultiAgentV2Enabled();
1255
+ const goEntries = buildCatalogEntriesFromObservedState({
1256
+ template: template ? JSON.parse(JSON.stringify(template)) : null,
1257
+ gptSlugs: [],
1258
+ goModels: orderedGoModels,
831
1259
  featured,
832
1260
  wsEnabled,
833
1261
  multiAgentMode,
834
1262
  exactComboSlugs,
835
1263
  accountSelectors,
836
- );
1264
+ suppressedBareNativeSlugs,
1265
+ disabledNativeAccountSlugs: new Set(),
1266
+ multiAgentV2Enabled,
1267
+ });
837
1268
  // Keep genuine native entries (gpt-*, codex-*) with their real per-model fields and append
838
1269
  // routed providers as namespaced slugs. Cursor and other adopted providers can expose model ids
839
1270
  // like `gpt-5.5`; those must not delete the native OpenAI/Codex base row.
1271
+ const baselineCatalog = readCatalogBackup(catalogPath);
840
1272
  const baseline = readNativeBaseline(catalogPath);
841
- const goIds = new Set(enabledGo.map(m => m.id));
842
1273
  const gatheredProviderNames = new Set(
843
1274
  Object.entries(config.providers ?? {})
844
1275
  .filter(([, prov]) => prov.disabled !== true)
845
1276
  .map(([name]) => name),
846
1277
  );
1278
+ const degradedProviderNames = new Set(
1279
+ providerModelOutcomes
1280
+ .filter(outcome => outcome.state === "degraded")
1281
+ .map(outcome => outcome.provider),
1282
+ );
1283
+ const selectedModelsByProvider = new Map<string, ReadonlySet<string>>(
1284
+ Object.entries(config.providers ?? {}).flatMap(([name, provider]) => (
1285
+ provider.disabled !== true
1286
+ && Array.isArray(provider.selectedModels)
1287
+ && provider.selectedModels.length > 0
1288
+ ? [[name, new Set(provider.selectedModels)] as const]
1289
+ : []
1290
+ )),
1291
+ );
847
1292
  // Central WS capability override on the FINAL on-disk catalog (the file Codex reads). Applies to
848
1293
  // native AND routed so the advertised flag matches the implemented endpoint (phase 120.4) and a
849
1294
  // native template can never leak supports_websockets while the flag is off.
@@ -851,33 +1296,45 @@ function writeRetainedCatalogSync({
851
1296
  // bare gpt-* rows that hard-404 via NoEnabledOpenAiProviderError. Keep natives when no
852
1297
  // providers are configured yet (fresh install / catalog bootstrap tests).
853
1298
  const accountBoundEntries = includeAccountBoundNativeOpenAi && accountSelectors.length > 0
854
- ? buildCatalogEntries(
855
- template ? JSON.parse(JSON.stringify(template)) : null,
856
- NATIVE_OPENAI_MODELS,
857
- [],
1299
+ ? buildCatalogEntriesFromObservedState({
1300
+ template: template ? JSON.parse(JSON.stringify(template)) : null,
1301
+ gptSlugs: NATIVE_OPENAI_MODELS,
1302
+ goModels: [],
858
1303
  featured,
859
1304
  wsEnabled,
860
1305
  multiAgentMode,
861
1306
  exactComboSlugs,
862
1307
  accountSelectors,
863
- ).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined)
1308
+ suppressedBareNativeSlugs,
1309
+ disabledNativeAccountSlugs: new Set([...disabledNativeSlugs(config)].filter(slug => suppressedBareNativeSlugs.has(slug))),
1310
+ multiAgentV2Enabled,
1311
+ }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined)
864
1312
  : [];
865
- catalog.models = mergeCatalogEntriesForSync(
866
- catalogModelsForMerge,
867
- goEntries,
1313
+ catalog.models = mergeCatalogEntriesFromObservedState({
1314
+ catalogModels: catalogModelsForMerge,
1315
+ baselineCatalogModels: baselineCatalog?.models ?? [],
1316
+ routedEntries: goEntries,
868
1317
  baseline,
869
1318
  featured,
870
1319
  wsEnabled,
871
- goIds,
872
1320
  template,
873
- new Set(config.disabledModels ?? []),
1321
+ disabledModels: new Set(config.disabledModels ?? []),
1322
+ selectedModelsByProvider,
874
1323
  gatheredProviderNames,
1324
+ degradedProviderNames,
1325
+ legacyCustomModelSlugs: legacyCustomModelCatalogSlugs(config),
875
1326
  multiAgentMode,
1327
+ multiAgentV2Enabled,
876
1328
  exactComboSlugs,
877
1329
  hasPhysicalComboProvider,
878
1330
  includeNativeOpenAi,
879
1331
  accountBoundEntries,
880
- );
1332
+ suppressedBareNativeSlugs,
1333
+ policy: {
1334
+ ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY,
1335
+ warningPolicy: "emit",
1336
+ },
1337
+ });
881
1338
  clampCatalogModelsToCodexSupport(catalog.models);
882
1339
 
883
1340
  replaceActiveCodexCatalog(permit, owningCodexHome, {
@@ -953,6 +1410,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<RetainedCata
953
1410
  }
954
1411
 
955
1412
  const comboOmissions: ComboCatalogOmission[] = [];
1413
+ const providerModelOutcomes: CatalogGatherProviderModelOutcome[] = [];
956
1414
  // Settle the bundled template, then baseline, and only then await. Reading it
957
1415
  // here makes the memo ours before anyone else can move it, so a bundled swap
958
1416
  // during the await is an outside change rather than our own side effect.
@@ -966,7 +1424,10 @@ export async function syncCatalogModels(config: OcxConfig): Promise<RetainedCata
966
1424
  evidence: retainedCatalogSyncEvidence(config, preflightRead.catalogPath, preflightRead.catalog),
967
1425
  processEvidence: retainedCatalogProcessEvidence(),
968
1426
  };
969
- const goModels = await gatherRoutedModels(config, { comboOmissions });
1427
+ const goModels = await gatherRoutedModels(config, {
1428
+ comboOmissions,
1429
+ providerModelOutcomes,
1430
+ });
970
1431
  const committed = withCatalogWriteSerialization(owningCodexHome, permit => {
971
1432
  // Desired state can flip OFF during the provider await above. The catalog
972
1433
  // evidence revalidation below cannot see that — intent lives in our config,
@@ -987,6 +1448,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<RetainedCata
987
1448
  return writeRetainedCatalogSync({
988
1449
  config,
989
1450
  goModels,
1451
+ providerModelOutcomes,
990
1452
  comboOmissions,
991
1453
  read: current,
992
1454
  permit,