From ed5ae6ca3cee9576df0097bf038f4e8af558f4b3 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 18 Sep 2026 16:05:07 +0900 Subject: [PATCH] feat(catalog): suppress synthetic max per model Add a model-keyed provider setting that flows through catalog hints and skips missing-max synthesis in both routed construction and observed-state repair while retaining ultra and real max rungs. Preserved rows that already contain max keep it until healthy discovery rebuilds them because the catalog stores no provenance that can distinguish an older synthetic rung from a provider-declared rung. --- .../docs/reference/configuration/providers.md | 1 + scripts/test-layout/layout.json | 1 + src/codex/catalog/build-entries.ts | 22 ++- src/codex/catalog/derive-entry.ts | 9 +- src/codex/catalog/effort.ts | 16 +- src/codex/catalog/gather-capture.ts | 1 + src/codex/catalog/model-hints.ts | 28 ++++ src/codex/catalog/parsing.ts | 2 + src/codex/catalog/retained-sync.ts | 7 + src/codex/convergence.ts | 3 + src/config/schema/config-schema.ts | 11 ++ src/config/schema/leaf-validators.ts | 1 + src/providers/model-rename-migration.ts | 1 + src/server/auth-cors.ts | 3 + src/server/management/config-routes.ts | 4 +- .../management/provider-capability-config.ts | 42 +++++- src/server/management/provider-routes.ts | 42 ++++-- src/types/provider.ts | 2 + structure/catalog.md | 11 ++ .../codex-catalog-ladders.test.ts | 138 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + .../management-provider-synthetic-max.test.ts | 110 ++++++++++++++ 22 files changed, 428 insertions(+), 28 deletions(-) create mode 100644 tests/server/management-provider-synthetic-max.test.ts diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 982c892fda4..e218f7ff36f 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 5b3f4c3daf3..714d0f9f1d7 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 884b9e46a5d..c9ca8e1ec7b 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 8b1d5290c3b..1d0a09e46c1 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 0e5901c2a44..4206983183b 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 8bf566b4fe4..318fac9bd39 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 45a97b55706..4d58bace599 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 78a5ab93e63..ff10fc0c20b 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 52b80ee4c21..7f49c0ea13c 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 806ef481156..c7f31ced057 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 ddd0c6dd143..61ca118cdbc 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 febfec6e1dd..c1353880ba1 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 13129264da6..89c9b6f577e 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 58ee12e50cc..554f9e298b4 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 74b7398f773..52363af8361 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 ed85c534ae8..5223e13e05e 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 395e8f61c5a..0e3fe224dd6 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 04ef4024679..774958df2d6 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 fe5d375aaed..5e96f33ba95 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 00000000000..711b66f8a52 --- /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); + } +});