diff --git a/devlog/_plan/260817_native_model_auto_compact_budget/001_design.md b/devlog/_plan/260817_native_model_auto_compact_budget/001_design.md new file mode 100644 index 0000000000..18e106da8f --- /dev/null +++ b/devlog/_plan/260817_native_model_auto_compact_budget/001_design.md @@ -0,0 +1,62 @@ +# Per-model ChatGPT auto-compaction budgets + +## Problem + +The catalog currently derives `auto_compact_token_limit` from one fixed rule: 90% of the +advertised context window, clamped by any measured max-input ceiling. That is a sound fallback, +but it cannot express that Sol, Terra, Luna, and Daybreak may need different working reserves. +Changing the hard context window is the wrong control: it also changes admission and model +capability reporting. + +OpenCodex also cannot honestly choose a new threshold after inspecting each request. Codex reads +the catalog before the request, decides when to issue `/responses/compact`, and owns the history +that replaces the pre-compaction conversation. The proxy sees the ordinary request only after that +decision. Rewriting a shared catalog per request would race unrelated tasks, while silently issuing +a second model call would not replace the client's history and could duplicate or lose context. + +## Contract + +Add a provider setting: + +```http +PATCH /api/providers?name=openai +Content-Type: application/json + +{ + "modelAutoCompactTokenLimits": { + "gpt-5.6-sol": 800000, + "gpt-5.6-terra": 810000, + "gpt-5.6-luna": 760000, + "gpt-daybreak-blue-latest": 700000 + } +} +``` + +The same map may be stored alongside the canonical provider fields in +`providers.openai.modelAutoCompactTokenLimits`. + +The map is model-specific and soft-policy only: + +- each value may lower, never raise, the model's derived/default budget; +- hard `context_window`, `max_context_window`, and measured max-input admission stay authoritative; +- the provider-wide `providerContextCaps.openai` remains the final ceiling; +- bare, account-qualified, pinned/fallback, custom native-alias, and combo rows use the same value; +- a combo chooses the earliest effective budget among all possible targets; +- malformed, unsafe, unknown, or selector-qualified canonical OpenAI keys are rejected atomically. + +The native family defaults to a 272,000-token advertised window, so its default effective budget is +244,800 tokens (90%). When the existing native-window control opts a model into the measured +922,000-token ceiling, its derived budget becomes 829,800 tokens. All four models can then be tuned +independently below their own effective window; a soft budget never opts a model into a wider hard +window and we do not invent different product defaults without measurements. + +## Future adaptive profiles + +True task-aware compaction belongs behind an explicit Codex-client capability. A future client can +select a deterministic profile (for example balanced, tool/output-heavy, or multi-agent), calculate +a request/session budget, and clamp it below this model-specific ceiling. Structural signals such as +tool-schema size, images, output reservation, and collaboration mode are reproducible; asking the +model to judge its own threshold is not suitable for enforcement because it adds a prompt-injectable, +nondeterministic call exactly when context is scarce. + +Until such a capability exists, this per-model setting is the safe static authority and fallback. diff --git a/src/codex/catalog/aggregation.ts b/src/codex/catalog/aggregation.ts index a4736ab4b0..3b2a519a7d 100644 --- a/src/codex/catalog/aggregation.ts +++ b/src/codex/catalog/aggregation.ts @@ -12,7 +12,7 @@ import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, mo import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata"; import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; import { getProviderRegistryEntry } from "../../providers/registry"; -import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap"; +import { applyProviderContextCap, clampAutoCompactTokenLimit, providerContextCap } from "../../providers/context-cap"; import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../../providers/slug-codec"; import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity"; import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; @@ -156,6 +156,15 @@ export function deriveComboCatalogModel( const maxInputTokens = Math.min( ...members.map(member => member.maxInputTokens ?? member.contextWindow!), ); + // A failover combo must compact early enough for every possible target. Missing + // per-model metadata means "use that member's normal derived budget", not "ignore it". + const autoCompactTokenLimit = Math.min( + ...members.map(member => clampAutoCompactTokenLimit( + member.contextWindow!, + member.maxInputTokens, + member.autoCompactTokenLimit, + )), + ); const defaultReasoningEffort = effectiveComboDefault( combo.defaultEffort, reasoningEfforts, @@ -167,6 +176,7 @@ export function deriveComboCatalogModel( owned_by: COMBO_NAMESPACE, contextWindow, maxInputTokens, + autoCompactTokenLimit, ...(hasLimitingContextCapMetadata ? { contextCapped } : {}), inputModalities, reasoningEfforts, @@ -207,6 +217,7 @@ export function comboCatalogWarningSignature( key, contextWindow: member?.contextWindow ?? null, maxInputTokens: member?.maxInputTokens ?? null, + autoCompactTokenLimit: member?.autoCompactTokenLimit ?? null, inputModalities: [...new Set(member?.inputModalities ?? [])].sort(), reasoningEfforts: [...new Set(member?.reasoningEfforts ?? [])].sort(), parallelToolCalls: member?.parallelToolCalls === true, @@ -296,6 +307,7 @@ export function normalizedOpenAiApiSignature(model: CatalogModel): string { id: model.id, contextWindow: model.contextWindow ?? null, maxInputTokens: model.maxInputTokens ?? null, + autoCompactTokenLimit: model.autoCompactTokenLimit ?? null, inputModalities: [...new Set(model.inputModalities ?? [])].sort(), reasoningEfforts: [...new Set(model.reasoningEfforts ?? [])].sort(), ownedBy: model.owned_by ?? null, diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index a2c462d046..75aa6f2c23 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -12,7 +12,7 @@ import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, mo import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata"; import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; import { getProviderRegistryEntry } from "../../providers/registry"; -import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap"; +import { applyProviderContextCap, clampAutoCompactTokenLimit, providerContextCap } from "../../providers/context-cap"; import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec"; import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity"; import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; @@ -125,9 +125,21 @@ export function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel) if (typeof model.contextWindow === "number" && model.contextWindow > 0) { entry.context_window = model.contextWindow; entry.max_context_window = model.contextWindow; - entry.auto_compact_token_limit = Math.min( - Math.floor(model.contextWindow * 0.9), - model.maxInputTokens ?? Number.POSITIVE_INFINITY, + } + const effectiveContextWindow = typeof model.contextWindow === "number" && model.contextWindow > 0 + ? model.contextWindow + : typeof entry.context_window === "number" && entry.context_window > 0 + ? entry.context_window + : 128_000; + if ( + (typeof model.contextWindow === "number" && model.contextWindow > 0) + || (typeof model.autoCompactTokenLimit === "number" && model.autoCompactTokenLimit > 0) + || (typeof model.maxInputTokens === "number" && model.maxInputTokens > 0) + ) { + entry.auto_compact_token_limit = clampAutoCompactTokenLimit( + effectiveContextWindow, + model.maxInputTokens, + model.autoCompactTokenLimit, ); } if (Array.isArray(model.inputModalities) && model.inputModalities.length > 0) { diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index 6733f5aa6c..a9e69a95dc 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -13,7 +13,7 @@ import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, mo import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata"; import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; import { getProviderRegistryEntry } from "../../providers/registry"; -import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap"; +import { applyProviderContextCap, clampAutoCompactTokenLimit, providerContextCap } from "../../providers/context-cap"; import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec"; import { identifyRoutedModel } from "../../adapters/identity"; import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; @@ -144,10 +144,22 @@ const NATIVE_GPT56_FAMILY = new Set([ NATIVE_DAYBREAK_BLUE_MODEL, ]); -export const NATIVE_OPENAI_CONTEXT_OVERRIDES: Record = { +export interface NativeOpenAiContextOverride { + contextWindow?: number; + maxContextWindow?: number; + /** Measured hard input ceiling. This is admission authority, not compaction policy. */ + maxInputTokens?: number; + /** Model-specific soft budget exposed to Codex as auto_compact_token_limit. */ + autoCompactTokenLimit?: number; +} + +export const NATIVE_OPENAI_CONTEXT_OVERRIDES: Record = { "gpt-5.5": { contextWindow: 272_000, maxContextWindow: 272_000 }, "gpt-5.4": { contextWindow: 1_000_000, maxContextWindow: 1_000_000 }, "gpt-5.3-codex-spark": { contextWindow: 100_000, maxContextWindow: 100_000 }, + // The shared clamp derives 90% of the effective 272k default (or 922k opt-in) for every + // GPT-5.6 member. Per-model soft budgets may lower that value; no ineffective Sol-only + // upper bound is stored here. "gpt-5.6-sol": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_MAX_INPUT_TOKENS, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS }, "gpt-5.6-terra": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_MAX_INPUT_TOKENS, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS }, "gpt-5.6-luna": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_MAX_INPUT_TOKENS, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS }, @@ -274,6 +286,30 @@ export function nativeOpenAiMaxInputTokens(slug: string, limits?: NativeContextL return window === undefined ? narrowed : Math.min(narrowed, window); } +/** Effective native soft budget. User configuration may lower, but never raise, the model default. */ +export function nativeOpenAiAutoCompactTokenLimit( + slug: string, + limits?: NativeContextLimitsInput, + configuredLimit?: number, +): number | undefined { + const contextWindow = nativeOpenAiContextWindow(slug, limits); + if (contextWindow === undefined) return undefined; + const nativeDefault = NATIVE_OPENAI_CONTEXT_OVERRIDES[slug]?.autoCompactTokenLimit; + const positiveConfigured = typeof configuredLimit === "number" && configuredLimit > 0 + ? configuredLimit + : undefined; + const softLimit = nativeDefault === undefined + ? positiveConfigured + : positiveConfigured === undefined + ? nativeDefault + : Math.min(nativeDefault, positiveConfigured); + return clampAutoCompactTokenLimit( + contextWindow, + nativeOpenAiMaxInputTokens(slug, limits), + softLimit, + ); +} + export function nativeInputModalities(slug: string): string[] { const upstream = PINNED_NATIVE_CAPABILITY_ENTRIES.get(slug); if (Array.isArray(upstream?.input_modalities) && upstream!.input_modalities!.length > 0) { @@ -384,7 +420,9 @@ export function desktopVisibleNativeSlugs( ]); } -export function nativeModelRows(config: Pick): Array<{ slug: string; disabled: boolean; contextWindow?: number; maxInputTokens?: number }> { +export function nativeModelRows( + config: Pick, +): Array<{ slug: string; disabled: boolean; contextWindow?: number; maxInputTokens?: number; autoCompactTokenLimit?: number }> { const disabled = disabledNativeSlugs(config); const shadowed = configuredNativeAliasSlugs(config); // Both user levers, not just the cap: a per-model window set from the dashboard has to show @@ -393,11 +431,17 @@ export function nativeModelRows(config: Pick !shadowed.has(slug)).map(slug => { const contextWindow = nativeOpenAiContextWindow(slug, limits); const maxInputTokens = nativeOpenAiMaxInputTokens(slug, limits); + const autoCompactTokenLimit = nativeOpenAiAutoCompactTokenLimit( + slug, + limits, + config.providers?.[OPENAI_CODEX_PROVIDER_ID]?.modelAutoCompactTokenLimits?.[slug], + ); return { slug, disabled: disabled.has(slug), ...(contextWindow !== undefined ? { contextWindow } : {}), ...(maxInputTokens !== undefined ? { maxInputTokens } : {}), + ...(autoCompactTokenLimit !== undefined ? { autoCompactTokenLimit } : {}), }; }); } diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index f42049150e..bbf3c46475 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -12,7 +12,7 @@ import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, mo import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata"; import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; import { getProviderRegistryEntry } from "../../providers/registry"; -import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap"; +import { applyProviderContextCap, clampAutoCompactTokenLimit, providerContextCap } from "../../providers/context-cap"; import { encodeRoutedModelId, routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec"; import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity"; import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; @@ -111,6 +111,8 @@ export interface CatalogModel { defaultReasoningEffort?: string; contextWindow?: number; maxInputTokens?: number; + /** Soft compaction budget; always clamped below the model's hard context and input ceilings. */ + autoCompactTokenLimit?: number; contextCap?: number; contextCapped?: boolean; inputModalities?: string[]; @@ -275,11 +277,21 @@ export function isNativeOpenAiEntry(entry: RawEntry): boolean { * longer trips this (922,000 window, 829,800 at 90%), but the routed and API-key rows carry * the same family at a 1,050,000 window where 90% would be 945,000 — past the ceiling. */ -function nativeAutoCompactLimit(contextWindow: number, maxInputTokens: number | undefined, contextCap?: number): number { - const ninety = Math.floor(contextWindow * 0.9); - if (typeof maxInputTokens !== "number" || maxInputTokens <= 0) return ninety; - const cappedMaxInput = applyProviderContextCap(maxInputTokens, contextCap) ?? maxInputTokens; - return Math.min(ninety, cappedMaxInput, contextWindow); +function nativeAutoCompactLimit( + contextWindow: number, + maxInputTokens: number | undefined, + nativeDefault: number | undefined, + configuredLimit: number | undefined, +): number { + const positiveConfigured = typeof configuredLimit === "number" && configuredLimit > 0 + ? configuredLimit + : undefined; + const softLimit = nativeDefault === undefined + ? positiveConfigured + : positiveConfigured === undefined + ? nativeDefault + : Math.min(nativeDefault, positiveConfigured); + return clampAutoCompactTokenLimit(contextWindow, maxInputTokens, softLimit); } /** @@ -302,7 +314,11 @@ function narrowNativeMaxContextWindow( return Math.min(value, Math.max(resolved, 1)); } -export function applyNativeOpenAiContextOverride(entry: RawEntry, limits?: NativeContextLimitsInput): void { +export function applyNativeOpenAiContextOverride( + entry: RawEntry, + limits?: NativeContextLimitsInput, + modelAutoCompactTokenLimits?: Readonly>, +): void { const nativeSlug = trustedAccountBoundNativeCatalogSlug(entry) ?? (isNativeOpenAiEntry(entry) ? entry.slug as string : undefined); if (!nativeSlug) return; @@ -317,7 +333,8 @@ export function applyNativeOpenAiContextOverride(entry: RawEntry, limits?: Nativ entry.auto_compact_token_limit = nativeAutoCompactLimit( contextWindow, nativeOpenAiMaxInputTokens(nativeSlug, limits) ?? override.maxInputTokens, - undefined, + override.autoCompactTokenLimit, + modelAutoCompactTokenLimits?.[nativeSlug], ); } if (typeof override.maxContextWindow === "number") { @@ -336,7 +353,18 @@ export function applyNativeOpenAiContextOverride(entry: RawEntry, limits?: Nativ entry.auto_compact_token_limit = nativeAutoCompactLimit( cappedContext, nativeOpenAiMaxInputTokens(nativeSlug, limits) ?? override?.maxInputTokens, - undefined, + override?.autoCompactTokenLimit, + modelAutoCompactTokenLimits?.[nativeSlug], + ); + } + const configuredAutoCompact = modelAutoCompactTokenLimits?.[nativeSlug]; + const effectiveContext = typeof entry.context_window === "number" ? entry.context_window : undefined; + if (typeof configuredAutoCompact === "number" && configuredAutoCompact > 0 && effectiveContext !== undefined) { + entry.auto_compact_token_limit = nativeAutoCompactLimit( + effectiveContext, + nativeOpenAiMaxInputTokens(nativeSlug, limits) ?? override?.maxInputTokens, + override?.autoCompactTokenLimit, + configuredAutoCompact, ); } const currentMax = typeof entry.max_context_window === "number" ? entry.max_context_window : undefined; diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 94ff8402f6..562a553204 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -39,7 +39,7 @@ import { } from "../../providers/service-tier"; import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../providers/registry"; import { parseAntigravityAvailableModels } from "../../providers/antigravity-models"; -import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap"; +import { applyProviderContextCap, clampAutoCompactTokenLimit, providerContextCap } from "../../providers/context-cap"; import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec"; import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity"; import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; @@ -74,7 +74,21 @@ import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } fr import { CODEX_CUSTOM_MODEL_CATALOG_KIND, JAWCODE_CATALOG_AUGMENT_PROVIDERS, catalogModelSlug, shouldExposeRoutedModel } from "./parsing"; import type { CatalogModel } from "./parsing"; -import { disabledNativeSlugs, hasComboTargets, isNativeOpenAiCapabilityAliasModel, NATIVE_GPT56_MAX_INPUT_TOKENS, nativeContextLimits, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata"; +import { + disabledNativeSlugs, + hasComboTargets, + isNativeOpenAiCapabilityAliasModel, + NATIVE_GPT56_MAX_INPUT_TOKENS, + nativeContextLimits, + nativeDefaultReasoningEffort, + nativeInputModalities, + nativeOpenAiAutoCompactTokenLimit, + nativeOpenAiContextWindow, + nativeOpenAiMaxInputTokens, + nativeOpenAiSlugs, + nativeParallelToolCalls, + nativeReasoningEfforts, +} from "./metadata"; import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation"; import type { ComboCatalogOmission } from "./aggregation"; import type { CatalogGatherProviderAuthEvidence } from "./filesystem-evidence"; @@ -569,6 +583,7 @@ function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Reco ctx: prov.contextWindow ?? null, ctxW: prov.modelContextWindows ?? null, maxIn: prov.modelMaxInputTokens ?? null, + autoCompact: prov.modelAutoCompactTokenLimits ?? null, inMod: prov.modelInputModalities ?? null, re: prov.modelReasoningEfforts ?? null, defRe: prov.modelDefaultReasoningEfforts ?? null, @@ -623,6 +638,12 @@ export function configuredMaxInputTokens(prov: OcxProviderConfig, id: string): n return typeof configured === "number" && configured > 0 ? configured : undefined; } +export function configuredAutoCompactTokenLimit(prov: OcxProviderConfig | undefined, id: string): number | undefined { + if (!prov) return undefined; + const configured = modelRecordValue(prov.modelAutoCompactTokenLimits, id); + return typeof configured === "number" && configured > 0 ? configured : undefined; +} + function configuredReasoningSummarySupport(prov: OcxProviderConfig | undefined, id: string): boolean | undefined { if (!prov) return undefined; const explicit = modelRecordValue(prov.modelSupportsReasoningSummaries, id); @@ -634,6 +655,7 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, void name; const configuredCap = configuredContextWindow(prov, model.id); const configuredMaxInput = configuredMaxInputTokens(prov, model.id); + const configuredAutoCompact = configuredAutoCompactTokenLimit(prov, model.id); let inputModalities = configuredInputModalities(prov, model.id); // Vision-sidecar coverage: `noVisionModels` marks models whose images the PROXY describes // (src/vision/index.ts). The catalog must still advertise image input for them — the Codex app @@ -666,6 +688,13 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, : configuredMaxInput, } : {}), + ...(configuredAutoCompact !== undefined + ? { + autoCompactTokenLimit: typeof model.autoCompactTokenLimit === "number" && model.autoCompactTokenLimit > 0 + ? Math.min(model.autoCompactTokenLimit, configuredAutoCompact) + : configuredAutoCompact, + } + : {}), ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}), ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}), ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}), @@ -677,10 +706,36 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, : {}), }; const capped = applyProviderContextCap(hinted.contextWindow, providerCap); - if (providerCap !== undefined && capped !== hinted.contextWindow) { - return { ...hinted, contextWindow: capped, contextCap: providerCap, contextCapped: true }; + const withCap = providerCap !== undefined && capped !== hinted.contextWindow + ? { ...hinted, contextWindow: capped, contextCap: providerCap, contextCapped: true } + : providerCap !== undefined + ? { ...hinted, contextCap: providerCap, contextCapped: false } + : hinted; + const boundedMaxInputTokens = typeof withCap.contextWindow === "number" + && withCap.contextWindow > 0 + && typeof withCap.maxInputTokens === "number" + && withCap.maxInputTokens > 0 + ? Math.min(withCap.maxInputTokens, withCap.contextWindow) + : withCap.maxInputTokens; + const bounded = boundedMaxInputTokens !== withCap.maxInputTokens + ? { ...withCap, maxInputTokens: boundedMaxInputTokens } + : withCap; + if ( + typeof bounded.contextWindow === "number" + && bounded.contextWindow > 0 + && typeof bounded.autoCompactTokenLimit === "number" + && bounded.autoCompactTokenLimit > 0 + ) { + return { + ...bounded, + autoCompactTokenLimit: clampAutoCompactTokenLimit( + bounded.contextWindow, + bounded.maxInputTokens, + bounded.autoCompactTokenLimit, + ), + }; } - return providerCap !== undefined ? { ...hinted, contextCap: providerCap, contextCapped: false } : hinted; + return bounded; } export function catalogHintsFromProviderConfig(name: string, prov: OcxProviderConfig, id: string, contextCap?: number): Partial { @@ -705,6 +760,7 @@ interface ComboCatalogMemberFallback { readonly contextWindow?: number; /** Input ceiling when it is lower than the window (native GPT-5.6: 922k under 1.05M). */ readonly maxInputTokens?: number; + readonly autoCompactTokenLimit?: number; readonly inputModalities?: readonly string[]; readonly reasoningEfforts?: readonly string[]; } @@ -739,17 +795,40 @@ export function resolveComboCatalogMember( : undefined; const addMaxInput = contextWindow !== undefined && !(typeof member.maxInputTokens === "number" && member.maxInputTokens > 0); + const fallbackAutoCompact = typeof fallback.autoCompactTokenLimit === "number" + && fallback.autoCompactTokenLimit > 0 + ? fallback.autoCompactTokenLimit + : undefined; const addModalities = (!Array.isArray(member.inputModalities) || member.inputModalities.length === 0) && fallback.inputModalities !== undefined; const addReasoning = member.reasoningEfforts === undefined && fallback.reasoningEfforts !== undefined; - if (!addMaxInput && !addModalities && !addReasoning) return member; + const maxInputTokens = addMaxInput + ? Math.min(fallback.maxInputTokens ?? contextWindow!, contextWindow!) + : member.maxInputTokens; + const currentAutoCompact = typeof member.autoCompactTokenLimit === "number" + && member.autoCompactTokenLimit > 0 + ? member.autoCompactTokenLimit + : undefined; + const effectiveAutoCompact = contextWindow !== undefined && fallbackAutoCompact !== undefined + ? clampAutoCompactTokenLimit( + contextWindow, + maxInputTokens, + currentAutoCompact === undefined + ? fallbackAutoCompact + : Math.min(currentAutoCompact, fallbackAutoCompact), + ) + : currentAutoCompact; + const adjustAutoCompact = effectiveAutoCompact !== undefined + && effectiveAutoCompact !== currentAutoCompact; + if (!addMaxInput && !adjustAutoCompact && !addModalities && !addReasoning) return member; return { ...member, // Never claim a larger input budget than the window, and prefer the model's own // measured ceiling when the fallback carries one. - ...(addMaxInput - ? { maxInputTokens: Math.min(fallback.maxInputTokens ?? contextWindow!, contextWindow!) } + ...(addMaxInput ? { maxInputTokens } : {}), + ...(adjustAutoCompact + ? { autoCompactTokenLimit: effectiveAutoCompact } : {}), ...(addModalities ? { inputModalities: [...fallback.inputModalities!] } : {}), ...(addReasoning ? { reasoningEfforts: [...fallback.reasoningEfforts!] } : {}), @@ -776,6 +855,15 @@ export function resolveComboCatalogMember( ...existing, contextWindow: capped, maxInputTokens: maxInput, + ...(typeof existing.autoCompactTokenLimit === "number" && existing.autoCompactTokenLimit > 0 + ? { + autoCompactTokenLimit: clampAutoCompactTokenLimit( + capped, + maxInput, + existing.autoCompactTokenLimit, + ), + } + : {}), contextCap, contextCapped: true as const, }); @@ -830,6 +918,14 @@ export function resolveComboCatalogMember( const maxInputTokens = effectiveMaxInput !== undefined ? Math.min(effectiveMaxInput, contextWindow) : contextWindow; + const autoCompactCandidates = [ + hinted.autoCompactTokenLimit, + base.autoCompactTokenLimit, + fallback?.autoCompactTokenLimit, + ].filter((value): value is number => typeof value === "number" && value > 0); + const autoCompactTokenLimit = autoCompactCandidates.length > 0 + ? clampAutoCompactTokenLimit(contextWindow, maxInputTokens, Math.min(...autoCompactCandidates)) + : undefined; return { ...hinted, @@ -837,6 +933,7 @@ export function resolveComboCatalogMember( ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}), contextWindow, maxInputTokens, + ...(autoCompactTokenLimit !== undefined ? { autoCompactTokenLimit } : {}), ...(fallbackCapped ? { contextCap, contextCapped: true as const } : {}), }; } @@ -1682,6 +1779,7 @@ async function gatherRoutedModelsUncached( // exposure decision goes through shouldExposeRoutedModel (single choke point). .filter(shouldExposeRoutedModel); const memberByKey = new Map(all.map(model => [`${model.provider}/${model.id}`, model])); + const openaiContextLimits = nativeContextLimits(config); // [Decision Log] // - 목적과 의도: 콤보 타겟에 native OpenAI(Codex login) 모델이 포함될 때 카탈로그에서 // 누락되는 버그(issue #268)를 수정. "openai" provider는 forward-auth(Codex login @@ -1697,17 +1795,14 @@ async function gatherRoutedModelsUncached( // - 다른 대안 대신 이 방식을 선택한 이유: 기존 native 모델 노출 경로(/v1/models, 온디스크 // 카탈로그 sync, management API)를 전혀 변경하지 않고 콤보 resolution만 수선하기 때문. // - 장점, 단점 및 영향: 장점 — 최소 수정, 기존 경로 무변경. 단점 — synthetic entries의 - // capability 데이터가 static/upstream snapshot 기반이므로, 사용자가 커스텀 config - // 힌트(modelContextWindows 등)로 native 모델의 context window를 오버라이드한 경우 - // 반영되지 않음. 하지만 nativeOpenAiContextWindow가 이미 config 오버라이드를 - // 우선시하므로 실제 충돌 가능성은 낮음. + // capability 데이터가 static/upstream snapshot 기반이므로 모든 operator overlay는 + // nativeContextLimits를 통해 명시적으로 전달해야 함. if (!hasComboTargets(config)) { // Skip the native slug injection entirely when no combos are configured — avoids // calling nativeOpenAiSlugs() (which reads the live Codex catalog from disk) for // configs that will never need it. } else { const disabled = disabledNativeSlugs(config); - const openaiContextCap = nativeContextLimits(config); const requiredNativeComboTargets = new Set(listComboIds(config).flatMap(id => { const combo = getCombo(config, id); return combo?.targets.flatMap(target => ( @@ -1718,7 +1813,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, openaiContextCap); + const contextWindow = nativeOpenAiContextWindow(slug, openaiContextLimits); if (contextWindow === undefined) continue; const synthetic: CatalogModel = { provider: "openai", @@ -1729,7 +1824,12 @@ async function gatherRoutedModelsUncached( // advertised 922,000 window is already capped at its measured ceiling), but the two // stay separate fields because routed/API rows of the same family run a wider window. // Falls back to the window for slugs with no separate ceiling. - maxInputTokens: Math.min(nativeOpenAiMaxInputTokens(slug, openaiContextCap) ?? contextWindow, contextWindow), + maxInputTokens: Math.min(nativeOpenAiMaxInputTokens(slug, openaiContextLimits) ?? contextWindow, contextWindow), + autoCompactTokenLimit: nativeOpenAiAutoCompactTokenLimit( + slug, + openaiContextLimits, + configuredAutoCompactTokenLimit(config.providers[OPENAI_CODEX_PROVIDER_ID]!, slug), + ), inputModalities: nativeInputModalities(slug), reasoningEfforts: nativeReasoningEfforts(slug), ...(nativeParallelToolCalls(slug) ? { parallelToolCalls: true } : {}), @@ -1747,17 +1847,26 @@ async function gatherRoutedModelsUncached( const combo = getCombo(config, id); if (!combo) continue; const nativeContextWindow = combo.nativeAlias && combo.alias - ? nativeOpenAiContextWindow(combo.alias, nativeContextLimits(config)) + ? nativeOpenAiContextWindow(combo.alias, openaiContextLimits) : undefined; const nativeAliasMaxInput = combo.nativeAlias && combo.alias ? (combo.alias.startsWith("gpt-5.6-") || combo.alias.includes("daybreak") ? NATIVE_GPT56_MAX_INPUT_TOKENS - : nativeOpenAiMaxInputTokens(combo.alias) ?? nativeOpenAiContextWindow(combo.alias)) + : nativeOpenAiMaxInputTokens(combo.alias, openaiContextLimits) + ?? nativeOpenAiContextWindow(combo.alias, openaiContextLimits)) + : undefined; + const nativeAliasAutoCompact = combo.nativeAlias && combo.alias + ? nativeOpenAiAutoCompactTokenLimit( + combo.alias, + openaiContextLimits, + configuredAutoCompactTokenLimit(config.providers[OPENAI_CODEX_PROVIDER_ID]!, combo.alias), + ) : undefined; const nativeAliasFallback = combo.nativeAlias && combo.alias && nativeContextWindow !== undefined ? { contextWindow: nativeContextWindow, ...(nativeAliasMaxInput !== undefined ? { maxInputTokens: nativeAliasMaxInput } : {}), + ...(nativeAliasAutoCompact !== undefined ? { autoCompactTokenLimit: nativeAliasAutoCompact } : {}), inputModalities: nativeInputModalities(combo.alias), reasoningEfforts: nativeReasoningEfforts(combo.alias), } @@ -1803,10 +1912,18 @@ async function gatherRoutedModelsUncached( && providerForCanonicalCheck !== undefined && isCanonicalOpenAiForwardProvider(providerForCanonicalCheck) && isNativeOpenAiCapabilityAliasModel(cm.modelId); + const providerHints = effectiveProvider + ? catalogHintsFromProviderConfig( + cm.provider, + effectiveProvider, + cm.modelId, + providerContextCap(config, cm.provider), + ) + : {}; const customNativeLimits = { - ...nativeContextLimits(config), + ...openaiContextLimits, ...(typeof cm.contextWindow === "number" && cm.contextWindow > 0 - ? { modelWindows: { ...(nativeContextLimits(config).modelWindows ?? {}), [cm.modelId]: cm.contextWindow } } + ? { modelWindows: { ...(openaiContextLimits.modelWindows ?? {}), [cm.modelId]: cm.contextWindow } } : {}), }; const nativeAliasContextWindow = codexForwardNativeCapabilityAlias @@ -1816,13 +1933,28 @@ async function gatherRoutedModelsUncached( ? nativeAliasContextWindow !== undefined ? nativeAliasContextWindow : cm.contextWindow - : nativeAliasContextWindow; + : nativeAliasContextWindow ?? providerHints.contextWindow; + // Input ceiling for a native capability alias, clamped to whatever window we settled on + // above. A custom row that lowered the window must not keep the full native input budget. const nativeAliasMaxInputTokens = codexForwardNativeCapabilityAlias ? nativeOpenAiMaxInputTokens(cm.modelId, customNativeLimits) : undefined; - const customMaxInputTokens = nativeAliasMaxInputTokens !== undefined && customContextWindow !== undefined - ? Math.min(nativeAliasMaxInputTokens, customContextWindow) - : nativeAliasMaxInputTokens; + const maxInputCandidates = [nativeAliasMaxInputTokens, providerHints.maxInputTokens] + .filter((value): value is number => typeof value === "number" && value > 0); + const customMaxInputTokens = maxInputCandidates.length > 0 + ? Math.min(...maxInputCandidates, customContextWindow ?? Number.POSITIVE_INFINITY) + : undefined; + const configuredAutoCompact = configuredAutoCompactTokenLimit(rawProvider, cm.modelId); + const nativeAliasAutoCompact = codexForwardNativeCapabilityAlias + ? nativeOpenAiAutoCompactTokenLimit( + cm.modelId, + customNativeLimits, + configuredAutoCompact, + ) + : providerHints.autoCompactTokenLimit ?? configuredAutoCompact; + const customAutoCompactTokenLimit = nativeAliasAutoCompact !== undefined && customContextWindow !== undefined + ? clampAutoCompactTokenLimit(customContextWindow, customMaxInputTokens, nativeAliasAutoCompact) + : nativeAliasAutoCompact; const nativeAliasDefaultEffort = codexForwardNativeCapabilityAlias ? nativeDefaultReasoningEffort(cm.modelId) : undefined; @@ -1840,6 +1972,9 @@ async function gatherRoutedModelsUncached( : codexForwardNativeCapabilityAlias ? { displayName: "Daybreak Blue" } : {}), ...(customContextWindow !== undefined ? { contextWindow: customContextWindow } : {}), ...(customMaxInputTokens !== undefined ? { maxInputTokens: customMaxInputTokens } : {}), + ...(customAutoCompactTokenLimit !== undefined + ? { autoCompactTokenLimit: customAutoCompactTokenLimit } + : {}), ...(cm.inputModalities ? { inputModalities: cm.inputModalities } : codexForwardNativeCapabilityAlias ? { inputModalities: nativeInputModalities(cm.modelId) } : {}), @@ -1877,10 +2012,25 @@ async function gatherRoutedModelsUncached( // along when it is actually a member — otherwise a provider default like "xhigh" would // re-apply onto a narrower custom ladder and override the fallback in applyReasoningLevels. const effectiveLadder = base.reasoningEfforts ?? replaced?.reasoningEfforts; + const mergedContextWindow = base.contextWindow ?? replaced?.contextWindow; + const mergedMaxInputTokens = base.maxInputTokens ?? replaced?.maxInputTokens; + const mergedAutoCompactCandidate = base.autoCompactTokenLimit ?? replaced?.autoCompactTokenLimit; + const mergedAutoCompactTokenLimit = mergedAutoCompactCandidate !== undefined + && typeof mergedContextWindow === "number" + && mergedContextWindow > 0 + ? clampAutoCompactTokenLimit( + mergedContextWindow, + mergedMaxInputTokens, + mergedAutoCompactCandidate, + ) + : mergedAutoCompactCandidate; const merged: CatalogModel = replaced ? { ...base, ...(base.contextWindow === undefined && replaced.contextWindow !== undefined ? { contextWindow: replaced.contextWindow } : {}), ...(base.maxInputTokens === undefined && replaced.maxInputTokens !== undefined ? { maxInputTokens: replaced.maxInputTokens } : {}), + ...(mergedAutoCompactTokenLimit !== undefined + ? { autoCompactTokenLimit: mergedAutoCompactTokenLimit } + : {}), ...(base.inputModalities === undefined && replaced.inputModalities !== undefined ? { inputModalities: replaced.inputModalities } : {}), ...(base.reasoningEfforts === undefined && replaced.reasoningEfforts !== undefined ? { reasoningEfforts: replaced.reasoningEfforts } : {}), ...(base.defaultReasoningEffort === undefined && replaced.defaultReasoningEffort !== undefined @@ -1954,19 +2104,31 @@ function augmentRoutedModelsWithCapturedOpenAiApiRows( const officialMaxInput = policy.modelMaxInputTokens?.[id]; const userContext = configured.modelContextWindows?.[id] ?? configured.contextWindow; const userMaxInput = configured.modelMaxInputTokens?.[id]; + const userAutoCompact = configured.modelAutoCompactTokenLimits?.[id]; const providerCap = providerContextCap(config, OPENAI_API_PROVIDER_ID); const contextWindow = typeof officialContext === "number" ? Math.min(officialContext, userContext ?? officialContext, providerCap ?? officialContext) : undefined; - const maxInputTokens = typeof officialMaxInput === "number" + const rawMaxInputTokens = typeof officialMaxInput === "number" ? Math.min(officialMaxInput, userMaxInput ?? officialMaxInput) : undefined; + const maxInputTokens = rawMaxInputTokens !== undefined && contextWindow !== undefined + ? Math.min(rawMaxInputTokens, contextWindow) + : rawMaxInputTokens; + const autoCompactTokenLimit = typeof userAutoCompact === "number" + && userAutoCompact > 0 + && contextWindow !== undefined + ? clampAutoCompactTokenLimit(contextWindow, maxInputTokens, userAutoCompact) + : undefined; return { provider: OPENAI_API_PROVIDER_ID, id, owned_by: OPENAI_API_PROVIDER_ID, ...(contextWindow ? { contextWindow } : {}), ...(maxInputTokens ? { maxInputTokens } : {}), + ...(autoCompactTokenLimit !== undefined + ? { autoCompactTokenLimit } + : {}), ...(policy.modelInputModalities?.[id] ? { inputModalities: [...policy.modelInputModalities[id]!] } : {}), ...(policy.modelReasoningEfforts?.[id] ? { reasoningEfforts: [...policy.modelReasoningEfforts[id]!] } : {}), }; diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 9ee2886028..51e236363c 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -13,7 +13,7 @@ import { modelInList } from "../../types"; import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata"; import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; -import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap"; +import { applyProviderContextCap } from "../../providers/context-cap"; import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../../providers/slug-codec"; import { identifyRoutedModel } from "../../adapters/identity"; import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; @@ -226,9 +226,14 @@ export function effectiveSubagentRoster( return { candidates, advertised, excluded }; } -export function finishUpstreamNativeEntry(clone: RawEntry, priority: number, contextCap?: NativeContextLimitsInput): RawEntry { +export function finishUpstreamNativeEntry( + clone: RawEntry, + priority: number, + contextCap?: NativeContextLimitsInput, + modelAutoCompactTokenLimits?: Readonly>, +): RawEntry { if (priority !== 9) clone.priority = priority; - applyNativeOpenAiContextOverride(clone, contextCap); + applyNativeOpenAiContextOverride(clone, contextCap, modelAutoCompactTokenLimits); // 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. @@ -280,6 +285,7 @@ export function deriveEntry( model?: CatalogModel, exactComboSlugs: ReadonlySet = new Set(), contextCap?: NativeContextLimitsInput, + modelAutoCompactTokenLimits?: Readonly>, ): RawEntry { const preserveExact = isExactComboCatalogModel(model, exactComboSlugs); const codexForwardNativeCapabilityAlias = model?.codexForwardNativeCapabilityAlias === true @@ -291,7 +297,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, contextCap); + if (upstream) return finishUpstreamNativeEntry(upstream, priority, contextCap, modelAutoCompactTokenLimits); } if (template || codexForwardNativeCapabilityAlias) { const e = JSON.parse(JSON.stringify(codexForwardNativeCapabilityAlias ?? template)) as RawEntry; @@ -337,7 +343,7 @@ export function deriveEntry( applyCatalogModelMetadata(e, model); if (model?.catalogKind) e.opencodex_catalog_kind = model.catalogKind; } else { - applyNativeOpenAiContextOverride(e, contextCap); + applyNativeOpenAiContextOverride(e, contextCap, modelAutoCompactTokenLimits); 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; @@ -383,7 +389,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, contextCap); + if (!isRouted) applyNativeOpenAiContextOverride(entry, contextCap, modelAutoCompactTokenLimits); return ensureStrictCatalogFields(normalizeServiceTiers(entry), { preserveExactInputModalities: preserveExact, isRouted, @@ -406,6 +412,7 @@ export interface ObservedCatalogEntryBuildInput { readonly multiAgentV2Enabled: boolean; readonly keepNativeChatGptOnV1?: boolean; readonly openaiContextCap?: NativeContextLimitsInput; + readonly openaiModelAutoCompactTokenLimits?: Readonly>; /** 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. */ @@ -428,6 +435,7 @@ export function buildCatalogEntries( accountNativeSlugs?: readonly string[], accountNativeSlugsBySelector?: ReadonlyMap, keepNativeChatGptOnV1 = false, + modelAutoCompactTokenLimits?: Readonly>, ): RawEntry[] { return buildCatalogEntriesFromObservedState({ template, @@ -443,6 +451,7 @@ export function buildCatalogEntries( multiAgentV2Enabled: isMultiAgentV2Enabled(), keepNativeChatGptOnV1, openaiContextCap: contextCap, + openaiModelAutoCompactTokenLimits: modelAutoCompactTokenLimits, accountNativeSlugs, accountNativeSlugsBySelector, }); @@ -464,6 +473,7 @@ export function buildCatalogEntriesFromObservedState({ multiAgentV2Enabled, keepNativeChatGptOnV1, openaiContextCap, + openaiModelAutoCompactTokenLimits, accountNativeSlugs, accountNativeSlugsBySelector, }: ObservedCatalogEntryBuildInput): RawEntry[] { @@ -533,7 +543,16 @@ 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, undefined, new Set(), openaiContextCap); + const native = deriveEntry( + template, + slug, + "OpenAI native model (Codex OAuth passthrough).", + 9, + undefined, + new Set(), + openaiContextCap, + openaiModelAutoCompactTokenLimits, + ); if (rank.has(slug)) native.priority = rank.get(slug)!; nativeEntries.push(native); const nativeAlias = nativeAliasesBySlug.get(slug); @@ -564,7 +583,16 @@ export function buildCatalogEntriesFromObservedState({ ?? gptSlugs; const accountNativeEntries = selectorNativeSlugs.map(slug => ( nativeEntriesBySlug.get(slug) - ?? deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9, undefined, new Set(), openaiContextCap) + ?? deriveEntry( + template, + slug, + "OpenAI native model (Codex OAuth passthrough).", + 9, + undefined, + new Set(), + openaiContextCap, + openaiModelAutoCompactTokenLimits, + ) )); for (const [nativeIndex, native] of accountNativeEntries.entries()) { const nativeSlug = String(native.slug); @@ -761,6 +789,7 @@ export interface ObservedCatalogMergeInput { readonly suppressedBareNativeSlugs?: ReadonlySet; readonly policy: ObservedCatalogMergePolicy; readonly openaiContextCap?: NativeContextLimitsInput; + readonly openaiModelAutoCompactTokenLimits?: Readonly>; } /** @@ -792,6 +821,7 @@ export function mergeCatalogEntriesFromObservedState({ suppressedBareNativeSlugs = new Set(), policy, openaiContextCap, + openaiModelAutoCompactTokenLimits, }: 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. @@ -927,7 +957,12 @@ export function mergeCatalogEntriesFromObservedState({ // genuine catalog entry (real display name) is preserved untouched. if (shouldUpgradeToUpstreamEntry(m)) { const upstream = upstreamNativeEntry(slug)!; - const finished = finishUpstreamNativeEntry(upstream, 9, openaiContextCap); + const finished = finishUpstreamNativeEntry( + upstream, + 9, + openaiContextCap, + openaiModelAutoCompactTokenLimits, + ); finished.priority = nativePriority(slug, upstream.priority); return finished; } @@ -959,6 +994,7 @@ export function mergeCatalogEntriesFromObservedState({ undefined, new Set(), openaiContextCap, + openaiModelAutoCompactTokenLimits, ); entry.priority = nativePriority(slug, upstreamNativeEntry(slug)?.priority); native.push(entry); @@ -1076,7 +1112,9 @@ export function mergeCatalogEntriesFromObservedState({ 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); + if (!isNativeAliasCatalogEntry(normalized)) { + applyNativeOpenAiContextOverride(normalized, openaiContextCap, openaiModelAutoCompactTokenLimits); + } const exactCombo = isExactComboCatalogEntry(m, exactComboSlugs); const e = ensureStrictCatalogFields(normalized, { preserveExactInputModalities: exactCombo, @@ -1149,6 +1187,7 @@ export function mergeCatalogEntriesForSync( ), openaiContextCap?: number, keepNativeChatGptOnV1 = false, + openaiModelAutoCompactTokenLimits?: Readonly>, ): 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. @@ -1185,6 +1224,7 @@ export function mergeCatalogEntriesForSync( accountBoundEntries, suppressedBareNativeSlugs, openaiContextCap, + openaiModelAutoCompactTokenLimits, policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, warningPolicy: "emit", @@ -1427,6 +1467,8 @@ function writeRetainedCatalogSync({ // Both user levers. Passing only the cap here is what let a per-model window the dashboard // had accepted get written back at full width in the on-disk catalog. const openaiContextCap = nativeContextLimits(config); + const openaiModelAutoCompactTokenLimits = config.providers[OPENAI_CODEX_PROVIDER_ID] + ?.modelAutoCompactTokenLimits; const accountSelectors = includeAccountBoundNativeOpenAi ? visibleCodexAccountSelectors(config) : []; @@ -1460,6 +1502,7 @@ function writeRetainedCatalogSync({ disabledNativeAccountSlugs: new Set(), multiAgentV2Enabled, openaiContextCap, + openaiModelAutoCompactTokenLimits, }); // 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 @@ -1506,6 +1549,7 @@ function writeRetainedCatalogSync({ multiAgentV2Enabled, keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true, openaiContextCap, + openaiModelAutoCompactTokenLimits, accountNativeSlugs, accountNativeSlugsBySelector, }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined) @@ -1532,6 +1576,7 @@ function writeRetainedCatalogSync({ accountBoundEntries, suppressedBareNativeSlugs, openaiContextCap, + openaiModelAutoCompactTokenLimits, policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, nativeBackfillSlugs: [...NATIVE_OPENAI_MODELS, ...observedNativeSlugs], diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index 6d501ef85d..70fea84a1e 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -54,6 +54,7 @@ import { disabledNativeSlugs, desktopAllowlistSuppressedNativeSlugs, NATIVE_OPENAI_MODELS, + nativeContextLimits, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, } from "./catalog/metadata"; @@ -67,6 +68,7 @@ import { } from "./catalog/effort"; import { codexRuntimeStatePath, peekCodexRuntimeProcessCache } from "./runtime"; import { withCatalogWriteSerialization } from "./catalog-write-serialization"; +import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; import { publishHashedCodexCatalogBackup, publishLegacyCodexCatalogBackup, @@ -246,6 +248,9 @@ function prepareCatalog( // selector-qualified rows when a live selector is configured. const observedNativeSlugs: string[] = []; const disabledNative = disabledNativeSlugs(config); + const openaiContextCap = nativeContextLimits(config); + const openaiModelAutoCompactTokenLimits = config.providers[OPENAI_CODEX_PROVIDER_ID] + ?.modelAutoCompactTokenLimits; const nativeCatalogModels = mergeCatalogModelsWithNativeRecovery( active?.models ?? catalog.models ?? [], [catalog.models ?? [], ...nativeRecoverySources], @@ -264,6 +269,8 @@ function prepareCatalog( suppressedBareNativeSlugs, disabledNativeAccountSlugs: new Set(), multiAgentV2Enabled, + openaiContextCap, + openaiModelAutoCompactTokenLimits, }); const accountBoundEntries = accountSelectors.length === 0 ? [] @@ -280,6 +287,8 @@ function prepareCatalog( disabledNativeAccountSlugs: new Set([...disabledNative].filter(slug => suppressedBareNativeSlugs.has(slug))), multiAgentV2Enabled, keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true, + openaiContextCap, + openaiModelAutoCompactTokenLimits, accountNativeSlugs, accountNativeSlugsBySelector, }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined); @@ -312,6 +321,8 @@ function prepareCatalog( includeNativeOpenAi, accountBoundEntries, suppressedBareNativeSlugs, + openaiContextCap, + openaiModelAutoCompactTokenLimits, policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, nativeBackfillSlugs: [...NATIVE_OPENAI_MODELS, ...observedNativeSlugs], diff --git a/src/config.ts b/src/config.ts index d879195e88..b26cf6719b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -68,6 +68,7 @@ import { type ProviderCostOverlay, } from "./types"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers"; +import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "./codex/catalog/native-models"; import { getProviderRegistryEntry, providerMatchesRegistryTransport, @@ -942,6 +943,20 @@ export function positiveIntegerRecordConfigError(value: unknown, field: string): return null; } +export function positiveSafeIntegerRecordConfigError(value: unknown, field: string): string | null { + if (value === undefined) return null; + if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`; + for (const [key, entry] of Object.entries(value)) { + if (!key.trim()) return `${field} keys must be nonblank model ids`; + if (typeof entry !== "number" || !Number.isSafeInteger(entry) || entry <= 0) { + return `${field}[${JSON.stringify(redactSecretString(key))}] must be a positive safe integer`; + } + } + return null; +} + export function positiveIntegerConfigError(value: unknown, field: string): string | null { if (value === undefined) return null; if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value <= 0) { @@ -1525,6 +1540,29 @@ const configSchema = z.object({ message: maxInputError, }); } + const autoCompactError = positiveSafeIntegerRecordConfigError( + (provider as { modelAutoCompactTokenLimits?: unknown }).modelAutoCompactTokenLimits, + "modelAutoCompactTokenLimits", + ); + if (autoCompactError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "modelAutoCompactTokenLimits"], + message: autoCompactError, + }); + } + if (name === OPENAI_CODEX_PROVIDER_ID && provider.modelAutoCompactTokenLimits) { + for (const modelId of Object.keys(provider.modelAutoCompactTokenLimits)) { + if (!SUPPORTED_NATIVE_OPENAI_SLUGS.has(modelId) || modelId.includes("/")) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "modelAutoCompactTokenLimits"], + message: `modelAutoCompactTokenLimits key ${JSON.stringify(redactSecretString(modelId))} must be an exact supported native model id`, + }); + break; + } + } + } const reasoningSummariesError = booleanRecordConfigError( (provider as { modelSupportsReasoningSummaries?: unknown }).modelSupportsReasoningSummaries, "modelSupportsReasoningSummaries", diff --git a/src/providers/context-cap.ts b/src/providers/context-cap.ts index 4a4ac3f376..213e4c04cb 100644 --- a/src/providers/context-cap.ts +++ b/src/providers/context-cap.ts @@ -27,6 +27,24 @@ export function applyProviderContextCap(contextWindow: number | undefined, cap: return contextWindow > cap ? cap : contextWindow; } +/** + * Resolve the client-facing soft compaction budget without changing any hard model limit. + * + * The 90% reserve remains the default safety envelope. A measured max-input ceiling and an + * operator-configured per-model value may only lower it; neither can advertise capacity that the + * authoritative context window does not have. + */ +export function clampAutoCompactTokenLimit( + contextWindow: number, + maxInputTokens?: number, + configuredLimit?: number, +): number { + const candidates = [Math.floor(contextWindow * 0.9), contextWindow]; + if (isValidContextCap(maxInputTokens)) candidates.push(Math.floor(maxInputTokens)); + if (isValidContextCap(configuredLimit)) candidates.push(Math.floor(configuredLimit)); + return Math.min(...candidates); +} + /** Effective global cap value: explicit config value, else the built-in default. */ export function globalContextCapValue(config: Pick): number { const value = config.contextCapValue; diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 5fb03f3d0c..146ebaf4e8 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -9,6 +9,7 @@ import { nonBlankStringArrayConfigError, positiveIntegerConfigError, positiveIntegerRecordConfigError, + positiveSafeIntegerRecordConfigError, providerBaseUrlConfigError, providerHeadersConfigError, providerModelCostsConfigError, @@ -25,6 +26,7 @@ import { providerConfigSeed } from "../providers/derive"; import type { OcxConfig, OcxProviderConfig } from "../types"; import { openRouterRoutingConfigError } from "../providers/openrouter-routing"; import { googleVertexLocationConfigError } from "../providers/google-vertex-location"; +import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../codex/catalog/native-models"; let _corsOrigin = "http://localhost:10100"; export function setCorsOrigin(port: number): void { _corsOrigin = `http://localhost:${port}`; } @@ -562,6 +564,9 @@ export function providerManagementConfigError(name: unknown, provider: unknown): if (contextOverlayError) return contextOverlayError; delete canonicalCandidate.contextWindow; delete canonicalCandidate.modelContextWindows; + // Per-model soft budgets are user-owned catalog policy. They may lower compaction timing but + // never alter the canonical provider transport or any hard model ceiling. + delete canonicalCandidate.modelAutoCompactTokenLimits; const canonical = seed && sameCanonicalProviderSeed(canonicalCandidate, seed); if (!canonical) { return `provider ${name} must equal the canonical built-in provider seed`; @@ -604,6 +609,18 @@ export function providerManagementConfigError(name: unknown, provider: unknown): if (apiKeyTransportError) return `provider ${name} ${apiKeyTransportError}`; const maxInputError = positiveIntegerRecordConfigError(raw.modelMaxInputTokens, "modelMaxInputTokens"); if (maxInputError) return `provider ${name} ${maxInputError}`; + const autoCompactError = positiveSafeIntegerRecordConfigError( + raw.modelAutoCompactTokenLimits, + "modelAutoCompactTokenLimits", + ); + if (autoCompactError) return `provider ${name} ${autoCompactError}`; + if (name === "openai" && raw.modelAutoCompactTokenLimits && typeof raw.modelAutoCompactTokenLimits === "object") { + for (const modelId of Object.keys(raw.modelAutoCompactTokenLimits as Record)) { + if (!SUPPORTED_NATIVE_OPENAI_SLUGS.has(modelId) || modelId.includes("/")) { + return `provider openai modelAutoCompactTokenLimits key ${JSON.stringify(redactSecretString(modelId))} must be an exact supported native model id`; + } + } + } const reasoningSummariesError = booleanRecordConfigError(raw.modelSupportsReasoningSummaries, "modelSupportsReasoningSummaries"); if (reasoningSummariesError) return `provider ${name} ${reasoningSummariesError}`; const reasoningSummaryDeliveryError = reasoningSummaryDeliveryRecordConfigError( @@ -701,6 +718,7 @@ export function safeConfigDTO(config: OcxConfig): unknown { "models", "contextWindow", "modelContextWindows", + "modelAutoCompactTokenLimits", "defaultMaxOutputTokens", "modelMaxOutputTokens", "openRouterRouting", diff --git a/src/server/index.ts b/src/server/index.ts index 87ece913e1..2e8fc6df1e 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -988,6 +988,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { @@ -82,6 +83,9 @@ export async function listManagementModelRows(config: OcxConfig): Promise { diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 8ecae2c46b..1f251c32e4 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import type { CatalogModel } from "../../codex/catalog"; import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; +import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../../codex/catalog/native-models"; import { clearGatherRoutedModelsInflight } from "../../codex/catalog/provider-fetch"; import { DEFAULT_SUBAGENT_MODELS, @@ -31,11 +32,12 @@ import { } from "../../oauth"; import { replaceProviderAccountSet } from "../../oauth/store"; import { providerDestinationResolvedError } from "../../lib/destination-policy"; +import { redactSecretString } from "../../lib/redact"; import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; import { ProviderOutboundPolicyError, providerOutboundGet, providerOutboundPost, providerRedirectError } from "../../lib/provider-outbound"; import { parseAntigravityAvailableModels } from "../../providers/antigravity-models"; import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; -import { deriveProviderPresets } from "../../providers/derive"; +import { deriveProviderPresets, providerConfigSeed } from "../../providers/derive"; import { effectiveGoogleMode, providerCodexAccountMode, providerMatchesRegistryTransport } from "../../providers/registry"; import { extractModelEnvelopeRows, @@ -47,7 +49,7 @@ import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; import { clearKeyCooldowns } from "../../providers/key-failover"; import { providerRequestPacingStatus } from "../../providers/request-pacing"; -import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; +import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match"; import { clearThreadAccountMap } from "../../codex/routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; @@ -247,6 +249,43 @@ function applyProviderPatchFields( } touched = true; } + if (Object.hasOwn(rawBody, "modelAutoCompactTokenLimits")) { + const value = rawBody.modelAutoCompactTokenLimits; + if (value === null) { + delete next.modelAutoCompactTokenLimits; + } else { + if (!isPlainRecord(value)) return { error: "modelAutoCompactTokenLimits must be a plain object or null" }; + // A null-prototype map keeps JSON keys such as "__proto__" as ordinary own keys, so the + // canonical supported-id validator can reject them instead of Object.prototype's setter + // silently turning the PATCH into a successful no-op. + const limits: Record = Object.assign( + Object.create(null) as Record, + next.modelAutoCompactTokenLimits ?? {}, + ); + for (const [model, limit] of Object.entries(value)) { + if (!model.trim()) return { error: "modelAutoCompactTokenLimits keys must be nonblank model ids" }; + if ( + name === OPENAI_CODEX_PROVIDER_ID + && (!SUPPORTED_NATIVE_OPENAI_SLUGS.has(model) || model.includes("/")) + ) { + return { + error: `provider openai modelAutoCompactTokenLimits key ${JSON.stringify(redactSecretString(model))} must be an exact supported native model id`, + }; + } + if (limit === null) { + delete limits[model]; + continue; + } + if (typeof limit !== "number" || !Number.isSafeInteger(limit) || limit <= 0) { + return { error: "modelAutoCompactTokenLimits values must be positive safe integers or null" }; + } + limits[model] = limit; + } + if (Object.keys(limits).length > 0) next.modelAutoCompactTokenLimits = limits; + else delete next.modelAutoCompactTokenLimits; + } + touched = true; + } if (Object.hasOwn(rawBody, "modelSupportsServiceTier")) { const value = rawBody.modelSupportsServiceTier; if (value === null) { @@ -386,6 +425,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { + const latest = config.providers[name]!; + const replay = applyProviderPatchFields(name, latest, rawBody, keys, config); + if ("error" in replay) { + replayError = replay.error; + return; + } + config.providers[name] = replay.next; + saveConfigPreservingClaudeCode(config); + }); + if (replayError !== undefined) return jsonResponse({ error: replayError }, 409); + reconcileLiveStateStores(); + clearModelCache(name); + const catalogRefresh = await convergeCodexCatalog(); + return jsonResponse({ success: true, name, catalogRefresh }); + } + // Field-mask editor: apply recognized fields onto a copy, then validate the MERGED // provider (canonical-seed guard covers openai; local-guard covers registry key providers). // API keys are never writable here — the api-keys endpoints own pool-integrated key writes. diff --git a/src/types.ts b/src/types.ts index 24c8faa6aa..2591d3333b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1446,6 +1446,11 @@ export interface OcxProviderConfig { modelInputModalities?: Record; /** Model-specific max input token limits. Values cap auto_compact_token_limit. */ modelMaxInputTokens?: Record; + /** + * Per-model soft compaction budgets. Values may only lower the derived 90%/max-input ceiling; + * hard context and input admission remain authoritative. + */ + modelAutoCompactTokenLimits?: Record; /** * Provider-wide fallback for chat-completions `max_tokens` when the caller omits * Responses `max_output_tokens`. Adapters still let an explicit request win. diff --git a/tests/claude-models-discovery.test.ts b/tests/claude-models-discovery.test.ts index ef47458a7f..48fdde2ddd 100644 --- a/tests/claude-models-discovery.test.ts +++ b/tests/claude-models-discovery.test.ts @@ -189,6 +189,55 @@ test("Codex discovery applies the OpenAI context cap to native rows (#1430)", as } }); +test("Codex discovery applies independent ChatGPT soft budgets to native rows", async () => { + const config = configWithStaticModels(); + config.providers.openai = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + liveModels: false, + modelContextWindows: { + "gpt-5.6-sol": 922_000, + "gpt-5.6-terra": 922_000, + "gpt-5.6-luna": 922_000, + "gpt-daybreak-blue-latest": 922_000, + }, + modelAutoCompactTokenLimits: { + "gpt-5.6-sol": 800_000, + "gpt-5.6-terra": 810_000, + "gpt-5.6-luna": 760_000, + "gpt-daybreak-blue-latest": 700_000, + }, + }; + // Account-qualified discovery uses the full supported native set, including the + // entitlement-gated Daybreak row; the no-selector path exposes only observed additions. + config.codexAccounts = [{ + id: "stored-side-account", + email: "private@example.test", + alias: "Private Display Name", + isMain: false, + }]; + config.codexAccountNamespaces = { desktop: "@main" }; + 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; auto_compact_token_limit?: number }>; + }; + for (const [slug, expected] of Object.entries( + config.providers.openai.modelAutoCompactTokenLimits!, + )) { + for (const catalogSlug of [slug, `desktop/${slug}`]) { + expect(json.models.find(model => model.slug === catalogSlug)?.auto_compact_token_limit) + .toBe(expected); + } + } + } 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 7086b6850a..43a3365966 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -199,12 +199,26 @@ describe("combo catalog capability intersection", () => { owned_by: "combo", contextWindow: 128_000, maxInputTokens: 100_000, + autoCompactTokenLimit: 100_000, inputModalities: ["text"], reasoningEfforts: ["low", "medium"], defaultReasoningEffort: "medium", }); }); + test("uses the earliest effective member compaction budget", () => { + expect(deriveComboCatalogModel("mixed", normalizedCombo(), [ + { ...memberA, autoCompactTokenLimit: 125_000 }, + { ...memberB, autoCompactTokenLimit: 80_000 }, + ])?.autoCompactTokenLimit).toBe(80_000); + + // Missing explicit metadata still contributes the member's derived max-input ceiling. + expect(deriveComboCatalogModel("mixed", normalizedCombo(), [ + { ...memberA, autoCompactTokenLimit: 125_000 }, + memberB, + ])?.autoCompactTokenLimit).toBe(100_000); + }); + test("handles vision, missing modalities, reasoning defaults, and parallel tools conservatively", () => { expect(deriveComboCatalogModel("vision", normalizedCombo({ defaultEffort: "low" }), [ memberA, @@ -1067,11 +1081,20 @@ describe("combo catalog capability intersection", () => { port: 10100, defaultProvider: "Nova1", providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + modelAutoCompactTokenLimits: { "gpt-5.6-sol": 80_000 }, + }, Nova1: { adapter: "openai-chat", baseUrl: "https://nova.example/v1", liveModels: false, models: ["codex/gpt-5.6-sol", "codex/gpt-5.4-mini"], + modelContextWindows: { "codex/gpt-5.6-sol": 922_000 }, + modelAutoCompactTokenLimits: { "codex/gpt-5.6-sol": 900_000 }, }, }, combos: { @@ -1094,8 +1117,9 @@ describe("combo catalog capability intersection", () => { expect(rows.find(row => row.provider === "combo" && row.id === "nova-sol")).toMatchObject({ alias: "gpt-5.6-sol", nativeAlias: true, - contextWindow: 272_000, - maxInputTokens: 272_000, + contextWindow: 922_000, + maxInputTokens: 922_000, + autoCompactTokenLimit: 80_000, inputModalities: ["text", "image"], reasoningEfforts: ["low", "medium", "high", "xhigh", "max", "ultra"], defaultReasoningEffort: "low", @@ -1413,6 +1437,25 @@ describe("combo catalog capability intersection", () => { contextCap: 50_000, contextCapped: true, }); + // A cap must also lower the member's existing soft budget before combo derivation. + expect(resolveComboCatalogMember( + { provider: "a", model: "wide" }, + new Map([["a/wide", { + provider: "a", + id: "wide", + contextWindow: 922_000, + maxInputTokens: 922_000, + autoCompactTokenLimit: 829_800, + }]]), + providers, + 700_000, + )).toMatchObject({ + contextWindow: 700_000, + maxInputTokens: 700_000, + autoCompactTokenLimit: 630_000, + contextCap: 700_000, + contextCapped: true, + }); // Disabled providers never contribute — even with a complete discovery row. expect(resolveComboCatalogMember( { provider: "a", model: "m1" }, @@ -2351,6 +2394,114 @@ test("a custom row inherits provider reasoning metadata from the provider-derive } }); +test("undiscovered custom rows inherit provider context, input, and soft-budget hints", async () => { + clearModelCache("custom-hints"); + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { throw new Error("fetch should not be called"); }) as typeof fetch; + try { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "custom-hints", + providers: { + "custom-hints": { + baseUrl: "https://custom.example.test/v1", + adapter: "openai-chat", + liveModels: false, + models: [], + modelContextWindows: { inherited: 120_000, narrowed: 120_000 }, + modelMaxInputTokens: { inherited: 90_000, narrowed: 90_000 }, + modelAutoCompactTokenLimits: { inherited: 80_000, narrowed: 80_000, fallback: 60_000 }, + }, + }, + customModels: [{ + id: "cm-inherited", + provider: "custom-hints", + modelId: "inherited", + addedAt: "2026-01-01T00:00:00.000Z", + }, { + id: "cm-narrowed", + provider: "custom-hints", + modelId: "narrowed", + contextWindow: 50_000, + addedAt: "2026-01-01T00:00:00.000Z", + }, { + id: "cm-fallback", + provider: "custom-hints", + modelId: "fallback", + addedAt: "2026-01-01T00:00:00.000Z", + }], + }); + + expect(models.find(model => model.id === "inherited")).toMatchObject({ + contextWindow: 120_000, + maxInputTokens: 90_000, + autoCompactTokenLimit: 80_000, + }); + expect(models.find(model => model.id === "narrowed")).toMatchObject({ + contextWindow: 50_000, + maxInputTokens: 50_000, + autoCompactTokenLimit: 45_000, + }); + expect(models.find(model => model.id === "fallback")).toMatchObject({ + autoCompactTokenLimit: 60_000, + }); + expect(models.find(model => model.id === "fallback")?.contextWindow).toBeUndefined(); + const entries = buildCatalogEntries(nativeTemplate(), [], models); + expect(entries.find(entry => entry.slug === "custom-hints/inherited")).toMatchObject({ + context_window: 120_000, + auto_compact_token_limit: 80_000, + }); + expect(entries.find(entry => entry.slug === "custom-hints/narrowed")).toMatchObject({ + context_window: 50_000, + auto_compact_token_limit: 45_000, + }); + expect(entries.find(entry => entry.slug === "custom-hints/fallback")).toMatchObject({ + context_window: 128_000, + auto_compact_token_limit: 60_000, + }); + } finally { + globalThis.fetch = originalFetch; + clearModelCache("custom-hints"); + } +}); + +test("custom rows reclamp a soft budget against context inherited from the replaced discovery row", async () => { + clearModelCache("replacement-context"); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response( + JSON.stringify({ data: [{ id: "wide-budget", context_length: 128_000 }] }), + { status: 200, headers: { "content-type": "application/json" } }, + )) as typeof fetch; + try { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "replacement-context", + providers: { + "replacement-context": { + adapter: "openai-chat", + baseUrl: "https://replacement-context.test/v1", + apiKey: "sk-test", + modelAutoCompactTokenLimits: { "wide-budget": 900_000 }, + }, + }, + customModels: [{ + id: "cm-replacement-context", + provider: "replacement-context", + modelId: "wide-budget", + addedAt: "2026-01-01T00:00:00.000Z", + }], + }); + + expect(models.find(model => model.id === "wide-budget")).toMatchObject({ + contextWindow: 128_000, + autoCompactTokenLimit: 115_200, + }); + } finally { + globalThis.fetch = originalFetch; + clearModelCache("replacement-context"); + } +}); + function openAiApiCatalogConfig(overrides: Record = {}): OcxConfig { return { port: 10100, @@ -2667,6 +2818,9 @@ describe("Codex catalog routed normalization", () => { expect(e?.tool_mode).toBe("code_mode_only"); expect(e?.use_responses_lite).toBe(true); } + expect(sol?.auto_compact_token_limit).toBe(244_800); + expect(terra?.auto_compact_token_limit).toBe(244_800); + expect(luna?.auto_compact_token_limit).toBe(244_800); }); test("gpt-5.6 snapshot entries keep prefer_websockets when websockets are enabled", () => { @@ -2699,6 +2853,50 @@ describe("Codex catalog routed normalization", () => { } }); + test("per-model native soft budgets project independently to bare and account-qualified rows", () => { + const limits = { + "gpt-5.6-sol": 800_000, + "gpt-5.6-terra": 810_000, + "gpt-5.6-luna": 760_000, + "gpt-daybreak-blue-latest": 700_000, + }; + const entries = buildCatalogEntries( + nativeTemplate(), + ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", NATIVE_DAYBREAK_BLUE_MODEL], + [], + undefined, + false, + "default", + new Set(), + ["main"], + new Set(), + new Set(), + { + modelWindows: Object.fromEntries( + Object.keys(limits).map(slug => [slug, 922_000]), + ), + }, + undefined, + undefined, + false, + limits, + ); + for (const [slug, expected] of Object.entries(limits)) { + const bare = entries.find(entry => entry.slug === slug); + const qualified = entries.find(entry => entry.slug === `main/${slug}`); + expect(bare).toMatchObject({ + context_window: 922_000, + max_context_window: 922_000, + auto_compact_token_limit: expected, + }); + expect(qualified).toMatchObject({ + context_window: 922_000, + max_context_window: 922_000, + auto_compact_token_limit: expected, + }); + } + }); + test("mergeCatalogEntriesForSync re-applies the openai cap to preserved and upgraded native rows (#1430)", () => { const cap = 272_000; const template = nativeTemplate(); @@ -2878,6 +3076,7 @@ describe("Codex catalog routed normalization", () => { baseUrl: "https://chatgpt.com/backend-api/codex", // The built-in OpenAI provider defaults an omitted authMode to forward. codexAccountMode: "pool", + modelAutoCompactTokenLimits: { [NATIVE_DAYBREAK_BLUE_MODEL]: 700_000 }, }, }, codexAccountPickerEnabled: false, @@ -2901,6 +3100,7 @@ describe("Codex catalog routed normalization", () => { catalogKind: CODEX_CUSTOM_MODEL_CATALOG_KIND, codexForwardNativeCapabilityAlias: true, contextWindow: 922_000, + autoCompactTokenLimit: 700_000, inputModalities: ["text", "image"], reasoningEfforts: ["low", "medium", "high", "xhigh", "max", "ultra"], defaultReasoningEffort: "low", @@ -2914,7 +3114,7 @@ describe("Codex catalog routed normalization", () => { display_name: "Daybreak Blue", context_window: 922_000, max_context_window: 922_000, - auto_compact_token_limit: 829_800, + auto_compact_token_limit: 700_000, comp_hash: "3000", tool_mode: "code_mode_only", use_responses_lite: true, @@ -4898,12 +5098,16 @@ describe("Codex catalog routed normalization", () => { adapter: "openai-chat", baseUrl: "https://meta-cap.test/v1", apiKey: "sk-test", + modelMaxInputTokens: { "wide-model": 450_000 }, + modelAutoCompactTokenLimits: { "wide-model": 400_000 }, }, }, }); expect(models.find(m => m.id === "wide-model")).toMatchObject({ contextWindow: 350_000, + maxInputTokens: 350_000, + autoCompactTokenLimit: 315_000, contextCap: 350_000, contextCapped: true, }); @@ -5163,11 +5367,28 @@ describe("OpenAI API trusted catalog augmentation", () => { test("user values only lower trusted context and max-input baselines", () => { const lowered = augmentRoutedModelsWithRegistryOpenAiApiRows([], openAiApiCatalogConfig({ - modelContextWindows: { "gpt-5.6-sol": 350_000, "gpt-5.6-terra": 2_000_000 }, + modelContextWindows: { "gpt-5.6-sol": 350_000, "gpt-5.6-terra": 2_000_000, "gpt-5.6-luna": 350_000 }, modelMaxInputTokens: { "gpt-5.6-sol": 300_000, "gpt-5.6-terra": 945_000 }, + modelAutoCompactTokenLimits: { "gpt-5.6-sol": 250_000, "gpt-5.6-terra": 800_000, "gpt-5.6-luna": 800_000 }, })); - expect(lowered.find(row => row.id === "gpt-5.6-sol")).toMatchObject({ contextWindow: 350_000, maxInputTokens: 300_000 }); - expect(lowered.find(row => row.id === "gpt-5.6-terra")).toMatchObject({ contextWindow: 1_050_000, maxInputTokens: 922_000 }); + expect(lowered.find(row => row.id === "gpt-5.6-sol")).toMatchObject({ + contextWindow: 350_000, + maxInputTokens: 300_000, + autoCompactTokenLimit: 250_000, + }); + expect(lowered.find(row => row.id === "gpt-5.6-terra")).toMatchObject({ + contextWindow: 1_050_000, + maxInputTokens: 922_000, + autoCompactTokenLimit: 800_000, + }); + expect(lowered.find(row => row.id === "gpt-5.6-luna")).toMatchObject({ + contextWindow: 350_000, + maxInputTokens: 350_000, + autoCompactTokenLimit: 315_000, + }); + const entries = buildCatalogEntries(nativeTemplate(), [], lowered); + expect(entries.find(row => row.slug === "openai-apikey/gpt-5.6-sol")?.auto_compact_token_limit).toBe(250_000); + expect(entries.find(row => row.slug === "openai-apikey/gpt-5.6-terra")?.auto_compact_token_limit).toBe(800_000); }); test("routed auto-compaction is bounded by max-input after effective context caps", () => { diff --git a/tests/codex-convergence-account-selectors.test.ts b/tests/codex-convergence-account-selectors.test.ts index eedd2616d4..c2751b6f9c 100644 --- a/tests/codex-convergence-account-selectors.test.ts +++ b/tests/codex-convergence-account-selectors.test.ts @@ -396,6 +396,34 @@ test("convergence projects the observed Daybreak row onto its selector and one b expect(models.filter(entry => entry.slug === "gpt-daybreak-blue-latest")).toHaveLength(1); }); +test("convergence preserves independent native soft budgets on bare and account rows", async () => { + writeCatalog([nativeEntry()]); + const nextConfig = config(true); + nextConfig.providers.openai!.modelContextWindows = { + "gpt-5.6-sol": 922_000, + "gpt-5.6-terra": 922_000, + "gpt-5.6-luna": 922_000, + "gpt-daybreak-blue-latest": 922_000, + }; + nextConfig.providers.openai!.modelAutoCompactTokenLimits = { + "gpt-5.6-sol": 800_000, + "gpt-5.6-terra": 810_000, + "gpt-5.6-luna": 760_000, + "gpt-daybreak-blue-latest": 700_000, + }; + + const models = (await convergeCatalog(nextConfig)).models ?? []; + for (const [slug, expected] of Object.entries( + nextConfig.providers.openai!.modelAutoCompactTokenLimits!, + )) { + expect(models.find(entry => entry.slug === slug)?.auto_compact_token_limit).toBe(expected); + expect(models.find(entry => entry.slug === `desktop/${slug}`)?.auto_compact_token_limit) + .toBe(expected); + expect(models.find(entry => entry.slug === `team/${slug}`)?.auto_compact_token_limit) + .toBe(expected); + } +}); + test("convergence preserves unrelated foreign rows alongside fresh configured provider rows", async () => { writeCatalog([ nativeEntry(), diff --git a/tests/codex-convergence-contract.test.ts b/tests/codex-convergence-contract.test.ts index b08be70bd4..312ce42a24 100644 --- a/tests/codex-convergence-contract.test.ts +++ b/tests/codex-convergence-contract.test.ts @@ -372,9 +372,9 @@ test("a failure cause never carries message text, paths or identifiers (#1784)", expect(body).not.toContain("failed writing"); }); -test("the route inventory contains exactly the specified 7 + 6 + 2 + 2 convergence calls", () => { +test("the route inventory contains exactly the specified 8 + 6 + 2 + 2 convergence calls", () => { const counts = Object.fromEntries([ - ["provider-routes.ts", 7], + ["provider-routes.ts", 8], ["model-routes.ts", 6], ["combo-routes.ts", 2], ["agent-settings-routes.ts", 2], @@ -386,7 +386,7 @@ test("the route inventory contains exactly the specified 7 + 6 + 2 + 2 convergen return [file, count]; })); expect(counts).toEqual({ - "provider-routes.ts": 7, + "provider-routes.ts": 8, "model-routes.ts": 6, "combo-routes.ts": 2, "agent-settings-routes.ts": 2, @@ -397,9 +397,9 @@ test("the route inventory contains exactly the specified 7 + 6 + 2 + 2 convergen * The inventory above is a bare count, so raising it is the obvious way to make this file * green again — and a count that only ever gets raised stops being a contract. #1541's * seventh call is legitimate: the attested reload route adopts a provider from disk into the - * live config, so it invalidates the same caches as the other write paths and must converge - * the catalog for the same reason. Assert that specific call directly, so a future bump - * cannot pass while some OTHER route quietly gained one, or while the reload route lost its own. + * live config. The eighth call belongs to the canonical OpenAI soft-budget PATCH, which changes + * model metadata without entering the generic provider editor. Assert both calls directly, so a + * future bump cannot pass while some OTHER route quietly gained one, or either path lost its own. */ test("the attested reload route converges the Codex catalog like the other write paths", () => { const source = readFileSync( @@ -409,6 +409,22 @@ test("the attested reload route converges the Codex catalog like the other write const handlerStart = source.indexOf("LOCAL_PROVIDER_RELOAD_PATH && req.method === \"POST\""); expect(handlerStart).toBeGreaterThan(-1); // The reload handler returns before the next route check; scope the search to its body. - const handlerBody = source.slice(handlerStart, source.indexOf("url.pathname ===", handlerStart + 1)); + const handlerEnd = source.indexOf("url.pathname ===", handlerStart + 1); + expect(handlerEnd).toBeGreaterThan(handlerStart); + const handlerBody = source.slice(handlerStart, handlerEnd); + expect(handlerBody).toContain("await convergeCodexCatalog()"); +}); + +test("the canonical OpenAI soft-budget patch converges the Codex catalog", () => { + const source = readFileSync( + join(import.meta.dir, "..", "src", "server", "management", "provider-routes.ts"), + "utf8", + ); + const handlerStart = source.indexOf("The canonical ChatGPT provider is enriched at runtime"); + expect(handlerStart).toBeGreaterThan(-1); + const handlerEnd = source.indexOf("// Field-mask editor", handlerStart); + expect(handlerEnd).toBeGreaterThan(handlerStart); + const handlerBody = source.slice(handlerStart, handlerEnd); + expect(handlerBody).toContain("modelAutoCompactTokenLimits"); expect(handlerBody).toContain("await convergeCodexCatalog()"); }); diff --git a/tests/config.test.ts b/tests/config.test.ts index 657953b771..50f8397b3f 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -17,6 +17,7 @@ import { parsePidFile, positiveIntegerConfigError, positiveIntegerRecordConfigError, + positiveSafeIntegerRecordConfigError, readConfigDiagnostics, readPid, readRuntimePort, @@ -1149,6 +1150,59 @@ describe("opencodex config defaults", () => { } }); + test("modelAutoCompactTokenLimits accepts model-specific positive safe integer budgets", () => { + const valid = { + "gpt-5.6-sol": 900_000, + "gpt-5.6-terra": 850_000, + "gpt-5.6-luna": 800_000, + "gpt-daybreak-blue-latest": 750_000, + }; + expect(positiveSafeIntegerRecordConfigError(valid, "modelAutoCompactTokenLimits")).toBeNull(); + writeConfig({ + port: 12345, + providers: { + custom: { + adapter: "openai-responses", + baseUrl: "https://example.test/v1", + modelAutoCompactTokenLimits: valid, + }, + }, + defaultProvider: "custom", + }); + expect(readConfigDiagnostics().config.providers.custom.modelAutoCompactTokenLimits).toEqual(valid); + + for (const invalid of [null, [], { model: 0 }, { model: -1 }, { model: 1.5 }, { model: "1" }, { model: 1e100 }]) { + expect(positiveSafeIntegerRecordConfigError(invalid, "modelAutoCompactTokenLimits")).not.toBeNull(); + } + const secretShapedKey = "sk-proj-" + "1234567890abcdefghijklmnop"; + const safeError = positiveSafeIntegerRecordConfigError( + { [secretShapedKey]: 0 }, + "modelAutoCompactTokenLimits", + ); + expect(safeError).toContain("[REDACTED]"); + expect(safeError).not.toContain(secretShapedKey); + }); + + test("canonical OpenAI soft budgets accept only exact supported native model ids", () => { + writeConfig({ + port: 12345, + openaiProviderTierVersion: 2, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + modelAutoCompactTokenLimits: { "main/gpt-5.6-sol": 800_000 }, + }, + }, + defaultProvider: "openai", + }); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("fallback"); + expect(diagnostics.error).toContain("exact supported native model id"); + }); + test("modelSupportsReasoningSummaries accepts only plain boolean records", () => { writeConfig({ port: 12345, diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index fd79c750e9..adbce8641c 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -694,6 +694,25 @@ describe("provider management validation", () => { } }); + test("an omitted or partial soft-budget map preserves the user's other model entries", async () => { + freshHome(); + const server = startServer(0); + try { + expect((await seedProvider(server.url, { + modelAutoCompactTokenLimits: { "deepseek-v4-flash": 700000 }, + })).status).toBe(200); + expect((await seedProvider(server.url, {})).status).toBe(200); + expect((await seedProvider(server.url, { + modelAutoCompactTokenLimits: { "kimi-k3": 250000 }, + })).status).toBe(200); + + expect(loadConfig().providers["opencode-go"]?.modelAutoCompactTokenLimits) + .toEqual({ "deepseek-v4-flash": 700000, "kimi-k3": 250000 }); + } finally { + await server.stop(true); + } + }); + test("an omitted contextWindow keeps the user's scalar", async () => { freshHome(); const server = startServer(0); @@ -820,6 +839,117 @@ describe("provider management validation", () => { expect(accepted.status).toBe(200); } + const acceptedNativeBudgets = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "openai", + provider: { + ...canonicalDirect, + codexAccountMode: "direct", + modelAutoCompactTokenLimits: { + "gpt-5.6-sol": 880_000, + "gpt-5.6-terra": 810_000, + "gpt-5.6-luna": 760_000, + "gpt-daybreak-blue-latest": 700_000, + }, + }, + }), + }); + expect(acceptedNativeBudgets.status).toBe(200); + expect(loadConfig().providers.openai.modelAutoCompactTokenLimits).toEqual({ + "gpt-5.6-sol": 880_000, + "gpt-5.6-terra": 810_000, + "gpt-5.6-luna": 760_000, + "gpt-daybreak-blue-latest": 700_000, + }); + + const patchedNativeBudgets = await fetch(new URL("/api/providers?name=openai", server.url), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + modelAutoCompactTokenLimits: { + "gpt-5.6-terra": 790_000, + "gpt-5.6-luna": null, + }, + }), + }); + const patchedNativeBudgetsBody = await patchedNativeBudgets.clone().json(); + expect({ status: patchedNativeBudgets.status, body: patchedNativeBudgetsBody }).toEqual({ + status: 200, + body: expect.objectContaining({ success: true }), + }); + expect(loadConfig().providers.openai.modelAutoCompactTokenLimits).toEqual({ + "gpt-5.6-sol": 880_000, + "gpt-5.6-terra": 790_000, + "gpt-daybreak-blue-latest": 700_000, + }); + + for (const modelId of ["unknown-model", "main/gpt-5.6-sol"]) { + const rejected = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "openai", + provider: { + ...canonicalDirect, + codexAccountMode: "pool", + modelAutoCompactTokenLimits: { [modelId]: 700_000 }, + }, + }), + }); + expect(rejected.status).toBe(400); + } + const protoKeyPatch = await fetch(new URL("/api/providers?name=openai", server.url), { + method: "PATCH", + headers: { "content-type": "application/json" }, + // Keep this as raw JSON: an object literal treats __proto__ specially before stringify. + body: '{"modelAutoCompactTokenLimits":{"__proto__":700000}}', + }); + expect(protoKeyPatch.status).toBe(400); + for (const rawBody of [ + '{"modelAutoCompactTokenLimits":{"unknown-model":null}}', + '{"modelAutoCompactTokenLimits":{"main/gpt-5.6-sol":null}}', + '{"modelAutoCompactTokenLimits":{"__proto__":null}}', + ]) { + const rejectedTombstone = await fetch(new URL("/api/providers?name=openai", server.url), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: rawBody, + }); + expect(rejectedTombstone.status).toBe(400); + } + const unsafeNativeBudget = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "openai", + provider: { + ...canonicalDirect, + codexAccountMode: "direct", + modelAutoCompactTokenLimits: { "gpt-5.6-sol": 1e100 }, + }, + }), + }); + expect(unsafeNativeBudget.status).toBe(400); + const secretShapedKey = "sk-proj-" + "1234567890abcdefghijklmnop"; + const secretKeyBudget = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "openai", + provider: { + ...canonicalDirect, + codexAccountMode: "direct", + modelAutoCompactTokenLimits: { [secretShapedKey]: 0 }, + }, + }), + }); + expect(secretKeyBudget.status).toBe(400); + const secretKeyBody = await secretKeyBudget.text(); + expect(secretKeyBody).toContain("[REDACTED]"); + expect(secretKeyBody).not.toContain(secretShapedKey); + const legacyMulti = await fetch(new URL("/api/providers", server.url), { method: "POST", headers: { "content-type": "application/json" }, @@ -995,9 +1125,17 @@ describe("provider management validation", () => { expect(legacy.status).toBe(400); const dto = await fetch(new URL("/api/config", server.url)).then(response => response.json()) as { - providers: Record; + providers: Record; + }>; }; expect(dto.providers.openai.codexAccountMode).toBe("direct"); + expect(dto.providers.openai.modelAutoCompactTokenLimits).toEqual({ + "gpt-5.6-sol": 880_000, + "gpt-5.6-terra": 790_000, + "gpt-daybreak-blue-latest": 700_000, + }); expect(dto.providers["openai-multi"]).toBeUndefined(); expect(dto.providers["custom-max-input"]).not.toHaveProperty("modelMaxInputTokens"); @@ -2637,6 +2775,7 @@ describe("provider management validation", () => { models: ["wide", "narrow"], contextWindow: 256_000, modelContextWindows: { narrow: 64_000 }, + modelAutoCompactTokenLimits: { narrow: 50_000 }, modelSupportsServiceTier: { narrow: false }, }, }, @@ -2660,27 +2799,32 @@ describe("provider management validation", () => { name: string; contextWindow?: number; modelContextWindows?: Record; + modelAutoCompactTokenLimits?: Record; }>; expect(rows.find(row => row.name === "relay")).toMatchObject({ contextWindow: 256_000, modelContextWindows: { narrow: 64_000 }, + modelAutoCompactTokenLimits: { narrow: 50_000 }, modelSupportsServiceTier: { narrow: false }, }); const updated = await request("PATCH", { contextWindow: 350_000, modelContextWindows: { wide: 350_000 }, + modelAutoCompactTokenLimits: { wide: 300_000 }, modelSupportsServiceTier: { wide: true }, }); expect(updated?.status).toBe(200); expect(liveConfig.providers.relay).toMatchObject({ contextWindow: 350_000, modelContextWindows: { wide: 350_000, narrow: 64_000 }, + modelAutoCompactTokenLimits: { wide: 300_000, narrow: 50_000 }, modelSupportsServiceTier: { wide: true, narrow: false }, }); expect(loadConfig().providers.relay).toMatchObject({ contextWindow: 350_000, modelContextWindows: { wide: 350_000, narrow: 64_000 }, + modelAutoCompactTokenLimits: { wide: 300_000, narrow: 50_000 }, modelSupportsServiceTier: { wide: true, narrow: false }, }); @@ -2694,6 +2838,9 @@ describe("provider management validation", () => { { modelContextWindows: { wide: 1e100 } }, { modelContextWindows: { "": 100_000 } }, { modelContextWindows: { wide: -1 } }, + { modelAutoCompactTokenLimits: { wide: 1e100 } }, + { modelAutoCompactTokenLimits: { "": 100_000 } }, + { modelAutoCompactTokenLimits: { wide: -1 } }, { modelSupportsServiceTier: { wide: "yes" } }, { modelSupportsServiceTier: { "": true } }, ]) { @@ -2702,23 +2849,29 @@ describe("provider management validation", () => { expect(liveConfig.providers.relay).toMatchObject({ contextWindow: 350_000, modelContextWindows: { wide: 350_000, narrow: 64_000 }, + modelAutoCompactTokenLimits: { wide: 300_000, narrow: 50_000 }, modelSupportsServiceTier: { wide: true, narrow: false }, }); expect((await request("PATCH", { modelContextWindows: { wide: null } }))?.status).toBe(200); expect(liveConfig.providers.relay.modelContextWindows).toEqual({ narrow: 64_000 }); + expect((await request("PATCH", { modelAutoCompactTokenLimits: { wide: null } }))?.status).toBe(200); + expect(liveConfig.providers.relay.modelAutoCompactTokenLimits).toEqual({ narrow: 50_000 }); + expect((await request("PATCH", { modelSupportsServiceTier: { wide: null } }))?.status).toBe(200); expect(liveConfig.providers.relay.modelSupportsServiceTier).toEqual({ narrow: false }); const cleared = await request("PATCH", { contextWindow: null, modelContextWindows: null, + modelAutoCompactTokenLimits: null, modelSupportsServiceTier: null, }); expect(cleared?.status).toBe(200); expect(liveConfig.providers.relay.contextWindow).toBeUndefined(); expect(liveConfig.providers.relay.modelContextWindows).toBeUndefined(); + expect(liveConfig.providers.relay.modelAutoCompactTokenLimits).toBeUndefined(); expect(liveConfig.providers.relay.modelSupportsServiceTier).toBeUndefined(); }); @@ -2930,6 +3083,45 @@ describe("provider management validation", () => { expect(second?.status).toBe(200); expect(liveConfig.providers.hdr.headers).toEqual({ "X-A": "a", "X-B": "b" }); }); + + test("concurrent canonical OpenAI soft-budget PATCHes preserve different model keys", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const liveConfig: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "openai", + openaiProviderTierVersion: 2, + providers: { openai: { ...canonicalDirect } }, + }; + saveConfig(liveConfig); + const patch = async (body: unknown) => { + const req = new Request("http://127.0.0.1/api/providers?name=openai", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + return handleManagementAPI(req, new URL(req.url), liveConfig, { + createManagementConvergeCodex: catalogConvergenceFactory(), + }); + }; + + const [sol, terra] = await Promise.all([ + patch({ modelAutoCompactTokenLimits: { "gpt-5.6-sol": 880_000 } }), + patch({ modelAutoCompactTokenLimits: { "gpt-5.6-terra": 810_000 } }), + ]); + expect(sol?.status).toBe(200); + expect(terra?.status).toBe(200); + expect(liveConfig.providers.openai.modelAutoCompactTokenLimits).toEqual({ + "gpt-5.6-sol": 880_000, + "gpt-5.6-terra": 810_000, + }); + expect(loadConfig().providers.openai.modelAutoCompactTokenLimits).toEqual({ + "gpt-5.6-sol": 880_000, + "gpt-5.6-terra": 810_000, + }); + }); test("provider context-cap API persists toggles and annotates model rows", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index 08e0c4eb19..61ab90b6cb 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -70,6 +70,10 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(rows.find(r => r.slug === "gpt-5.5")?.disabled).toBe(false); // Known context metadata rides along for the dashboard. expect(rows.find(r => r.slug === "gpt-5.6-sol")?.contextWindow).toBe(272_000); + expect(rows.find(r => r.slug === "gpt-5.6-sol")?.autoCompactTokenLimit).toBe(244_800); + expect(rows.find(r => r.slug === "gpt-5.6-terra")?.autoCompactTokenLimit).toBe(244_800); + expect(rows.find(r => r.slug === "gpt-5.6-luna")?.autoCompactTokenLimit).toBe(244_800); + expect(rows.find(r => r.slug === "gpt-daybreak-blue-latest")?.autoCompactTokenLimit).toBe(244_800); }); test("a per-model window sets the native row and never exceeds the measured ceiling", () => { @@ -175,6 +179,77 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(other.find(r => r.slug === "gpt-5.6-sol")?.contextWindow).toBe(272_000); }); + test("native ChatGPT soft budgets are independently configurable and never widen hard limits", () => { + const rows = nativeModelRows(makeConfig({ + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + modelContextWindows: { + "gpt-5.6-sol": 922_000, + "gpt-5.6-terra": 922_000, + "gpt-5.6-luna": 922_000, + "gpt-daybreak-blue-latest": 922_000, + }, + modelAutoCompactTokenLimits: { + "gpt-5.6-sol": 800_000, + "gpt-5.6-terra": 810_000, + "gpt-5.6-luna": 760_000, + "gpt-daybreak-blue-latest": 700_000, + }, + }, + }, + })); + expect(rows.find(row => row.slug === "gpt-5.6-sol")?.autoCompactTokenLimit).toBe(800_000); + expect(rows.find(row => row.slug === "gpt-5.6-terra")?.autoCompactTokenLimit).toBe(810_000); + expect(rows.find(row => row.slug === "gpt-5.6-luna")?.autoCompactTokenLimit).toBe(760_000); + expect(rows.find(row => row.slug === "gpt-daybreak-blue-latest")?.autoCompactTokenLimit).toBe(700_000); + for (const row of rows) { + if (row.autoCompactTokenLimit === undefined || row.contextWindow === undefined) continue; + expect(row.autoCompactTokenLimit).toBeLessThanOrEqual(row.contextWindow); + if (row.maxInputTokens !== undefined) { + expect(row.autoCompactTokenLimit).toBeLessThanOrEqual(row.maxInputTokens); + } + } + + const capped = nativeModelRows(makeConfig({ + providerContextCaps: { openai: 350_000 }, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + modelAutoCompactTokenLimits: { "gpt-5.6-terra": 900_000 }, + }, + }, + })); + expect(capped.find(row => row.slug === "gpt-5.6-terra")?.contextWindow).toBe(350_000); + expect(capped.find(row => row.slug === "gpt-5.6-terra")?.autoCompactTokenLimit).toBe(315_000); + + const cannotWiden = nativeModelRows(makeConfig({ + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + modelContextWindows: { + "gpt-5.6-sol": 922_000, + "gpt-5.6-terra": 922_000, + }, + modelAutoCompactTokenLimits: { + "gpt-5.6-sol": 950_000, + "gpt-5.6-terra": 950_000, + }, + }, + }, + })); + expect(cannotWiden.find(row => row.slug === "gpt-5.6-sol")?.autoCompactTokenLimit) + .toBe(829_800); + expect(cannotWiden.find(row => row.slug === "gpt-5.6-terra")?.autoCompactTokenLimit) + .toBe(829_800); + }); + test("native aliases suppress their native dashboard row and activate Desktop allowlist pruning", () => { const config = makeConfig({ disabledModels: ["gpt-5.6-sol", "gpt-5.5"],