diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index 80ba299fc..0c3fd0913 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -107,11 +107,12 @@ const PINNED_NATIVE_CAPABILITY_ENTRIES: Map = new Map( .map(m => [m.slug as string, m]), ); -export function nativeOpenAiContextWindow(slug: string): number | undefined { - return NATIVE_OPENAI_CONTEXT_OVERRIDES[slug]?.contextWindow +export function nativeOpenAiContextWindow(slug: string, contextCap?: number): number | undefined { + const raw = NATIVE_OPENAI_CONTEXT_OVERRIDES[slug]?.contextWindow ?? (typeof PINNED_NATIVE_CAPABILITY_ENTRIES.get(slug)?.context_window === "number" ? PINNED_NATIVE_CAPABILITY_ENTRIES.get(slug)!.context_window as number : undefined); + return applyProviderContextCap(raw, contextCap) ?? raw; } export function nativeInputModalities(slug: string): string[] { @@ -199,11 +200,12 @@ export function desktopVisibleNativeSlugs(config: Pick): Array<{ slug: string; disabled: boolean; contextWindow?: number }> { +export function nativeModelRows(config: Pick): Array<{ slug: string; disabled: boolean; contextWindow?: number }> { const disabled = disabledNativeSlugs(config); const shadowed = configuredNativeAliasSlugs(config); + const openaiContextCap = providerContextCap(config, OPENAI_CODEX_PROVIDER_ID); return NATIVE_OPENAI_MODELS.filter(slug => !shadowed.has(slug)).map(slug => { - const contextWindow = nativeOpenAiContextWindow(slug); + const contextWindow = nativeOpenAiContextWindow(slug, openaiContextCap); return { slug, disabled: disabled.has(slug), ...(contextWindow !== undefined ? { contextWindow } : {}) }; }); } diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 74a93d7ca..7d69b811d 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -259,18 +259,34 @@ export function isNativeOpenAiEntry(entry: RawEntry): boolean { return typeof entry.slug === "string" && !entry.slug.includes("/"); } -export function applyNativeOpenAiContextOverride(entry: RawEntry): void { +export function applyNativeOpenAiContextOverride(entry: RawEntry, contextCap?: number): void { const nativeSlug = trustedAccountBoundNativeCatalogSlug(entry) ?? (isNativeOpenAiEntry(entry) ? entry.slug as string : undefined); if (!nativeSlug) return; const override = NATIVE_OPENAI_CONTEXT_OVERRIDES[nativeSlug]; - if (!override) return; - if (typeof override.contextWindow === "number") { - entry.context_window = override.contextWindow; - entry.auto_compact_token_limit = Math.floor(override.contextWindow * 0.9); + if (override) { + if (typeof override.contextWindow === "number") { + const contextWindow = applyProviderContextCap(override.contextWindow, contextCap) ?? override.contextWindow; + entry.context_window = contextWindow; + entry.auto_compact_token_limit = Math.floor(contextWindow * 0.9); + } + if (typeof override.maxContextWindow === "number") { + entry.max_context_window = applyProviderContextCap(override.maxContextWindow, contextCap) ?? override.maxContextWindow; + } + } + // providerContextCaps.openai is a ceiling for native OpenAI rows regardless of where the + // advertised window came from (#1430): preserved rows without a hardcoded override (e.g. + // gpt-5.4-mini) must stay under the cap too, and auto-compaction follows the capped window. + const currentContext = typeof entry.context_window === "number" ? entry.context_window : undefined; + const cappedContext = applyProviderContextCap(currentContext, contextCap); + if (cappedContext !== currentContext && typeof cappedContext === "number") { + entry.context_window = cappedContext; + entry.auto_compact_token_limit = Math.floor(cappedContext * 0.9); } - if (typeof override.maxContextWindow === "number") { - entry.max_context_window = override.maxContextWindow; + const currentMax = typeof entry.max_context_window === "number" ? entry.max_context_window : undefined; + const cappedMax = applyProviderContextCap(currentMax, contextCap); + if (cappedMax !== currentMax) { + entry.max_context_window = cappedMax; } } diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 1327197a6..a723fe087 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1639,6 +1639,7 @@ async function gatherRoutedModelsUncached( // configs that will never need it. } else { const disabled = disabledNativeSlugs(config); + const openaiContextCap = providerContextCap(config, OPENAI_CODEX_PROVIDER_ID); const requiredNativeComboTargets = new Set(listComboIds(config).flatMap(id => { const combo = getCombo(config, id); return combo?.targets.flatMap(target => ( @@ -1649,7 +1650,7 @@ async function gatherRoutedModelsUncached( // A bare native disable key hides the native row, not a combo that targets it. // Keep synthetic native metadata available to those combos. if (disabled.has(slug) && !requiredNativeComboTargets.has(slug)) continue; - const contextWindow = nativeOpenAiContextWindow(slug); + const contextWindow = nativeOpenAiContextWindow(slug, openaiContextCap); if (contextWindow === undefined) continue; const synthetic: CatalogModel = { provider: "openai", diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index f48c215b1..12866bcb1 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -29,6 +29,7 @@ import type { NormalizedComboConfig } from "../../combos/types"; 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 { 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"; @@ -190,9 +191,9 @@ export function effectiveSubagentRoster( return { candidates, advertised, excluded }; } -export function finishUpstreamNativeEntry(clone: RawEntry, priority: number): RawEntry { +export function finishUpstreamNativeEntry(clone: RawEntry, priority: number, contextCap?: number): RawEntry { if (priority !== 9) clone.priority = priority; - applyNativeOpenAiContextOverride(clone); + applyNativeOpenAiContextOverride(clone, contextCap); // GPT-5.6 natives keep their exact upstream ladders (e.g. luna has max but no ultra). // Older natives (gpt-5.5 / 5.4 / 5.4-mini / 5.3-codex-spark) get mock max + ultra // (wire-clamped to xhigh). Ultra is always advertised regardless of v2 toggle. @@ -243,6 +244,7 @@ export function deriveEntry( priority: number, model?: CatalogModel, exactComboSlugs: ReadonlySet = new Set(), + contextCap?: number, ): RawEntry { const preserveExact = isExactComboCatalogModel(model, exactComboSlugs); const isRouted = model !== undefined; @@ -251,7 +253,7 @@ export function deriveEntry( // reasoning ladder — e.g. luna has no ultra — default effort, identity, model_messages) // instead of cloning an older template. const upstream = upstreamNativeEntry(slug); - if (upstream) return finishUpstreamNativeEntry(upstream, priority); + if (upstream) return finishUpstreamNativeEntry(upstream, priority, contextCap); } if (template) { const e = JSON.parse(JSON.stringify(template)) as RawEntry; @@ -286,7 +288,7 @@ export function deriveEntry( applyCatalogModelMetadata(e, model); if (model?.catalogKind) e.opencodex_catalog_kind = model.catalogKind; } else { - applyNativeOpenAiContextOverride(e); + applyNativeOpenAiContextOverride(e, contextCap); if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(e); else ensureUltraReasoningLevel(e); // Non-5.6 natives (5.5, 5.4, 5.4-mini, spark) do not support responses-lite; @@ -324,7 +326,7 @@ export function deriveEntry( if (model && isRouted) applyCatalogMetadata(entry, model.provider, model.id, model.contextCap); applyCatalogModelMetadata(entry, model); if (model?.catalogKind) entry.opencodex_catalog_kind = model.catalogKind; - if (!isRouted) applyNativeOpenAiContextOverride(entry); + if (!isRouted) applyNativeOpenAiContextOverride(entry, contextCap); return ensureStrictCatalogFields(normalizeServiceTiers(entry), { preserveExactInputModalities: preserveExact, isRouted, @@ -343,6 +345,7 @@ export interface ObservedCatalogEntryBuildInput { readonly suppressedBareNativeSlugs: ReadonlySet; readonly disabledNativeAccountSlugs: ReadonlySet; readonly multiAgentV2Enabled: boolean; + readonly openaiContextCap?: number; } /** Build entries with the process-observed Codex feature state. */ @@ -357,6 +360,7 @@ export function buildCatalogEntries( accountSelectors: readonly string[] = [], suppressedBareNativeSlugs: ReadonlySet = new Set(), disabledNativeAccountSlugs: ReadonlySet = new Set(), + contextCap?: number, ): RawEntry[] { return buildCatalogEntriesFromObservedState({ template, @@ -370,6 +374,7 @@ export function buildCatalogEntries( suppressedBareNativeSlugs, disabledNativeAccountSlugs, multiAgentV2Enabled: isMultiAgentV2Enabled(), + openaiContextCap: contextCap, }); } @@ -386,6 +391,7 @@ export function buildCatalogEntriesFromObservedState({ suppressedBareNativeSlugs, disabledNativeAccountSlugs, multiAgentV2Enabled, + openaiContextCap, }: 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 @@ -420,7 +426,7 @@ export function buildCatalogEntriesFromObservedState({ .filter(model => model.provider === COMBO_NAMESPACE) .map(catalogModelSlug)); for (const slug of gptSlugs) { - const native = deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9); + const native = deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9, undefined, new Set(), openaiContextCap); if (rank.has(slug)) native.priority = rank.get(slug)!; nativeEntries.push(native); const nativeAlias = nativeAliasesBySlug.get(slug); @@ -623,6 +629,7 @@ export interface ObservedCatalogMergeInput { readonly accountBoundEntries: readonly RawEntry[]; readonly suppressedBareNativeSlugs?: ReadonlySet; readonly policy: ObservedCatalogMergePolicy; + readonly openaiContextCap?: number; } /** @@ -652,6 +659,7 @@ export function mergeCatalogEntriesFromObservedState({ accountBoundEntries, suppressedBareNativeSlugs = new Set(), policy, + openaiContextCap, }: ObservedCatalogMergeInput): RawEntry[] { // Raw catalog rows contain nested arrays/objects that normalization mutates. Detach every row at // the observed-core boundary so callers can safely retain evidence objects or repeat the merge. @@ -786,7 +794,7 @@ export function mergeCatalogEntriesFromObservedState({ // genuine catalog entry (real display name) is preserved untouched. if (shouldUpgradeToUpstreamEntry(m)) { const upstream = upstreamNativeEntry(slug)!; - const finished = finishUpstreamNativeEntry(upstream, 9); + const finished = finishUpstreamNativeEntry(upstream, 9, openaiContextCap); finished.priority = nativePriority(slug, upstream.priority); return finished; } @@ -815,6 +823,9 @@ export function mergeCatalogEntriesFromObservedState({ slug, "OpenAI native model (Codex OAuth passthrough).", nativePriority(slug, upstreamNativeEntry(slug)?.priority), + undefined, + new Set(), + openaiContextCap, ); entry.priority = nativePriority(slug, upstreamNativeEntry(slug)?.priority); native.push(entry); @@ -927,7 +938,7 @@ export function mergeCatalogEntriesFromObservedState({ const managedEntries = [...finalRoutedEntries, ...alignedAccountBoundEntries]; const mergedEntries = [...native, ...managedEntries].map(m => { const normalized = normalizeServiceTiers(m); - if (!isNativeAliasCatalogEntry(normalized)) applyNativeOpenAiContextOverride(normalized); + if (!isNativeAliasCatalogEntry(normalized)) applyNativeOpenAiContextOverride(normalized, openaiContextCap); const exactCombo = isExactComboCatalogEntry(m, exactComboSlugs); const e = ensureStrictCatalogFields(normalized, { preserveExactInputModalities: exactCombo, @@ -997,6 +1008,7 @@ export function mergeCatalogEntriesForSync( isNativeAliasCatalogEntry(entry) && typeof entry.slug === "string" ? [entry.slug] : [] )), ), + openaiContextCap?: number, ): RawEntry[] { // Retained for source compatibility with the original helper contract. Raw provider ids must // not suppress same-named native rows; actual admitted combo entries own that decision now. @@ -1031,6 +1043,7 @@ export function mergeCatalogEntriesForSync( includeNativeOpenAi, accountBoundEntries, suppressedBareNativeSlugs, + openaiContextCap, policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, warningPolicy: "emit", @@ -1248,6 +1261,7 @@ function writeRetainedCatalogSync({ const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE); const includeNativeOpenAi = shouldIncludeNativeOpenAi(config); const includeAccountBoundNativeOpenAi = shouldIncludeAccountBoundNativeOpenAi(config); + const openaiContextCap = providerContextCap(config, OPENAI_CODEX_PROVIDER_ID); const accountSelectors = includeAccountBoundNativeOpenAi ? visibleCodexAccountSelectors(config) : []; @@ -1265,6 +1279,7 @@ function writeRetainedCatalogSync({ suppressedBareNativeSlugs, disabledNativeAccountSlugs: new Set(), multiAgentV2Enabled, + openaiContextCap, }); // Keep genuine native entries (gpt-*, codex-*) with their real per-model fields and append // routed providers as namespaced slugs. Cursor and other adopted providers can expose model ids @@ -1309,6 +1324,7 @@ function writeRetainedCatalogSync({ suppressedBareNativeSlugs, disabledNativeAccountSlugs: new Set([...disabledNativeSlugs(config)].filter(slug => suppressedBareNativeSlugs.has(slug))), multiAgentV2Enabled, + openaiContextCap, }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined) : []; catalog.models = mergeCatalogEntriesFromObservedState({ @@ -1331,6 +1347,7 @@ function writeRetainedCatalogSync({ includeNativeOpenAi, accountBoundEntries, suppressedBareNativeSlugs, + openaiContextCap, policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, warningPolicy: "emit", diff --git a/src/providers/registry.ts b/src/providers/registry.ts index bd56aca15..54c623027 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -300,12 +300,6 @@ const MINIMAX_M3_REASONING_EFFORT_MAP: Record = { const OPENAI_GPT56_MODELS = ["gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]; const OPENAI_GPT56_PRO_MODELS = ["gpt-5.6-sol-pro", "gpt-5.6-terra-pro", "gpt-5.6-luna-pro"]; const OPENAI_API_GPT56_CONTEXT_WINDOW = 1_050_000; -const OPENAI_CODEX_GPT56_CONTEXT_WINDOW = 372_000; -const OPENAI_GPT56_CONTEXT_WINDOWS = { - "gpt-5.6-sol": OPENAI_CODEX_GPT56_CONTEXT_WINDOW, - "gpt-5.6-terra": OPENAI_CODEX_GPT56_CONTEXT_WINDOW, - "gpt-5.6-luna": OPENAI_CODEX_GPT56_CONTEXT_WINDOW, -}; const OPENAI_API_GPT56_CONTEXT_WINDOWS: Record = { ...Object.fromEntries([...OPENAI_GPT56_MODELS, ...OPENAI_GPT56_PRO_MODELS].map(id => [id, OPENAI_API_GPT56_CONTEXT_WINDOW])), "gpt-5.5": OPENAI_API_GPT56_CONTEXT_WINDOW, diff --git a/src/routing/capability.ts b/src/routing/capability.ts index 157e0e6f9..b02083f4f 100644 --- a/src/routing/capability.ts +++ b/src/routing/capability.ts @@ -11,7 +11,8 @@ */ import type { OcxConfig } from "../types"; -import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; +import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; +import { applyProviderContextCap, providerContextCap } from "../providers/context-cap"; import { PROVIDER_REGISTRY } from "../providers/registry"; import { nativeInputModalities, @@ -145,13 +146,18 @@ export function candidateCapabilityEvidence( const provider = config.providers[providerName]; const registryEntry = PROVIDER_REGISTRY.find(entry => entry.id === providerName); const catalogRow = cachedCatalogModels().find(model => model.provider === providerName && model.id === modelId); - const isNative = providerName === "openai" && !modelId.includes("/"); + const isNative = providerName === OPENAI_CODEX_PROVIDER_ID && !modelId.includes("/"); - const contextWindow = provider?.modelContextWindows?.[modelId] + const rawContextWindow = provider?.modelContextWindows?.[modelId] ?? provider?.contextWindow ?? registryEntry?.modelContextWindows?.[modelId] ?? catalogRow?.contextWindow ?? (isNative ? nativeOpenAiContextWindow(modelId) : undefined); + // providerContextCaps.openai also ceilings native OpenAI rows (#1430), so routing + // evidence never contradicts a capped catalog entry. + const contextWindow = isNative + ? applyProviderContextCap(rawContextWindow, providerContextCap(config, OPENAI_CODEX_PROVIDER_ID)) ?? rawContextWindow + : rawContextWindow; const modalities = provider?.modelInputModalities?.[modelId] ?? registryEntry?.modelInputModalities?.[modelId] diff --git a/src/server/index.ts b/src/server/index.ts index 60b27973c..ed761bb40 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -43,7 +43,8 @@ import { import { acquireServerBackgroundLifecycle } from "./background-lifecycle"; import { runOpenAiTierStartupMigration } from "../providers/openai-tier-startup"; import { runAlibabaRegionStartupMigration } from "../providers/alibaba-region-startup"; -import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; +import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; +import { providerContextCap } from "../providers/context-cap"; import { providerCodexAccountMode } from "../providers/registry"; import type { StorageCleanupPolicy } from "../types"; import { @@ -911,7 +912,19 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server 0 ? NATIVE_OPENAI_MODELS : nativeSlugs; - const entries = buildCatalogEntries(loadCatalogTemplate(), catalogNativeSlugs, goOrdered, config.subagentModels, websocketsEnabled(config), maMode as "v1" | "default" | "v2", exactComboCatalogSlugs(config), accountSelectors, suppressedBareNativeSlugs); + const entries = buildCatalogEntries( + loadCatalogTemplate(), + catalogNativeSlugs, + goOrdered, + config.subagentModels, + websocketsEnabled(config), + maMode as "v1" | "default" | "v2", + exactComboCatalogSlugs(config), + accountSelectors, + suppressedBareNativeSlugs, + new Set(), + providerContextCap(config, OPENAI_CODEX_PROVIDER_ID), + ); return jsonResponse({ models: applyNativeVisibility( entries, diff --git a/tests/claude-models-discovery.test.ts b/tests/claude-models-discovery.test.ts index f5046fffd..07acc676d 100644 --- a/tests/claude-models-discovery.test.ts +++ b/tests/claude-models-discovery.test.ts @@ -156,6 +156,37 @@ test("OpenAI list shape and Codex catalog shape stay unchanged", async () => { } }); +test("Codex discovery applies the OpenAI context cap to native rows (#1430)", async () => { + const config = configWithStaticModels(); + config.providers.openai = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + liveModels: false, + }; + config.providerContextCaps = { openai: 272_000 }; + saveConfig(config); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/models?client_version=1.0.0", server.url)); + expect(response.status).toBe(200); + const json = await response.json() as { + models: Array<{ + slug: string; + context_window?: number; + max_context_window?: number; + auto_compact_token_limit?: number; + }>; + }; + expect(json.models.find(model => model.slug === "gpt-5.6-sol")).toMatchObject({ + context_window: 272_000, + max_context_window: 272_000, + auto_compact_token_limit: 244_800, + }); + } finally { + await server.stop(true); + } +}); + test("exact account disables affect only the matching OpenAI and Codex discovery row", async () => { const config = configWithStaticModels(); config.providers.openai = { diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 59f9e4aad..e57b1481d 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { applyNativeVisibility, augmentRoutedModelsWithMetadata, augmentRoutedModelsWithRegistryOpenAiApiRows, buildCatalogEntries, buildComboCatalogOmission, catalogModelSlug, clampCatalogModelsToCodexSupport, clampEntryToCodexSupportedEfforts, clampedDefaultEffort, CODEX_ACCOUNT_BOUND_CATALOG_KIND, CODEX_NATIVE_ALIAS_CATALOG_KIND, comboCatalogOmissionReason, deriveComboCatalogModel, exactComboCatalogSlugs, filterCatalogVisibleModels, filterSupportedNativeSlugs, gatherRoutedModels as gatherRoutedModelsDirect, isDatedVariantId, isMediaGenerationModelId, loadBundledCodexCatalog, materializeBundledCodexCatalog, mergeCatalogEntriesForSync, NATIVE_OPENAI_MODELS, normalizeRoutedCatalogEntry, resetCatalogRuntimeStateForTests, resetOpenAiApiCatalogWarningStateForTests, resolveComboCatalogMember, shouldExposeRoutedModel } from "../src/codex/catalog"; +import { applyNativeVisibility, augmentRoutedModelsWithMetadata, augmentRoutedModelsWithRegistryOpenAiApiRows, buildCatalogEntries, buildComboCatalogOmission, catalogModelSlug, clampCatalogModelsToCodexSupport, clampEntryToCodexSupportedEfforts, clampedDefaultEffort, CODEX_ACCOUNT_BOUND_CATALOG_KIND, CODEX_NATIVE_ALIAS_CATALOG_KIND, comboCatalogOmissionReason, deriveComboCatalogModel, exactComboCatalogSlugs, filterCatalogVisibleModels, filterSupportedNativeSlugs, gatherRoutedModels as gatherRoutedModelsDirect, isDatedVariantId, isMediaGenerationModelId, loadBundledCodexCatalog, materializeBundledCodexCatalog, mergeCatalogEntriesForSync, NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, normalizeRoutedCatalogEntry, resetCatalogRuntimeStateForTests, resetOpenAiApiCatalogWarningStateForTests, resolveComboCatalogMember, shouldExposeRoutedModel } from "../src/codex/catalog"; import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, @@ -2402,6 +2402,123 @@ describe("Codex catalog routed normalization", () => { expect(sol?.supports_websockets).toBe(true); }); + test("providerContextCaps.openai ceilings native GPT-5.6 catalog rows (#1430)", () => { + const cap = 272_000; + const entries = buildCatalogEntries( + nativeTemplate(), + ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"], + [], + undefined, + false, + "default", + new Set(), + [], + new Set(), + new Set(), + cap, + ); + for (const slug of ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]) { + const entry = entries.find(e => e.slug === slug); + expect(entry?.context_window).toBe(cap); + expect(entry?.max_context_window).toBe(cap); + expect(entry?.auto_compact_token_limit).toBe(244_800); + } + }); + + test("mergeCatalogEntriesForSync re-applies the openai cap to preserved and upgraded native rows (#1430)", () => { + const cap = 272_000; + const template = nativeTemplate(); + // A preserved genuine row and a fallback-quality row (display_name stamped with + // the bare slug) both pass through the final native-override pass on merge. + const genuineSol = { + ...template, + slug: "gpt-5.6-sol", + display_name: "GPT-5.6-Sol", + context_window: 372_000, + max_context_window: 372_000, + auto_compact_token_limit: 334_800, + supported_reasoning_levels: [ + { effort: "low", description: "l" }, { effort: "high", description: "h" }, + { effort: "max", description: "m" }, { effort: "ultra", description: "u" }, + ], + }; + const merged = mergeCatalogEntriesForSync( + [genuineSol], + [], + new Map(), + [], + false, + new Set(), + template, + new Set(), + new Set(), + "default", + new Set(), + false, + true, + [], + new Set(), + new Set(), + cap, + ); + const sol = merged.find(e => e.slug === "gpt-5.6-sol"); + expect(sol?.context_window).toBe(cap); + expect(sol?.max_context_window).toBe(cap); + expect(sol?.auto_compact_token_limit).toBe(244_800); + // The backfilled luna row (upstream snapshot) is capped the same way. + const luna = merged.find(e => e.slug === "gpt-5.6-luna"); + expect(luna?.context_window).toBe(cap); + expect(luna?.max_context_window).toBe(cap); + expect(luna?.auto_compact_token_limit).toBe(244_800); + }); + + test("preserved gpt-5.4-mini rows get the openai cap without a hardcoded override (#1430)", () => { + const cap = 200_000; + const template = nativeTemplate(); + // gpt-5.4-mini has no NATIVE_OPENAI_CONTEXT_OVERRIDES entry; its windows come + // from the preserved disk row and must still be capped on merge. + const genuine54Mini = { + ...template, + slug: "gpt-5.4-mini", + display_name: "GPT-5.4-Mini", + context_window: 272_000, + max_context_window: 272_000, + auto_compact_token_limit: 244_800, + }; + const merged = mergeCatalogEntriesForSync( + [genuine54Mini], + [], + new Map(), + [], + false, + new Set(), + template, + new Set(), + new Set(), + "default", + new Set(), + false, + true, + [], + new Set(), + new Set(), + cap, + ); + const mini = merged.find(e => e.slug === "gpt-5.4-mini"); + expect(mini?.context_window).toBe(cap); + expect(mini?.max_context_window).toBe(cap); + expect(mini?.auto_compact_token_limit).toBe(180_000); + }); + + test("nativeOpenAiContextWindow applies the openai cap as a ceiling only when provided", () => { + expect(nativeOpenAiContextWindow("gpt-5.6-sol")).toBe(372_000); + expect(nativeOpenAiContextWindow("gpt-5.6-sol", 272_000)).toBe(272_000); + // A cap above the native value is a ceiling, not a floor. + expect(nativeOpenAiContextWindow("gpt-5.6-sol", 500_000)).toBe(372_000); + // Non-5.6 natives are capped the same way. + expect(nativeOpenAiContextWindow("gpt-5.4", 272_000)).toBe(272_000); + }); + test("catalog sync upgrades fallback-quality gpt-5.6 entries but preserves genuine ones", () => { // Fallback-quality: display_name stamped with the bare slug (ocx synthesis signature), // wrong ladder (ultra on luna) left by an older ocx version. diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index b294ba6f3..8cde1866c 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -66,6 +66,20 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(rows.find(r => r.slug === "gpt-5.6-sol")?.contextWindow).toBe(372_000); }); + test("nativeModelRows applies providerContextCaps.openai as a ceiling (#1430)", () => { + const rows = nativeModelRows({ + disabledModels: [], + providerContextCaps: { openai: 272_000 }, + }); + expect(rows.find(r => r.slug === "gpt-5.6-sol")?.contextWindow).toBe(272_000); + expect(rows.find(r => r.slug === "gpt-5.6-luna")?.contextWindow).toBe(272_000); + // gpt-5.5 (272k native) is unchanged by the same cap. + expect(rows.find(r => r.slug === "gpt-5.5")?.contextWindow).toBe(272_000); + // A cap for another provider leaves natives untouched. + const other = nativeModelRows({ providerContextCaps: { "openai-apikey": 128_000 } }); + expect(other.find(r => r.slug === "gpt-5.6-sol")?.contextWindow).toBe(372_000); + }); + test("native aliases suppress their native dashboard row and activate Desktop allowlist pruning", () => { const config = makeConfig({ disabledModels: ["gpt-5.6-sol", "gpt-5.5"], diff --git a/tests/route-explainability.test.ts b/tests/route-explainability.test.ts index db8446329..cf0cc4574 100644 --- a/tests/route-explainability.test.ts +++ b/tests/route-explainability.test.ts @@ -200,6 +200,38 @@ describe("route explainability (RI-09)", () => { expect(Object.prototype.hasOwnProperty.call(evidence, "encryptedCodexTasks")).toBe(false); }); + test("providerContextCaps.openai ceilings native openai capability evidence (#1430)", () => { + const evidence = candidateCapabilityEvidence({ + ...config(), + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "pool", + }, + }, + providerContextCaps: { openai: 272_000 }, + }, "openai", "gpt-5.6-sol"); + expect(evidence.contextWindow).toBe(272_000); + expect(evidence.encryptedCodexTasks).toBe(true); + }); + + test("native openai capability evidence keeps the 372k default without a cap", () => { + const evidence = candidateCapabilityEvidence({ + ...config(), + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "pool", + }, + }, + }, "openai", "gpt-5.6-sol"); + expect(evidence.contextWindow).toBe(372_000); + }); + test("CLI logs explain encodes request ids and supports --json", async () => { const { handleObserveCommand } = await import("../src/cli/observe"); const calls: Array<{ path: string; init?: RequestInit }> = [];