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..70ea33de92 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,12 @@ 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 +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 8a2b039c26..a2ba4b01b2 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -39,7 +39,13 @@ 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; the observation must +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.ts b/src/codex/catalog.ts index 7b26cb9ebd..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 { 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, 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/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..7285213fa7 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"; @@ -33,8 +34,8 @@ import upstreamModelsSnapshot from "../data/upstream-models.json"; import type { RawEntry } from "./parsing"; -import { readCurrentCatalogOrCache, unique } from "./bundled"; -import { trustedAccountBoundNativeCatalogSlug } from "./account-models"; +import { readCurrentCatalogOrCache, readCurrentCodexCatalog, readCurrentCodexModelsCache, unique } from "./bundled"; +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 }> { @@ -214,6 +240,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 +249,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 +286,154 @@ 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"; +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); +} + +/** + * 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" + && 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 + || !hasNativeCatalogRowShape(entry) + || (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]); +} + +/** + * 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[], +): 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..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 { 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, 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, @@ -346,6 +347,10 @@ 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[]; + /** 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. */ @@ -361,6 +366,8 @@ export function buildCatalogEntries( suppressedBareNativeSlugs: ReadonlySet = new Set(), disabledNativeAccountSlugs: ReadonlySet = new Set(), contextCap?: number, + accountNativeSlugs?: readonly string[], + accountNativeSlugsBySelector?: ReadonlyMap, ): RawEntry[] { return buildCatalogEntriesFromObservedState({ template, @@ -375,6 +382,8 @@ export function buildCatalogEntries( disabledNativeAccountSlugs, multiAgentV2Enabled: isMultiAgentV2Enabled(), openaiContextCap: contextCap, + accountNativeSlugs, + accountNativeSlugsBySelector, }); } @@ -392,6 +401,8 @@ export function buildCatalogEntriesFromObservedState({ disabledNativeAccountSlugs, 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 @@ -450,8 +461,16 @@ export function buildCatalogEntriesFromObservedState({ emittedNativeAliases.add(nativeAlias); emittedNativeAliasSlugs.add(slug); } + const nativeEntriesBySlug = new Map(nativeEntries.map(entry => [String(entry.slug), entry] as const)); for (const [selectorIndex, selector] of accountSelectors.entries()) { - for (const [nativeIndex, native] of nativeEntries.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; const e = JSON.parse(JSON.stringify(native)) as RawEntry; @@ -785,6 +804,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 +956,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 +994,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 +1080,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 +1189,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 +1208,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 +1311,20 @@ 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 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({ @@ -1343,6 +1385,8 @@ function writeRetainedCatalogSync({ disabledNativeAccountSlugs: new Set([...disabledNativeSlugs(config)].filter(slug => suppressedBareNativeSlugs.has(slug))), multiAgentV2Enabled, openaiContextCap, + accountNativeSlugs, + accountNativeSlugsBySelector, }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined) : []; catalog.models = mergeCatalogEntriesFromObservedState({ @@ -1368,6 +1412,7 @@ function writeRetainedCatalogSync({ openaiContextCap, policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, + nativeBackfillSlugs: [...NATIVE_OPENAI_MODELS, ...observedNativeSlugs], warningPolicy: "emit", }, }); @@ -1591,10 +1636,32 @@ 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 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 : ""; + 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, + opencodex_account_observed_selectors: mainSelectors, + })); 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..4054e5000c 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -49,6 +49,8 @@ import { import { exactComboCatalogSlugs } from "./catalog/aggregation"; import { isNativeAliasCatalogEntry, + accountBoundNativeOpenAiSlugs, + accountBoundNativeOpenAiSlugsBySelector, 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,15 @@ function prepareCatalog( const accountSelectors = shouldIncludeAccountBoundNativeOpenAi(config) ? visibleCodexAccountSelectors(config) : []; + const accountNativeSlugs = accountSelectors.length > 0 + ? accountBoundNativeOpenAiSlugs(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 ?? [], @@ -265,6 +277,8 @@ function prepareCatalog( suppressedBareNativeSlugs, 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>( @@ -296,6 +310,7 @@ function prepareCatalog( suppressedBareNativeSlugs, policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, + nativeBackfillSlugs: [...NATIVE_OPENAI_MODELS, ...observedNativeSlugs], warningPolicy: "suppress", }, }); @@ -384,6 +399,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..ed0f50b4cd 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -887,10 +887,12 @@ 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 @@ -945,7 +953,7 @@ 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 +967,15 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server 0, + new Set(accountNativeSlugs), ), }, 200, req, policy); } @@ -1010,13 +1021,16 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server 0 ? NATIVE_OPENAI_MODELS.filter(slug => !disabledNatives.has(slug)) : []; + const bareSelectorNativeSlugs = accountSelectors.length > 0 + ? selectorNativeSlugs + : []; const visibleNatives = includeNativeOpenAi ? accountSelectors.length > 0 - ? selectorNativeSlugs.filter(slug => !shadowedNativeSlugs.has(slug)) + ? bareSelectorNativeSlugs.filter(slug => !shadowedNativeSlugs.has(slug)) : 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 08703816de..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 { catalogModelSlug, configuredNativeAliasSlugs, disabledNativeSlugs, invalidateCodexModelsCache, nativeModelRows, 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,7 +234,14 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise row.slug)); + const accountNativeQualified = shouldIncludeAccountBoundNativeOpenAi(config) + ? [...accountBoundNativeOpenAiSlugsBySelector(config).entries()].flatMap(([selector, slugs]) => + slugs.filter(slug => !nativeModelRows(config).some(row => row.slug === slug)).map(slug => `${selector}/${slug}`)) + : []; + const supportedNative = new Set([ + ...nativeModelRows(config).map(row => row.slug), + ...accountNativeQualified, + ]); const targets: Array<{ id: string; native: boolean }> = []; const seen = new Set(); for (const value of body.targets) { @@ -283,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 fc7f0f162f..fa83d10adf 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, + accountBoundNativeOpenAiSlugsBySelector, 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,9 +52,21 @@ export async function listManagementModelRows(config: OcxConfig): Promise { - const reasoningEfforts = nativeReasoningEfforts(row.slug).filter(isVisionReasoningEffort); - const defaultReasoningEffort = nativeDefaultReasoningEffort(row.slug); + const nativeRows = nativeModelRows(config).map(row => ({ ...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: `${selector}/${slug}`, + metadataSlug: slug, + disabled: disabled.has(`${selector}/${slug}`) || disabled.has(slug), + contextWindow: undefined, + }))) + : []; + 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 d35f292728..d1fa9cdc39 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -32,6 +32,13 @@ 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. 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 `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..3a0827b027 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,80 @@ 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, + 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"); + + 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(false); + + 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: "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); + } +}); + 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..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" }], }; @@ -371,6 +374,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..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" }], }; @@ -352,6 +355,32 @@ 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, + 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 === "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); +}); + 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..af2dd1dbfd 100644 --- a/tests/codex-models-cache-invalidate.test.ts +++ b/tests/codex-models-cache-invalidate.test.ts @@ -54,6 +54,33 @@ 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, + 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); + 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..9cbcea106f 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, @@ -37,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", }; } @@ -137,6 +142,76 @@ 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 = [ + { ...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"); + 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([{ + ...nativeTemplate(), + 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("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(),