From 74bed11e4f0d8e1286c36f5214bf16b9e0776251 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:53:42 +0800 Subject: [PATCH 1/3] fix(codex): preserve account-scoped native model ids --- .../content/docs/guides/codex-app-models.md | 4 +- .../docs/reference/configuration/routing.md | 4 +- src/codex/catalog.ts | 2 +- src/codex/catalog/bundled.ts | 16 ++++ src/codex/catalog/metadata.ts | 76 ++++++++++++++++++- src/codex/catalog/sync.ts | 58 ++++++++++++-- src/codex/convergence.ts | 16 ++++ src/server/index.ts | 31 ++++++-- src/server/management/model-routes.ts | 7 +- src/server/management/model-rows.ts | 17 ++++- structure/03_catalog-and-subagents.md | 6 ++ tests/claude-models-discovery.test.ts | 63 +++++++++++++++ tests/codex-catalog-sync-hardening.test.ts | 35 +++++++++ ...odex-convergence-account-selectors.test.ts | 20 +++++ tests/codex-models-cache-invalidate.test.ts | 18 +++++ tests/native-model-toggle.test.ts | 43 +++++++++++ 16 files changed, 397 insertions(+), 19 deletions(-) diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index 95116237ff..8d58851ad4 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -16,7 +16,9 @@ opencodex adds separate `/` rows for the mapped a the bare native rows from the Codex picker. Selector labels are user-chosen public names with no built-in account-role meaning. Selecting a qualified row uses only its mapped account, does not change the active Pool account, and fails closed instead of switching accounts when the target is -unavailable. See [Exact Codex account selectors](/reference/configuration/routing/#exact-codex-account-selectors). +unavailable. If Codex's account-scoped catalog contains a visible, API-supported OpenAI-family id +that is not yet in opencodex's static set, the exact id is preserved as a selector-qualified row +only; it is not added to the bare or API-key model list. See [Exact Codex account selectors](/reference/configuration/routing/#exact-codex-account-selectors). When the `codexAccountNamespaces` map is empty, account-qualified picker rows are off. If `codexAccountPickerEnabled` is omitted with a non-empty map, they are treated as enabled for diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index 8a2b039c26..dbf6b4ded8 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -39,7 +39,9 @@ more than one provider, so use explicit namespaces when a bare model could be am `codexAccountNamespaces` maps a public selector such as `side` to one stored Codex account. A request for `side/gpt-5.6-sol` uses only that account, even when the canonical `openai` provider is in Direct mode, and sends the bare `gpt-5.6-sol` model id upstream. Only bare native OpenAI-family -ids are valid after the selector. +ids are valid after the selector. Account-scoped ids observed in Codex's current model catalog may +also be preserved exactly when they are not yet part of opencodex's static set; such ids remain +selector-qualified and are not promoted into the global bare model list. Exact selection bypasses Pool assignment strategy and ordinary thread affinity. If the mapped account is missing, paused, cooling down, unusable, or requires reauthentication, the request fails diff --git a/src/codex/catalog.ts b/src/codex/catalog.ts index 7b26cb9ebd..e5a2180c5f 100644 --- a/src/codex/catalog.ts +++ b/src/codex/catalog.ts @@ -2,7 +2,7 @@ // Public surface preserved exactly; importers keep using "src/codex/catalog". export { isMediaGenerationModelId, shouldExposeRoutedModel, readCodexCatalogPath, readCatalog, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing"; export type { CatalogModel, MultiAgentMode } from "./catalog/parsing"; -export { CODEX_NATIVE_ALIAS_CATALOG_KIND, NATIVE_OPENAI_MODELS, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, isNativeAliasCatalogEntry, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeInputModalities, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi } from "./catalog/metadata"; +export { accountBoundNativeOpenAiSlugs, CODEX_NATIVE_ALIAS_CATALOG_KIND, NATIVE_OPENAI_MODELS, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, isNativeAliasCatalogEntry, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, observedAccountBoundNativeEntries, observedAccountBoundNativeOpenAiSlugs, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeInputModalities, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi } from "./catalog/metadata"; export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled"; export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort"; export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithMetadata, resolveComboCatalogMember, configuredComboTargetModelsByProvider } from "./catalog/provider-fetch"; diff --git a/src/codex/catalog/bundled.ts b/src/codex/catalog/bundled.ts index 11407af411..267da154ac 100644 --- a/src/codex/catalog/bundled.ts +++ b/src/codex/catalog/bundled.ts @@ -522,6 +522,22 @@ export function readCurrentCatalogOrCache(): RawCatalog | null { return readCatalog(path) ?? readCatalog(activeCodexModelsCachePath()); } +/** + * Read the user-owned Codex catalog surfaces without substituting the bundled catalog. + * + * The bundled catalog is intentionally the authority for static native metadata on the default + * path. Account-qualified discovery needs the opposite view: an exact model id that Codex has + * observed in the user's catalog/cache may be account-scoped even when this release does not know + * it statically yet. + */ +export function readCurrentCodexCatalog(): RawCatalog | null { + return readCatalog(readCodexCatalogPath()); +} + +export function readCurrentCodexModelsCache(): RawCatalog | null { + return readCatalog(activeCodexModelsCachePath()); +} + export function loadCatalogTemplate(): RawEntry | null { const catalogPath = readCodexCatalogPath(); const bundled = loadBundledCodexCatalog(); diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index 0c3fd0913f..d61a7b7e4e 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -33,7 +33,7 @@ import upstreamModelsSnapshot from "../data/upstream-models.json"; import type { RawEntry } from "./parsing"; -import { readCurrentCatalogOrCache, unique } from "./bundled"; +import { readCurrentCatalogOrCache, readCurrentCodexCatalog, readCurrentCodexModelsCache, unique } from "./bundled"; import { trustedAccountBoundNativeCatalogSlug } from "./account-models"; import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds"; import { NATIVE_OPENAI_MODELS, SUPPORTED_NATIVE_OPENAI_SLUGS } from "./native-models"; @@ -214,6 +214,7 @@ export function applyNativeVisibility( entries: RawEntry[], disabledModels: ReadonlySet, hideBareNative = false, + observedNativeSlugs: ReadonlySet = new Set(), ): RawEntry[] { for (const entry of entries) { if (isNativeAliasCatalogEntry(entry)) continue; @@ -222,7 +223,7 @@ export function applyNativeVisibility( const nativeSlug = accountBoundSlug ?? slug; if (!nativeSlug || (!accountBoundSlug && slug.includes("/")) - || !SUPPORTED_NATIVE_OPENAI_SLUGS.has(nativeSlug)) continue; + || (!SUPPORTED_NATIVE_OPENAI_SLUGS.has(nativeSlug) && !observedNativeSlugs.has(nativeSlug))) continue; const disabled = disabledModels.has(nativeSlug) || (accountBoundSlug !== undefined && disabledModels.has(slug)); entry.visibility = disabled || (!accountBoundSlug && hideBareNative) @@ -259,6 +260,77 @@ export function nativeOpenAiSlugs(): string[] { return live.length > 0 ? unique([...live, ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS]) : NATIVE_OPENAI_MODELS; } +const ACCOUNT_BOUND_OPENAI_NATIVE_PREFIX = /^(?:gpt-|o1-|o3-|o4-)/; +const ACCOUNT_BOUND_OBSERVED_NATIVE_MARKER = "opencodex_account_observed_native"; + +function isAccountBoundOpenAiNativeSlug(slug: string): boolean { + return !slug.includes("/") && ACCOUNT_BOUND_OPENAI_NATIVE_PREFIX.test(slug); +} + +function observedAccountBoundNativeSlug(entry: RawEntry): string | undefined { + const accountBound = trustedAccountBoundNativeCatalogSlug(entry); + const slug = accountBound ?? (typeof entry.slug === "string" ? entry.slug : ""); + if (!isAccountBoundOpenAiNativeSlug(slug) + || entry.supported_in_api !== true + || (entry.visibility !== "list" && entry[ACCOUNT_BOUND_OBSERVED_NATIVE_MARKER] !== true)) { + return undefined; + } + return slug; +} + +/** + * Return exact, previously observed account-native rows that are not in the static release set. + * The result is used only to carry a hidden observation across startup cache invalidation. + */ +export function observedAccountBoundNativeEntries( + observedEntries: readonly RawEntry[], +): RawEntry[] { + const seen = new Set(); + return observedEntries.flatMap(entry => { + const slug = observedAccountBoundNativeSlug(entry); + // Only carry bare upstream observations across cache replacement. Account-qualified rows are + // already a projection of the current selector map and must not preserve private/stale labels. + if (!slug + || typeof entry.slug !== "string" + || entry.slug.includes("/") + || SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug) + || seen.has(slug)) return []; + seen.add(slug); + return [structuredClone(entry)]; + }); +} + +/** + * Native ids observed in the user's Codex catalog/cache for account-qualified discovery. + * + * Unknown ids are deliberately returned only to callers that build selector-qualified rows. The + * static bare set remains the source of truth for global/API-key discovery, while this preserves + * exact account-scoped ids such as `gpt-daybreak-blue-latest` until the static set catches up. + */ +export function accountBoundNativeOpenAiSlugs( + observedEntries: readonly RawEntry[] = [ + ...(readCurrentCodexModelsCache()?.models ?? []), + // Existing generated rows are also safe to reuse after a process starts without a cache + // invalidation pass; bare user-authored catalog rows are intentionally not trusted here. + ...(readCurrentCodexCatalog()?.models ?? []).filter(entry => + trustedAccountBoundNativeCatalogSlug(entry) !== undefined), + ], +): string[] { + const observed = observedEntries.flatMap(entry => { + const slug = observedAccountBoundNativeSlug(entry); + return slug === undefined ? [] : [slug]; + }); + return unique([...NATIVE_OPENAI_MODELS, ...observed]); +} + +/** Unknown native ids observed from Codex, excluding the static release set. */ +export function observedAccountBoundNativeOpenAiSlugs( + observedEntries?: readonly RawEntry[], +): string[] { + const all = accountBoundNativeOpenAiSlugs(observedEntries); + return all.filter(slug => !SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug)); +} + function catalogNativeSlugs(): string[] { const cat = readCurrentCatalogOrCache(); const models = cat?.models ?? []; diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 9f7a169217..bbdca6d476 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -34,7 +34,7 @@ import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing"; import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing"; -import { applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata"; +import { accountBoundNativeOpenAiSlugs, applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, observedAccountBoundNativeEntries, observedAccountBoundNativeOpenAiSlugs, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata"; import { bundledCatalogCacheState, loadBundledCodexCatalog, @@ -346,6 +346,8 @@ export interface ObservedCatalogEntryBuildInput { readonly disabledNativeAccountSlugs: ReadonlySet; readonly multiAgentV2Enabled: boolean; readonly openaiContextCap?: number; + /** Additional native ids to clone under account selectors, without creating bare rows. */ + readonly accountNativeSlugs?: readonly string[]; } /** Build entries with the process-observed Codex feature state. */ @@ -361,6 +363,7 @@ export function buildCatalogEntries( suppressedBareNativeSlugs: ReadonlySet = new Set(), disabledNativeAccountSlugs: ReadonlySet = new Set(), contextCap?: number, + accountNativeSlugs?: readonly string[], ): RawEntry[] { return buildCatalogEntriesFromObservedState({ template, @@ -375,6 +378,7 @@ export function buildCatalogEntries( disabledNativeAccountSlugs, multiAgentV2Enabled: isMultiAgentV2Enabled(), openaiContextCap: contextCap, + accountNativeSlugs, }); } @@ -392,6 +396,7 @@ export function buildCatalogEntriesFromObservedState({ disabledNativeAccountSlugs, multiAgentV2Enabled, openaiContextCap, + accountNativeSlugs, }: ObservedCatalogEntryBuildInput): RawEntry[] { // Codex's models-manager sorts by `priority` ASC and advertises the first 5 picker-visible // models to spawn_agent (sort_by_key(priority) + MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT=5). Catalog @@ -450,8 +455,13 @@ export function buildCatalogEntriesFromObservedState({ emittedNativeAliases.add(nativeAlias); emittedNativeAliasSlugs.add(slug); } + const nativeEntriesBySlug = new Map(nativeEntries.map(entry => [String(entry.slug), entry] as const)); + const accountNativeEntries = (accountNativeSlugs ?? gptSlugs).map(slug => ( + nativeEntriesBySlug.get(slug) + ?? deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9, undefined, new Set(), openaiContextCap) + )); for (const [selectorIndex, selector] of accountSelectors.entries()) { - for (const [nativeIndex, native] of nativeEntries.entries()) { + for (const [nativeIndex, native] of accountNativeEntries.entries()) { const nativeSlug = String(native.slug); if (disabledNativeAccountSlugs.has(nativeSlug)) continue; const e = JSON.parse(JSON.stringify(native)) as RawEntry; @@ -785,6 +795,7 @@ export function mergeCatalogEntriesFromObservedState({ && !(m.slug as string).includes("/") && m.owned_by !== COMBO_NAMESPACE && (policy.unsupportedNativeEntries === "preserve" + || policy.nativeBackfillSlugs.includes(m.slug as string) || !isUnsupportedOpenAiNativeSlug(m.slug as string))) .map(m => { const slug = m.slug as string; @@ -936,6 +947,11 @@ export function mergeCatalogEntriesFromObservedState({ } const managedEntries = [...finalRoutedEntries, ...alignedAccountBoundEntries]; + const observedNativeSlugs = new Set(alignedAccountBoundEntries.flatMap(entry => { + const slug = trustedAccountBoundNativeCatalogSlug(entry); + return slug === undefined ? [] : [slug]; + })); + for (const slug of policy.nativeBackfillSlugs) observedNativeSlugs.add(slug); const mergedEntries = [...native, ...managedEntries].map(m => { const normalized = normalizeServiceTiers(m); if (!isNativeAliasCatalogEntry(normalized)) applyNativeOpenAiContextOverride(normalized, openaiContextCap); @@ -969,7 +985,7 @@ export function mergeCatalogEntriesFromObservedState({ // clobber a hide flag back to list. Bare ids disable every account clone; qualified ids disable // only their generated account row. const versionedEntries = applyMultiAgentMode( - applyNativeVisibility(mergedEntries, disabledModels, alignedAccountBoundEntries.length > 0), + applyNativeVisibility(mergedEntries, disabledModels, alignedAccountBoundEntries.length > 0, observedNativeSlugs), multiAgentMode, multiAgentV2Enabled, ); @@ -1055,6 +1071,7 @@ interface RetainedCatalogSyncRead { readonly catalogPath: string; readonly catalog: RawCatalog; readonly onDiskCatalog: RawCatalog | null; + readonly modelsCache: RawCatalog | null; readonly evidence: string; /** * Process-local epochs, baselined AFTER our own gather rather than with the @@ -1163,9 +1180,10 @@ function readRetainedCatalogSync(config: OcxConfig): RetainedCatalogSyncRead | n // merge source. Preservation must inspect the file that this sync is about to overwrite; // otherwise an empty/partial provider gather cannot see routed or user-native rows on disk. const onDiskCatalog = readCatalog(catalogPath); + const modelsCache = readCatalog(activeCodexModelsCachePath()); const evidence = retainedCatalogSyncEvidence(config, catalogPath, catalog); // `processEvidence` is filled in after the provider await, not here. - return { catalogPath, catalog, onDiskCatalog, evidence, processEvidence: "" }; + return { catalogPath, catalog, onDiskCatalog, modelsCache, evidence, processEvidence: "" }; } function revalidateRetainedCatalogSync( @@ -1181,6 +1199,7 @@ function revalidateRetainedCatalogSync( catalogPath, catalog: JSON.parse(JSON.stringify(prepared.catalog)) as RawCatalog, onDiskCatalog: readCatalog(catalogPath), + modelsCache: readCatalog(activeCodexModelsCachePath()), evidence, processEvidence: prepared.processEvidence, }; @@ -1283,6 +1302,17 @@ function writeRetainedCatalogSync({ const accountSelectors = includeAccountBoundNativeOpenAi ? visibleCodexAccountSelectors(config) : []; + const observedAccountNativeEntries = [ + ...(read.modelsCache?.models ?? []), + ...(onDiskCatalog?.models ?? []).filter(entry => + trustedAccountBoundNativeCatalogSlug(entry) !== undefined), + ]; + const accountNativeSlugs = accountSelectors.length > 0 + ? accountBoundNativeOpenAiSlugs(observedAccountNativeEntries) + : []; + const observedNativeSlugs = accountSelectors.length === 0 + ? observedAccountBoundNativeOpenAiSlugs(observedAccountNativeEntries) + : []; const wsEnabled = websocketsEnabled(config); const multiAgentV2Enabled = isMultiAgentV2Enabled(); const goEntries = buildCatalogEntriesFromObservedState({ @@ -1343,6 +1373,7 @@ function writeRetainedCatalogSync({ disabledNativeAccountSlugs: new Set([...disabledNativeSlugs(config)].filter(slug => suppressedBareNativeSlugs.has(slug))), multiAgentV2Enabled, openaiContextCap, + accountNativeSlugs, }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined) : []; catalog.models = mergeCatalogEntriesFromObservedState({ @@ -1368,6 +1399,7 @@ function writeRetainedCatalogSync({ openaiContextCap, policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, + nativeBackfillSlugs: [...NATIVE_OPENAI_MODELS, ...observedNativeSlugs], warningPolicy: "emit", }, }); @@ -1591,10 +1623,26 @@ export function invalidateCodexModelsCacheWithPermit( if (!existsSync(catalogPath)) return false; const catalog = JSON.parse(readFileSync(catalogPath, "utf8")); const models = catalog.models ?? catalog; + const currentCache = readCatalog(activeCodexModelsCachePath()); + const existingSlugs = new Set(models.flatMap((entry: RawEntry) => + typeof entry.slug === "string" ? [entry.slug] : [])); + const observedAccountModels = observedAccountBoundNativeEntries(currentCache?.models ?? []) + .filter(entry => { + const slug = typeof entry.slug === "string" ? entry.slug : ""; + return !existingSlugs.has(slug); + }) + .map(entry => ({ + ...entry, + // Keep the observation in Codex's cache without advertising a new bare picker row. The + // next OpenCodex catalog sync consumes this marker and creates only selector-qualified + // rows for the currently configured public account selectors. + visibility: "hide", + opencodex_account_observed_native: true, + })); const wrapper = { fetched_at: "2000-01-01T00:00:00Z", client_version: "0.0.0", - models, + models: [...models, ...observedAccountModels], }; replaceCodexModelsCache(permit, owningCodexHome, { path: activeCodexModelsCachePath(), diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index 12edf3285d..de85e10a15 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -49,6 +49,8 @@ import { import { exactComboCatalogSlugs } from "./catalog/aggregation"; import { isNativeAliasCatalogEntry, + accountBoundNativeOpenAiSlugs, + observedAccountBoundNativeOpenAiSlugs, disabledNativeSlugs, desktopAllowlistSuppressedNativeSlugs, NATIVE_OPENAI_MODELS, @@ -216,6 +218,7 @@ function prepareCatalog( baselineCatalogModels: readonly Readonly>[], degradedProviderNames: ReadonlySet, nativeRecoverySources: readonly (readonly RawEntry[])[] = [], + observedAccountNativeEntries: readonly RawEntry[] = [], ): RawCatalog { const catalog = JSON.parse(JSON.stringify(source.catalog)) as RawCatalog; const template = findNativeTemplate(catalog); @@ -232,6 +235,12 @@ function prepareCatalog( const accountSelectors = shouldIncludeAccountBoundNativeOpenAi(config) ? visibleCodexAccountSelectors(config) : []; + const accountNativeSlugs = accountSelectors.length > 0 + ? accountBoundNativeOpenAiSlugs(observedAccountNativeEntries) + : []; + const observedNativeSlugs = accountSelectors.length === 0 + ? observedAccountBoundNativeOpenAiSlugs(observedAccountNativeEntries) + : []; const disabledNative = disabledNativeSlugs(config); const nativeCatalogModels = mergeCatalogModelsWithNativeRecovery( active?.models ?? catalog.models ?? [], @@ -265,6 +274,7 @@ function prepareCatalog( suppressedBareNativeSlugs, disabledNativeAccountSlugs: new Set([...disabledNative].filter(slug => suppressedBareNativeSlugs.has(slug))), multiAgentV2Enabled, + accountNativeSlugs, }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined); const gatheredProviderNames = new Set(enabledProviders.map(([name]) => name)); const selectedModelsByProvider = new Map>( @@ -296,6 +306,7 @@ function prepareCatalog( suppressedBareNativeSlugs, policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, + nativeBackfillSlugs: [...NATIVE_OPENAI_MODELS, ...observedNativeSlugs], warningPolicy: "suppress", }, }); @@ -384,6 +395,11 @@ export async function gatherCodexCatalogCandidate( catalogFrom(keyedBackupBytes)?.models ?? [], catalogFrom(legacyBackupBytes)?.models ?? [], ], + [ + ...(catalogFrom(cacheBytes)?.models ?? []), + ...(catalogFrom(activeBytes)?.models ?? []).filter(entry => + trustedAccountBoundNativeCatalogSlug(entry) !== undefined), + ], ); const preparedCatalogBytes = catalogBytes(preparedCatalog); const preparedCacheBytes = `${JSON.stringify({ diff --git a/src/server/index.ts b/src/server/index.ts index 8a5ffd0103..67731b2c06 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -887,10 +887,18 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server 0 - ? NATIVE_OPENAI_MODELS + ? [...new Set([...NATIVE_OPENAI_MODELS, ...accountNativeSlugs])] : nativeSlugs; const entries = buildCatalogEntries( loadCatalogTemplate(), @@ -959,12 +970,14 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server 0, + new Set(accountNativeSlugs), ), }, 200, req, policy); } @@ -1008,12 +1021,18 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server 0 - ? NATIVE_OPENAI_MODELS.filter(slug => !disabledNatives.has(slug)) + ? accountNativeSlugs.filter(slug => !disabledNatives.has(slug)) + : []; + const bareSelectorNativeSlugs = accountSelectors.length > 0 + ? selectorNativeSlugs + : []; + const observedVisibleNatives = accountSelectors.length === 0 + ? observedAccountNativeSlugs.filter(slug => !disabledNatives.has(slug) && !shadowedNativeSlugs.has(slug)) : []; const visibleNatives = includeNativeOpenAi ? accountSelectors.length > 0 - ? selectorNativeSlugs.filter(slug => !shadowedNativeSlugs.has(slug)) - : visibleNativeSlugs(config) + ? bareSelectorNativeSlugs.filter(slug => !shadowedNativeSlugs.has(slug)) + : [...new Set([...visibleNativeSlugs(config), ...observedVisibleNatives])] : []; const visibleAccountNatives = accountSelectors.flatMap(selector => selectorNativeSlugs.flatMap(metadataId => { diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index 08703816de..701c275b31 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -30,7 +30,7 @@ function readInputModalities(raw: unknown): { values?: string[]; error?: string return { values: raw as string[] }; } import type { CatalogModel } from "../../codex/catalog"; -import { catalogModelSlug, configuredNativeAliasSlugs, disabledNativeSlugs, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; +import { accountBoundNativeOpenAiSlugs, catalogModelSlug, configuredNativeAliasSlugs, disabledNativeSlugs, invalidateCodexModelsCache, nativeModelRows, shouldIncludeAccountBoundNativeOpenAi, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; import { CatalogGatherBusyError } from "../../codex/catalog/provider-fetch"; import { getProviderLiveModelCount } from "../../codex/model-cache"; import { @@ -234,7 +234,10 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise row.slug)); + const supportedNative = new Set([ + ...nativeModelRows(config).map(row => row.slug), + ...(shouldIncludeAccountBoundNativeOpenAi(config) ? accountBoundNativeOpenAiSlugs() : []), + ]); const targets: Array<{ id: string; native: boolean }> = []; const seen = new Set(); for (const value of body.targets) { diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index fc7f0f162f..5b54ea6c18 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -11,11 +11,14 @@ import type { CatalogModel } from "../../codex/catalog"; import { catalogModelSlug, + accountBoundNativeOpenAiSlugs, nativeDefaultReasoningEffort, + NATIVE_OPENAI_MODELS, nativeInputModalities, nativeModelRows, nativeReasoningEfforts, uniqueCatalogModelsForPublicList, + shouldIncludeAccountBoundNativeOpenAi, } from "../../codex/catalog"; import type { ExportModel } from "../../clients/config-export"; import { providerContextCap } from "../../providers/context-cap"; @@ -49,7 +52,19 @@ export async function listManagementModelRows(config: OcxConfig): Promise { + const nativeRows = [ + ...nativeModelRows(config), + ...(shouldIncludeAccountBoundNativeOpenAi(config) + ? accountBoundNativeOpenAiSlugs() + .filter(slug => !NATIVE_OPENAI_MODELS.includes(slug)) + .map(slug => ({ + slug, + disabled: disabled.has(slug), + contextWindow: undefined, + })) + : []), + ]; + const native: ManagementModelRow[] = nativeRows.map(row => { const reasoningEfforts = nativeReasoningEfforts(row.slug).filter(isVisionReasoningEffort); const defaultReasoningEffort = nativeDefaultReasoningEffort(row.slug); return { diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index d35f292728..88281f65a6 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -32,6 +32,12 @@ custom catalog remains the native metadata/template authority even when a bundle warm. Both paths may use an admitted matching bundled memo only as installed-runtime capability evidence to remove unsupported reasoning efforts; convergence never probes Codex itself. +When account selectors are enabled, the sync path may also observe exact, visible, API-supported +OpenAI-family ids from Codex's user-owned catalog/cache. Unknown ids are carried through startup +cache invalidation as hidden observations and are emitted only as current-selector-qualified rows; +they never expand the bare native or API-key model list. This keeps account-scoped upstream ids +such as `gpt-daybreak-blue-latest` callable without treating them as a static release allowlist. + The app-server's model list comes from this shared catalog, not from patching the App. Codex Desktop may still apply its remote native-only allowlist after `model/list`; an explicitly configured combo `nativeAlias` is the bounded compatibility path. It replaces one supported bare native row with a diff --git a/tests/claude-models-discovery.test.ts b/tests/claude-models-discovery.test.ts index 07acc676df..e846bc4ee0 100644 --- a/tests/claude-models-discovery.test.ts +++ b/tests/claude-models-discovery.test.ts @@ -3,9 +3,11 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../src/config"; +import { handleManagementAPI } from "../src/server/management-api"; import { startServer } from "../src/server"; import type { OcxConfig } from "../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; +import { ManagementRequest } from "./helpers/management-auth"; // Full-suite Windows load: startServer + discovery GETs exceed the default 5s budget // (same flake class as 810fa115 / claude-management-api). @@ -334,6 +336,67 @@ test("Codex discovery restores account rows for supported natives hidden on disk } }); +test("Codex discovery preserves an observed account-only native id exactly", async () => { + const config = configWithStaticModels(); + config.providers.openai = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + liveModels: false, + }; + config.codexAccountNamespaces = { team: "@main" }; + saveConfig(config); + + const catalogPath = join(isolatedCodexHome!.path, "observed-native-catalog.json"); + writeFileSync( + join(isolatedCodexHome!.path, "config.toml"), + 'model_catalog_json = "observed-native-catalog.json"\n', + "utf8", + ); + writeFileSync(catalogPath, JSON.stringify({ + models: [ + { slug: "gpt-5.5", visibility: "list", supported_in_api: true }, + ], + }), "utf8"); + writeFileSync(join(isolatedCodexHome!.path, "models_cache.json"), JSON.stringify({ + models: [{ + slug: "gpt-daybreak-blue-latest", + visibility: "hide", + supported_in_api: true, + opencodex_account_observed_native: true, + }], + }), "utf8"); + + const { resetCatalogRuntimeStateForTests } = await import("../src/codex/catalog"); + resetCatalogRuntimeStateForTests(); + const server = startServer(0); + try { + const plain = await fetch(new URL("/v1/models", server.url)) + .then(response => response.json()) as { data: Array<{ id: string }> }; + expect(plain.data).toContainEqual(expect.objectContaining({ id: "team/gpt-daybreak-blue-latest" })); + expect(plain.data.some(model => model.id === "gpt-daybreak-blue-latest")).toBe(true); + + const managementUrl = new URL("http://localhost/api/models"); + const managementResponse = await handleManagementAPI( + new ManagementRequest(managementUrl), + managementUrl, + config, + ); + const management = await managementResponse!.json() as Array<{ id: string; native?: boolean }>; + expect(management).toContainEqual(expect.objectContaining({ + id: "gpt-daybreak-blue-latest", + native: true, + })); + + const catalog = await fetch(new URL("/v1/models?client_version=1.0.0", server.url)) + .then(response => response.json()) as { models: Array<{ slug: string; visibility?: string }> }; + expect(catalog.models.find(model => model.slug === "team/gpt-daybreak-blue-latest")) + .toMatchObject({ visibility: "list" }); + expect(catalog.models.find(model => model.slug === "gpt-daybreak-blue-latest")?.visibility).toBe("hide"); + } finally { + await server.stop(true); + } +}); + test("account selectors stay out of discovery when no canonical OpenAI provider is enabled", async () => { const config = configWithStaticModels(); config.codexAccountNamespaces = { desktop: "@main" }; diff --git a/tests/codex-catalog-sync-hardening.test.ts b/tests/codex-catalog-sync-hardening.test.ts index a3363632f0..07b83776e3 100644 --- a/tests/codex-catalog-sync-hardening.test.ts +++ b/tests/codex-catalog-sync-hardening.test.ts @@ -371,6 +371,41 @@ describe("Codex catalog sync hardening", () => { expect(JSON.stringify(rows)).not.toContain("Private Display Name"); }); + test("account sync preserves an observed account-only native id without creating a bare row", () => { + const catalogPath = join(codexHome, "catalog.json"); + writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); + writeFileSync(catalogPath, JSON.stringify({ + models: [nativeEntry("gpt-5.5", 0)], + }, null, 2) + "\n"); + writeFileSync(join(codexHome, "models_cache.json"), JSON.stringify({ + models: [{ + ...nativeEntry("gpt-daybreak-blue-latest", 1), + supported_in_api: true, + visibility: "hide", + opencodex_account_observed_native: true, + }], + }, null, 2) + "\n"); + + const r = runScript(codexHome, opencodexHome, ` + const { syncCatalogModels } = require("./src/codex/catalog"); + syncCatalogModels({ + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + liveModels: false + } + }, + codexAccountNamespaces: { team: "@main" } + }).then(res => console.log(JSON.stringify(res))); + `); + expect(r.status).toBe(0); + + const rows = JSON.parse(readFileSync(catalogPath, "utf8")).models as Array<{ slug: string }>; + expect(rows.some(row => row.slug === "team/gpt-daybreak-blue-latest")).toBe(true); + expect(rows.some(row => row.slug === "gpt-daybreak-blue-latest")).toBe(false); + }); + test("a live provider row shadowed by an account selector warns once per runtime generation", () => { const catalogPath = join(codexHome, "catalog.json"); writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); diff --git a/tests/codex-convergence-account-selectors.test.ts b/tests/codex-convergence-account-selectors.test.ts index 099f31c7cb..77b5840b05 100644 --- a/tests/codex-convergence-account-selectors.test.ts +++ b/tests/codex-convergence-account-selectors.test.ts @@ -352,6 +352,26 @@ test("convergence drops unsupported bare native rows and never qualifies them", ))).toBe(true); }); +test("convergence preserves an observed account-only native id without creating a bare row", async () => { + writeCatalog([nativeEntry()]); + writeFileSync(join(codexHome, "models_cache.json"), JSON.stringify({ + models: [{ + slug: "gpt-daybreak-blue-latest", + visibility: "hide", + supported_in_api: true, + opencodex_account_observed_native: true, + }], + }, null, 2) + "\n"); + + const catalog = await convergeCatalog(config(true)); + const models = catalog.models ?? []; + expect(models.find(entry => entry.slug === "team/gpt-daybreak-blue-latest")).toMatchObject({ + visibility: "list", + opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, + }); + expect(models.some(entry => entry.slug === "gpt-daybreak-blue-latest")).toBe(false); +}); + test("convergence preserves unrelated foreign rows alongside fresh configured provider rows", async () => { writeCatalog([ nativeEntry(), diff --git a/tests/codex-models-cache-invalidate.test.ts b/tests/codex-models-cache-invalidate.test.ts index 0bff240fc8..b3131c23ea 100644 --- a/tests/codex-models-cache-invalidate.test.ts +++ b/tests/codex-models-cache-invalidate.test.ts @@ -54,6 +54,24 @@ describe("invalidateCodexModelsCache write gate (#476 / #518)", () => { expect(cache.models).toEqual([{ slug: "gpt-5.5" }]); }); + test("preserves an observed unknown native as a hidden sync observation", () => { + writeFileSync(join(codexHome, "opencodex-catalog.json"), JSON.stringify({ + models: [{ slug: "gpt-5.5" }], + }, null, 2) + "\n"); + writeFileSync(join(codexHome, "models_cache.json"), JSON.stringify({ + models: [{ slug: "gpt-daybreak-blue-latest", visibility: "list", supported_in_api: true }], + }, null, 2) + "\n"); + + expect(invalidateCodexModelsCache()).toBe(true); + const cache = JSON.parse(readFileSync(join(codexHome, "models_cache.json"), "utf8")) as { + models: Array>; + }; + expect(cache.models.find(model => model.slug === "gpt-daybreak-blue-latest")).toMatchObject({ + visibility: "hide", + opencodex_account_observed_native: true, + }); + }); + test("refuses the cache rewrite when desired state flipped OFF between commit and reacquisition", () => { // The commit-path desired-state check runs under the FIRST catalog permit; // refreshCodexModelCatalog then releases K before invalidateCodexModelsCache diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index 8cde1866c4..b7f49e75aa 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { + accountBoundNativeOpenAiSlugs, accountBoundNativeDisplayName, accountBoundNativeModelSlugs, applyNativeVisibility, @@ -10,6 +11,8 @@ import { mergeCatalogEntriesForSync, NATIVE_OPENAI_MODELS, nativeModelRows, + observedAccountBoundNativeEntries, + observedAccountBoundNativeOpenAiSlugs, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, trustedAccountBoundNativeCatalogSlug, @@ -137,6 +140,46 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(entries.every(entry => Number.isInteger(entry.priority))).toBe(true); }); + test("observed account-only native ids stay qualified and do not expand the bare set", () => { + const observedEntries = [ + { slug: "gpt-daybreak-blue-latest", visibility: "list", supported_in_api: true }, + { slug: "gpt-hidden-daybreak", visibility: "hide", supported_in_api: true }, + { slug: "gpt-not-an-api-model", visibility: "list", supported_in_api: false }, + { slug: "provider/gpt-daybreak-blue-latest", visibility: "list", supported_in_api: true }, + ]; + expect(accountBoundNativeOpenAiSlugs(observedEntries)).toContain("gpt-daybreak-blue-latest"); + expect(accountBoundNativeOpenAiSlugs(observedEntries)).not.toContain("gpt-hidden-daybreak"); + expect(accountBoundNativeOpenAiSlugs(observedEntries)).not.toContain("gpt-not-an-api-model"); + + const entries = buildCatalogEntries( + nativeTemplate(), + ["gpt-5.5"], + [], + [], + false, + "default", + new Set(), + ["team"], + new Set(), + new Set(), + undefined, + accountBoundNativeOpenAiSlugs(observedEntries), + ); + expect(entries.find(entry => entry.slug === "gpt-daybreak-blue-latest")).toBeUndefined(); + expect(entries.find(entry => entry.slug === "team/gpt-daybreak-blue-latest")).toMatchObject({ + opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, + visibility: "list", + }); + + expect(observedAccountBoundNativeEntries([{ + slug: "gpt-daybreak-blue-latest", + visibility: "hide", + supported_in_api: true, + opencodex_account_observed_native: true, + }])).toHaveLength(1); + expect(observedAccountBoundNativeOpenAiSlugs(observedEntries)).toEqual(["gpt-daybreak-blue-latest"]); + }); + test("exact account disables hide only the matching generated picker row", () => { const entries = buildCatalogEntries( nativeTemplate(), From 30f810eebc01e990af1e74f649cf1b0fba6758c9 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:42:37 +0800 Subject: [PATCH 2/3] fix(codex): isolate observed account-native model ids --- .../content/docs/guides/codex-app-models.md | 3 +- .../docs/reference/configuration/routing.md | 5 +- src/codex/catalog.ts | 2 +- src/codex/catalog/metadata.ts | 93 ++++++++++++++++++- src/codex/catalog/sync.ts | 35 +++++-- src/codex/convergence.ts | 12 ++- src/server/index.ts | 29 +++--- src/server/management/model-routes.ts | 11 ++- src/server/management/model-rows.ts | 26 +++--- structure/03_catalog-and-subagents.md | 9 +- tests/claude-models-discovery.test.ts | 17 +++- tests/codex-catalog-sync-hardening.test.ts | 3 + ...odex-convergence-account-selectors.test.ts | 11 ++- tests/codex-models-cache-invalidate.test.ts | 11 ++- tests/native-model-toggle.test.ts | 17 +++- 15 files changed, 220 insertions(+), 64 deletions(-) diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index 8d58851ad4..75122bdc8e 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -18,7 +18,8 @@ built-in account-role meaning. Selecting a qualified row uses only its mapped ac change the active Pool account, and fails closed instead of switching accounts when the target is unavailable. If Codex's account-scoped catalog contains a visible, API-supported OpenAI-family id that is not yet in opencodex's static set, the exact id is preserved as a selector-qualified row -only; it is not added to the bare or API-key model list. See [Exact Codex account selectors](/reference/configuration/routing/#exact-codex-account-selectors). +only when its observed account provenance matches an eligible selector; it is not copied to an +unrelated account and is not added to the bare or API-key model list. See [Exact Codex account selectors](/reference/configuration/routing/#exact-codex-account-selectors). When the `codexAccountNamespaces` map is empty, account-qualified picker rows are off. If `codexAccountPickerEnabled` is omitted with a non-empty map, they are treated as enabled for diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index dbf6b4ded8..8e3f46a7a3 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -40,8 +40,9 @@ more than one provider, so use explicit namespaces when a bare model could be am request for `side/gpt-5.6-sol` uses only that account, even when the canonical `openai` provider is in Direct mode, and sends the bare `gpt-5.6-sol` model id upstream. Only bare native OpenAI-family ids are valid after the selector. Account-scoped ids observed in Codex's current model catalog may -also be preserved exactly when they are not yet part of opencodex's static set; such ids remain -selector-qualified and are not promoted into the global bare model list. +also be preserved exactly when they are not yet part of opencodex's static set; the observation must +carry native catalog provenance, remains qualified to its matching account selector, and is not +promoted into the global bare model list. Exact selection bypasses Pool assignment strategy and ordinary thread affinity. If the mapped account is missing, paused, cooling down, unusable, or requires reauthentication, the request fails diff --git a/src/codex/catalog.ts b/src/codex/catalog.ts index e5a2180c5f..f597108b4f 100644 --- a/src/codex/catalog.ts +++ b/src/codex/catalog.ts @@ -2,7 +2,7 @@ // Public surface preserved exactly; importers keep using "src/codex/catalog". export { isMediaGenerationModelId, shouldExposeRoutedModel, readCodexCatalogPath, readCatalog, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing"; export type { CatalogModel, MultiAgentMode } from "./catalog/parsing"; -export { accountBoundNativeOpenAiSlugs, CODEX_NATIVE_ALIAS_CATALOG_KIND, NATIVE_OPENAI_MODELS, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, isNativeAliasCatalogEntry, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, observedAccountBoundNativeEntries, observedAccountBoundNativeOpenAiSlugs, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeInputModalities, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi } from "./catalog/metadata"; +export { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, CODEX_NATIVE_ALIAS_CATALOG_KIND, NATIVE_OPENAI_MODELS, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, isNativeAliasCatalogEntry, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, observedAccountBoundNativeEntries, observedAccountBoundNativeOpenAiSlugs, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeInputModalities, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi } from "./catalog/metadata"; export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled"; export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort"; export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithMetadata, resolveComboCatalogMember, configuredComboTargetModelsByProvider } from "./catalog/provider-fetch"; diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index d61a7b7e4e..31a9b40735 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -4,6 +4,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from import { delimiter, dirname, join, resolve } from "node:path"; import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "../../config"; import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "../paths"; +import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../account-namespaces"; import { clearModelCache, DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelsFetchCoolingDown, markModelsFetchFailure, setCached } from "../model-cache"; import { buildModelsRequest, resolveModelsAuthToken } from "../../oauth"; import type { OcxConfig, OcxProviderConfig } from "../../types"; @@ -34,7 +35,7 @@ import upstreamModelsSnapshot from "../data/upstream-models.json"; import type { RawEntry } from "./parsing"; import { readCurrentCatalogOrCache, readCurrentCodexCatalog, readCurrentCodexModelsCache, unique } from "./bundled"; -import { trustedAccountBoundNativeCatalogSlug } from "./account-models"; +import { trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./account-models"; import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds"; import { NATIVE_OPENAI_MODELS, SUPPORTED_NATIVE_OPENAI_SLUGS } from "./native-models"; export { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds"; @@ -194,10 +195,35 @@ export function shouldIncludeNativeOpenAi(config: Pick): return !hasEnabledProvider || shouldIncludeAccountBoundNativeOpenAi(config); } +type AccountSelectorConfig = Pick< + OcxConfig, + "codexAccounts" | "codexAccountNamespaces" | "codexAccountPickerEnabled" +>; + +function mainAccountSelectors(config: AccountSelectorConfig): string[] { + const targets = new Map(codexAccountNamespaceEntries(config)); + return visibleCodexAccountSelectors(config).filter(selector => + isMainCodexAccountTarget(targets.get(selector) ?? "")); +} + /** Native slugs exposed to Claude Desktop show/export/apply (opt-out via claudeCode.desktopNativeModels). */ -export function desktopVisibleNativeSlugs(config: Pick): string[] { +export function desktopVisibleNativeSlugs( + config: Pick, +): string[] { if (config.claudeCode?.desktopNativeModels === false) return []; - return visibleNativeSlugs(config); + const visible = visibleNativeSlugs(config); + if (!shouldIncludeAccountBoundNativeOpenAi(config)) return visible; + const qualified = [...accountBoundNativeOpenAiSlugsBySelector(config).entries()].flatMap(([selector, slugs]) => + slugs + .filter(slug => !SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug)) + .map(slug => `${selector}/${slug}`), + ); + const disabled = new Set(config.disabledModels ?? []); + return unique([ + ...visible, + ...qualified.filter(slug => !disabled.has(slug) && !disabled.has(slug.slice(slug.indexOf("/") + 1))), + ]); } export function nativeModelRows(config: Pick): Array<{ slug: string; disabled: boolean; contextWindow?: number }> { @@ -262,16 +288,34 @@ export function nativeOpenAiSlugs(): string[] { const ACCOUNT_BOUND_OPENAI_NATIVE_PREFIX = /^(?:gpt-|o1-|o3-|o4-)/; const ACCOUNT_BOUND_OBSERVED_NATIVE_MARKER = "opencodex_account_observed_native"; +const ACCOUNT_BOUND_OBSERVED_SELECTORS_MARKER = "opencodex_account_observed_selectors"; function isAccountBoundOpenAiNativeSlug(slug: string): boolean { return !slug.includes("/") && ACCOUNT_BOUND_OPENAI_NATIVE_PREFIX.test(slug); } +function hasNativeCatalogProvenance(entry: RawEntry): boolean { + const levels = entry.supported_reasoning_levels; + const messages = entry.model_messages; + return typeof entry.base_instructions === "string" + && entry.base_instructions.length > 0 + && (typeof entry.comp_hash === "string" || entry.comp_hash === null) + && entry.shell_type === "shell_command" + && Array.isArray(levels) + && levels.length > 0 + && levels.every(level => typeof level === "object" && level !== null + && typeof (level as { effort?: unknown }).effort === "string") + && typeof messages === "object" + && messages !== null + && !Array.isArray(messages); +} + function observedAccountBoundNativeSlug(entry: RawEntry): string | undefined { const accountBound = trustedAccountBoundNativeCatalogSlug(entry); const slug = accountBound ?? (typeof entry.slug === "string" ? entry.slug : ""); if (!isAccountBoundOpenAiNativeSlug(slug) || entry.supported_in_api !== true + || !hasNativeCatalogProvenance(entry) || (entry.visibility !== "list" && entry[ACCOUNT_BOUND_OBSERVED_NATIVE_MARKER] !== true)) { return undefined; } @@ -323,6 +367,49 @@ export function accountBoundNativeOpenAiSlugs( return unique([...NATIVE_OPENAI_MODELS, ...observed]); } +/** + * Resolve account-native ids per public selector. Bare observations come from Codex's main + * catalog/cache, so they are eligible only for selectors that target the main account. A + * generated qualified row carries its own selector and never gets copied to an unrelated pool + * account. An explicit observation marker is public selector metadata only; private account ids + * never enter the catalog or cache. + */ +export function accountBoundNativeOpenAiSlugsBySelector( + config: AccountSelectorConfig, + observedEntries: readonly RawEntry[] = [ + ...(readCurrentCodexModelsCache()?.models ?? []), + ...(readCurrentCodexCatalog()?.models ?? []).filter(entry => + trustedAccountBoundNativeCatalogSlug(entry) !== undefined), + ], +): ReadonlyMap { + const selectors = visibleCodexAccountSelectors(config); + const mainSelectors = new Set(mainAccountSelectors(config)); + const result = new Map>( + selectors.map(selector => [selector, new Set(NATIVE_OPENAI_MODELS)]), + ); + for (const entry of observedEntries) { + const slug = observedAccountBoundNativeSlug(entry); + if (slug === undefined || SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug)) continue; + const generated = trustedAccountBoundNativeCatalogSlug(entry); + const generatedSelector = generated === undefined || typeof entry.slug !== "string" + ? undefined + : entry.slug.slice(0, entry.slug.indexOf("/")); + const markedSelectors = Array.isArray(entry[ACCOUNT_BOUND_OBSERVED_SELECTORS_MARKER]) + ? entry[ACCOUNT_BOUND_OBSERVED_SELECTORS_MARKER].filter((value): value is string => typeof value === "string") + : []; + const eligible = generatedSelector !== undefined + ? (mainSelectors.has(generatedSelector) ? [generatedSelector] : []) + : markedSelectors.length > 0 + ? markedSelectors.filter(selector => mainSelectors.has(selector)) + : [...mainSelectors]; + for (const selector of eligible) { + const rows = result.get(selector); + if (rows) rows.add(slug); + } + } + return new Map([...result.entries()].map(([selector, slugs]) => [selector, [...slugs]])); +} + /** Unknown native ids observed from Codex, excluding the static release set. */ export function observedAccountBoundNativeOpenAiSlugs( observedEntries?: readonly RawEntry[], diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index bbdca6d476..98e4dc7083 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -30,11 +30,12 @@ import { providerDestinationResolvedError } from "../../lib/destination-policy"; import { redactSecretString } from "../../lib/redact"; import upstreamModelsSnapshot from "../data/upstream-models.json"; import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; +import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../account-namespaces"; import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing"; import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing"; -import { accountBoundNativeOpenAiSlugs, applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, observedAccountBoundNativeEntries, observedAccountBoundNativeOpenAiSlugs, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata"; +import { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, observedAccountBoundNativeEntries, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata"; import { bundledCatalogCacheState, loadBundledCodexCatalog, @@ -348,6 +349,8 @@ export interface ObservedCatalogEntryBuildInput { readonly openaiContextCap?: number; /** Additional native ids to clone under account selectors, without creating bare rows. */ readonly accountNativeSlugs?: readonly string[]; + /** Per-selector account ids; unknown observations must not be copied to unrelated accounts. */ + readonly accountNativeSlugsBySelector?: ReadonlyMap; } /** Build entries with the process-observed Codex feature state. */ @@ -364,6 +367,7 @@ export function buildCatalogEntries( disabledNativeAccountSlugs: ReadonlySet = new Set(), contextCap?: number, accountNativeSlugs?: readonly string[], + accountNativeSlugsBySelector?: ReadonlyMap, ): RawEntry[] { return buildCatalogEntriesFromObservedState({ template, @@ -379,6 +383,7 @@ export function buildCatalogEntries( multiAgentV2Enabled: isMultiAgentV2Enabled(), openaiContextCap: contextCap, accountNativeSlugs, + accountNativeSlugsBySelector, }); } @@ -397,6 +402,7 @@ export function buildCatalogEntriesFromObservedState({ multiAgentV2Enabled, openaiContextCap, accountNativeSlugs, + accountNativeSlugsBySelector, }: ObservedCatalogEntryBuildInput): RawEntry[] { // Codex's models-manager sorts by `priority` ASC and advertises the first 5 picker-visible // models to spawn_agent (sort_by_key(priority) + MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT=5). Catalog @@ -456,11 +462,14 @@ export function buildCatalogEntriesFromObservedState({ emittedNativeAliasSlugs.add(slug); } const nativeEntriesBySlug = new Map(nativeEntries.map(entry => [String(entry.slug), entry] as const)); - const accountNativeEntries = (accountNativeSlugs ?? gptSlugs).map(slug => ( - nativeEntriesBySlug.get(slug) - ?? deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9, undefined, new Set(), openaiContextCap) - )); for (const [selectorIndex, selector] of accountSelectors.entries()) { + const selectorNativeSlugs = accountNativeSlugsBySelector?.get(selector) + ?? accountNativeSlugs + ?? gptSlugs; + const accountNativeEntries = selectorNativeSlugs.map(slug => ( + nativeEntriesBySlug.get(slug) + ?? deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9, undefined, new Set(), openaiContextCap) + )); for (const [nativeIndex, native] of accountNativeEntries.entries()) { const nativeSlug = String(native.slug); if (disabledNativeAccountSlugs.has(nativeSlug)) continue; @@ -1310,9 +1319,12 @@ function writeRetainedCatalogSync({ const accountNativeSlugs = accountSelectors.length > 0 ? accountBoundNativeOpenAiSlugs(observedAccountNativeEntries) : []; - const observedNativeSlugs = accountSelectors.length === 0 - ? observedAccountBoundNativeOpenAiSlugs(observedAccountNativeEntries) - : []; + const accountNativeSlugsBySelector = accountSelectors.length > 0 + ? accountBoundNativeOpenAiSlugsBySelector(config, observedAccountNativeEntries) + : new Map(); + // Unknown account-native ids have no safe bare/global identity. They are only projected through + // the selector map above; the no-selector catalog remains the static native/API-key surface. + const observedNativeSlugs: string[] = []; const wsEnabled = websocketsEnabled(config); const multiAgentV2Enabled = isMultiAgentV2Enabled(); const goEntries = buildCatalogEntriesFromObservedState({ @@ -1374,6 +1386,7 @@ function writeRetainedCatalogSync({ multiAgentV2Enabled, openaiContextCap, accountNativeSlugs, + accountNativeSlugsBySelector, }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined) : []; catalog.models = mergeCatalogEntriesFromObservedState({ @@ -1626,6 +1639,11 @@ export function invalidateCodexModelsCacheWithPermit( const currentCache = readCatalog(activeCodexModelsCachePath()); const existingSlugs = new Set(models.flatMap((entry: RawEntry) => typeof entry.slug === "string" ? [entry.slug] : [])); + const currentConfig = loadConfig(); + const mainSelectors = visibleCodexAccountSelectors(currentConfig).filter(selector => { + const target = new Map(codexAccountNamespaceEntries(currentConfig)).get(selector); + return isMainCodexAccountTarget(target ?? ""); + }); const observedAccountModels = observedAccountBoundNativeEntries(currentCache?.models ?? []) .filter(entry => { const slug = typeof entry.slug === "string" ? entry.slug : ""; @@ -1638,6 +1656,7 @@ export function invalidateCodexModelsCacheWithPermit( // rows for the currently configured public account selectors. visibility: "hide", opencodex_account_observed_native: true, + opencodex_account_observed_selectors: mainSelectors, })); const wrapper = { fetched_at: "2000-01-01T00:00:00Z", diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index de85e10a15..4054e5000c 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -50,7 +50,7 @@ import { import { isNativeAliasCatalogEntry, accountBoundNativeOpenAiSlugs, - observedAccountBoundNativeOpenAiSlugs, + accountBoundNativeOpenAiSlugsBySelector, disabledNativeSlugs, desktopAllowlistSuppressedNativeSlugs, NATIVE_OPENAI_MODELS, @@ -238,9 +238,12 @@ function prepareCatalog( const accountNativeSlugs = accountSelectors.length > 0 ? accountBoundNativeOpenAiSlugs(observedAccountNativeEntries) : []; - const observedNativeSlugs = accountSelectors.length === 0 - ? observedAccountBoundNativeOpenAiSlugs(observedAccountNativeEntries) - : []; + const accountNativeSlugsBySelector = accountSelectors.length > 0 + ? accountBoundNativeOpenAiSlugsBySelector(config, observedAccountNativeEntries) + : new Map(); + // Unknown account-native ids have no safe bare/global identity. They are only projected through + // selector-qualified rows when a live selector is configured. + const observedNativeSlugs: string[] = []; const disabledNative = disabledNativeSlugs(config); const nativeCatalogModels = mergeCatalogModelsWithNativeRecovery( active?.models ?? catalog.models ?? [], @@ -275,6 +278,7 @@ function prepareCatalog( disabledNativeAccountSlugs: new Set([...disabledNative].filter(slug => suppressedBareNativeSlugs.has(slug))), multiAgentV2Enabled, accountNativeSlugs, + accountNativeSlugsBySelector, }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined); const gatheredProviderNames = new Set(enabledProviders.map(([name]) => name)); const selectedModelsByProvider = new Map>( diff --git a/src/server/index.ts b/src/server/index.ts index 67731b2c06..ed0f50b4cd 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -887,17 +887,11 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server(); + const accountNativeSlugs = [...new Set( + [...accountNativeSlugsBySelector.values()].flatMap(slugs => [...slugs]), + )]; const goEnabled = filterCatalogVisibleModels(goModels, config); const goOrdered = orderForSubagents(goEnabled, config.subagentModels); // Claude Code / Claude Desktop gateway model discovery (GET /v1/models with @@ -971,6 +968,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server 0 - ? accountNativeSlugs.filter(slug => !disabledNatives.has(slug)) + ? NATIVE_OPENAI_MODELS.filter(slug => !disabledNatives.has(slug)) : []; const bareSelectorNativeSlugs = accountSelectors.length > 0 ? selectorNativeSlugs : []; - const observedVisibleNatives = accountSelectors.length === 0 - ? observedAccountNativeSlugs.filter(slug => !disabledNatives.has(slug) && !shadowedNativeSlugs.has(slug)) - : []; const visibleNatives = includeNativeOpenAi ? accountSelectors.length > 0 ? bareSelectorNativeSlugs.filter(slug => !shadowedNativeSlugs.has(slug)) - : [...new Set([...visibleNativeSlugs(config), ...observedVisibleNatives])] + : visibleNativeSlugs(config) : []; const visibleAccountNatives = accountSelectors.flatMap(selector => - selectorNativeSlugs.flatMap(metadataId => { + (accountNativeSlugsBySelector.get(selector) ?? []).filter(metadataId => !disabledNatives.has(metadataId)).flatMap(metadataId => { const id = `${selector}/${metadataId}`; return disabledModels.has(id) ? [] : [{ id, metadataId }]; }) diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index 701c275b31..1016029953 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -30,7 +30,7 @@ function readInputModalities(raw: unknown): { values?: string[]; error?: string return { values: raw as string[] }; } import type { CatalogModel } from "../../codex/catalog"; -import { accountBoundNativeOpenAiSlugs, catalogModelSlug, configuredNativeAliasSlugs, disabledNativeSlugs, invalidateCodexModelsCache, nativeModelRows, shouldIncludeAccountBoundNativeOpenAi, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; +import { accountBoundNativeOpenAiSlugsBySelector, catalogModelSlug, configuredNativeAliasSlugs, disabledNativeSlugs, invalidateCodexModelsCache, nativeModelRows, shouldIncludeAccountBoundNativeOpenAi, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; import { CatalogGatherBusyError } from "../../codex/catalog/provider-fetch"; import { getProviderLiveModelCount } from "../../codex/model-cache"; import { @@ -234,9 +234,13 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise + slugs.filter(slug => !nativeModelRows(config).some(row => row.slug === slug)).map(slug => `${selector}/${slug}`)) + : []; const supportedNative = new Set([ ...nativeModelRows(config).map(row => row.slug), - ...(shouldIncludeAccountBoundNativeOpenAi(config) ? accountBoundNativeOpenAiSlugs() : []), + ...accountNativeQualified, ]); const targets: Array<{ id: string; native: boolean }> = []; const seen = new Set(); @@ -286,13 +290,14 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise(); + const accountNativeIds = provider === "openai" ? new Set(accountNativeQualified) : new Set(); const nativeAliasSlugs = provider === "openai" ? configuredNativeAliasSlugs(config) : new Set(); disabled = disabled.filter(stored => ( knownComboSelectors.has(stored) || nativeAliasSlugs.has(stored) - || (!stored.startsWith(`${provider}/`) && !nativeIds.has(stored)) + || (!stored.startsWith(`${provider}/`) && !nativeIds.has(stored) && !accountNativeIds.has(stored)) )); } } else { diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index 5b54ea6c18..fa83d10adf 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -11,7 +11,7 @@ import type { CatalogModel } from "../../codex/catalog"; import { catalogModelSlug, - accountBoundNativeOpenAiSlugs, + accountBoundNativeOpenAiSlugsBySelector, nativeDefaultReasoningEffort, NATIVE_OPENAI_MODELS, nativeInputModalities, @@ -52,21 +52,21 @@ export async function listManagementModelRows(config: OcxConfig): Promise ({ ...row, metadataSlug: row.slug })); + const accountNativeRows = shouldIncludeAccountBoundNativeOpenAi(config) + ? [...accountBoundNativeOpenAiSlugsBySelector(config).entries()].flatMap(([selector, slugs]) => + slugs .filter(slug => !NATIVE_OPENAI_MODELS.includes(slug)) .map(slug => ({ - slug, - disabled: disabled.has(slug), + slug: `${selector}/${slug}`, + metadataSlug: slug, + disabled: disabled.has(`${selector}/${slug}`) || disabled.has(slug), contextWindow: undefined, - })) - : []), - ]; - const native: ManagementModelRow[] = nativeRows.map(row => { - const reasoningEfforts = nativeReasoningEfforts(row.slug).filter(isVisionReasoningEffort); - const defaultReasoningEffort = nativeDefaultReasoningEffort(row.slug); + }))) + : []; + const native: ManagementModelRow[] = [...nativeRows, ...accountNativeRows].map(row => { + const reasoningEfforts = nativeReasoningEfforts(row.metadataSlug).filter(isVisionReasoningEffort); + const defaultReasoningEffort = nativeDefaultReasoningEffort(row.metadataSlug); return { provider: "openai", id: row.slug, diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index 88281f65a6..d1fa9cdc39 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -33,10 +33,11 @@ warm. Both paths may use an admitted matching bundled memo only as installed-run evidence to remove unsupported reasoning efforts; convergence never probes Codex itself. When account selectors are enabled, the sync path may also observe exact, visible, API-supported -OpenAI-family ids from Codex's user-owned catalog/cache. Unknown ids are carried through startup -cache invalidation as hidden observations and are emitted only as current-selector-qualified rows; -they never expand the bare native or API-key model list. This keeps account-scoped upstream ids -such as `gpt-daybreak-blue-latest` callable without treating them as a static release allowlist. +OpenAI-family ids from Codex's user-owned catalog/cache. Only rows with native catalog provenance +are trusted; unknown ids are carried through startup cache invalidation as hidden observations and +are emitted only as selector-qualified rows whose account provenance matches. They never expand +the bare native or API-key model list. This keeps account-scoped upstream ids such as +`gpt-daybreak-blue-latest` callable without treating them as a static release allowlist. The app-server's model list comes from this shared catalog, not from patching the App. Codex Desktop may still apply its remote native-only allowlist after `model/list`; an explicitly configured combo diff --git a/tests/claude-models-discovery.test.ts b/tests/claude-models-discovery.test.ts index e846bc4ee0..3a0827b027 100644 --- a/tests/claude-models-discovery.test.ts +++ b/tests/claude-models-discovery.test.ts @@ -362,6 +362,11 @@ test("Codex discovery preserves an observed account-only native id exactly", asy slug: "gpt-daybreak-blue-latest", visibility: "hide", supported_in_api: true, + shell_type: "shell_command", + comp_hash: "native-comp-hash", + model_messages: { instructions_template: "You are Codex." }, + base_instructions: "You are Codex.", + supported_reasoning_levels: [{ effort: "medium", description: "Medium" }], opencodex_account_observed_native: true, }], }), "utf8"); @@ -373,7 +378,7 @@ test("Codex discovery preserves an observed account-only native id exactly", asy const plain = await fetch(new URL("/v1/models", server.url)) .then(response => response.json()) as { data: Array<{ id: string }> }; expect(plain.data).toContainEqual(expect.objectContaining({ id: "team/gpt-daybreak-blue-latest" })); - expect(plain.data.some(model => model.id === "gpt-daybreak-blue-latest")).toBe(true); + expect(plain.data.some(model => model.id === "gpt-daybreak-blue-latest")).toBe(false); const managementUrl = new URL("http://localhost/api/models"); const managementResponse = await handleManagementAPI( @@ -383,15 +388,23 @@ test("Codex discovery preserves an observed account-only native id exactly", asy ); const management = await managementResponse!.json() as Array<{ id: string; native?: boolean }>; expect(management).toContainEqual(expect.objectContaining({ - id: "gpt-daybreak-blue-latest", + id: "team/gpt-daybreak-blue-latest", native: true, })); + expect(management.some(model => model.id === "gpt-daybreak-blue-latest")).toBe(false); const catalog = await fetch(new URL("/v1/models?client_version=1.0.0", server.url)) .then(response => response.json()) as { models: Array<{ slug: string; visibility?: string }> }; expect(catalog.models.find(model => model.slug === "team/gpt-daybreak-blue-latest")) .toMatchObject({ visibility: "list" }); expect(catalog.models.find(model => model.slug === "gpt-daybreak-blue-latest")?.visibility).toBe("hide"); + + const { claudeCodeNativeAlias } = await import("../src/claude/alias"); + const anthropic = await fetch(new URL("/v1/models?flavor=anthropic&ids=cli", server.url), { + headers: { "anthropic-version": "2023-06-01" }, + }).then(response => response.json()) as { data: Array<{ id: string }> }; + expect(anthropic.data.some(model => model.id === claudeCodeNativeAlias("team/gpt-daybreak-blue-latest"))).toBe(true); + expect(anthropic.data.some(model => model.id === claudeCodeNativeAlias("gpt-daybreak-blue-latest"))).toBe(false); } finally { await server.stop(true); } diff --git a/tests/codex-catalog-sync-hardening.test.ts b/tests/codex-catalog-sync-hardening.test.ts index 07b83776e3..0617407273 100644 --- a/tests/codex-catalog-sync-hardening.test.ts +++ b/tests/codex-catalog-sync-hardening.test.ts @@ -51,6 +51,9 @@ function nativeEntry(slug: string, priority: number): Record { description: "native", priority, visibility: "list", + shell_type: "shell_command", + comp_hash: "native-comp-hash", + model_messages: { instructions_template: "You are Codex." }, base_instructions: "You are Codex, a coding agent based on GPT-5.", supported_reasoning_levels: [{ effort: "medium", description: "m" }], }; diff --git a/tests/codex-convergence-account-selectors.test.ts b/tests/codex-convergence-account-selectors.test.ts index 77b5840b05..12fa07e235 100644 --- a/tests/codex-convergence-account-selectors.test.ts +++ b/tests/codex-convergence-account-selectors.test.ts @@ -67,6 +67,9 @@ function nativeEntry(visibility = "list"): RawEntry { description: "Native", priority: 1, visibility, + shell_type: "shell_command", + comp_hash: "native-comp-hash", + model_messages: { instructions_template: "You are Codex." }, base_instructions: "You are Codex.", supported_reasoning_levels: [{ effort: "medium", description: "Medium" }], }; @@ -359,16 +362,22 @@ test("convergence preserves an observed account-only native id without creating slug: "gpt-daybreak-blue-latest", visibility: "hide", supported_in_api: true, + shell_type: "shell_command", + comp_hash: "native-comp-hash", + model_messages: { instructions_template: "You are Codex." }, + base_instructions: "You are Codex.", + supported_reasoning_levels: [{ effort: "medium", description: "Medium" }], opencodex_account_observed_native: true, }], }, null, 2) + "\n"); const catalog = await convergeCatalog(config(true)); const models = catalog.models ?? []; - expect(models.find(entry => entry.slug === "team/gpt-daybreak-blue-latest")).toMatchObject({ + expect(models.find(entry => entry.slug === "desktop/gpt-daybreak-blue-latest")).toMatchObject({ visibility: "list", opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, }); + expect(models.some(entry => entry.slug === "team/gpt-daybreak-blue-latest")).toBe(false); expect(models.some(entry => entry.slug === "gpt-daybreak-blue-latest")).toBe(false); }); diff --git a/tests/codex-models-cache-invalidate.test.ts b/tests/codex-models-cache-invalidate.test.ts index b3131c23ea..af2dd1dbfd 100644 --- a/tests/codex-models-cache-invalidate.test.ts +++ b/tests/codex-models-cache-invalidate.test.ts @@ -59,7 +59,16 @@ describe("invalidateCodexModelsCache write gate (#476 / #518)", () => { models: [{ slug: "gpt-5.5" }], }, null, 2) + "\n"); writeFileSync(join(codexHome, "models_cache.json"), JSON.stringify({ - models: [{ slug: "gpt-daybreak-blue-latest", visibility: "list", supported_in_api: true }], + models: [{ + slug: "gpt-daybreak-blue-latest", + visibility: "list", + supported_in_api: true, + shell_type: "shell_command", + comp_hash: "native-comp-hash", + model_messages: { instructions_template: "You are Codex." }, + base_instructions: "You are Codex.", + supported_reasoning_levels: [{ effort: "medium", description: "Medium" }], + }], }, null, 2) + "\n"); expect(invalidateCodexModelsCache()).toBe(true); diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index b7f49e75aa..b82dddf5d1 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -40,6 +40,8 @@ function nativeTemplate(): Record { { effort: "low", description: "native low" }, { effort: "high", description: "native high" }, ], + shell_type: "shell_command", + comp_hash: "native-comp-hash", }; } @@ -142,10 +144,10 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { test("observed account-only native ids stay qualified and do not expand the bare set", () => { const observedEntries = [ - { slug: "gpt-daybreak-blue-latest", visibility: "list", supported_in_api: true }, - { slug: "gpt-hidden-daybreak", visibility: "hide", supported_in_api: true }, - { slug: "gpt-not-an-api-model", visibility: "list", supported_in_api: false }, - { slug: "provider/gpt-daybreak-blue-latest", visibility: "list", supported_in_api: true }, + { ...nativeTemplate(), slug: "gpt-daybreak-blue-latest", visibility: "list", supported_in_api: true }, + { ...nativeTemplate(), slug: "gpt-hidden-daybreak", visibility: "hide", supported_in_api: true }, + { ...nativeTemplate(), slug: "gpt-not-an-api-model", visibility: "list", supported_in_api: false }, + { ...nativeTemplate(), slug: "provider/gpt-daybreak-blue-latest", visibility: "list", supported_in_api: true }, ]; expect(accountBoundNativeOpenAiSlugs(observedEntries)).toContain("gpt-daybreak-blue-latest"); expect(accountBoundNativeOpenAiSlugs(observedEntries)).not.toContain("gpt-hidden-daybreak"); @@ -172,6 +174,7 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { }); expect(observedAccountBoundNativeEntries([{ + ...nativeTemplate(), slug: "gpt-daybreak-blue-latest", visibility: "hide", supported_in_api: true, @@ -180,6 +183,12 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(observedAccountBoundNativeOpenAiSlugs(observedEntries)).toEqual(["gpt-daybreak-blue-latest"]); }); + test("a hand-edited cache row without native catalog provenance is ignored", () => { + const handEdited = [{ slug: "gpt-daybreak-blue-latest", visibility: "list", supported_in_api: true }]; + expect(accountBoundNativeOpenAiSlugs(handEdited)).not.toContain("gpt-daybreak-blue-latest"); + expect(observedAccountBoundNativeEntries(handEdited)).toEqual([]); + }); + test("exact account disables hide only the matching generated picker row", () => { const entries = buildCatalogEntries( nativeTemplate(), From 54c21ed161f42a15263a2b030f849404af449475 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 13 Aug 2026 00:36:46 +0900 Subject: [PATCH 3/3] docs(codex): call the row check plausibility, not provenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on top of @Yuxin-Qiao's commits. The behavior is kept; what changes is the claim made about it. hasNativeCatalogProvenance checked only field shape — base_instructions non-empty, comp_hash string-or-null, shell_type, a reasoning-levels array, model_messages an object. There is no signature, source identity, or server attestation, and models_cache.json is a user-owned file, so a complete hand-written row passes. I reproduced that directly before changing anything: a forged gpt-* row with those fields is accepted. Calling that provenance is the problem, not the acceptance itself. It grants nothing new — router.ts already routes any bare gpt-* id under an account selector regardless of the catalog, so the effect is that a poisoned row gets ADVERTISED through discovery, not that a new route or credential becomes reachable. And anyone who can rewrite that cache can already edit config.json or run ocx directly. So the predicate is renamed to say what it does, the two docs pages drop the provenance language, and a test pins the accepting behavior with the reasoning attached. A future reader who wants rejection needs a real provenance signal, not a longer list of fields to match. The existing minimal-row test is kept and retitled: filtering malformed rows is what this check is actually for. --- .../content/docs/guides/codex-app-models.md | 6 +++-- .../docs/reference/configuration/routing.md | 7 ++++-- src/codex/catalog/metadata.ts | 20 +++++++++++++-- tests/native-model-toggle.test.ts | 25 ++++++++++++++++++- 4 files changed, 51 insertions(+), 7 deletions(-) diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index 75122bdc8e..70ea33de92 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -18,8 +18,10 @@ built-in account-role meaning. Selecting a qualified row uses only its mapped ac change the active Pool account, and fails closed instead of switching accounts when the target is unavailable. If Codex's account-scoped catalog contains a visible, API-supported OpenAI-family id that is not yet in opencodex's static set, the exact id is preserved as a selector-qualified row -only when its observed account provenance matches an eligible selector; it is not copied to an -unrelated account and is not added to the bare or API-key model list. See [Exact Codex account selectors](/reference/configuration/routing/#exact-codex-account-selectors). +for eligible main-account selectors; it is not copied to an unrelated account and is not added to +the bare or API-key model list. The row is matched on the field shape a real catalog row has, +which filters malformed entries — it does not prove the id came from an upstream response, since +the cache is a user-owned file. See [Exact Codex account selectors](/reference/configuration/routing/#exact-codex-account-selectors). When the `codexAccountNamespaces` map is empty, account-qualified picker rows are off. If `codexAccountPickerEnabled` is omitted with a non-empty map, they are treated as enabled for diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index 8e3f46a7a3..a2ba4b01b2 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -41,8 +41,11 @@ request for `side/gpt-5.6-sol` uses only that account, even when the canonical ` in Direct mode, and sends the bare `gpt-5.6-sol` model id upstream. Only bare native OpenAI-family ids are valid after the selector. Account-scoped ids observed in Codex's current model catalog may also be preserved exactly when they are not yet part of opencodex's static set; the observation must -carry native catalog provenance, remains qualified to its matching account selector, and is not -promoted into the global bare model list. +carry the field shape of a real catalog row, stays qualified to its matching account selector, and +is never promoted into the global bare model list. That shape check filters malformed and minimal +rows — it is not a trust control, because the models cache is a user-owned file and a complete +hand-written row is indistinguishable from an upstream observation. Nothing new becomes routable: +a bare `gpt-*` id under an account selector is accepted by the router regardless of the catalog. Exact selection bypasses Pool assignment strategy and ordinary thread affinity. If the mapped account is missing, paused, cooling down, unusable, or requires reauthentication, the request fails diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index 31a9b40735..7285213fa7 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -294,7 +294,23 @@ function isAccountBoundOpenAiNativeSlug(slug: string): boolean { return !slug.includes("/") && ACCOUNT_BOUND_OPENAI_NATIVE_PREFIX.test(slug); } -function hasNativeCatalogProvenance(entry: RawEntry): boolean { +/** + * Shape/plausibility filter for a candidate account-native row. **This is not a trust control.** + * + * It checks that a row carries the field shape a real Codex catalog row has, which rejects + * malformed and minimal hand-written rows. It cannot distinguish a genuine upstream observation + * from a complete row typed by hand into `$CODEX_HOME/models_cache.json`: there is no signature, + * source identity, or server attestation to check. A full-shape forged row is accepted, and + * `observedFullShapeRowIsAccepted` in tests/native-model-toggle.test.ts pins that so nobody + * later mistakes this predicate for a security boundary. + * + * That is acceptable here because the file is user-owned and written by Codex itself: anyone + * able to rewrite it can already edit `config.json` or run `ocx` directly, and `router.ts` + * accepts any bare `gpt-*` id under an account namespace regardless of this catalog. What the + * filter buys is that garbage rows do not get advertised through discovery — not that an + * advertised row is proven genuine. + */ +function hasNativeCatalogRowShape(entry: RawEntry): boolean { const levels = entry.supported_reasoning_levels; const messages = entry.model_messages; return typeof entry.base_instructions === "string" @@ -315,7 +331,7 @@ function observedAccountBoundNativeSlug(entry: RawEntry): string | undefined { const slug = accountBound ?? (typeof entry.slug === "string" ? entry.slug : ""); if (!isAccountBoundOpenAiNativeSlug(slug) || entry.supported_in_api !== true - || !hasNativeCatalogProvenance(entry) + || !hasNativeCatalogRowShape(entry) || (entry.visibility !== "list" && entry[ACCOUNT_BOUND_OBSERVED_NATIVE_MARKER] !== true)) { return undefined; } diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index b82dddf5d1..9cbcea106f 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -183,12 +183,35 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(observedAccountBoundNativeOpenAiSlugs(observedEntries)).toEqual(["gpt-daybreak-blue-latest"]); }); - test("a hand-edited cache row without native catalog provenance is ignored", () => { + test("a minimal hand-edited cache row is ignored", () => { const handEdited = [{ slug: "gpt-daybreak-blue-latest", visibility: "list", supported_in_api: true }]; expect(accountBoundNativeOpenAiSlugs(handEdited)).not.toContain("gpt-daybreak-blue-latest"); expect(observedAccountBoundNativeEntries(handEdited)).toEqual([]); }); + // The shape check is NOT a trust control, and this pins that so the next reader does not + // assume it is one. `models_cache.json` is a user-owned file with no signature or source + // identity to verify, so a complete hand-written row is indistinguishable from a real + // observation and is accepted. That is acceptable here only because it grants nothing new: + // `router.ts` already routes any bare `gpt-*` id under an account selector regardless of the + // catalog, so the effect is advertisement in discovery, not a newly reachable route. If this + // test ever needs to flip to rejection, the fix is a real provenance signal, not a longer + // list of fields to match. + test("a full-shape hand-written row IS accepted — the check is plausibility, not provenance", () => { + const forged = [{ + slug: "gpt-not-a-real-model", + visibility: "list", + supported_in_api: true, + base_instructions: "anything non-empty", + comp_hash: null, + shell_type: "shell_command", + supported_reasoning_levels: [{ effort: "high" }], + model_messages: {}, + }]; + + expect(accountBoundNativeOpenAiSlugs(forged)).toContain("gpt-not-a-real-model"); + }); + test("exact account disables hide only the matching generated picker row", () => { const entries = buildCatalogEntries( nativeTemplate(),