diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 982c892fda..e218f7ff36 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -181,6 +181,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `refreshPolicy?` | `"proactive" \| "lazy-only" \| "disabled"` | Override this OAuth provider's Token Guardian policy. | | `reasoningEfforts?` | `string[]` | Provider-wide Codex reasoning labels to advertise and send. For `google`-adapter providers, a configured ladder also asserts `thinkingLevel` capability: direct and Vertex non-image requests send the selected effort as `generationConfig.thinkingConfig.thinkingLevel`, while Cloud Code Assist uses its envelope-specific path. | | `modelReasoningEfforts?` | `Record` | Per-model labels. An empty list hides effort control. As with `reasoningEfforts`, each configured `google`-adapter ladder asserts `thinkingLevel` capability; direct and Vertex non-image requests use the flat Gemini path, while Cloud Code Assist sends it under its request envelope. | +| `modelSuppressSyntheticMax?` | `Record` | Catalog-only per-model switch. `true` prevents OpenCodex from adding a missing synthetic `max` rung while retaining a provider-declared `max` and continuing to add `ultra` to reasoning-capable ladders. A preserved row that already contains `max` keeps it until healthy discovery rebuilds that row, because the catalog does not persist whether an older `max` was synthetic. If a configured default names a suppressed missing `max`, the catalog falls back to the highest real rung below it. Codex uses the same ladder membership for the picker and explicit `spawn_agent` effort validation, so an explicit `max` spawn can fail client-side before proxy clamping; `ultra` remains the supported harness path. | | `modelSupportsReasoningSummaries?` | `Record` | Set a model to `false` to stop advertising summaries and strip summary-delivery fields. | | `modelReasoningSummaryDelivery?` | `Record` | Per-model Responses delivery enum; rewrites an existing delivery field. | | `modelAdapters?` | `Record` | Per-model `openai-chat` or `openai-responses` wire override for mixed-wire gateways. Explicit entries beat registry defaults. The OpenCode Go preset selects Responses for `gpt-5.6-luna` while leaving sibling models on their documented wires; DeepSeek can select native Responses for `deepseek-v4-flash`; and GitHub Copilot declares Responses-only defaults for the following models (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`) because those models reject `/chat/completions` for agent traffic. Models without a built-in default (for example `gpt-5.4-nano`) can be opted in here. Single-wire upstream pins and canonical ChatGPT forward reject overrides. | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 5b3f4c3daf..714d0f9f1d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -924,6 +924,7 @@ "management-origin-tls.test.ts": "server", "management-provider-pinsless-validation.test.ts": "server", "management-provider-proto-override.test.ts": "server", + "management-provider-synthetic-max.test.ts": "server", "management-provider-validation.test.ts": "server", "management-route-registry.test.ts": "server", "management-workflow-budget-routes.test.ts": "server", diff --git a/src/codex/catalog/build-entries.ts b/src/codex/catalog/build-entries.ts index 884b9e46a5..c9ca8e1ec7 100644 --- a/src/codex/catalog/build-entries.ts +++ b/src/codex/catalog/build-entries.ts @@ -26,7 +26,7 @@ import { } from "./metadata"; import { resetBundledCatalogCacheForTests } from "./bundled"; import { isMultiAgentV2Enabled } from "../features"; -import { ensureUltraReasoningLevel, isGpt56NativeSlug } from "./effort"; +import { clampedDefaultEffort, ensureUltraReasoningLevel, isGpt56NativeSlug } from "./effort"; import { clearGatherRoutedModelsInflight, lastDropWarnSignature } from "./provider-fetch"; import { accountSelectorShadowCollisionWarnings, @@ -490,6 +490,8 @@ export interface ObservedCatalogMergeInput { readonly includeNativeOpenAi: boolean; readonly accountBoundEntries: readonly RawEntry[]; readonly suppressedBareNativeSlugs?: ReadonlySet; + /** Routed slugs that must not gain a missing synthetic max rung during retained-row repair. */ + readonly suppressedSyntheticMaxSlugs?: ReadonlySet; readonly policy: ObservedCatalogMergePolicy; readonly openaiContextCap?: NativeContextLimitsInput; /** Exact display-only labels for bare native OpenAI models. */ @@ -528,6 +530,7 @@ export function mergeCatalogEntriesFromObservedState({ includeNativeOpenAi, accountBoundEntries, suppressedBareNativeSlugs = new Set(), + suppressedSyntheticMaxSlugs = new Set(), policy, openaiContextCap, nativeDisplayNames, @@ -863,16 +866,29 @@ export function mergeCatalogEntriesFromObservedState({ }); // Mock-max universality (260709): preserved routed entries from disk may predate // the max rung — ensure it here so subagent max spawns validate on every - // reasoning-capable entry. max only: 5.6 exact ladders (luna: no ultra) stay intact. + // reasoning-capable entry. A suppressed preserved row that already contains max keeps it; + // without persisted provenance, only a healthy provider rebuild can distinguish and remove + // an older synthetic rung from a real provider-declared rung. max only: 5.6 exact ladders + // (luna: no ultra) stay intact. if (!freshCustomEntries.has(m) && !exactCombo && !reserveProjection && !String(e.slug ?? "").startsWith("opencode-go/")) { const levels = Array.isArray(e.supported_reasoning_levels) ? e.supported_reasoning_levels as Array<{ effort?: string }> : []; - if (levels.length > 0 && !levels.some(level => level.effort === "max")) { + if (levels.length > 0 + && !suppressedSyntheticMaxSlugs.has(String(e.slug ?? "")) + && !levels.some(level => level.effort === "max")) { levels.push(CODEX_REASONING_LEVELS.find(level => level.effort === "max") ?? { effort: "max", description: "Maximum reasoning depth for the hardest problems" }); e.supported_reasoning_levels = levels; } + if (suppressedSyntheticMaxSlugs.has(String(e.slug ?? "")) + && typeof e.default_reasoning_level === "string" + && !levels.some(level => level.effort === e.default_reasoning_level)) { + e.default_reasoning_level = clampedDefaultEffort( + e.default_reasoning_level, + levels.flatMap(level => typeof level.effort === "string" ? [level.effort] : []), + ); + } } if (wsEnabled) e.supports_websockets = true; else { diff --git a/src/codex/catalog/derive-entry.ts b/src/codex/catalog/derive-entry.ts index 8b1d5290c3..1d0a09e46c 100644 --- a/src/codex/catalog/derive-entry.ts +++ b/src/codex/catalog/derive-entry.ts @@ -160,6 +160,7 @@ export function deriveEntry( preserveExactReasoning || codexForwardNativeCapabilityAlias !== null || preservePinnedNativeCustomReasoning(model), + model?.suppressSyntheticMax === true, ); // This exact provider/model pair is the ChatGPT/Codex forward surface. Keep the pinned // native tool/search/responses-lite contract while preserving the routed slug and wire id. @@ -208,7 +209,13 @@ export function deriveEntry( }; if (isRouted) { applyRoutedCodexToolMode(entry, model?.codexToolMode); - applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExactReasoning || preservePinnedNativeCustomReasoning(model)); + applyReasoningLevels( + entry, + model?.reasoningEfforts, + model?.defaultReasoningEffort, + preserveExactReasoning || preservePinnedNativeCustomReasoning(model), + model?.suppressSyntheticMax === true, + ); } else { applyReasoningLevels(entry, isGpt56NativeSlug(slug) ? undefined : ["low", "medium", "high", "xhigh"]); diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index 0e5901c2a4..4206983183 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -226,10 +226,12 @@ export function applyReasoningLevels( effortsOverride?: string[], defaultOverride?: string, preserveExact = false, + suppressSyntheticMax = false, ): void { let efforts = sanitizeCodexReasoningEfforts(effortsOverride) ?? ROUTED_REASONING_LEVELS.map(l => l.effort); - // Mock top tiers (user decision 260709): every reasoning-capable model advertises `max` - // even when the provider ladder stops lower — subagent spawns pass `max` DIRECTLY + // Mock top tiers (user decision 260709): reasoning-capable routed models advertise `max` + // even when the provider ladder stops lower, unless the model opts out of that synthesis. + // Subagent spawns pass `max` DIRECTLY // (no ultra->max client conversion) and codex-rs validates it by catalog membership, // so a missing max rung hard-fails spawn_agent effort overrides. The wire stays honest: // routed adapters clamp via clampToSupportedCodexEffort and natives via @@ -237,7 +239,7 @@ export function applyReasoningLevels( // reasoning-capable, so it must not grow synthetic top rungs. if (!preserveExact && efforts.length > 0 && efforts.some(effort => effort !== "none" && effort !== "minimal")) { const additions: string[] = []; - if (!efforts.includes("max")) additions.push("max"); + if (!suppressSyntheticMax && !efforts.includes("max")) additions.push("max"); if (!efforts.includes("ultra")) additions.push("ultra"); if (additions.length > 0) efforts = sanitizeCodexReasoningEfforts([...efforts, ...additions]) ?? efforts; } @@ -258,9 +260,11 @@ export function applyReasoningLevels( } entry.default_reasoning_level = defaultOverride && efforts.includes(defaultOverride) ? defaultOverride - : efforts.includes("medium") ? "medium" : efforts.includes("high") ? "high" - // Sentinels never become the implicit default when real rungs are declared. - : efforts.find(effort => effort !== "none" && effort !== "minimal") ?? efforts[0]; + : suppressSyntheticMax && defaultOverride === "max" + ? clampedDefaultEffort(defaultOverride, efforts) + : efforts.includes("medium") ? "medium" : efforts.includes("high") ? "high" + // Sentinels never become the implicit default when real rungs are declared. + : efforts.find(effort => effort !== "none" && effort !== "minimal") ?? efforts[0]; } /** diff --git a/src/codex/catalog/gather-capture.ts b/src/codex/catalog/gather-capture.ts index 8bf566b4fe..318fac9bd3 100644 --- a/src/codex/catalog/gather-capture.ts +++ b/src/codex/catalog/gather-capture.ts @@ -499,6 +499,7 @@ function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Reco inMod: prov.modelInputModalities ?? null, capabilities: prov.modelCapabilities ?? null, re: prov.modelReasoningEfforts ?? null, + suppressMax: prov.modelSuppressSyntheticMax ?? null, defRe: prov.modelDefaultReasoningEfforts ?? null, rsSum: prov.modelSupportsReasoningSummaries ?? null, verbosity: prov.modelSupportsVerbosity ?? null, diff --git a/src/codex/catalog/model-hints.ts b/src/codex/catalog/model-hints.ts index 45a97b5570..4d58bace59 100644 --- a/src/codex/catalog/model-hints.ts +++ b/src/codex/catalog/model-hints.ts @@ -296,6 +296,7 @@ export function applyProviderConfigHints( inputModalities = base.includes("image") ? [...base] : [...base, "image"]; } const reasoningEfforts = configuredReasoningEfforts(prov, model.id); + const suppressSyntheticMax = modelRecordValue(prov.modelSuppressSyntheticMax, model.id) === true; const defaultReasoningEffort = modelRecordValue(prov.modelDefaultReasoningEfforts, model.id) ?? model.defaultReasoningEffort; const supportsReasoningSummaries = configuredReasoningSummarySupport(prov, model.id); const supportsVerbosity = configuredVerbositySupport(name, prov, model.id); @@ -305,6 +306,7 @@ export function applyProviderConfigHints( supportsServiceTier: _staleServiceTier, fastTierDescription: _staleFastTierDescription, providerAlias: _staleProviderAlias, + suppressSyntheticMax: _staleSuppressSyntheticMax, ...modelWithoutServiceTier } = model; // 已发现窗口只允许被配置值压低;缺窗口时,已开的 Context cap 就是实际窗口。 @@ -321,6 +323,7 @@ export function applyProviderConfigHints( ...(hintedWindow !== undefined ? { contextWindow: hintedWindow } : {}), ...(inputModalities ? { inputModalities } : {}), ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}), + ...(suppressSyntheticMax ? { suppressSyntheticMax: true } : {}), ...(configuredMaxInput !== undefined ? { maxInputTokens: typeof model.maxInputTokens === "number" && model.maxInputTokens > 0 @@ -394,6 +397,31 @@ export function applyConfigHintsToCachedModels( ): CatalogModel[] { return models.map(model => applyProviderConfigHints(name, prov, model, contextCap, metadataModelIdCaseFold, effectiveAlias)); } + +/** Catalog slugs whose configured model must not gain a missing synthetic max rung. */ +export function suppressedSyntheticMaxCatalogSlugs( + config: Pick, + models: readonly CatalogModel[], + observedEntries: readonly { slug?: unknown }[] = [], +): ReadonlySet { + const slugs = new Set(models + .filter(model => model.suppressSyntheticMax === true) + .map(catalogModelSlug)); + for (const [provider, providerConfig] of Object.entries(config.providers)) { + const configured = providerConfig.modelSuppressSyntheticMax ?? {}; + const encoded = Object.fromEntries(Object.entries(configured) + .map(([modelId, suppress]) => [routedSlug(provider, modelId).slice(provider.length + 1), suppress])); + for (const [modelId, suppress] of Object.entries(configured)) { + if (suppress === true) slugs.add(routedSlug(provider, modelId)); + } + for (const entry of observedEntries) { + const slug = typeof entry.slug === "string" ? entry.slug : ""; + if (!slug.startsWith(`${provider}/`)) continue; + if (modelRecordValue(encoded, slug.slice(provider.length + 1)) === true) slugs.add(slug); + } + } + return slugs; +} export const QUIET_AUTHORITATIVE_CATALOG_PROVIDERS = new Set(["kimi", "xai"]); export const CALLABLE_CONFIGURED_COMPATIBILITY_MODELS: Readonly>> = { diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 78a5ab93e6..ff10fc0c20 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -112,6 +112,8 @@ export interface CatalogModel { displayName?: string; owned_by?: string; reasoningEfforts?: string[]; + /** Suppress only catalog synthesis of a missing max rung; provider-declared max survives. */ + suppressSyntheticMax?: boolean; defaultReasoningEffort?: string; contextWindow?: number; maxInputTokens?: number; diff --git a/src/codex/catalog/retained-sync.ts b/src/codex/catalog/retained-sync.ts index 52b80ee4c2..7f49c0ea13 100644 --- a/src/codex/catalog/retained-sync.ts +++ b/src/codex/catalog/retained-sync.ts @@ -51,6 +51,7 @@ import { trustedAccountBoundNativeCatalogSlug } from "./account-models"; import { bundledCatalogCacheState, loadBundledCodexCatalog } from "./bundled"; import { isMultiAgentV2Enabled } from "../features"; import { clampCatalogModelsToCodexSupport } from "./effort"; +import { suppressedSyntheticMaxCatalogSlugs } from "./model-hints"; import { filterCatalogVisibleModels, gatherRoutedModels, type CatalogGatherProviderModelOutcome } from "./provider-fetch"; import { dedupeCatalogEntriesBySlug, enforceCatalogSlugUniqueness, exactComboCatalogSlugs, type ComboCatalogOmission } from "./aggregation"; import { @@ -322,6 +323,11 @@ function writeRetainedCatalogSync({ const enabledGo = filterCatalogVisibleModels(goModels, config); const featured = config.subagentModels ?? []; const orderedGoModels = orderForSubagents(enabledGo, featured); // stable tie-break among equal priorities + const suppressedSyntheticMaxSlugs = suppressedSyntheticMaxCatalogSlugs( + config, + orderedGoModels, + catalogModelsForMerge, + ); const modelPickerOrder = config.modelPickerOrder ?? []; const multiAgentMode: MultiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default"; const exactComboSlugs = exactComboCatalogSlugs(config); @@ -523,6 +529,7 @@ function writeRetainedCatalogSync({ includeNativeOpenAi, accountBoundEntries, suppressedBareNativeSlugs, + suppressedSyntheticMaxSlugs, openaiContextCap, nativeDisplayNames: config.providers[OPENAI_CODEX_PROVIDER_ID]?.modelDisplayNames, nativeMultiAgentDefaults: nativePinBaseline, diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index 806ef48115..c7f31ced05 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -70,6 +70,7 @@ import { clampCatalogModelsToObservedCodexSupport, supportedCodexReasoningEffortsFromObservedCatalog, } from "./catalog/effort"; +import { suppressedSyntheticMaxCatalogSlugs } from "./catalog/model-hints"; import { codexRuntimeStatePath, peekCodexRuntimeProcessCache } from "./runtime"; import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "./account-namespaces"; import { MAIN_CODEX_ACCOUNT_ID } from "./main-account"; @@ -306,6 +307,7 @@ function prepareCatalog( [catalog.models ?? [], ...nativeRecoverySources], ); const catalogModels = nativeCatalogModels; + const suppressedSyntheticMaxSlugs = suppressedSyntheticMaxCatalogSlugs(config, ordered, catalogModels); const routedEntries = buildCatalogEntriesFromObservedState({ template: template ? JSON.parse(JSON.stringify(template)) : null, gptSlugs: [], @@ -372,6 +374,7 @@ function prepareCatalog( includeNativeOpenAi, accountBoundEntries, suppressedBareNativeSlugs, + suppressedSyntheticMaxSlugs, openaiContextCap, nativeDisplayNames: config.providers[OPENAI_CODEX_PROVIDER_ID]?.modelDisplayNames, nativeMultiAgentDefaults: nativeMultiAgentDefaults(baselineCatalogModels), diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index ddd0c6dd14..61ca118cdb 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -468,6 +468,17 @@ export const configSchema = z.object({ message: reasoningSummariesError, }); } + const suppressSyntheticMaxError = booleanRecordConfigError( + (provider as { modelSuppressSyntheticMax?: unknown }).modelSuppressSyntheticMax, + "modelSuppressSyntheticMax", + ); + if (suppressSyntheticMaxError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "modelSuppressSyntheticMax"], + message: suppressSyntheticMaxError, + }); + } const verbositySupportError = booleanRecordConfigError( (provider as { modelSupportsVerbosity?: unknown }).modelSupportsVerbosity, "modelSupportsVerbosity", diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index febfec6e1d..c1353880ba 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -252,6 +252,7 @@ export const providerConfigSchema = z.object({ fastWire: fastWireSchema.nullable().optional(), supportsServiceTier: z.boolean().optional(), modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(), + modelSuppressSyntheticMax: z.record(z.string().min(1), z.boolean()).optional(), preserveResponsesReasoningContent: z.boolean().optional(), decodesNativeCompactionBlobs: z.boolean().optional(), allowEncryptedV2AgentTasks: z.boolean().optional(), diff --git a/src/providers/model-rename-migration.ts b/src/providers/model-rename-migration.ts index 13129264da..89c9b6f577 100644 --- a/src/providers/model-rename-migration.ts +++ b/src/providers/model-rename-migration.ts @@ -86,6 +86,7 @@ const MODEL_KEYED_RECORDS = [ "modelMaxOutputTokens", "modelInputModalities", "modelReasoningEfforts", + "modelSuppressSyntheticMax", "modelDefaultReasoningEfforts", "modelReasoningEffortMap", ] as const; diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 58ee12e50c..554f9e298b 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -799,6 +799,8 @@ export function providerManagementConfigError( } const reasoningSummariesError = booleanRecordConfigError(raw.modelSupportsReasoningSummaries, "modelSupportsReasoningSummaries"); if (reasoningSummariesError) return `provider ${name} ${reasoningSummariesError}`; + const suppressSyntheticMaxError = booleanRecordConfigError(raw.modelSuppressSyntheticMax, "modelSuppressSyntheticMax"); + if (suppressSyntheticMaxError) return `provider ${name} ${suppressSyntheticMaxError}`; const reasoningSummaryDeliveryError = reasoningSummaryDeliveryRecordConfigError( raw.modelReasoningSummaryDelivery, raw.modelSupportsReasoningSummaries, @@ -986,6 +988,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { refreshPolicy: "editor", reasoningEfforts: "editor", modelReasoningEfforts: "editor", + modelSuppressSyntheticMax: "editor", modelDefaultReasoningEfforts: "editor", pinnedReasoningEffort: "editor", modelPinnedReasoningEfforts: "editor", diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 74b7398f77..52363af836 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -110,7 +110,7 @@ import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from ". import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost"; import type { PersistedUsageAttempt } from "../../usage/log"; import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; -import { withProviderServiceTierDTO } from "./provider-capability-config"; +import { withProviderCatalogCapabilityDTO } from "./provider-capability-config"; import { applySystemEnvToggle } from "../system-env"; import { getCachedStartupHealth, invalidateStartupHealthCache } from "../startup-health-cache"; import { runWindowsTrayAction } from "../windows-tray-control"; @@ -268,7 +268,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise | undefined { +export function providerCatalogCapabilityConfigError(name: unknown, provider: unknown): string | null { + const serviceTierError = providerServiceTierConfigError(name, provider); + if (serviceTierError) return serviceTierError; + if (typeof name !== "string" || !provider || typeof provider !== "object" || Array.isArray(provider)) { + return null; + } + const error = booleanRecordConfigError( + (provider as { modelSuppressSyntheticMax?: unknown }).modelSuppressSyntheticMax, + "modelSuppressSyntheticMax", + ); + return error ? `provider ${name} ${error}` : null; +} + +function publicBooleanRecord(value: unknown): Record | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const entries = Object.entries(value).filter(([model, supported]) => model.trim().length > 0 && typeof supported === "boolean", @@ -40,9 +49,28 @@ export function withProviderServiceTierDTO(dto: unknown, config: OcxConfig): unk for (const [name, provider] of Object.entries(config.providers)) { const dtoProvider = providers[name]; if (!dtoProvider || typeof dtoProvider !== "object" || Array.isArray(dtoProvider)) continue; - const capabilities = publicServiceTierRecord(provider.modelSupportsServiceTier); + const capabilities = publicBooleanRecord(provider.modelSupportsServiceTier); if (capabilities === undefined) continue; projectedProviders[name] = { ...(dtoProvider as Record), modelSupportsServiceTier: capabilities }; } return { ...root, providers: projectedProviders }; } + +/** Project all catalog-only model capability maps across the dashboard config boundary. */ +export function withProviderCatalogCapabilityDTO(dto: unknown, config: OcxConfig): unknown { + const projected = withProviderServiceTierDTO(dto, config); + if (!projected || typeof projected !== "object" || Array.isArray(projected)) return projected; + const root = projected as { providers?: unknown }; + if (!root.providers || typeof root.providers !== "object" || Array.isArray(root.providers)) return projected; + + const providers = root.providers as Record; + const projectedProviders: Record = { ...providers }; + for (const [name, provider] of Object.entries(config.providers)) { + const dtoProvider = providers[name]; + if (!dtoProvider || typeof dtoProvider !== "object" || Array.isArray(dtoProvider)) continue; + const capabilities = publicBooleanRecord(provider.modelSuppressSyntheticMax); + if (capabilities === undefined) continue; + projectedProviders[name] = { ...(dtoProvider as Record), modelSuppressSyntheticMax: capabilities }; + } + return { ...root, providers: projectedProviders }; +} diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index ed85c534ae..5223e13e05 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -104,7 +104,7 @@ import { type ProviderEditorConfigDTO, type ProviderEditorProviderDTO, } from "../auth-cors"; -import { providerServiceTierConfigError } from "./provider-capability-config"; +import { providerCatalogCapabilityConfigError } from "./provider-capability-config"; import { providerEmptyToolOutputConfigError } from "../../config/provider-validation"; import { applySystemEnvToggle } from "../system-env"; import { @@ -265,7 +265,7 @@ function providerEditorCandidate( // the seed check must only pin the canonical transport/auth keys. const providerError = providerManagementConfigError(name, transportCandidate, { allowOperatorOverlays: true }) ?? providerEmptyToolOutputConfigError(name, transportCandidate) - ?? providerServiceTierConfigError(name, transportCandidate); + ?? providerCatalogCapabilityConfigError(name, transportCandidate); if (providerError) return { ok: false, status: 400, error: providerError, code: "invalid_provider" }; providers[name] = merged; } @@ -647,6 +647,29 @@ function applyProviderPatchFields( } touched = true; } + if (Object.hasOwn(rawBody, "modelSuppressSyntheticMax")) { + const value = rawBody.modelSuppressSyntheticMax; + if (value === null) { + delete next.modelSuppressSyntheticMax; + } else { + if (!isPlainRecord(value)) return { error: "modelSuppressSyntheticMax must be a plain object or null" }; + const capabilities: Record = { ...(next.modelSuppressSyntheticMax ?? {}) }; + for (const [model, suppressed] of Object.entries(value)) { + if (!model.trim()) return { error: "modelSuppressSyntheticMax keys must be nonblank model ids" }; + if (suppressed === null) { + delete capabilities[model]; + continue; + } + if (typeof suppressed !== "boolean") { + return { error: "modelSuppressSyntheticMax values must be booleans or null" }; + } + capabilities[model] = suppressed; + } + if (Object.keys(capabilities).length > 0) next.modelSuppressSyntheticMax = capabilities; + else delete next.modelSuppressSyntheticMax; + } + touched = true; + } if (Object.hasOwn(rawBody, "noStructuredOutputModels")) { const value = rawBody.noStructuredOutputModels; if (value === null) { @@ -892,6 +915,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise; + /** Catalog-only: do not synthesize a missing max rung for matching routed models. */ + modelSuppressSyntheticMax?: Record; /** Model-specific default Codex reasoning tier; must also be present in the visible tier list. */ modelDefaultReasoningEfforts?: Record; /** Operator-owned effort override; none omits effort and uses the provider default. */ diff --git a/structure/catalog.md b/structure/catalog.md index 395e8f61c5..0e3fe224dd 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -380,6 +380,17 @@ Ultra is always advertised in the catalog regardless of the `multi_agent_v2` tog controls only the multi-agent collab surface, not ultra visibility. The `nativeEffortClamp` function wire-clamps ultra/max to each model's real top rung (e.g. gpt-5.5 ultra → xhigh on the wire). +For routed models, `modelSuppressSyntheticMax` is a catalog-only per-model setting. A true value +prevents `src/codex/catalog/effort.ts` from adding a missing synthetic `max` and prevents +`src/codex/catalog/build-entries.ts` from repairing that missing rung during observed-state merge. +It never removes a provider-declared `max`, and `ultra` remains advertised. If the configured default +names a suppressed missing `max`, the catalog selects the highest real rung below it. A degraded sync +also preserves any `max` already recorded on disk: without persisted provenance OpenCodex cannot +distinguish an older synthetic rung from a real provider rung, so only a later healthy provider rebuild +can remove the former. Codex uses this same membership for the picker and explicit `spawn_agent` +effort validation; an explicit `max` spawn can therefore fail client-side before proxy wire clamping, +while retained `ultra` remains the supported harness path. + `effortCap` and `subagentEffortCap` are hard ceilings applied on the V2 path (`src/server/effort-policy.ts`): they lower or preserve the requested effort rather than rejecting the request, and they never raise it. diff --git a/tests/codex-integration/codex-catalog-ladders.test.ts b/tests/codex-integration/codex-catalog-ladders.test.ts index 04ef402467..774958df2d 100644 --- a/tests/codex-integration/codex-catalog-ladders.test.ts +++ b/tests/codex-integration/codex-catalog-ladders.test.ts @@ -10,11 +10,19 @@ */ import { describe, expect, test } from "bun:test"; import { + CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, buildCatalogEntries, + mergeCatalogEntriesFromObservedState, mergeCatalogEntriesForSync, nativeEffortClamp, shouldApplyNativeEffortClamp, } from "../../src/codex/catalog"; +import type { ObservedCatalogMergeInput } from "../../src/codex/catalog"; +import { + applyConfigHintsToCachedModels, + applyProviderConfigHints, + suppressedSyntheticMaxCatalogSlugs, +} from "../../src/codex/catalog/model-hints"; function template(): Record { return { @@ -37,6 +45,136 @@ function template(): Record { function efforts(entry: { supported_reasoning_levels?: unknown }): string[] { return (entry.supported_reasoning_levels as Array<{ effort: string }> ?? []).map(l => l.effort); } + +function mergeObserved( + input: Pick + & Partial, +): Record[] { + return mergeCatalogEntriesFromObservedState({ + baselineCatalogModels: [], + baseline: new Map(), + featured: [], + wsEnabled: false, + template: template(), + disabledModels: new Set(), + selectedModelsByProvider: new Map(), + gatheredProviderNames: new Set(), + degradedProviderNames: new Set(), + legacyCustomModelSlugs: new Set(), + multiAgentMode: "default", + multiAgentV2Enabled: false, + exactComboSlugs: new Set(), + hasPhysicalComboProvider: false, + includeNativeOpenAi: true, + accountBoundEntries: [], + policy: { + ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, + warningPolicy: "emit", + }, + ...input, + }); +} + +describe("synthetic max suppression", () => { + test("resolves discovered, cached, case-folded, and family-key model settings", () => { + const provider = { + adapter: "openai-chat", + baseUrl: "https://relay.example.test/v1", + modelSuppressSyntheticMax: { "MODEL-A": true, family: true }, + }; + expect(applyProviderConfigHints("relay", provider, { + id: "model-a", + provider: "relay", + }).suppressSyntheticMax).toBe(true); + expect(applyConfigHintsToCachedModels("relay", provider, [{ + id: "family:latest", + provider: "relay", + }])[0]?.suppressSyntheticMax).toBe(true); + expect([...suppressedSyntheticMaxCatalogSlugs( + { providers: { relay: provider } }, + [], + [{ slug: "relay/family:latest" }], + )]).toContain("relay/family:latest"); + }); + + test("suppresses only missing routed max, retains ultra and declared max, and clamps a missing max default", () => { + const routed = [ + { id: "ordinary", provider: "relay", reasoningEfforts: ["low", "medium", "high", "xhigh"] }, + { + id: "suppressed", + provider: "relay", + reasoningEfforts: ["low", "medium", "high", "xhigh"], + defaultReasoningEffort: "max", + suppressSyntheticMax: true, + }, + { + id: "declared-max", + provider: "relay", + reasoningEfforts: ["low", "high", "max"], + defaultReasoningEffort: "max", + suppressSyntheticMax: true, + }, + ]; + const entries = buildCatalogEntries(template(), ["gpt-5.5"], routed as never, [], false); + const ordinary = entries.find(entry => entry.slug === "relay/ordinary")!; + const suppressed = entries.find(entry => entry.slug === "relay/suppressed")!; + const declared = entries.find(entry => entry.slug === "relay/declared-max")!; + const native = entries.find(entry => entry.slug === "gpt-5.5")!; + + expect(efforts(ordinary)).toEqual(["low", "medium", "high", "xhigh", "max", "ultra"]); + expect(efforts(suppressed)).toEqual(["low", "medium", "high", "xhigh", "ultra"]); + expect(suppressed.default_reasoning_level).toBe("xhigh"); + expect(efforts(declared)).toEqual(["low", "high", "max", "ultra"]); + expect(declared.default_reasoning_level).toBe("max"); + expect(efforts(native)).toEqual(["low", "medium", "high", "xhigh", "max", "ultra"]); + }); + + test("observed-state repair does not re-add max to a suppressed preserved routed row", () => { + const disk = { + ...template(), + slug: "relay/suppressed", + display_name: "relay/suppressed", + supported_reasoning_levels: [ + { effort: "low", description: "l" }, + { effort: "high", description: "h" }, + { effort: "xhigh", description: "x" }, + { effort: "ultra", description: "u" }, + ], + default_reasoning_level: "max", + }; + const diskWithMax = { + ...disk, + slug: "relay/preserved-max", + display_name: "relay/preserved-max", + supported_reasoning_levels: [ + ...disk.supported_reasoning_levels, + { effort: "max", description: "m" }, + ], + }; + const merged = mergeObserved({ + catalogModels: [disk, diskWithMax], + routedEntries: [], + gatheredProviderNames: new Set(["relay"]), + degradedProviderNames: new Set(["relay"]), + suppressedSyntheticMaxSlugs: suppressedSyntheticMaxCatalogSlugs({ + providers: { + relay: { + adapter: "openai-chat", + baseUrl: "https://relay.example.test/v1", + modelSuppressSyntheticMax: { suppressed: true, "preserved-max": true }, + }, + }, + }, [], [disk, diskWithMax]), + }); + const preserved = merged.find(entry => entry.slug === "relay/suppressed")!; + const preservedMax = merged.find(entry => entry.slug === "relay/preserved-max")!; + + expect(efforts(preserved)).toEqual(["low", "high", "xhigh", "ultra"]); + expect(preserved.default_reasoning_level).toBe("xhigh"); + expect(efforts(preservedMax)).toContain("max"); + expect(preservedMax.default_reasoning_level).toBe("max"); + }); +}); describe("catalog ultra (always-on)", () => { const routed = [{ id: "glm-5.2", provider: "opencode-go", reasoningEfforts: ["low", "medium", "high", "xhigh"] }]; diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index fe5d375aae..5e96f33ba9 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -750,6 +750,7 @@ "management-origin-tls.test.ts": "server", "management-provider-pinsless-validation.test.ts": "server", "management-provider-proto-override.test.ts": "server", + "management-provider-synthetic-max.test.ts": "server", "management-provider-validation.test.ts": "server", "management-route-registry.test.ts": "server", "management-workflow-budget-routes.test.ts": "server", diff --git a/tests/server/management-provider-synthetic-max.test.ts b/tests/server/management-provider-synthetic-max.test.ts new file mode 100644 index 0000000000..711b66f8a5 --- /dev/null +++ b/tests/server/management-provider-synthetic-max.test.ts @@ -0,0 +1,110 @@ +import { expect, setDefaultTimeout, spyOn, test } from "bun:test"; +import { existsSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { loadConfig, saveConfig } from "../../src/config"; +import * as destinationPolicy from "../../src/lib/destination-policy"; +import { providerEditorConfigDTO } from "../../src/server/auth-cors"; +import { handleManagementAPI } from "../../src/server/management-api"; +import { + providerCatalogCapabilityConfigError, + withProviderCatalogCapabilityDTO, +} from "../../src/server/management/provider-capability-config"; +import type { OcxConfig } from "../../src/types"; +import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; +import { ManagementRequest as Request } from "../helpers/management-auth"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +setDefaultTimeout(60_000); + +test("synthetic-max config survives provider POST, PATCH replay, projection, and full overwrite", async () => { + const testDir = mkdtempSync(join(tmpdir(), "ocx-management-provider-synthetic-max-")); + const previousHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = testDir; + + const live: OcxConfig = { + port: 0, + defaultProvider: "relay", + providers: { + relay: { + adapter: "openai-chat", + baseUrl: "https://relay.example.test/v1", + liveModels: false, + models: ["old", "new"], + modelSuppressSyntheticMax: { old: true }, + }, + }, + }; + saveConfig(live); + const resolved = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + const request = async (method: string, path: string, body?: unknown) => { + const url = new URL(`http://localhost${path}`); + return (await handleManagementAPI(new Request(url, { + method, + headers: body === undefined ? undefined : { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }), url, live, { createManagementConvergeCodex: catalogConvergenceFactory() }))!; + }; + + try { + expect(providerCatalogCapabilityConfigError("relay", { + modelSuppressSyntheticMax: { old: "yes" }, + })).toContain("modelSuppressSyntheticMax.old must be a boolean"); + const projected = withProviderCatalogCapabilityDTO( + { providers: { relay: { adapter: "openai-chat" } } }, + live, + ) as { providers: { relay: Record } }; + expect(projected.providers.relay.modelSuppressSyntheticMax).toEqual({ old: true }); + + const listed = await request("GET", "/api/providers"); + expect(listed.status).toBe(200); + expect((await listed.json() as Array>) + .find(provider => provider.name === "relay")?.modelSuppressSyntheticMax).toEqual({ old: true }); + + const patched = await request("PATCH", "/api/providers?name=relay", { + modelSuppressSyntheticMax: { old: null, new: true }, + }); + expect(patched.status).toBe(200); + expect(live.providers.relay?.modelSuppressSyntheticMax).toEqual({ new: true }); + expect(loadConfig().providers.relay?.modelSuppressSyntheticMax).toEqual({ new: true }); + + expect((await request("PATCH", "/api/providers?name=relay", { + modelSuppressSyntheticMax: { new: "yes" }, + })).status).toBe(400); + expect(loadConfig().providers.relay?.modelSuppressSyntheticMax).toEqual({ new: true }); + + const posted = await request("POST", "/api/providers", { + name: "second", + provider: { + adapter: "openai-chat", + baseUrl: "https://second.example.test/v1", + liveModels: false, + modelSuppressSyntheticMax: { alpha: true }, + }, + }); + expect(posted.status).toBe(200); + expect(loadConfig().providers.second?.modelSuppressSyntheticMax).toEqual({ alpha: true }); + expect((await request("POST", "/api/providers", { + name: "invalid", + provider: { + adapter: "openai-chat", + baseUrl: "https://invalid.example.test/v1", + modelSuppressSyntheticMax: { alpha: 1 }, + }, + })).status).toBe(400); + + const baseline = providerEditorConfigDTO(loadConfig()); + const next = structuredClone(baseline); + next.providers.relay!.modelSuppressSyntheticMax = { overwritten: true }; + const overwritten = await request("PUT", "/api/providers", { baseline, next }); + expect(overwritten.status).toBe(200); + expect(live.providers.relay?.modelSuppressSyntheticMax).toEqual({ overwritten: true }); + expect(loadConfig().providers.relay?.modelSuppressSyntheticMax).toEqual({ overwritten: true }); + } finally { + resolved.mockRestore(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (existsSync(testDir)) removeTreeWithRetry(testDir); + } +});