From 71279bc52f5ed9ed494ddd37f73dfc2bd8bc1c0b Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 21 Aug 2026 03:38:00 +0900 Subject: [PATCH 1/4] feat(codex): add per-model compaction budgets --- src/codex/catalog/aggregation.ts | 12 ++ src/codex/catalog/effort.ts | 21 ++- src/codex/catalog/metadata.ts | 28 ++- src/codex/catalog/parsing.ts | 41 ++--- src/codex/catalog/provider-fetch.ts | 166 +++++++++++++++--- src/codex/catalog/sync.ts | 2 +- src/codex/convergence.ts | 5 + src/config.ts | 12 ++ src/providers/auto-compact-budget.ts | 65 +++++++ src/server/auth-cors.ts | 9 + src/server/management/model-rows.ts | 4 + src/server/management/provider-routes.ts | 86 +++++++-- src/types/provider.ts | 5 + tests/auto-compact-budget.test.ts | 57 ++++++ tests/codex-catalog.test.ts | 142 ++++++++++++++- ...odex-convergence-account-selectors.test.ts | 15 ++ tests/config.test.ts | 34 ++++ tests/management-provider-validation.test.ts | 77 +++++++- tests/native-model-toggle.test.ts | 25 ++- 19 files changed, 726 insertions(+), 80 deletions(-) create mode 100644 src/providers/auto-compact-budget.ts create mode 100644 tests/auto-compact-budget.test.ts diff --git a/src/codex/catalog/aggregation.ts b/src/codex/catalog/aggregation.ts index a605534227..73c1745954 100644 --- a/src/codex/catalog/aggregation.ts +++ b/src/codex/catalog/aggregation.ts @@ -13,6 +13,7 @@ import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, r import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; import { getProviderRegistryEntry } from "../../providers/registry"; import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap"; +import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget"; import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../../providers/slug-codec"; import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity"; import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; @@ -154,8 +155,16 @@ export function deriveComboCatalogModel( // combo would have the same window even without the cap. const contextCapped = limitingMembers.every(member => member.contextCapped === true); const maxInputTokens = Math.min( + contextWindow, ...members.map(member => member.maxInputTokens ?? member.contextWindow!), ); + 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, @@ -210,6 +220,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, @@ -299,6 +310,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 0648b64d17..2d6494e2fd 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -13,6 +13,7 @@ import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, r import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; import { getProviderRegistryEntry } from "../../providers/registry"; import { applyProviderContextCap, providerContextCap, resolveUnknownRoutedContextWindow } from "../../providers/context-cap"; +import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget"; import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec"; import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity"; import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; @@ -128,9 +129,23 @@ export function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel) if (typeof resolvedContext === "number" && resolvedContext > 0) { entry.context_window = resolvedContext; entry.max_context_window = resolvedContext; - entry.auto_compact_token_limit = Math.min( - Math.floor(resolvedContext * 0.9), - model.maxInputTokens ?? Number.POSITIVE_INFINITY, + entry.auto_compact_token_limit = clampAutoCompactTokenLimit( + resolvedContext, + model.maxInputTokens, + model.autoCompactTokenLimit, + ); + } else if ( + typeof entry.context_window === "number" + && entry.context_window > 0 + && typeof model.maxInputTokens === "number" + && model.maxInputTokens > 0 + ) { + // A conservative routed fallback is not evidence for applying the optional soft policy, + // but a measured/configured input ceiling is still a hard bound. Compact before that + // ceiling even when the provider supplied no authoritative context window. + entry.auto_compact_token_limit = clampAutoCompactTokenLimit( + entry.context_window, + model.maxInputTokens, ); } if (Array.isArray(model.inputModalities) && model.inputModalities.length > 0) { diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index d071bf59bd..5494d12ddd 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -14,6 +14,7 @@ import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, r import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; import { getProviderRegistryEntry, providerCodexAccountMode } from "../../providers/registry"; import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap"; +import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget"; import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec"; import { identifyRoutedModel } from "../../adapters/identity"; import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; @@ -204,6 +205,8 @@ export interface NativeContextLimits { readonly providerWindow?: number; /** `providers.openai.modelContextWindows` — per-model, wins over `providerWindow`. */ readonly modelWindows?: Readonly>; + /** `providers.openai.modelAutoCompactTokenLimits` — soft, lowering-only budgets. */ + readonly modelAutoCompactTokenLimits?: Readonly>; } export type NativeContextLimitsInput = NativeContextLimits | number | undefined; @@ -227,12 +230,18 @@ export function nativeContextLimits( const window = positiveInt(value); if (window !== undefined) modelWindows[slug] = window; } + const modelAutoCompactTokenLimits: Record = {}; + for (const [slug, value] of Object.entries(provider?.modelAutoCompactTokenLimits ?? {})) { + const budget = positiveInt(value); + if (budget !== undefined) modelAutoCompactTokenLimits[slug] = budget; + } return { ...(positiveInt(providerContextCap(config, OPENAI_CODEX_PROVIDER_ID)) !== undefined ? { cap: providerContextCap(config, OPENAI_CODEX_PROVIDER_ID) } : {}), ...(positiveInt(provider?.contextWindow) !== undefined ? { providerWindow: provider!.contextWindow } : {}), ...(Object.keys(modelWindows).length > 0 ? { modelWindows } : {}), + ...(Object.keys(modelAutoCompactTokenLimits).length > 0 ? { modelAutoCompactTokenLimits } : {}), }; } @@ -277,6 +286,21 @@ export function nativeOpenAiMaxInputTokens(slug: string, limits?: NativeContextL return window === undefined ? narrowed : Math.min(narrowed, window); } +/** Effective native soft budget after every hard window/input limit is resolved. */ +export function nativeOpenAiAutoCompactTokenLimit( + slug: string, + limits?: NativeContextLimitsInput, +): number | undefined { + const contextWindow = nativeOpenAiContextWindow(slug, limits); + if (contextWindow === undefined) return undefined; + const configured = positiveInt(asLimits(limits).modelAutoCompactTokenLimits?.[slug]); + return clampAutoCompactTokenLimit( + contextWindow, + nativeOpenAiMaxInputTokens(slug, limits), + configured, + ); +} + 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) { @@ -387,7 +411,7 @@ 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 @@ -403,11 +427,13 @@ 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); 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 a2a1c86c7c..f649ef88dd 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -31,7 +31,8 @@ import { redactSecretString } from "../../lib/redact"; import upstreamModelsSnapshot from "../data/upstream-models.json"; -import { NATIVE_OPENAI_CONTEXT_OVERRIDES, SUPPORTED_NATIVE_OPENAI_SLUGS, UPSTREAM_NATIVE_ENTRIES, isNativeOpenAiCapabilityAliasModel, nativeMultiAgentVersion, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, type NativeContextLimitsInput } from "./metadata"; +import { NATIVE_OPENAI_CONTEXT_OVERRIDES, SUPPORTED_NATIVE_OPENAI_SLUGS, UPSTREAM_NATIVE_ENTRIES, isNativeOpenAiCapabilityAliasModel, nativeMultiAgentVersion, nativeOpenAiAutoCompactTokenLimit, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, type NativeContextLimitsInput } from "./metadata"; +import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget"; import { trustedAccountBoundNativeCatalogSlug } from "./account-models"; import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds"; @@ -111,6 +112,8 @@ export interface CatalogModel { defaultReasoningEffort?: string; contextWindow?: number; maxInputTokens?: number; + /** Soft client compaction threshold; hard context/input limits remain authoritative. */ + autoCompactTokenLimit?: number; contextCap?: number; contextCapped?: boolean; inputModalities?: string[]; @@ -274,22 +277,6 @@ export function isNativeOpenAiEntry(entry: RawEntry): boolean { return typeof entry.slug === "string" && !entry.slug.includes("/"); } -/** - * Auto-compaction threshold for a native row. - * - * The usual rule is 90% of the window, but a row whose input ceiling sits below that has to - * clamp to the ceiling instead — otherwise the client keeps filling until upstream answers - * `context_length_exceeded` and compaction never gets a chance to run. Native GPT-5.6 no - * 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); -} - /** * Narrow any already-resolved native window by the user levers. * @@ -322,11 +309,6 @@ export function applyNativeOpenAiContextOverride(entry: RawEntry, limits?: Nativ if (typeof override.contextWindow === "number") { const contextWindow = nativeOpenAiContextWindow(nativeSlug, limits) ?? override.contextWindow; entry.context_window = contextWindow; - entry.auto_compact_token_limit = nativeAutoCompactLimit( - contextWindow, - nativeOpenAiMaxInputTokens(nativeSlug, limits) ?? override.maxInputTokens, - undefined, - ); } if (typeof override.maxContextWindow === "number") { const maxContextWindow = narrowNativeMaxContextWindow(nativeSlug, override.maxContextWindow, limits); @@ -341,17 +323,22 @@ export function applyNativeOpenAiContextOverride(entry: RawEntry, limits?: Nativ const cappedContext = narrowNativeMaxContextWindow(nativeSlug, currentContext, limits); if (cappedContext !== currentContext && typeof cappedContext === "number") { entry.context_window = cappedContext; - entry.auto_compact_token_limit = nativeAutoCompactLimit( - cappedContext, - nativeOpenAiMaxInputTokens(nativeSlug, limits) ?? override?.maxInputTokens, - undefined, - ); } const currentMax = typeof entry.max_context_window === "number" ? entry.max_context_window : undefined; const cappedMax = narrowNativeMaxContextWindow(nativeSlug, currentMax, limits); if (cappedMax !== currentMax) { entry.max_context_window = cappedMax; } + const effectiveContext = typeof entry.context_window === "number" && entry.context_window > 0 + ? entry.context_window + : undefined; + if (effectiveContext !== undefined) { + entry.auto_compact_token_limit = clampAutoCompactTokenLimit( + effectiveContext, + nativeOpenAiMaxInputTokens(nativeSlug, limits) ?? override?.maxInputTokens, + nativeOpenAiAutoCompactTokenLimit(nativeSlug, limits), + ); + } } export function ensureStrictCatalogFields( diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 565f6057a4..3c956dd489 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -41,6 +41,7 @@ import type { FastPolicyAuthority } from "../../providers/fastwire"; import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../providers/registry"; import { parseAntigravityAvailableModels, registerAntigravityDiscoveredWireModels } from "../../providers/antigravity-models"; import { applyProviderContextCap, providerContextCap, resolveUnknownRoutedContextWindow } from "../../providers/context-cap"; +import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget"; import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec"; import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity"; import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; @@ -75,7 +76,7 @@ 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"; @@ -571,6 +572,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, @@ -625,6 +627,17 @@ 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" && Number.isSafeInteger(configured) && configured > 0 + ? configured + : undefined; +} + function configuredReasoningSummarySupport(prov: OcxProviderConfig | undefined, id: string): boolean | undefined { if (!prov) return undefined; const explicit = modelRecordValue(prov.modelSupportsReasoningSummaries, id); @@ -636,6 +649,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 @@ -689,10 +703,31 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, ...(prov.codexToolMode !== undefined ? { codexToolMode: prov.codexToolMode } : {}), }; const capped = applyProviderContextCap(hinted.contextWindow, providerCap); - if (providerCap !== undefined && capped !== hinted.contextWindow) { - return { ...hinted, contextWindow: capped, contextCap: providerCap, contextCapped: true }; - } - return providerCap !== undefined ? { ...hinted, contextCap: providerCap, contextCapped: false } : hinted; + const withCap = providerCap !== undefined + ? capped !== hinted.contextWindow + ? { ...hinted, contextWindow: capped, contextCap: providerCap, contextCapped: true } + : { ...hinted, contextCap: providerCap, contextCapped: false } + : hinted; + const contextWindow = typeof withCap.contextWindow === "number" && withCap.contextWindow > 0 + ? withCap.contextWindow + : undefined; + const boundedMaxInput = typeof withCap.maxInputTokens === "number" && withCap.maxInputTokens > 0 + ? (contextWindow !== undefined ? Math.min(withCap.maxInputTokens, contextWindow) : withCap.maxInputTokens) + : undefined; + const withHardBounds = boundedMaxInput !== undefined && boundedMaxInput !== withCap.maxInputTokens + ? { ...withCap, maxInputTokens: boundedMaxInput } + : withCap; + const softCandidates = [model.autoCompactTokenLimit, configuredAutoCompact] + .filter((value): value is number => typeof value === "number" && value > 0); + if (contextWindow === undefined || softCandidates.length === 0) return withHardBounds; + return { + ...withHardBounds, + autoCompactTokenLimit: clampAutoCompactTokenLimit( + contextWindow, + boundedMaxInput, + Math.min(...softCandidates), + ), + }; } export function catalogHintsFromProviderConfig(name: string, prov: OcxProviderConfig, id: string, contextCap?: number): Partial { @@ -719,6 +754,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[]; } @@ -747,26 +783,33 @@ export function resolveComboCatalogMember( if (prov?.disabled === true) return undefined; const withFallbackMetadata = (member: CatalogModel): CatalogModel => { - if (!fallback) return member; const contextWindow = typeof member.contextWindow === "number" && member.contextWindow > 0 ? member.contextWindow : undefined; - const addMaxInput = contextWindow !== undefined + const addMaxInput = fallback !== undefined && contextWindow !== undefined && !(typeof member.maxInputTokens === "number" && member.maxInputTokens > 0); + const effectiveMaxInput = addMaxInput + ? Math.min(fallback?.maxInputTokens ?? contextWindow!, contextWindow!) + : member.maxInputTokens; + const softCandidates = [member.autoCompactTokenLimit, fallback?.autoCompactTokenLimit] + .filter((value): value is number => typeof value === "number" && value > 0); + const autoCompactTokenLimit = contextWindow !== undefined && softCandidates.length > 0 + ? clampAutoCompactTokenLimit(contextWindow, effectiveMaxInput, Math.min(...softCandidates)) + : member.autoCompactTokenLimit; + const adjustAutoCompact = autoCompactTokenLimit !== member.autoCompactTokenLimit; const addModalities = (!Array.isArray(member.inputModalities) || member.inputModalities.length === 0) - && fallback.inputModalities !== undefined; + && fallback?.inputModalities !== undefined; const addReasoning = member.reasoningEfforts === undefined - && fallback.reasoningEfforts !== undefined; - if (!addMaxInput && !addModalities && !addReasoning) return member; + && fallback?.reasoningEfforts !== undefined; + 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!) } - : {}), - ...(addModalities ? { inputModalities: [...fallback.inputModalities!] } : {}), - ...(addReasoning ? { reasoningEfforts: [...fallback.reasoningEfforts!] } : {}), + ...(addMaxInput ? { maxInputTokens: effectiveMaxInput } : {}), + ...(adjustAutoCompact && autoCompactTokenLimit !== undefined ? { autoCompactTokenLimit } : {}), + ...(addModalities ? { inputModalities: [...fallback!.inputModalities!] } : {}), + ...(addReasoning ? { reasoningEfforts: [...fallback!.reasoningEfforts!] } : {}), }; }; @@ -847,6 +890,18 @@ export function resolveComboCatalogMember( const maxInputTokens = effectiveMaxInput !== undefined ? Math.min(effectiveMaxInput, contextWindow) : contextWindow; + const softCandidates = [ + hinted.autoCompactTokenLimit, + base.autoCompactTokenLimit, + fallback?.autoCompactTokenLimit, + configuredAutoCompactTokenLimit(prov, target.model), + ].filter((value): value is number => typeof value === "number" && value > 0); + // A generic 128k synthesis is a catalog compatibility fallback, not evidence + // that a configured soft policy has an authoritative window to clamp against. + const hasAuthoritativeAutoCompactBasis = usedDiscoveredWindow || contextCap !== undefined; + const autoCompactTokenLimit = hasAuthoritativeAutoCompactBasis && softCandidates.length > 0 + ? clampAutoCompactTokenLimit(contextWindow, maxInputTokens, Math.min(...softCandidates)) + : undefined; return { ...hinted, @@ -854,6 +909,7 @@ export function resolveComboCatalogMember( ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}), contextWindow, maxInputTokens, + ...(autoCompactTokenLimit !== undefined ? { autoCompactTokenLimit } : {}), ...(fallbackCapped ? { contextCap, contextCapped: true as const } : {}), }; } @@ -1757,6 +1813,7 @@ async function gatherRoutedModelsUncached( // 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), + autoCompactTokenLimit: nativeOpenAiAutoCompactTokenLimit(slug, openaiContextCap), inputModalities: nativeInputModalities(slug), reasoningEfforts: nativeReasoningEfforts(slug), ...(nativeParallelToolCalls(slug) ? { parallelToolCalls: true } : {}), @@ -1773,18 +1830,23 @@ async function gatherRoutedModelsUncached( for (const id of listComboIds(config)) { const combo = getCombo(config, id); if (!combo) continue; + const comboNativeLimits = nativeContextLimits(config); const nativeContextWindow = combo.nativeAlias && combo.alias - ? nativeOpenAiContextWindow(combo.alias, nativeContextLimits(config)) + ? nativeOpenAiContextWindow(combo.alias, comboNativeLimits) : 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)) : undefined; + const nativeAliasAutoCompact = combo.nativeAlias && combo.alias + ? nativeOpenAiAutoCompactTokenLimit(combo.alias, comboNativeLimits) + : 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), } @@ -1847,9 +1909,23 @@ async function gatherRoutedModelsUncached( const nativeAliasMaxInputTokens = codexForwardNativeCapabilityAlias ? nativeOpenAiMaxInputTokens(cm.modelId, customNativeLimits) : undefined; - const customMaxInputTokens = nativeAliasMaxInputTokens !== undefined && customContextWindow !== undefined - ? Math.min(nativeAliasMaxInputTokens, customContextWindow) - : nativeAliasMaxInputTokens; + const configuredMaxInput = rawProvider + ? configuredMaxInputTokens(rawProvider, cm.modelId) + : undefined; + const hardMaxCandidates = [nativeAliasMaxInputTokens, configuredMaxInput] + .filter((value): value is number => typeof value === "number" && value > 0); + const customMaxInputTokens = hardMaxCandidates.length > 0 + ? Math.min( + ...hardMaxCandidates, + ...(customContextWindow !== undefined ? [customContextWindow] : []), + ) + : undefined; + const configuredAutoCompact = configuredAutoCompactTokenLimit(rawProvider, cm.modelId); + const customAutoCompactTokenLimit = codexForwardNativeCapabilityAlias + ? nativeOpenAiAutoCompactTokenLimit(cm.modelId, customNativeLimits) + : customContextWindow !== undefined && configuredAutoCompact !== undefined + ? clampAutoCompactTokenLimit(customContextWindow, customMaxInputTokens, configuredAutoCompact) + : undefined; const nativeAliasDefaultEffort = codexForwardNativeCapabilityAlias ? nativeDefaultReasoningEffort(cm.modelId) : undefined; @@ -1870,6 +1946,7 @@ 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) } : {}), @@ -1915,10 +1992,18 @@ 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 mergedMaxInputCandidates = [base.maxInputTokens, replaced?.maxInputTokens] + .filter((value): value is number => typeof value === "number" && value > 0); + const mergedMaxInput = mergedMaxInputCandidates.length > 0 + ? Math.min(...mergedMaxInputCandidates) + : undefined; const merged: CatalogModel = replaced ? { ...base, ...(base.contextWindow === undefined && replaced.contextWindow !== undefined ? { contextWindow: replaced.contextWindow } : {}), - ...(base.maxInputTokens === undefined && replaced.maxInputTokens !== undefined ? { maxInputTokens: replaced.maxInputTokens } : {}), + ...(mergedMaxInput !== undefined ? { maxInputTokens: mergedMaxInput } : {}), + ...(base.autoCompactTokenLimit === undefined && replaced.autoCompactTokenLimit !== undefined + ? { autoCompactTokenLimit: replaced.autoCompactTokenLimit } + : {}), ...(base.inputModalities === undefined && replaced.inputModalities !== undefined ? { inputModalities: replaced.inputModalities } : {}), ...(base.reasoningEfforts === undefined && replaced.reasoningEfforts !== undefined ? { reasoningEfforts: replaced.reasoningEfforts } : {}), ...(base.defaultReasoningEffort === undefined && replaced.defaultReasoningEffort !== undefined @@ -1935,14 +2020,36 @@ async function gatherRoutedModelsUncached( // (#349/#344). Deliberately NOT the full applyProviderConfigHints pass — custom rows are a // user override, so their explicit contextWindow / inputModalities / reasoning fields must be // preserved verbatim (the hint pass would cap context and overwrite modalities from registry). + const mergedContext = typeof merged.contextWindow === "number" && merged.contextWindow > 0 + ? merged.contextWindow + : undefined; + const boundedMergedMaxInput = typeof merged.maxInputTokens === "number" && merged.maxInputTokens > 0 + ? (mergedContext !== undefined ? Math.min(merged.maxInputTokens, mergedContext) : merged.maxInputTokens) + : undefined; + const mergedWithHardBounds = boundedMergedMaxInput !== undefined + && boundedMergedMaxInput !== merged.maxInputTokens + ? { ...merged, maxInputTokens: boundedMergedMaxInput } + : merged; + const mergedSoftCandidates = [mergedWithHardBounds.autoCompactTokenLimit, configuredAutoCompact] + .filter((value): value is number => typeof value === "number" && value > 0); + const mergedWithAutoCompact: CatalogModel = mergedContext !== undefined && mergedSoftCandidates.length > 0 + ? { + ...mergedWithHardBounds, + autoCompactTokenLimit: clampAutoCompactTokenLimit( + mergedContext, + boundedMergedMaxInput, + Math.min(...mergedSoftCandidates), + ), + } + : mergedWithHardBounds; const enrichedProvider = enrichedByName.get(cm.provider) ?? rawProvider; - if (enrichedProvider && modelInList(enrichedProvider.noVisionModels, merged.id)) { - const current = merged.inputModalities ?? ["text"]; + if (enrichedProvider && modelInList(enrichedProvider.noVisionModels, mergedWithAutoCompact.id)) { + const current = mergedWithAutoCompact.inputModalities ?? ["text"]; if (!current.includes("image")) { - return { ...merged, inputModalities: [...current, "image"] }; + return { ...mergedWithAutoCompact, inputModalities: [...current, "image"] }; } } - return merged; + return mergedWithAutoCompact; }); // Custom rows override discovered rows that encode to the same Codex-facing slug. const customKeys = new Set(customModels.map(c => routedSlug(c.provider, c.id))); @@ -1998,7 +2105,15 @@ function augmentRoutedModelsWithCapturedOpenAiApiRows( ? Math.min(officialContext, userContext ?? officialContext, providerCap ?? officialContext) : undefined; const maxInputTokens = typeof officialMaxInput === "number" - ? Math.min(officialMaxInput, userMaxInput ?? officialMaxInput) + ? Math.min( + officialMaxInput, + userMaxInput ?? officialMaxInput, + contextWindow ?? officialMaxInput, + ) + : undefined; + const configuredAutoCompact = configuredAutoCompactTokenLimit(configured, id); + const autoCompactTokenLimit = contextWindow !== undefined && configuredAutoCompact !== undefined + ? clampAutoCompactTokenLimit(contextWindow, maxInputTokens, configuredAutoCompact) : undefined; return { provider: OPENAI_API_PROVIDER_ID, @@ -2006,6 +2121,7 @@ function augmentRoutedModelsWithCapturedOpenAiApiRows( 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 70b93ee7bd..0b3253f7f7 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -1158,7 +1158,7 @@ export function mergeCatalogEntriesForSync( isNativeAliasCatalogEntry(entry) && typeof entry.slug === "string" ? [entry.slug] : [] )), ), - openaiContextCap?: number, + openaiContextCap?: NativeContextLimitsInput, keepNativeChatGptOnV1 = false, ): RawEntry[] { // Retained for source compatibility with the original helper contract. Raw provider ids must diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index d5aeb893b7..ffb8af4aa9 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"; @@ -286,6 +287,7 @@ function prepareCatalog( // selector-qualified rows when a live selector is configured. const observedNativeSlugs: string[] = []; const disabledNative = disabledNativeSlugs(config); + const openaiContextCap = nativeContextLimits(config); const nativeCatalogModels = mergeCatalogModelsWithNativeRecovery( active?.models ?? catalog.models ?? [], [catalog.models ?? [], ...nativeRecoverySources], @@ -304,6 +306,7 @@ function prepareCatalog( suppressedBareNativeSlugs, disabledNativeAccountSlugs: new Set(), multiAgentV2Enabled, + openaiContextCap, }); const accountBoundEntries = accountSelectors.length === 0 ? [] @@ -320,6 +323,7 @@ function prepareCatalog( disabledNativeAccountSlugs: new Set([...disabledNative].filter(slug => suppressedBareNativeSlugs.has(slug))), multiAgentV2Enabled, keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true, + openaiContextCap, accountNativeSlugs, accountNativeSlugsBySelector, }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined); @@ -352,6 +356,7 @@ function prepareCatalog( includeNativeOpenAi, accountBoundEntries, suppressedBareNativeSlugs, + openaiContextCap, policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, nativeBackfillSlugs: [...availableBareNativeSlugs, ...observedNativeSlugs], diff --git a/src/config.ts b/src/config.ts index dcf34313a4..333ef45210 100644 --- a/src/config.ts +++ b/src/config.ts @@ -70,6 +70,7 @@ import { type ProviderCostOverlay, } from "./types"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers"; +import { modelAutoCompactTokenLimitsConfigError } from "./providers/auto-compact-budget"; import { fastWireDeclarationError, hasFastWireCapabilityConflict } from "./providers/fastwire"; import { getProviderRegistryEntry, @@ -1497,6 +1498,17 @@ const configSchema = z.object({ message: maxInputError, }); } + const autoCompactError = modelAutoCompactTokenLimitsConfigError( + (provider as { modelAutoCompactTokenLimits?: unknown }).modelAutoCompactTokenLimits, + { requireNativeIds: name === OPENAI_CODEX_PROVIDER_ID }, + ); + if (autoCompactError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "modelAutoCompactTokenLimits"], + message: autoCompactError, + }); + } const reasoningSummariesError = booleanRecordConfigError( (provider as { modelSupportsReasoningSummaries?: unknown }).modelSupportsReasoningSummaries, "modelSupportsReasoningSummaries", diff --git a/src/providers/auto-compact-budget.ts b/src/providers/auto-compact-budget.ts new file mode 100644 index 0000000000..8275d10cff --- /dev/null +++ b/src/providers/auto-compact-budget.ts @@ -0,0 +1,65 @@ +import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../codex/catalog/native-models"; +import { redactSecretString } from "../lib/redact"; + +const RESERVED_OBJECT_KEYS = new Set(["__proto__", "constructor", "prototype"]); + +function positiveSafeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0; +} + +/** + * Resolve a client-facing soft compaction budget without changing any hard + * model limit. Configuration and measured input ceilings may only lower the + * default 90% envelope. + */ +export function clampAutoCompactTokenLimit( + contextWindow: number, + maxInputTokens?: number, + configuredLimit?: number, +): number { + const candidates = [Math.floor(contextWindow * 0.9), contextWindow]; + if (positiveSafeInteger(maxInputTokens)) candidates.push(maxInputTokens); + if (positiveSafeInteger(configuredLimit)) candidates.push(configuredLimit); + return Math.min(...candidates); +} + +export type AutoCompactBudgetValidationOptions = Readonly<{ + /** PATCH accepts null for whole-map and per-key deletion. */ + allowTombstones?: boolean; + /** The canonical ChatGPT provider accepts only exact supported native ids. */ + requireNativeIds?: boolean; +}>; + +/** Shared config/load/management boundary for per-model soft budgets. */ +export function modelAutoCompactTokenLimitsConfigError( + value: unknown, + options: AutoCompactBudgetValidationOptions = {}, +): string | null { + const field = "modelAutoCompactTokenLimits"; + if (value === undefined || (options.allowTombstones && value === null)) return null; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return `${field} must be a plain object${options.allowTombstones ? " or null" : ""}`; + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + return `${field} must be a plain object with own properties`; + } + for (const [modelId, entry] of Object.entries(value as Record)) { + const safeModelId = JSON.stringify(redactSecretString(modelId)); + if (!modelId.trim()) return `${field} keys must be nonblank model ids`; + if (RESERVED_OBJECT_KEYS.has(modelId)) { + return `${field} key ${safeModelId} is reserved`; + } + if (options.requireNativeIds + && (modelId.includes("/") || !SUPPORTED_NATIVE_OPENAI_SLUGS.has(modelId))) { + return `${field} key ${safeModelId} must be an exact supported native model id`; + } + if (options.allowTombstones && entry === null) continue; + if (!positiveSafeInteger(entry)) { + return `${field}[${safeModelId}] must be a positive safe integer${ + options.allowTombstones ? " or null" : "" + }`; + } + } + return null; +} diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 77ffa085c2..2da23fa3c2 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -24,6 +24,7 @@ import { effectiveGoogleMode, getProviderRegistryEntry, providerCodexAccountMode import { providerConfigSeed } from "../providers/derive"; import type { OcxConfig, OcxProviderConfig } from "../types"; import { openRouterRoutingConfigError } from "../providers/openrouter-routing"; +import { modelAutoCompactTokenLimitsConfigError } from "../providers/auto-compact-budget"; import { googleVertexLocationConfigError } from "../providers/google-vertex-location"; import { xaiResponsesOptInState } from "../providers/xai-responses-opt-in"; @@ -563,6 +564,8 @@ export function providerManagementConfigError(name: unknown, provider: unknown): if (contextOverlayError) return contextOverlayError; delete canonicalCandidate.contextWindow; delete canonicalCandidate.modelContextWindows; + // User-owned soft compaction policy; it does not alter the canonical transport seed. + delete canonicalCandidate.modelAutoCompactTokenLimits; const canonical = seed && sameCanonicalProviderSeed(canonicalCandidate, seed); if (!canonical) { return `provider ${name} must equal the canonical built-in provider seed`; @@ -605,6 +608,11 @@ 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 = modelAutoCompactTokenLimitsConfigError( + raw.modelAutoCompactTokenLimits, + { requireNativeIds: name === "openai" }, + ); + if (autoCompactError) return `provider ${name} ${autoCompactError}`; const reasoningSummariesError = booleanRecordConfigError(raw.modelSupportsReasoningSummaries, "modelSupportsReasoningSummaries"); if (reasoningSummariesError) return `provider ${name} ${reasoningSummariesError}`; const reasoningSummaryDeliveryError = reasoningSummaryDeliveryRecordConfigError( @@ -705,6 +713,7 @@ export function safeConfigDTO(config: OcxConfig): unknown { "models", "contextWindow", "modelContextWindows", + "modelAutoCompactTokenLimits", "defaultMaxOutputTokens", "modelMaxOutputTokens", "openRouterRouting", diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index 8fb625e79d..c4e6ca0210 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -63,6 +63,7 @@ export async function listManagementModelRows(config: OcxConfig): Promise { @@ -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 47909e50f0..5c107135f8 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -36,7 +36,7 @@ import { ProviderOutboundPolicyError, providerOutboundGet, providerOutboundPost, import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; 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, @@ -54,6 +54,7 @@ import { clearThreadAccountMap } from "../../codex/routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; import { clearModelCache, getProviderDiscoveryStatus } from "../../codex/model-cache"; import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; +import { modelAutoCompactTokenLimitsConfigError } from "../../providers/auto-compact-budget"; import { resolveCodexHomeDir } from "../../codex/home"; import { readUsageEntries } from "../../usage/log"; import { getUsageDebugLogEntries } from "../../usage/debug"; @@ -266,6 +267,29 @@ function applyProviderPatchFields( } touched = true; } + if (Object.hasOwn(rawBody, "modelAutoCompactTokenLimits")) { + const value = rawBody.modelAutoCompactTokenLimits; + const error = modelAutoCompactTokenLimitsConfigError(value, { + allowTombstones: true, + requireNativeIds: name === "openai", + }); + if (error) return { error }; + if (value === null) { + delete next.modelAutoCompactTokenLimits; + } else { + const budgets: Record = Object.assign( + Object.create(null) as Record, + next.modelAutoCompactTokenLimits ?? {}, + ); + for (const [model, budget] of Object.entries(value as Record)) { + if (budget === null) delete budgets[model]; + else budgets[model] = budget; + } + if (Object.keys(budgets).length > 0) next.modelAutoCompactTokenLimits = budgets; + else delete next.modelAutoCompactTokenLimits; + } + touched = true; + } if (Object.hasOwn(rawBody, "modelSupportsServiceTier")) { const value = rawBody.modelSupportsServiceTier; if (value === null) { @@ -369,6 +393,28 @@ function applyProviderPatchFields( return { next, touched, editorTouched, enablingOpenAi, headersTouched }; } +/** Validate the canonical OpenAI soft-budget overlay against a fresh registry seed. */ +function canonicalOpenAiBudgetPatchError( + provider: OcxProviderConfig, + rawBody: Record, + keys: string[], + config: OcxConfig, +): string | null { + if (!isCanonicalOpenAiForwardProvider(provider)) { + return "provider openai must be the canonical built-in provider"; + } + const entry = getProviderRegistryEntry("openai"); + if (!entry) return "provider openai registry seed is unavailable"; + const seed = providerConfigSeed(entry); + if (provider.codexAccountMode !== undefined) seed.codexAccountMode = provider.codexAccountMode; + if (provider.modelAutoCompactTokenLimits !== undefined) { + seed.modelAutoCompactTokenLimits = { ...provider.modelAutoCompactTokenLimits }; + } + const applied = applyProviderPatchFields("openai", seed, rawBody, keys, config); + if ("error" in applied) return applied.error; + return providerManagementConfigError("openai", applied.next); +} + export async function handleProviderRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps, principal, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx; @@ -405,6 +451,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise key === "requestPacing"); if (applied.editorTouched && !pacingOnly) { - const providerError = providerManagementConfigError(name, next); + const providerError = canonicalBudgetOnly + ? canonicalOpenAiBudgetPatchError(next, rawBody, keys, config) + : providerManagementConfigError(name, next); if (providerError) return jsonResponse({ error: providerError }, 400); - const serviceTierError = providerServiceTierConfigError(name, next); - if (serviceTierError) return jsonResponse({ error: serviceTierError }, 400); - const resolvedError = await providerDestinationResolvedError(name, next); - if (resolvedError) return jsonResponse({ error: resolvedError }, 400); + if (!canonicalBudgetOnly) { + const serviceTierError = providerServiceTierConfigError(name, next); + if (serviceTierError) return jsonResponse({ error: serviceTierError }, 400); + const resolvedError = await providerDestinationResolvedError(name, next); + if (resolvedError) return jsonResponse({ error: resolvedError }, 400); + } } else if (applied.enablingOpenAi) { // Same DNS gate as POST: Clash fake-IP only. Never honor a persisted // allowPrivateNetwork on this path — it must not bypass the built-in guard. @@ -682,15 +742,19 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise; /** Model-specific max input token limits. Values cap auto_compact_token_limit. */ modelMaxInputTokens?: Record; + /** + * Per-model soft compaction budgets. Values may only lower the effective + * context/max-input envelope; they never raise hard admission limits. + */ + 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/auto-compact-budget.test.ts b/tests/auto-compact-budget.test.ts new file mode 100644 index 0000000000..f7973d9247 --- /dev/null +++ b/tests/auto-compact-budget.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; + +import { + clampAutoCompactTokenLimit, + modelAutoCompactTokenLimitsConfigError, +} from "../src/providers/auto-compact-budget"; + +describe("per-model auto-compaction budgets", () => { + test("configuration only lowers the effective hard-limit envelope", () => { + expect(clampAutoCompactTokenLimit(1_000)).toBe(900); + expect(clampAutoCompactTokenLimit(1_000, 800)).toBe(800); + expect(clampAutoCompactTokenLimit(1_000, 800, 700)).toBe(700); + expect(clampAutoCompactTokenLimit(1_000, 800, 5_000)).toBe(800); + }); + + test("one validation contract handles config, native ids, and PATCH tombstones", () => { + expect(modelAutoCompactTokenLimitsConfigError({ model: 64_000 })).toBeNull(); + expect(modelAutoCompactTokenLimitsConfigError( + { "gpt-5.6-sol": 64_000 }, + { requireNativeIds: true }, + )).toBeNull(); + expect(modelAutoCompactTokenLimitsConfigError( + { model: null }, + { allowTombstones: true }, + )).toBeNull(); + expect(modelAutoCompactTokenLimitsConfigError(null, { allowTombstones: true })).toBeNull(); + + for (const invalid of [ + null, + [], + { model: 0 }, + { model: 1.5 }, + { model: Number.MAX_SAFE_INTEGER + 1 }, + { model: null }, + JSON.parse('{"__proto__": 1000}'), + { constructor: 1000 }, + Object.create({ inherited: 1000 }), + ]) { + expect(modelAutoCompactTokenLimitsConfigError(invalid)).not.toBeNull(); + } + expect(modelAutoCompactTokenLimitsConfigError( + { "team/gpt-5.6-sol": 64_000 }, + { requireNativeIds: true }, + )).toContain("exact supported native model id"); + expect(modelAutoCompactTokenLimitsConfigError( + { "team/gpt-5.6-sol": null }, + { allowTombstones: true, requireNativeIds: true }, + )).toContain("exact supported native model id"); + }); + + test("validation errors redact secret-shaped model ids", () => { + const secret = "api_key=sk-secret-provider-key"; + const error = modelAutoCompactTokenLimitsConfigError({ [secret]: 0 }); + expect(error).toContain("[REDACTED]"); + expect(error).not.toContain("sk-secret-provider-key"); + }); +}); diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index d41daa58ad..6537acee98 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -199,12 +199,25 @@ 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("never advertises combo max-input or compaction above its smallest final window", () => { + const derived = deriveComboCatalogModel("bounded", normalizedCombo(), [ + { provider: "a", id: "m1", contextWindow: 700_000, maxInputTokens: 922_000 }, + { provider: "b", id: "m2", contextWindow: 800_000, maxInputTokens: 900_000 }, + ]); + expect(derived).toMatchObject({ + contextWindow: 700_000, + maxInputTokens: 700_000, + autoCompactTokenLimit: 630_000, + }); + }); + test("handles vision, missing modalities, reasoning defaults, and parallel tools conservatively", () => { expect(deriveComboCatalogModel("vision", normalizedCombo({ defaultEffort: "low" }), [ memberA, @@ -975,8 +988,8 @@ describe("combo catalog capability intersection", () => { port: 10100, defaultProvider: "a", providers: { - a: { adapter: "openai-chat", baseUrl: "https://a.example/v1", liveModels: false, models: ["m1"], modelContextWindows: { m1: 200_000 } }, - b: { adapter: "openai-chat", baseUrl: "https://b.example/v1", liveModels: false, models: ["m2"], modelContextWindows: { m2: 128_000 } }, + a: { adapter: "openai-chat", baseUrl: "https://a.example/v1", liveModels: false, models: ["m1"], modelContextWindows: { m1: 200_000 }, modelAutoCompactTokenLimits: { m1: 150_000 } }, + b: { adapter: "openai-chat", baseUrl: "https://b.example/v1", liveModels: false, models: ["m2"], modelContextWindows: { m2: 128_000 }, modelAutoCompactTokenLimits: { m2: 80_000 } }, }, combos: { mixed: { targets: [{ provider: "a", model: "m1" }, { provider: "b", model: "m2" }] }, @@ -993,6 +1006,14 @@ describe("combo catalog capability intersection", () => { expect(first.map(model => `${model.provider}/${model.id}`)).toEqual([ "a/m1", "b/m2", "combo/mixed", ]); + expect(first.find(model => model.provider === "combo" && model.id === "mixed")) + .toMatchObject({ contextWindow: 128_000, maxInputTokens: 128_000, autoCompactTokenLimit: 80_000 }); + expect(buildCatalogEntries(nativeTemplate(), [], first) + .find(entry => entry.slug === "combo/mixed")).toMatchObject({ + context_window: 128_000, + max_context_window: 128_000, + auto_compact_token_limit: 80_000, + }); expect(filterCatalogVisibleModels(first, config).some(model => model.id === "mixed")).toBe(false); expect(warn).toHaveBeenCalledTimes(1); expect(String(warn.mock.calls[0]?.[0])).toContain("[REDACTED]"); @@ -1892,6 +1913,54 @@ describe("configured CatalogModel displayName -> catalog display_name", () => { } }); + test("a custom row clamps its soft budget to the provider max-input ceiling", async () => { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "custom-budget", + providers: { + "custom-budget": { + baseUrl: "https://custom-budget.example.test/v1", + adapter: "openai-chat", + liveModels: false, + models: [], + modelMaxInputTokens: { renamed: 60_000, contextless: 60_000 }, + modelAutoCompactTokenLimits: { renamed: 80_000, contextless: 10_000 }, + }, + }, + customModels: [{ + id: "custom-budget-row", + provider: "custom-budget", + modelId: "renamed", + contextWindow: 321_000, + }, { + id: "custom-budget-contextless", + provider: "custom-budget", + modelId: "contextless", + }], + }); + const model = models.find(row => row.provider === "custom-budget" && row.id === "renamed"); + expect(model).toMatchObject({ + contextWindow: 321_000, + maxInputTokens: 60_000, + autoCompactTokenLimit: 60_000, + }); + expect(buildCatalogEntries(nativeTemplate(), [], models) + .find(entry => entry.slug === "custom-budget/renamed")).toMatchObject({ + context_window: 321_000, + max_context_window: 321_000, + auto_compact_token_limit: 60_000, + }); + const contextless = models.find(row => row.provider === "custom-budget" && row.id === "contextless"); + expect(contextless).toMatchObject({ maxInputTokens: 60_000 }); + expect(contextless).not.toHaveProperty("autoCompactTokenLimit"); + expect(buildCatalogEntries(nativeTemplate(), [], models) + .find(entry => entry.slug === "custom-budget/contextless")).toMatchObject({ + context_window: 128_000, + max_context_window: 128_000, + auto_compact_token_limit: 60_000, + }); + }); + test("a customModel reasoning ladder overrides the inherited provider ladder end-to-end", async () => { clearModelCache("custom-provider"); const originalFetch = globalThis.fetch; @@ -3087,6 +3156,38 @@ describe("Codex catalog routed normalization", () => { } }); + test("bare and account-qualified native rows inherit one lowering-only soft budget", () => { + const entries = buildCatalogEntries( + nativeTemplate(), + NATIVE_OPENAI_MODELS, + [], + undefined, + false, + "default", + new Set(), + ["team"], + new Set(), + new Set(), + { modelAutoCompactTokenLimits: { "gpt-5.6-sol": 120_000 } }, + ); + const bare = entries.find(entry => entry.slug === "gpt-5.6-sol"); + const account = entries.find(entry => entry.slug === "team/gpt-5.6-sol"); + + expect(bare).toMatchObject({ + context_window: 272_000, + max_context_window: 272_000, + auto_compact_token_limit: 120_000, + }); + expect(account).toMatchObject({ + context_window: 272_000, + max_context_window: 272_000, + auto_compact_token_limit: 120_000, + opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, + }); + expect(account?.context_window).toBe(bare?.context_window); + expect(account?.max_context_window).toBe(bare?.max_context_window); + }); + test("routed entries drop stale native max context with the template window (#992)", () => { const template = { ...nativeTemplate(), @@ -4655,6 +4756,7 @@ describe("Codex catalog routed normalization", () => { apiKey: "sk-test", models: ["static-model"], modelContextWindows: { "static-model": 321_000 }, + modelAutoCompactTokenLimits: { "static-model": 80_000 }, modelInputModalities: { "static-model": ["text", "image"] }, }, }, @@ -4664,10 +4766,37 @@ describe("Codex catalog routed normalization", () => { expect(routed?.context_window).toBe(321_000); expect(routed?.max_context_window).toBe(321_000); - expect(routed?.auto_compact_token_limit).toBe(288_900); + expect(routed?.auto_compact_token_limit).toBe(80_000); expect(routed?.input_modalities).toEqual(["text", "image"]); }); + test("an unknown window ignores the configured soft budget instead of treating 128k as policy evidence", async () => { + globalThis.fetch = (async () => new Response("{}", { status: 503 })) as typeof fetch; + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "unknown-soft", + providers: { + "unknown-soft": { + adapter: "openai-chat", + baseUrl: "https://unknown-soft.test/v1", + liveModels: false, + models: ["model"], + modelAutoCompactTokenLimits: { model: 10_000 }, + }, + }, + }); + const model = models.find(row => row.provider === "unknown-soft" && row.id === "model"); + expect(model).not.toHaveProperty("autoCompactTokenLimit"); + + const emitted = buildCatalogEntries(nativeTemplate(), [], models) + .find(entry => entry.slug === "unknown-soft/model"); + expect(emitted).toMatchObject({ + context_window: 128_000, + max_context_window: 128_000, + auto_compact_token_limit: 115_200, + }); + }); + // #1073's exact reproduction: a provider whose /models returns nothing but ids. Two cases, // deliberately not one — a single test that sets `modelContextWindows` would keep passing // with the provider-wide `?? prov.contextWindow` fallback deleted, because the per-model @@ -4874,6 +5003,7 @@ describe("Codex catalog routed normalization", () => { apiKey: "sk-test", contextWindow: 128_000, modelContextWindows: { "wide-model": 100_000 }, + modelMaxInputTokens: { "wide-model": 200_000 }, modelInputModalities: { "wide-model": ["text"] }, }, }, @@ -4881,6 +5011,7 @@ describe("Codex catalog routed normalization", () => { expect(models.find(m => m.id === "wide-model")).toMatchObject({ contextWindow: 100_000, + maxInputTokens: 100_000, inputModalities: ["text"], }); expect(models.find(m => m.id === "small-model")?.contextWindow).toBe(64_000); @@ -5195,11 +5326,12 @@ 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 }, - modelMaxInputTokens: { "gpt-5.6-sol": 300_000, "gpt-5.6-terra": 945_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, "gpt-5.6-luna": 900_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-luna")).toMatchObject({ contextWindow: 350_000, maxInputTokens: 350_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 48d15e6da7..a0e367091b 100644 --- a/tests/codex-convergence-account-selectors.test.ts +++ b/tests/codex-convergence-account-selectors.test.ts @@ -325,6 +325,21 @@ test("convergence renders account-qualified rows and preserves only non-generate } }); +test("convergence preserves one configured soft budget on bare and account-native rows", async () => { + writeCatalog([nativeEntry()]); + const nextConfig = config(true); + nextConfig.providers.openai!.modelAutoCompactTokenLimits = { "gpt-5.6-sol": 120_000 }; + + const models = (await convergeCatalog(nextConfig)).models ?? []; + for (const slug of ["gpt-5.6-sol", "desktop/gpt-5.6-sol", "team/gpt-5.6-sol"]) { + expect(models.find(entry => entry.slug === slug)).toMatchObject({ + context_window: 272_000, + max_context_window: 272_000, + auto_compact_token_limit: 120_000, + }); + } +}); + test("disabling the picker removes generated rows, restores bare rows, and retains foreign rows", async () => { writeCatalog([ nativeEntry("hide"), diff --git a/tests/config.test.ts b/tests/config.test.ts index 4f86db8743..ec715a86e5 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1619,6 +1619,40 @@ describe("opencodex config defaults", () => { expect(readConfigDiagnostics().error).toContain("providers.custom.modelMaxInputTokens"); }); + test("disk config validates per-model auto-compaction budgets with native exact ids", () => { + writeConfig({ + port: 10100, + providers: { + custom: { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + modelAutoCompactTokenLimits: { model: 1.5 }, + }, + }, + defaultProvider: "custom", + }); + expect(readConfigDiagnostics().source).toBe("fallback"); + expect(readConfigDiagnostics().error).toContain("providers.custom.modelAutoCompactTokenLimits"); + + rmSync(testDir, { recursive: true, force: true }); + mkdirSync(testDir, { recursive: true }); + writeConfig({ + port: 10100, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + modelAutoCompactTokenLimits: { "team/gpt-5.6-sol": 64_000 }, + }, + }, + defaultProvider: "openai", + }); + expect(readConfigDiagnostics().source).toBe("fallback"); + expect(readConfigDiagnostics().error).toContain("exact supported native model id"); + }); + test("disk config preserves valid OpenRouter routing and rejects invalid destinations", () => { writeConfig({ port: 10100, diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 7aaaba950a..ed53706bc9 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -670,13 +670,18 @@ describe("provider management validation", () => { freshHome(); const server = startServer(0); try { - expect((await seedProvider(server.url, { modelContextWindows: { "deepseek-v4-flash": 900000 } })).status).toBe(200); + expect((await seedProvider(server.url, { + modelContextWindows: { "deepseek-v4-flash": 900000 }, + modelAutoCompactTokenLimits: { "deepseek-v4-flash": 120000 }, + })).status).toBe(200); expect((await seedProvider(server.url, {})).status).toBe(200); // The user's key survives, and the registry seed is NOT persisted into user config: // router.ts fills registry values beneath user entries at resolve time, so writing // them here would be a side effect of an unrelated save. expect(loadConfig().providers["opencode-go"]?.modelContextWindows).toEqual({ "deepseek-v4-flash": 900000 }); + expect(loadConfig().providers["opencode-go"]?.modelAutoCompactTokenLimits) + .toEqual({ "deepseek-v4-flash": 120000 }); } finally { await server.stop(true); } @@ -686,11 +691,19 @@ describe("provider management validation", () => { freshHome(); const server = startServer(0); try { - expect((await seedProvider(server.url, { modelContextWindows: { "deepseek-v4-flash": 900000 } })).status).toBe(200); - expect((await seedProvider(server.url, { modelContextWindows: { "kimi-k3": 300000 } })).status).toBe(200); + expect((await seedProvider(server.url, { + modelContextWindows: { "deepseek-v4-flash": 900000 }, + modelAutoCompactTokenLimits: { "deepseek-v4-flash": 120000 }, + })).status).toBe(200); + expect((await seedProvider(server.url, { + modelContextWindows: { "kimi-k3": 300000 }, + modelAutoCompactTokenLimits: { "kimi-k3": 90000 }, + })).status).toBe(200); expect(loadConfig().providers["opencode-go"]?.modelContextWindows) .toEqual({ "deepseek-v4-flash": 900000, "kimi-k3": 300000 }); + expect(loadConfig().providers["opencode-go"]?.modelAutoCompactTokenLimits) + .toEqual({ "deepseek-v4-flash": 120000, "kimi-k3": 90000 }); } finally { await server.stop(true); } @@ -841,6 +854,9 @@ describe("provider management validation", () => { ["map-shape", { ...canonicalDirect, modelContextWindows: [] }], ["map-value", { ...canonicalDirect, modelContextWindows: { "gpt-5.6-sol": "wide" } }], ["map-key", { ...canonicalDirect, modelContextWindows: { " ": 500_000 } }], + ["soft-map-shape", { ...canonicalDirect, modelAutoCompactTokenLimits: [] }], + ["soft-map-value", { ...canonicalDirect, modelAutoCompactTokenLimits: { "gpt-5.6-sol": 1e100 } }], + ["soft-map-key", { ...canonicalDirect, modelAutoCompactTokenLimits: { "team/gpt-5.6-sol": 120_000 } }], ] as const) { const response = await fetch(new URL("/api/providers", server.url), { method: "POST", @@ -855,6 +871,7 @@ describe("provider management validation", () => { // the proxy advertises. for (const [, provider] of [ ["per-model", { ...canonicalDirect, modelContextWindows: { "gpt-5.6-sol": 500_000 } }], + ["soft-per-model", { ...canonicalDirect, modelAutoCompactTokenLimits: { "gpt-5.6-sol": 120_000 } }], ["provider-wide", { ...canonicalDirect, contextWindow: 500_000 }], ] as const) { const response = await fetch(new URL("/api/providers", server.url), { @@ -865,6 +882,19 @@ describe("provider management validation", () => { expect(response.status).toBe(200); } + // POST enriches the canonical row with registry-owned capabilities. A later budget-only + // PATCH must validate the overlay against a fresh seed instead of rejecting those fields. + const patchedBudget = 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": 90_000 } }), + }); + expect(patchedBudget.status).toBe(200); + expect(loadConfig().providers.openai.modelAutoCompactTokenLimits).toEqual({ + "gpt-5.6-sol": 120_000, + "gpt-5.6-terra": 90_000, + }); + const acceptedCustom = await fetch(new URL("/api/providers", server.url), { method: "POST", headers: { "content-type": "application/json" }, @@ -997,9 +1027,16 @@ 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": 120_000, + "gpt-5.6-terra": 90_000, + }); expect(dto.providers["openai-multi"]).toBeUndefined(); expect(dto.providers["custom-max-input"]).not.toHaveProperty("modelMaxInputTokens"); @@ -2735,6 +2772,7 @@ describe("provider management validation", () => { models: ["wide", "narrow"], contextWindow: 256_000, modelContextWindows: { narrow: 64_000 }, + modelAutoCompactTokenLimits: { narrow: 32_000 }, modelSupportsServiceTier: { narrow: false }, }, }, @@ -2758,27 +2796,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: 32_000 }, modelSupportsServiceTier: { narrow: false }, }); const updated = await request("PATCH", { contextWindow: 350_000, modelContextWindows: { wide: 350_000 }, + modelAutoCompactTokenLimits: { wide: 100_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: 100_000, narrow: 32_000 }, modelSupportsServiceTier: { wide: true, narrow: false }, }); expect(loadConfig().providers.relay).toMatchObject({ contextWindow: 350_000, modelContextWindows: { wide: 350_000, narrow: 64_000 }, + modelAutoCompactTokenLimits: { wide: 100_000, narrow: 32_000 }, modelSupportsServiceTier: { wide: true, narrow: false }, }); @@ -2792,6 +2835,9 @@ describe("provider management validation", () => { { modelContextWindows: { wide: 1e100 } }, { modelContextWindows: { "": 100_000 } }, { modelContextWindows: { wide: -1 } }, + { modelAutoCompactTokenLimits: { wide: 1e100 } }, + { modelAutoCompactTokenLimits: { "": 100_000 } }, + { modelAutoCompactTokenLimits: { constructor: 100_000 } }, { modelSupportsServiceTier: { wide: "yes" } }, { modelSupportsServiceTier: { "": true } }, ]) { @@ -2800,24 +2846,32 @@ describe("provider management validation", () => { expect(liveConfig.providers.relay).toMatchObject({ contextWindow: 350_000, modelContextWindows: { wide: 350_000, narrow: 64_000 }, + modelAutoCompactTokenLimits: { wide: 100_000, narrow: 32_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: 32_000 }); + expect(loadConfig().providers.relay.modelAutoCompactTokenLimits).toEqual({ narrow: 32_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(); + expect(loadConfig().providers.relay.modelAutoCompactTokenLimits).toBeUndefined(); }); test("provider PATCH manages custom headers with merge and clear semantics", async () => { @@ -2999,7 +3053,7 @@ describe("provider management validation", () => { "x-opencode-client": "desktop", }); }); - test("concurrent provider PATCHes merge different headers", async () => { + test("concurrent provider PATCHes serialize mixed fields and per-model soft budgets", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); process.env.OPENCODEX_HOME = TEST_DIR; @@ -3034,6 +3088,19 @@ describe("provider management validation", () => { expect(first?.status).toBe(200); expect(second?.status).toBe(200); expect(liveConfig.providers.hdr.headers).toEqual({ "X-A": "a", "X-B": "b" }); + + const [third, fourth] = await Promise.all([ + patch("hdr", { + headers: { "X-C": "c" }, + modelAutoCompactTokenLimits: { m1: 80_000 }, + }), + patch("hdr", { modelAutoCompactTokenLimits: { m2: 64_000 } }), + ]); + expect(third?.status).toBe(200); + expect(fourth?.status).toBe(200); + expect(liveConfig.providers.hdr.headers).toEqual({ "X-A": "a", "X-B": "b", "X-C": "c" }); + expect(liveConfig.providers.hdr.modelAutoCompactTokenLimits).toEqual({ m1: 80_000, m2: 64_000 }); + expect(loadConfig().providers.hdr.modelAutoCompactTokenLimits).toEqual({ m1: 80_000, m2: 64_000 }); }); test("provider context-cap API persists toggles and annotates model rows", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index 3dabda66c6..77120e6f9f 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -123,16 +123,37 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(nativeModelRows(both).find(r => r.slug === "gpt-5.6-sol")?.contextWindow).toBe(350_000); }); + test("a per-model soft budget lowers compaction without changing native hard limits", () => { + const configured = { + providers: { openai: { modelAutoCompactTokenLimits: { "gpt-5.6-sol": 120_000 } } }, + } as never; + const row = nativeModelRows(configured).find(item => item.slug === "gpt-5.6-sol"); + expect(row).toMatchObject({ + contextWindow: 272_000, + maxInputTokens: 272_000, + autoCompactTokenLimit: 120_000, + }); + + const oversized = { + providers: { openai: { modelAutoCompactTokenLimits: { "gpt-5.6-sol": 2_000_000 } } }, + } as never; + expect(nativeModelRows(oversized).find(item => item.slug === "gpt-5.6-sol")) + .toMatchObject({ contextWindow: 272_000, maxInputTokens: 272_000, autoCompactTokenLimit: 244_800 }); + }); + test("the on-disk catalog entry lands at the same width as the dashboard row", () => { // Regression: applyNativeOpenAiContextOverride used to re-read the static table and apply // only the cap, so a saved per-model window showed up in /api/models and was written back // at 922,000 in the Codex catalog. - const limits = { providers: { openai: { modelContextWindows: { "gpt-5.6-sol": 500_000 } } } } as never; + const limits = { providers: { openai: { + modelContextWindows: { "gpt-5.6-sol": 500_000 }, + modelAutoCompactTokenLimits: { "gpt-5.6-sol": 120_000 }, + } } } as never; const entry: Record = { slug: "gpt-5.6-sol", context_window: 922_000, max_context_window: 922_000 }; applyNativeOpenAiContextOverride(entry as never, nativeContextLimits(limits)); expect(entry.context_window).toBe(500_000); expect(entry.max_context_window).toBe(500_000); - expect(entry.auto_compact_token_limit).toBe(450_000); // 90% of the narrowed window + expect(entry.auto_compact_token_limit).toBe(120_000); }); test("the advertised native window stays inside the measured ceiling after Codex spends 95% of it", () => { From 581381a627c1396f0ff2c55bf962f169ffdad865 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 21 Aug 2026 08:26:32 +0900 Subject: [PATCH 2/4] docs(config): document per-model compact budgets --- .../src/content/docs/fr/reference/configuration/providers.md | 1 + .../src/content/docs/ja/reference/configuration/providers.md | 1 + .../src/content/docs/ko/reference/configuration/providers.md | 1 + docs-site/src/content/docs/reference/configuration/providers.md | 1 + .../src/content/docs/ru/reference/configuration/providers.md | 1 + .../src/content/docs/tr/reference/configuration/providers.md | 1 + .../src/content/docs/zh-cn/reference/configuration/providers.md | 1 + .../src/content/docs/zh-tw/reference/configuration/providers.md | 1 + 8 files changed, 8 insertions(+) diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index feedf5ad0c..822c7ba47c 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -84,6 +84,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `modelContextWindows?` | `Record` | Valeurs de repli ou plafonds de contexte par modèle. Ils remplacent `contextWindow` : une fenêtre inconnue utilise la valeur configurée, tandis que des métadonnées actives plus faibles restent déterminantes. | | `modelInputModalities?` | `Record` | Conseils de saisie par modèle tels que `["text"]` ou `["text", "image"]`. | | `modelMaxInputTokens?` | `Record` | Limites d'entrée maximales positives par modèle utilisées pour les conseils de compactage automatique du catalogue. | +| `modelAutoCompactTokenLimits?` | `Record` | Budgets souples de compactage automatique par modèle, sous forme d'entiers sûrs positifs. Ils peuvent uniquement abaisser l'enveloppe effective de 90 % du contexte ou de l'entrée maximale et sont omis lorsqu'aucune fenêtre de contexte faisant autorité n'est connue. Pour le fournisseur canonique `openai`, les clés doivent être les identifiants exacts de modèles natifs pris en charge, sans préfixe de fournisseur ni de sélecteur de compte. PATCH fusionne les entrées ; `null` supprime une clé, tandis que `null` pour le champ entier efface la table. Ces marqueurs `null` sont réservés à PATCH. | | `defaultMaxOutputTokens?` | `number` | Solution de secours `openai-chat` à l’échelle du fournisseur lorsque le client omet `max_output_tokens`. | | `modelMaxOutputTokens?` | `Record` | Budgets de repli `openai-chat` positifs par modèle ; les correspondances exactes ou par motif priment sur la valeur par défaut du fournisseur. | | `modelCosts?` | `Record` | Prix affichés par modèle (USD par 1M de jetons), indexés par l'identifiant exact du modèle en amont de ce fournisseur — et non par un identifiant de fournisseur ni par une étiquette routée `provider/model`, par exemple `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. Tout identifiant de modèle constitue une clé valide : les fournisseurs personnalisés peuvent cibler n'importe quel point de terminaison compatible avec OpenAI au moyen de l'adaptateur `openai-chat`, et les identifiants de fournisseur locaux ou internes fonctionnent même s'ils sont absents des catalogues intégrés. Les prix configurés par l'utilisateur priment sur les catalogues intégrés dans les estimations des pages Journaux (`~$`) et Utilisation. Les entrées historiques sont recalculées à partir de la surcharge actuelle ; modifier un prix peut donc changer les totaux antérieurs. L'ordre de repli est le suivant : `modelCosts` défini par l'utilisateur → catalogue jawcode → surcharge des prix attendus → repli propre au fournisseur au niveau du modèle. Une entrée entièrement nulle passe à la source suivante. Chaque tarif doit être un nombre fini positif ou nul, inférieur ou égal à 1 000 000 (USD par 1M de jetons) ; les lignes hors plage sont rejetées par l'interface de gestion et ignorées au chargement. Ces valeurs servent uniquement à l'estimation lors de l'affichage : les surcharges n'affectent jamais le routage, la sélection des comptes, les quotas ni la facturation. | diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 27cf1406ec..f1210892a5 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -72,6 +72,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `modelContextWindows?` | `Record` | モデルごとのコンテキスト値および上限。`contextWindow` より優先され、ウィンドウが不明なら設定値を使い、より小さいライブメタデータがあればそちらが優先されます。 | | `modelInputModalities?` | `Record` | `["text"]` や `["text", "image"]` などのモデルごとの入力ヒント。 | | `modelMaxInputTokens?` | `Record` |カタログの自動圧縮ヒントに使用されるモデルごとの正の最大入力制限。 | +| `modelAutoCompactTokenLimits?` | `Record` | モデルごとの正の安全な整数によるソフト自動圧縮予算。実効値であるコンテキストまたは最大入力の 90% の上限を下げることだけができ、信頼できるコンテキストウィンドウが不明な場合は出力されません。canonical `openai` では、キーは provider や account-selector の接頭辞を含まない、サポート対象の正確なネイティブモデル ID でなければなりません。provider PATCH はエントリをマージし、キーを `null` にするとそのキーを削除し、フィールド全体を `null` にするとマップを消去します。これらの `null` tombstone は PATCH 専用です。 | | `defaultMaxOutputTokens?` | `number` |クライアントが `max_output_tokens` を省略した場合の、プロバイダー全体の `openai-chat` フォールバック。 | | `modelMaxOutputTokens?` | `Record` |モデルごとの `openai-chat` フォールバック バジェットがプラスになります。正確な/パターン一致はプロバイダーのデフォルトを上回ります。 | | `modelCosts?` | `Record` | モデルごとの表示価格(100万トークンあたりの米ドル)。そのプロバイダーの正確なアップストリーム モデル ID をキーにします(プロバイダー識別子やルーティングされた `provider/model` ラベルではありません)。値は `input`, `output`, `cacheRead`, `cacheWrite` の 4 フィールドです(例: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`)。組み込みカタログにないモデル ID も、任意の OpenAI 互換エンドポイントを対象とするカスタムプロバイダーや、ローカル・内部プロバイダーで有効です。ユーザー設定の価格は Logs の `~$` と Usage の見積もりで組み込みカタログより優先されます。過去のエントリも現在のオーバーレイで再計算されるため、価格を編集すると過去の合計が変わることがあります(フォールバック順: ユーザー設定 → jawcode カタログ → expected-price オーバーレイ → モデル別ベンダー価格)。全ゼロのエントリは次のソースにフォールバックします。各レートは 0 以上の有限数で、最大 1,000,000(100万トークンあたりの米ドル)です。範囲外の行は管理境界で拒否され、読み込み時に破棄されます。表示専用の見積もりであり、ルーティング・アカウント選択・クォータ・請求には影響しません。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 707129b2ed..ccacb0a94f 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -72,6 +72,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `modelContextWindows?` | `Record` | 모델별 컨텍스트 값이자 상한입니다. `contextWindow`보다 우선하며, 창 크기를 알 수 없으면 설정값을 쓰고 더 작은 라이브 메타데이터가 있으면 그쪽을 따릅니다. | | `modelInputModalities?` | `Record` | `["text"]` 또는 `["text", "image"]` 같은 모델별 입력 힌트입니다. | | `modelMaxInputTokens?` | `Record` | 카탈로그 자동 압축 힌트에 쓰는 양수 모델별 최대 입력 한도입니다. | +| `modelAutoCompactTokenLimits?` | `Record` | 모델별 양의 안전 정수형 소프트 자동 압축 예산입니다. 유효한 컨텍스트 또는 최대 입력의 90% 한도를 낮출 수만 있으며, 신뢰할 수 있는 컨텍스트 창을 알 수 없으면 내보내지 않습니다. canonical `openai`에서는 키가 공급자나 계정 선택자 접두사가 없는 정확한 지원 네이티브 모델 ID여야 합니다. 공급자 PATCH는 항목을 병합하며, 키를 `null`로 지정하면 해당 키를 삭제하고 필드 전체를 `null`로 지정하면 맵을 지웁니다. 이 `null` tombstone은 PATCH에서만 사용할 수 있습니다. | | `defaultMaxOutputTokens?` | `number` | 클라이언트가 `max_output_tokens`를 생략했을 때 쓰는 공급자 전반의 `openai-chat` 폴백입니다. | | `modelMaxOutputTokens?` | `Record` | 양수 모델별 `openai-chat` 폴백 예산입니다. 정확한 일치와 패턴 일치가 공급자 기본값보다 우선합니다. | | `modelCosts?` | `Record` | 모델별 표시 가격(100만 토큰당 USD). 해당 공급자의 정확한 업스트림 모델 ID를 키로 사용하며(공급자 식별자나 라우팅된 `provider/model` 레이블이 아님) 값은 `input`, `output`, `cacheRead`, `cacheWrite` 네 필드입니다(예: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`). 커스텀 공급자는 `openai-chat` 어댑터로 임의의 OpenAI 호환 엔드포인트를 대상으로 할 수 있으며, 내장 카탈로그에 없는 로컬·내부 공급자 ID도 유효합니다. 사용자 구성 가격은 Logs `~$` 및 Usage 추정에서 내장 카탈로그보다 우선합니다. 기존 항목도 현재 오버레이로 다시 계산되므로 가격을 편집하면 과거 합계가 바뀔 수 있습니다(폴백 순서: 사용자 설정 → jawcode 카탈로그 → expected-price 오버레이 → 모델별 벤더 가격). 전부 0인 항목은 다음 소스로 폴백합니다. 각 요율은 0 이상의 유한한 숫자이며 최대 1,000,000(100만 토큰당 USD)입니다. 범위를 벗어난 행은 관리 경계에서 거부되고 로드 시 삭제됩니다. 표시 전용 추정이며 라우팅·계정 선택·할당량·청구에는 영향을 주지 않습니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index c44b628714..85cef14e1e 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -85,6 +85,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `modelContextWindows?` | `Record` | Per-model context fallbacks/caps. These override `contextWindow`: an unknown window uses the configured value, while smaller live metadata remains authoritative. | | `modelInputModalities?` | `Record` | Per-model input hints such as `["text"]` or `["text", "image"]`. | | `modelMaxInputTokens?` | `Record` | Positive per-model max input limits used for catalog auto-compaction hints. | +| `modelAutoCompactTokenLimits?` | `Record` | Positive safe-integer per-model soft auto-compaction budgets. Values can only lower the effective 90%-of-context/max-input envelope and are omitted when no authoritative context window is known. For canonical `openai`, keys must be exact supported native model IDs without provider or account-selector prefixes. Provider PATCH merges entries; set a key to `null` to delete it or the whole field to `null` to clear the map. These `null` tombstones are PATCH-only. | | `defaultMaxOutputTokens?` | `number` | Provider-wide `openai-chat` fallback when the client omits `max_output_tokens`. | | `modelMaxOutputTokens?` | `Record` | Positive per-model `openai-chat` fallback budgets; exact/pattern matches beat the provider default. | | `modelCosts?` | `Record` | Per-model display prices (USD per 1M tokens), keyed by that provider's exact upstream model id — not a provider identifier or a routed `provider/model` label, e.g. `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. Any model id is a valid key — custom providers may target any OpenAI-compatible endpoint through the `openai-chat` adapter, and local or internal provider ids work even when they are absent from the built-in catalogs. User-configured prices win over the built-in catalogs in the Logs `~$` and Usage estimates; historical entries are repriced from the current overlay, so editing a price can move past totals. The fallback order is user `modelCosts` → exact official correction → jawcode catalog → expected-price overlay → model-level vendor fallback, and an all-zero entry falls through to the next source in that sequence. Each rate must be a non-negative finite number at most 1,000,000 (USD per 1M tokens); out-of-range rows are rejected by the management boundary and dropped on load. Display-time estimation only: overlays never affect routing, account selection, quotas, or billing. | diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 21b70bebb5..c415517074 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -85,6 +85,7 @@ cross-route credential fallback не существует. Строки API GPT- | `modelContextWindows?` | `Record` | Значения и cap'ы контекста по отдельным моделям. Перекрывают `contextWindow`: если окно неизвестно, берётся заданное значение, а более маленькая live-metadata остаётся авторитетной. | | `modelInputModalities?` | `Record` | Подсказки modality по модели, например `["text"]` или `["text", "image"]`. | | `modelMaxInputTokens?` | `Record` | Положительные лимиты max input по моделям, используемые для подсказок auto-compaction в каталоге. | +| `modelAutoCompactTokenLimits?` | `Record` | Мягкие бюджеты автосжатия по моделям в виде положительных безопасных целых чисел. Они могут только уменьшать эффективную границу в 90 % контекста или максимального ввода и не выдаются, если авторитетное окно контекста неизвестно. Для канонического `openai` ключами могут быть только точные поддерживаемые ID нативных моделей без префиксов провайдера или селектора аккаунта. PATCH провайдера объединяет записи: `null` для ключа удаляет его, а `null` для всего поля очищает карту. Такие маркеры `null` допустимы только в PATCH. | | `defaultMaxOutputTokens?` | `number` | Provider-wide fallback для `openai-chat`, когда клиент не передал `max_output_tokens`. | | `modelMaxOutputTokens?` | `Record` | Положительные fallback-budget'ы `openai-chat` по моделям; exact/pattern-match имеет приоритет над provider-default. | | `modelCosts?` | `Record` | Отображаемые цены по моделям (USD за 1M токенов), ключ — точный upstream id модели этого провайдера (не идентификатор провайдера и не маршрутизируемая метка `provider/model`), значение — четыре поля: `input`, `output`, `cacheRead`, `cacheWrite` (пример: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`). Любой id допустим — кастомный провайдер может указывать на любой OpenAI-совместимый endpoint через адаптер `openai-chat`, а локальные и внутренние провайдеры работают даже без строки во встроенных каталогах. Пользовательские цены имеют приоритет над встроенными каталогами в оценках `~$` в Logs и Usage; исторические записи пересчитываются по текущему оверлею, поэтому изменение цены может сдвинуть прошлые суммы (порядок: пользователь → каталог jawcode → expected-price overlay → вендорская цена модели); полностью нулевая запись переходит к следующему источнику. Каждая ставка должна быть неотрицательным конечным числом не более 1 000 000 (USD за 1M токенов); строки вне диапазона отклоняются на управляющей границе и отбрасываются при загрузке. Только оценка для отображения: оверлеи не влияют на маршрутизацию, выбор аккаунта, квоты или биллинг. | diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index d4e414700a..4db3211bc5 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -91,6 +91,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `modelContextWindows?` | `Record` | Model başına bağlam geri dönüşleri/sınırları. Bunlar `contextWindow`'u geçersiz kılar: bilinmeyen bir pencere yapılandırılmış değeri kullanırken, daha küçük canlı meta veriler yetkili kalır. | | `modelInputModalities?` | `Record` | Model başına girdi ipuçları, örn. `["text"]` veya `["text", "image"]`. | | `modelMaxInputTokens?` | `Record` | Katalog otomatik sıkıştırma ipuçları için kullanılan pozitif model başına maksimum girdi sınırları. | +| `modelAutoCompactTokenLimits?` | `Record` | Model başına pozitif güvenli tamsayı biçiminde yumuşak otomatik sıkıştırma bütçeleri. Değerler yalnızca bağlamın veya maksimum girdinin etkin %90 zarfını düşürebilir ve yetkili bir bağlam penceresi bilinmiyorsa yayımlanmaz. Canonical `openai` için anahtarlar, sağlayıcı veya hesap seçici öneki olmadan desteklenen tam yerel model kimlikleri olmalıdır. Sağlayıcı PATCH girdileri birleştirir; bir anahtarı `null` yapmak o anahtarı siler, alanın tamamını `null` yapmak haritayı temizler. Bu `null` silme işaretleri yalnızca PATCH içindir. | | `defaultMaxOutputTokens?` | `number` | İstemci `max_output_tokens` değerini atladığında sağlayıcı genelinde `openai-chat` geri dönüşü. | | `modelMaxOutputTokens?` | `Record` | Pozitif model başına `openai-chat` geri dönüş bütçeleri; tam/kalıp eşleşmeleri sağlayıcı varsayılanını yener. | | `modelCosts?` | `Record` | Sağlayıcının tam yukarı akış model kimliğine göre anahtarlanan model başına görüntüleme fiyatları (1M token başına USD) — bir sağlayıcı tanımlayıcısı veya yönlendirilen `provider/model` etiketi değil, örn. `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. Herhangi bir model kimliği geçerli bir anahtardır — özel sağlayıcılar `openai-chat` adaptörü aracılığıyla herhangi bir OpenAI uyumlu uç noktayı hedefleyebilir ve yerel veya dahili sağlayıcı kimlikleri yerleşik kataloglarda bulunmasalar bile çalışır. Kullanıcı tarafından yapılandırılan fiyatlar Günlükler `~$` ve Kullanım tahminlerinde yerleşik katalogları yener; geçmiş girdiler geçerli katmandan yeniden fiyatlandırılır, bu nedenle bir fiyatı düzenlemek geçmiş toplamları değiştirebilir. Geri dönüş sırası: kullanıcı `modelCosts` → jawcode kataloğu → beklenen fiyat katmanı → model düzeyinde satıcı geri dönüşü ve tamamen sıfır bir girdi bu dizideki bir sonraki kaynağa düşer. Her oran en fazla 1.000.000 (1M token başına USD) olan negatif olmayan sonlu bir sayı olmalıdır; aralık dışı satırlar yönetim sınırı tarafından reddedilir ve yükleme sırasında bırakılır. Yalnızca görüntüleme zamanı tahmini: katmanlar yönlendirmeyi, hesap seçimini, kotaları veya faturalandırmayı asla etkilemez. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 1564842cbb..3630a9ba6c 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -72,6 +72,7 @@ selector,而不是分配一个新名称。 | `modelContextWindows?` | `Record` | 按模型设置的上下文数值与上限。优先于 `contextWindow`:窗口未知时采用所配置的数值,而更小的实时元数据仍然优先。 | | `modelInputModalities?` | `Record` | 按模型设置的输入提示,例如 `["text"]` 或 `["text", "image"]`。 | | `modelMaxInputTokens?` | `Record` | 正数型、按模型设置的最大输入限制,用于目录自动压缩提示。 | +| `modelAutoCompactTokenLimits?` | `Record` | 按模型设置的正安全整数软自动压缩预算。该值只能降低“上下文或最大输入的 90%”这一有效上限;没有已知的权威上下文窗口时不会输出。对于规范 `openai`,键必须是受支持的精确原生模型 ID,且不得包含提供者或账户选择器前缀。提供者 PATCH 会合并条目;将某个键设为 `null` 会删除该键,将整个字段设为 `null` 会清空映射。这些 `null` 删除标记仅适用于 PATCH。 | | `defaultMaxOutputTokens?` | `number` | 当客户端省略 `max_output_tokens` 时,`openai-chat` 的提供者级回退值。 | | `modelMaxOutputTokens?` | `Record` | 正数型、按模型设置的 `openai-chat` 回退预算;精确/模式匹配优先于提供者默认值。 | | `modelCosts?` | `Record` | 按模型设置的显示价格(每 100 万 token 的美元数),以该提供者的精确上游模型 ID 为键(不是提供者标识符或路由后的 `provider/model` 标签),值为四个字段:`input`、`output`、`cacheRead`、`cacheWrite`(示例:`{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`)。任何模型 ID 都是有效键——自定义提供者可以通过 `openai-chat` 适配器指向任意 OpenAI 兼容端点,即使不存在于内置目录中,本地 OpenAI 兼容和内部提供者的 ID 同样有效。用户配置的价格在 Logs 的 `~$` 和 Usage 估算中优先于内置目录;历史条目也会按当前覆盖项重新计价,因此修改价格可能改变过去的总额(回退顺序:用户配置 → jawcode 目录 → expected-price 覆盖 → 模型级厂商价格);全零条目会回退到该顺序中的下一个来源。每个费率必须是大于等于 0 的有限数字,且不超过 1,000,000(每 100 万 token 的美元数);超出范围的条目会在管理边界被拒绝,并在加载时被丢弃。仅用于显示的估算:覆盖项不影响路由、账户选择、配额或计费。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index f47b5bef05..b0a46f49ec 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -54,6 +54,7 @@ description: 供應商項目、認證、端點、模型目錄、配額、context | `modelContextWindows?` | `Record` | Per-model context 上限。這些覆寫 `contextWindow` 且永不提高較小的即時中繼資料。 | | `modelInputModalities?` | `Record` | Per-model 輸入提示,如 `["text"]` 或 `["text", "image"]`。 | | `modelMaxInputTokens?` | `Record` | 用於目錄自動壓縮提示的正數 per-model max input 限制。 | +| `modelAutoCompactTokenLimits?` | `Record` | Per-model 正安全整數型 soft 自動壓縮預算。此值只能降低「context 或 max input 的 90%」這個有效上限;沒有已知的權威 context window 時不會輸出。對 canonical `openai` 而言,key 必須是受支援的精確 native model ID,且不得含 provider 或 account-selector 前綴。Provider PATCH 會合併項目;將單一 key 設為 `null` 會刪除該 key,將整個欄位設為 `null` 會清空 map。這些 `null` tombstone 僅供 PATCH 使用。 | | `defaultMaxOutputTokens?` | `number` | 當客戶端省略 `max_output_tokens` 時的供應商範圍 `openai-chat` 後備。 | | `modelMaxOutputTokens?` | `Record` | 正數 per-model `openai-chat` 後援預算;精確/模式比對勝過供應商預設。 | | `headers?` | `Record` | 額外上游標頭。Authorization、cookie、API-key 標頭、內嵌換行與無效名稱被拒絕。 | From 8ed8d09ff23f1a86fa593f080ddb17f9cc1a8080 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:02:42 +0900 Subject: [PATCH 3/4] fix(codex): redact compaction budget validation names --- src/server/auth-cors.ts | 4 +++- tests/management-provider-validation.test.ts | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 2da23fa3c2..f1484480c6 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -612,7 +612,9 @@ export function providerManagementConfigError(name: unknown, provider: unknown): raw.modelAutoCompactTokenLimits, { requireNativeIds: name === "openai" }, ); - if (autoCompactError) return `provider ${name} ${autoCompactError}`; + if (autoCompactError) { + return `provider ${JSON.stringify(redactSecretString(name))} ${autoCompactError}`; + } const reasoningSummariesError = booleanRecordConfigError(raw.modelSupportsReasoningSummaries, "modelSupportsReasoningSummaries"); if (reasoningSummariesError) return `provider ${name} ${reasoningSummariesError}`; const reasoningSummaryDeliveryError = reasoningSummaryDeliveryRecordConfigError( diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index ed53706bc9..c9621fc79e 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -476,6 +476,18 @@ describe("provider management validation", () => { expect(secretNameError).toContain("[REDACTED]"); }); + test("provider management redacts provider names from auto-compaction validation errors", () => { + const secretName = "sk-super-secret-9876"; + const error = providerManagementConfigError(secretName, { + adapter: "openai-chat", + baseUrl: "https://api.example.test/v1", + modelAutoCompactTokenLimits: { model: 0 }, + })!; + expect(error).toContain("modelAutoCompactTokenLimits"); + expect(error).not.toContain(secretName); + expect(error).toContain("[REDACTED]"); + }); + test("provider request pacing PATCH persists provider and model limits without catalog churn", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); From 0be75f29257868dc267102688e7041db3f865d24 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sat, 22 Aug 2026 09:35:35 +0900 Subject: [PATCH 4/4] fix(codex): require context window for combo compact budget --- src/codex/catalog/provider-fetch.ts | 4 +++- tests/codex-catalog.test.ts | 31 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 3c956dd489..b2a6f6a6c7 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -898,7 +898,9 @@ export function resolveComboCatalogMember( ].filter((value): value is number => typeof value === "number" && value > 0); // A generic 128k synthesis is a catalog compatibility fallback, not evidence // that a configured soft policy has an authoritative window to clamp against. - const hasAuthoritativeAutoCompactBasis = usedDiscoveredWindow || contextCap !== undefined; + const hasAuthoritativeAutoCompactBasis = hintedContext !== undefined + || fallbackContext !== undefined + || contextCap !== undefined; const autoCompactTokenLimit = hasAuthoritativeAutoCompactBasis && softCandidates.length > 0 ? clampAutoCompactTokenLimit(contextWindow, maxInputTokens, Math.min(...softCandidates)) : undefined; diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 6537acee98..07c2a0c8be 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -4797,6 +4797,37 @@ describe("Codex catalog routed normalization", () => { }); }); + test("a max-input-only Combo member ignores the configured soft budget", async () => { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "max-only", + providers: { + "max-only": { + adapter: "openai-chat", + baseUrl: "https://max-only.test/v1", + liveModels: false, + models: [], + modelMaxInputTokens: { model: 80_000 }, + modelAutoCompactTokenLimits: { model: 10_000 }, + }, + }, + combos: { + "max-only-combo": { + strategy: "failover", + targets: [{ provider: "max-only", model: "model", weight: 1 }], + }, + }, + }); + + expect(models.find(row => row.provider === "max-only" && row.id === "model")).toBeUndefined(); + expect(models.find(row => row.provider === "combo" && row.id === "max-only-combo")) + .toMatchObject({ + contextWindow: 80_000, + maxInputTokens: 80_000, + autoCompactTokenLimit: 72_000, + }); + }); + // #1073's exact reproduction: a provider whose /models returns nothing but ids. Two cases, // deliberately not one — a single test that sets `modelContextWindows` would keep passing // with the provider-wide `?? prov.contextWindow` fallback deleted, because the per-model