diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index c44b628714..52908471ff 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -96,6 +96,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `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` | Per-model catalog policy keyed by upstream model id. `true` prevents OpenCodex from adding a missing synthetic `max`; it never removes a declared `max` or Codex's `ultra`. Missing or `false` keeps the default synthesis. Pair it with `modelReasoningEfforts` when the provider-native ladder matters. Because the catalog has no hidden spawn-only rung, an explicit `spawn_agent` request for `max` can fail client-side Codex validation for an opted-in model when the catalog omits that rung, before any request mapping or adapter logic runs. This is an advanced config-file setting; Dashboard provider edits preserve it but do not expose an editor. | | `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 its GPT-5 family (`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`) 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/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index 0648b64d17..8bb4be9ac6 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -109,6 +109,16 @@ export function catalogEntryEfforts(entry: RawEntry): string[] { } export const ROUTED_REASONING_LEVELS = [...CODEX_REASONING_LEVELS]; +export const MAX_REASONING_PROVENANCE_FIELD = "opencodex_max_provenance"; + +export function stampMaxReasoningProvenance(entry: RawEntry, providerEfforts?: string[]): void { + if (!catalogEntryEfforts(entry).includes("max")) { + delete entry[MAX_REASONING_PROVENANCE_FIELD]; + return; + } + const declared = sanitizeCodexReasoningEfforts(providerEfforts); + entry[MAX_REASONING_PROVENANCE_FIELD] = declared?.includes("max") === true ? "provider" : "synthetic"; +} export function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel): void { if (!model) return; @@ -206,18 +216,25 @@ export function applyReasoningLevels( effortsOverride?: string[], defaultOverride?: string, preserveExact = false, + suppressSyntheticMax = false, ): void { - let efforts = sanitizeCodexReasoningEfforts(effortsOverride) ?? ROUTED_REASONING_LEVELS.map(l => l.effort); + const declaredEfforts = sanitizeCodexReasoningEfforts(effortsOverride); + let efforts = declaredEfforts ?? 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 // (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 // nativeEffortClamp (max -> the model's real top rung). A `none`-only ladder is NOT - // reasoning-capable, so it must not grow synthetic top rungs. + // reasoning-capable, so it must not grow synthetic top rungs. Providers may suppress + // only the invented max rung for models whose upstream rejects it; ultra remains a + // Codex-side delegation control, and a real configured max is never removed. + if (suppressSyntheticMax && declaredEfforts?.includes("max") !== true) { + efforts = efforts.filter(effort => effort !== "max"); + } 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; } @@ -324,6 +341,10 @@ export function clampEntryToCodexSupportedEfforts( : CODEX_REASONING_LEVELS .filter(level => level.effort === "low" || level.effort === "medium" || level.effort === "high") .map(level => ({ ...level })); + if (entry[MAX_REASONING_PROVENANCE_FIELD] === "synthetic" + && !catalogEntryEfforts(entry).includes("max")) { + delete entry[MAX_REASONING_PROVENANCE_FIELD]; + } } const currentDefault = entry.default_reasoning_level; if (typeof currentDefault === "string" && !supported.has(currentDefault)) { diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 74353197f7..e4767a4d1f 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -109,6 +109,8 @@ export interface CatalogModel { owned_by?: string; reasoningEfforts?: string[]; defaultReasoningEffort?: string; + /** Transient catalog policy derived from provider configuration. */ + suppressSyntheticMax?: boolean; contextWindow?: number; maxInputTokens?: number; contextCap?: number; diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 565f6057a4..c7f06bc685 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -574,6 +574,7 @@ function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Reco inMod: prov.modelInputModalities ?? null, re: prov.modelReasoningEfforts ?? null, defRe: prov.modelDefaultReasoningEfforts ?? null, + suppressMax: prov.modelSuppressSyntheticMax ?? null, rsSum: prov.modelSupportsReasoningSummaries ?? null, rsDel: prov.modelReasoningSummaryDelivery ?? null, serviceTier: prov.modelSupportsServiceTier ?? null, @@ -647,13 +648,15 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, } const reasoningEfforts = configuredReasoningEfforts(prov, model.id); const defaultReasoningEffort = modelRecordValue(prov.modelDefaultReasoningEfforts, model.id) ?? model.defaultReasoningEffort; + const suppressSyntheticMax = modelRecordValue(prov.modelSuppressSyntheticMax, model.id) === true; const supportsReasoningSummaries = configuredReasoningSummarySupport(prov, model.id); const fastPolicy = fastPolicyForModel(prov, model.id, name); const supportsServiceTier = serviceTierSupportFromPolicy(fastPolicy); const { supportsServiceTier: _staleServiceTier, fastTierDescription: _staleFastTierDescription, - ...modelWithoutServiceTier + suppressSyntheticMax: _staleSuppressSyntheticMax, + ...modelWithoutDerivedHints } = model; // 已发现窗口只允许被配置值压低;缺窗口时,已开的 Context cap 就是实际窗口。 const discoveredWindow = typeof model.contextWindow === "number" && model.contextWindow > 0 @@ -663,7 +666,7 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, ? (configuredCap !== undefined ? Math.min(discoveredWindow, configuredCap) : discoveredWindow) : (configuredCap ?? (providerCap !== undefined ? resolveUnknownRoutedContextWindow(providerCap) : undefined)); const hinted = { - ...modelWithoutServiceTier, + ...modelWithoutDerivedHints, ...(hintedWindow !== undefined ? { contextWindow: hintedWindow } : {}), ...(inputModalities ? { inputModalities } : {}), ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}), @@ -675,6 +678,7 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, } : {}), ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}), + ...(suppressSyntheticMax ? { suppressSyntheticMax: true } : {}), ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}), ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}), ...(supportsServiceTier === true && fastPolicy.fastTierDescription !== undefined @@ -1854,6 +1858,9 @@ async function gatherRoutedModelsUncached( ? nativeDefaultReasoningEffort(cm.modelId) : undefined; const supportsReasoningSummaries = configuredReasoningSummarySupport(rawProvider, cm.modelId); + const suppressSyntheticMax = effectiveProvider + ? modelRecordValue(effectiveProvider.modelSuppressSyntheticMax, cm.modelId) === true + : false; const fastPolicy = effectiveProvider ? fastPolicyForModel(effectiveProvider, cm.modelId, cm.provider) : undefined; @@ -1874,6 +1881,7 @@ async function gatherRoutedModelsUncached( ? { inputModalities: cm.inputModalities } : codexForwardNativeCapabilityAlias ? { inputModalities: nativeInputModalities(cm.modelId) } : {}), ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}), + ...(suppressSyntheticMax ? { suppressSyntheticMax: true } : {}), // Native-alias defaults apply only where the custom row declares nothing: the explicit // spreads below must win (later in object order), so a stored `[]` stays empty and a // declared ladder is never replaced by the alias's native ladder. diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 70b93ee7bd..158cbd8ffd 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -14,7 +14,7 @@ import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, mo import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata"; import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap"; -import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../../providers/slug-codec"; +import { encodeRoutedModelId, routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../../providers/slug-codec"; import { identifyRoutedModel } from "../../adapters/identity"; import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; @@ -50,7 +50,7 @@ import { resetBundledCatalogCacheForTests, } from "./bundled"; import { isMultiAgentV2Enabled } from "../features"; -import { applyCatalogModelMetadata, applyReasoningLevels, catalogEntryEfforts, clampCatalogModelsToCodexSupport, ensureGpt56ReasoningLevels, ensureUltraReasoningLevel, isGpt56NativeSlug } from "./effort"; +import { applyCatalogModelMetadata, applyReasoningLevels, catalogEntryEfforts, clampedDefaultEffort, clampCatalogModelsToCodexSupport, ensureGpt56ReasoningLevels, ensureUltraReasoningLevel, isGpt56NativeSlug, MAX_REASONING_PROVENANCE_FIELD, stampMaxReasoningProvenance } from "./effort"; import { clearGatherRoutedModelsInflight, filterCatalogVisibleModels, @@ -336,7 +336,9 @@ export function deriveEntry( model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact || codexForwardNativeCapabilityAlias !== null, + model?.suppressSyntheticMax === true, ); + stampMaxReasoningProvenance(e, model?.reasoningEfforts); // 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. if (!codexForwardNativeCapabilityAlias) { @@ -385,7 +387,14 @@ export function deriveEntry( }; if (isRouted) { applyRoutedCodexToolMode(entry, model?.codexToolMode); - applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact); + applyReasoningLevels( + entry, + model?.reasoningEfforts, + model?.defaultReasoningEffort, + preserveExact, + model?.suppressSyntheticMax === true, + ); + stampMaxReasoningProvenance(entry, model?.reasoningEfforts); } else { applyReasoningLevels(entry, isGpt56NativeSlug(slug) ? undefined : ["low", "medium", "high", "xhigh"]); @@ -770,6 +779,7 @@ export interface ObservedCatalogMergeInput { readonly includeNativeOpenAi: boolean; readonly accountBoundEntries: readonly RawEntry[]; readonly suppressedBareNativeSlugs?: ReadonlySet; + readonly syntheticMaxSuppressedSlugs: ReadonlySet; readonly policy: ObservedCatalogMergePolicy; readonly openaiContextCap?: NativeContextLimitsInput; } @@ -801,6 +811,7 @@ export function mergeCatalogEntriesFromObservedState({ includeNativeOpenAi, accountBoundEntries, suppressedBareNativeSlugs = new Set(), + syntheticMaxSuppressedSlugs, policy, openaiContextCap, }: ObservedCatalogMergeInput): RawEntry[] { @@ -813,6 +824,9 @@ export function mergeCatalogEntriesFromObservedState({ const detachedAccountBoundEntries = accountBoundEntries .map(entry => structuredClone(entry) as RawEntry); const disabledModelKeys = new Set([...disabledModels].map(slugEquivalenceKey)); + const syntheticMaxSuppressedKeys = new Set( + [...syntheticMaxSuppressedSlugs].map(slugEquivalenceKey), + ); const legacyCustomModelKeys = new Set( [...legacyCustomModelSlugs].map(slugEquivalenceKey), ); @@ -1096,14 +1110,31 @@ 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. + const suppressSyntheticMax = typeof e.slug === "string" + && syntheticMaxSuppressedKeys.has(slugEquivalenceKey(e.slug)); if (!exactCombo) { - const levels = Array.isArray(e.supported_reasoning_levels) + let 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")) { - levels.push(CODEX_REASONING_LEVELS.find(level => level.effort === "max") - ?? { effort: "max", description: "Maximum reasoning depth for the hardest problems" }); + if (suppressSyntheticMax && e[MAX_REASONING_PROVENANCE_FIELD] === "synthetic") { + levels = levels.filter(level => level.effort !== "max"); e.supported_reasoning_levels = levels; + delete e[MAX_REASONING_PROVENANCE_FIELD]; + const currentDefault = e.default_reasoning_level; + const surviving = catalogEntryEfforts(e); + if (typeof currentDefault === "string" && !surviving.includes(currentDefault)) { + if (surviving.length === 0) delete e.default_reasoning_level; + else e.default_reasoning_level = clampedDefaultEffort(currentDefault, surviving); + } + } + if (levels.length > 0 && !levels.some(level => level.effort === "max")) { + const providerDeclaredMax = e[MAX_REASONING_PROVENANCE_FIELD] === "provider"; + if (providerDeclaredMax || !suppressSyntheticMax) { + 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; + e[MAX_REASONING_PROVENANCE_FIELD] = providerDeclaredMax ? "provider" : "synthetic"; + } } } if (wsEnabled) e.supports_websockets = true; @@ -1160,6 +1191,7 @@ export function mergeCatalogEntriesForSync( ), openaiContextCap?: number, keepNativeChatGptOnV1 = false, + syntheticMaxSuppressedSlugs: ReadonlySet = new Set(), ): RawEntry[] { // Retained for source compatibility with the original helper contract. Raw provider ids must // not suppress same-named native rows; actual admitted combo entries own that decision now. @@ -1195,6 +1227,7 @@ export function mergeCatalogEntriesForSync( includeNativeOpenAi, accountBoundEntries, suppressedBareNativeSlugs, + syntheticMaxSuppressedSlugs, openaiContextCap, policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, @@ -1203,6 +1236,32 @@ export function mergeCatalogEntriesForSync( }); } +/** Current config policy, kept explicit so degraded discovery cannot resurrect synthetic max. */ +export function syntheticMaxSuppressedCatalogSlugs( + config: Pick, + candidateEntries: readonly RawEntry[] = [], +): Set { + const slugs = new Set(); + for (const [provider, entry] of Object.entries(config.providers)) { + if (entry.disabled === true) continue; + const policy = entry.modelSuppressSyntheticMax ?? {}; + for (const [model, suppress] of Object.entries(policy)) { + if (suppress === true) slugs.add(routedSlug(provider, model)); + } + const encodedPolicy = Object.fromEntries( + Object.entries(policy).map(([model, suppress]) => [encodeRoutedModelId(model), suppress]), + ); + const prefix = `${provider}/`; + for (const candidate of candidateEntries) { + if (typeof candidate.slug !== "string" || !candidate.slug.startsWith(prefix)) continue; + if (modelRecordValue(encodedPolicy, candidate.slug.slice(prefix.length)) === true) { + slugs.add(candidate.slug); + } + } + } + return slugs; +} + interface RetainedCatalogSyncRead { readonly catalogPath: string; readonly catalog: RawCatalog; @@ -1588,6 +1647,11 @@ function writeRetainedCatalogSync({ includeNativeOpenAi, accountBoundEntries, suppressedBareNativeSlugs, + syntheticMaxSuppressedSlugs: syntheticMaxSuppressedCatalogSlugs(config, [ + ...catalogModelsForMerge, + ...(baselineCatalog?.models ?? []), + ...goEntries, + ]), openaiContextCap, policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index d5aeb893b7..01b0f0adb3 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -44,6 +44,7 @@ import { mergeCatalogEntriesFromObservedState, mergeCatalogModelsWithNativeRecovery, orderForSubagents, + syntheticMaxSuppressedCatalogSlugs, } from "./catalog/sync"; import { multiAgentV2EnabledFromConfigText } from "./features"; import { exactComboCatalogSlugs } from "./catalog/aggregation"; @@ -352,6 +353,11 @@ function prepareCatalog( includeNativeOpenAi, accountBoundEntries, suppressedBareNativeSlugs, + syntheticMaxSuppressedSlugs: syntheticMaxSuppressedCatalogSlugs(config, [ + ...catalogModels, + ...baselineCatalogModels, + ...routedEntries, + ]), policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, nativeBackfillSlugs: [...availableBareNativeSlugs, ...observedNativeSlugs], diff --git a/src/config.ts b/src/config.ts index dcf34313a4..1894b4af07 100644 --- a/src/config.ts +++ b/src/config.ts @@ -714,6 +714,7 @@ 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(), allowPrivateNetwork: z.boolean().optional(), @@ -1508,6 +1509,17 @@ 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 serviceTierModelsError = booleanRecordConfigError( (provider as { modelSupportsServiceTier?: unknown }).modelSupportsServiceTier, "modelSupportsServiceTier", diff --git a/src/providers/model-rename-migration.ts b/src/providers/model-rename-migration.ts index 3895386b58..d899aff638 100644 --- a/src/providers/model-rename-migration.ts +++ b/src/providers/model-rename-migration.ts @@ -88,6 +88,7 @@ const MODEL_KEYED_RECORDS = [ "modelReasoningEfforts", "modelDefaultReasoningEfforts", "modelReasoningEffortMap", + "modelSuppressSyntheticMax", ] as const; /** Provider fields that are flat lists of model ids. */ diff --git a/src/server/management/provider-capability-config.ts b/src/server/management/provider-capability-config.ts index ce58966df3..96908f4a61 100644 --- a/src/server/management/provider-capability-config.ts +++ b/src/server/management/provider-capability-config.ts @@ -1,4 +1,5 @@ import { booleanRecordConfigError } from "../../config"; +import { redactSecretString } from "../../lib/redact"; import type { OcxConfig } from "../../types"; /** @@ -17,6 +18,19 @@ export function providerServiceTierConfigError(name: unknown, provider: unknown) return error ? `provider ${name} ${error}` : null; } +export function providerSyntheticMaxConfigError(name: unknown, provider: unknown): string | null { + 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 ${JSON.stringify(redactSecretString(name))} ${redactSecretString(error)}` + : null; +} + function publicServiceTierRecord(value: unknown): Record | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const entries = Object.entries(value).filter(([model, supported]) => diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 47909e50f0..5bab0e7d5d 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -75,7 +75,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 { providerServiceTierConfigError } from "./provider-capability-config"; +import { providerServiceTierConfigError, providerSyntheticMaxConfigError } from "./provider-capability-config"; import { applySystemEnvToggle } from "../system-env"; import { LOCAL_PROVIDER_RELOAD_NAME_HEADER, @@ -500,6 +500,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise; /** Model-specific default Codex reasoning tier; must also be present in the visible tier list. */ modelDefaultReasoningEfforts?: Record; + /** Suppress only Codex's invented `max` tier for model-keyed upstream ids. */ + modelSuppressSyntheticMax?: Record; /** * Model-specific Codex reasoning-summary capability. Set false when an OpenAI-compatible * Responses backend rejects Codex summary-delivery fields for that model. diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index 52b635b7f7..b6cf09ea1d 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -255,6 +255,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). +Reasoning-capable routed rows also receive a missing synthetic `max` by default because Codex checks +explicit `spawn_agent` effort overrides against catalog membership before the request reaches the +proxy. `providers..modelSuppressSyntheticMax[model] = true` is the narrow exception: it keeps a +missing `max` absent while preserving a provider-declared `max` and Codex's `ultra`. Both catalog +construction and the final observed-state merge apply the current enabled-provider policy, including +second syncs and degraded-discovery preservation. Generated rows record whether `max` came from the +provider ladder or OpenCodex synthesis; degraded preservation removes only the latter and keeps an +unmarked legacy `max` fail-safe. The catalog has no hidden spawn-only rung, so an +explicit `spawn_agent` request for `max` may fail client-side for an opted-in model; request clamping +cannot repair a request Codex never sends. + `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-catalog.test.ts b/tests/codex-catalog.test.ts index f4e8bca863..a7fd66053c 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -40,6 +40,7 @@ import { import { CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, mergeCatalogEntriesFromObservedState, + syntheticMaxSuppressedCatalogSlugs, type ObservedCatalogMergeInput, } from "../src/codex/catalog/sync"; @@ -2312,6 +2313,7 @@ test("a custom row inherits provider reasoning metadata from the provider-derive selectedModels: ["qwen-coder-3b"], noReasoningModels: ["qwen-coder-3b"], modelReasoningEfforts: { "qwen-coder-3b": [] }, + modelSuppressSyntheticMax: { "qwen-coder-3b": true }, }, }, customModels: [ @@ -2335,6 +2337,7 @@ test("a custom row inherits provider reasoning metadata from the provider-derive expect(custom?.contextWindow).toBe(32768); expect(custom?.inputModalities).toEqual(["text"]); expect(custom?.reasoningEfforts).toEqual([]); + expect(custom?.suppressSyntheticMax).toBe(true); expect(custom?.parallelToolCalls).toBe(true); const entries = buildCatalogEntries(nativeTemplate(), [], models); @@ -2350,6 +2353,35 @@ test("a custom row inherits provider reasoning metadata from the provider-derive } }); +test("a custom-only row receives model-scoped synthetic-max policy", async () => { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "custom-only", + providers: { + "custom-only": { + adapter: "openai-responses", + baseUrl: "https://example.invalid/v1", + liveModels: false, + models: [], + modelSuppressSyntheticMax: { "gemini-3.7-flash": true }, + }, + }, + customModels: [{ + id: "custom-gemini", + provider: "custom-only", + modelId: "gemini-3.7-flash", + reasoningEfforts: ["low", "medium", "high"], + addedAt: "2026-01-01T00:00:00.000Z", + }], + }); + + expect(models).toHaveLength(1); + expect(models[0]?.suppressSyntheticMax).toBe(true); + const row = buildCatalogEntries(null, [], models)[0]; + expect((row?.supported_reasoning_levels as Array<{ effort: string }>).map(level => level.effort)) + .toEqual(["low", "medium", "high", "ultra"]); +}); + function openAiApiCatalogConfig(overrides: Record = {}): OcxConfig { return { port: 10100, @@ -2416,6 +2448,7 @@ function mergeObservedForTest( hasPhysicalComboProvider: false, includeNativeOpenAi: true, accountBoundEntries: [], + syntheticMaxSuppressedSlugs: new Set(), policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, warningPolicy: "emit", @@ -2425,6 +2458,204 @@ function mergeObservedForTest( } describe("Codex catalog routed normalization", () => { + test("model-scoped policy suppresses only invented max in template and fallback rows", () => { + const model = { + provider: "google", + id: "gemini-3.7-flash", + reasoningEfforts: ["low", "medium", "high"], + suppressSyntheticMax: true, + }; + + for (const template of [nativeTemplate(), null]) { + const row = buildCatalogEntries(template, [], [model]) + .find(entry => entry.slug === "google/gemini-3.7-flash"); + expect((row?.supported_reasoning_levels as Array<{ effort: string }>).map(level => level.effort)) + .toEqual(["low", "medium", "high", "ultra"]); + } + + const realMax = buildCatalogEntries(null, [], [{ + ...model, + reasoningEfforts: ["low", "high", "max"], + }]).find(entry => entry.slug === "google/gemini-3.7-flash"); + expect((realMax?.supported_reasoning_levels as Array<{ effort: string }>).map(level => level.effort)) + .toEqual(["low", "high", "max", "ultra"]); + + const defaultLadder = buildCatalogEntries(null, [], [{ + provider: "google", + id: "gemini-default", + suppressSyntheticMax: true, + }]).find(entry => entry.slug === "google/gemini-default"); + expect((defaultLadder?.supported_reasoning_levels as Array<{ effort: string }>).map(level => level.effort)) + .toEqual(["low", "medium", "high", "xhigh", "ultra"]); + }); + + test("unflagged, empty, none-only, and exact-combo ladders keep their existing behavior", () => { + const rows = buildCatalogEntries(null, [], [ + { provider: "google", id: "legacy", reasoningEfforts: ["low", "medium", "high"] }, + { provider: "google", id: "no-max", reasoningEfforts: ["low", "high"], suppressSyntheticMax: true }, + { provider: "google", id: "empty", reasoningEfforts: [], suppressSyntheticMax: true }, + { provider: "google", id: "none", reasoningEfforts: ["none"], suppressSyntheticMax: true }, + { provider: COMBO_NAMESPACE, id: "exact", reasoningEfforts: ["low", "high"], suppressSyntheticMax: true }, + ], undefined, false, "default", new Set(["combo/exact"])); + const efforts = (slug: string) => ( + rows.find(entry => entry.slug === slug)?.supported_reasoning_levels as Array<{ effort: string }> + ).map(level => level.effort); + + expect(efforts("google/legacy")).toEqual(["low", "medium", "high", "max", "ultra"]); + expect(efforts("google/no-max")).toEqual(["low", "high", "ultra"]); + expect(efforts("google/empty")).toEqual([]); + expect(efforts("google/none")).toEqual(["none"]); + expect(efforts("combo/exact")).toEqual(["low", "high"]); + }); + + test("final sync keeps suppression stable and restores legacy max after opt-out", () => { + const slug = "google/gemini-3.7-flash"; + const routed = buildCatalogEntries(null, [], [{ + provider: "google", + id: "gemini-3.7-flash", + reasoningEfforts: ["low", "medium", "high"], + suppressSyntheticMax: true, + }]).find(entry => entry.slug === slug)!; + const legacyRouted = buildCatalogEntries(null, [], [{ + provider: "google", + id: "gemini-3.7-flash", + reasoningEfforts: ["low", "medium", "high"], + }]).find(entry => entry.slug === slug)!; + const policy = new Set([slug]); + const merge = ( + catalogModels: Record[], + syntheticMaxSuppressedSlugs = policy, + routedEntries = [routed], + ) => + mergeObservedForTest({ + catalogModels, + routedEntries, + gatheredProviderNames: new Set(["google"]), + syntheticMaxSuppressedSlugs, + }); + + const first = merge([]); + const second = merge(first); + for (const rows of [first, second]) { + expect((rows.find(entry => entry.slug === slug)?.supported_reasoning_levels as Array<{ effort: string }>) + .map(level => level.effort)).toEqual(["low", "medium", "high", "ultra"]); + } + expect((merge(second, new Set(), [legacyRouted]).find(entry => entry.slug === slug) + ?.supported_reasoning_levels as Array<{ effort: string }>).map(level => level.effort)) + .toEqual(["low", "medium", "high", "max", "ultra"]); + }); + + test("degraded preservation removes only a previously synthesized max", () => { + const slug = "google/gemini-3.7-flash"; + const preserve = (entry: Record, suppress: boolean) => mergeObservedForTest({ + catalogModels: [entry], + routedEntries: [], + template: null, + gatheredProviderNames: new Set(["google"]), + degradedProviderNames: new Set(["google"]), + syntheticMaxSuppressedSlugs: suppress ? new Set([slug]) : new Set(), + }).find(row => row.slug === slug); + const priorWithoutMax = buildCatalogEntries(null, [], [{ + provider: "google", + id: "gemini-3.7-flash", + reasoningEfforts: ["low", "high"], + suppressSyntheticMax: true, + }]).find(entry => entry.slug === slug)!; + const priorSynthetic = preserve(priorWithoutMax, false)!; + expect((priorSynthetic.supported_reasoning_levels as Array<{ effort: string }>).map(level => level.effort)) + .toEqual(["low", "high", "ultra", "max"]); + expect(priorSynthetic.opencodex_max_provenance).toBe("synthetic"); + priorSynthetic.default_reasoning_level = "max"; + + const suppressed = preserve(priorSynthetic, true); + expect((suppressed?.supported_reasoning_levels as Array<{ effort: string }>).map(level => level.effort)) + .toEqual(["low", "high", "ultra"]); + expect(suppressed?.default_reasoning_level).toBe("high"); + expect(suppressed?.opencodex_max_provenance).toBeUndefined(); + + const priorProviderMax = buildCatalogEntries(null, [], [{ + provider: "google", + id: "gemini-3.7-flash", + reasoningEfforts: ["low", "high", "max"], + }]).find(entry => entry.slug === slug)!; + expect(priorProviderMax.opencodex_max_provenance).toBe("provider"); + + const preserved = preserve(priorProviderMax, true); + expect((preserved?.supported_reasoning_levels as Array<{ effort: string }>).map(level => level.effort)) + .toEqual(["low", "high", "max", "ultra"]); + expect(preserved?.opencodex_max_provenance).toBe("provider"); + + const clampedProviderMax = structuredClone(priorProviderMax); + clampCatalogModelsToObservedCodexSupport( + [clampedProviderMax], + new Set(["low", "medium", "high", "xhigh", "ultra"]), + ); + const preservedAfterClamp = preserve(clampedProviderMax, true); + expect((preservedAfterClamp?.supported_reasoning_levels as Array<{ effort: string }>).map(level => level.effort)) + .toEqual(["low", "high", "ultra", "max"]); + expect(preservedAfterClamp?.opencodex_max_provenance).toBe("provider"); + + const unknownMax = structuredClone(priorProviderMax); + delete unknownMax.opencodex_max_provenance; + const preservedUnknown = preserve(unknownMax, true); + expect((preservedUnknown?.supported_reasoning_levels as Array<{ effort: string }>).map(level => level.effort)) + .toEqual(["low", "high", "max", "ultra"]); + }); + + test("synthetic-max policy slugs come only from enabled true config entries", () => { + expect(syntheticMaxSuppressedCatalogSlugs({ + providers: { + google: { + adapter: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + modelSuppressSyntheticMax: { + "gemini-3.7-flash": true, + "vendor/model": true, + legacy: false, + }, + }, + disabled: { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + disabled: true, + modelSuppressSyntheticMax: { hidden: true }, + }, + }, + })).toEqual(new Set([ + "google/gemini-3.7-flash", + "google/vendor-model", + ])); + }); + + test("synthetic-max policy uses family and case-insensitive matching for preserved rows", () => { + const prior = buildCatalogEntries(null, [], [ + { provider: "google", id: "gemini-family:preview", reasoningEfforts: ["low", "high"] }, + { provider: "google", id: "GEMINI-CASE", reasoningEfforts: ["low", "high"] }, + ]); + const policy = syntheticMaxSuppressedCatalogSlugs({ + providers: { + google: { + adapter: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + modelSuppressSyntheticMax: { "gemini-family": true, "gemini-case": true }, + }, + }, + }, prior); + const preserved = mergeObservedForTest({ + catalogModels: prior, + routedEntries: [], + template: null, + gatheredProviderNames: new Set(["google"]), + degradedProviderNames: new Set(["google"]), + syntheticMaxSuppressedSlugs: policy, + }); + + for (const slug of ["google/gemini-family:preview", "google/GEMINI-CASE"]) { + expect((preserved.find(row => row.slug === slug)?.supported_reasoning_levels as Array<{ effort: string }>) + .map(level => level.effort)).toEqual(["low", "high", "ultra"]); + } + }); + test("does not reuse a routed native alias as the native catalog template", () => { const routedAlias = { ...nativeTemplate(), @@ -5024,6 +5255,30 @@ describe("Codex catalog routed normalization", () => { expect(models.find(m => m.id === "cached-model")?.contextWindow).toBe(80_000); }); + test("current config clears stale synthetic-max suppression from cached models", async () => { + const provider = "synthetic-max-cache"; + setCached(provider, [{ + provider, + id: "gemini-3.7-flash", + suppressSyntheticMax: true, + }]); + globalThis.fetch = (async () => new Response("{}", { status: 503 })) as typeof fetch; + + const models = await gatherRoutedModels({ + modelCacheTtlMs: 0, + providers: { + [provider]: { + adapter: "openai-responses", + baseUrl: "https://synthetic-max-cache.test/v1", + modelSuppressSyntheticMax: { "gemini-3.7-flash": false }, + }, + }, + }); + + expect(models.find(model => model.id === "gemini-3.7-flash")?.suppressSyntheticMax) + .toBeUndefined(); + }); + test("provider context-cap toggle applies to stale cached metadata", async () => { globalThis.fetch = (async () => new Response(JSON.stringify({ data: [{ @@ -5363,7 +5618,10 @@ describe("Codex reasoning-effort capability clamp", () => { })]), }); const before = JSON.stringify(observed); - const models = [routedEntry()]; + const models = [{ + ...routedEntry(), + opencodex_max_provenance: "synthetic", + }]; models[0]!.default_reasoning_level = "ultra"; const supported = supportedCodexReasoningEffortsFromObservedCatalog(observed); @@ -5376,6 +5634,7 @@ describe("Codex reasoning-effort capability clamp", () => { expect(models[0]!.supported_reasoning_levels.map(level => level.effort)) .toEqual(["low", "medium", "high", "xhigh"]); expect(models[0]!.default_reasoning_level).toBe("xhigh"); + expect(models[0]!.opencodex_max_provenance).toBeUndefined(); expect(JSON.stringify(observed)).toBe(before); }); diff --git a/tests/codex-v2-gate.test.ts b/tests/codex-v2-gate.test.ts index 9959c5656e..f6f4360a5b 100644 --- a/tests/codex-v2-gate.test.ts +++ b/tests/codex-v2-gate.test.ts @@ -1865,6 +1865,7 @@ describe("3-state multi-agent mode", () => { hasPhysicalComboProvider: false, includeNativeOpenAi: true, accountBoundEntries, + syntheticMaxSuppressedSlugs: new Set(), policy: { nativeBackfillSlugs: ["gpt-5.5"], unsupportedNativeEntries: "preserve", diff --git a/tests/config.test.ts b/tests/config.test.ts index 4f86db8743..8b01a01556 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1246,6 +1246,37 @@ describe("opencodex config defaults", () => { } }); + test("modelSuppressSyntheticMax accepts only plain boolean records", () => { + writeConfig({ + port: 12345, + providers: { + custom: { + adapter: "openai-responses", + baseUrl: "https://example.test/v1", + modelSuppressSyntheticMax: { "gemini-3.7-flash": true, legacy: false }, + }, + }, + defaultProvider: "custom", + }); + expect(readConfigDiagnostics().error).toBeNull(); + + for (const invalid of [[], { model: "true" }, { "": true }]) { + writeConfig({ + port: 12345, + providers: { + custom: { + adapter: "openai-responses", + baseUrl: "https://example.test/v1", + modelSuppressSyntheticMax: invalid, + }, + }, + defaultProvider: "custom", + }); + expect(readConfigDiagnostics().source).toBe("fallback"); + expect(readConfigDiagnostics().error).toContain("modelSuppressSyntheticMax"); + } + }); + test("modelReasoningSummaryDelivery validates known values and rejects summary opt-out conflicts (#538)", () => { writeConfig({ port: 12345, diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 7aaaba950a..4b961b07dd 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -30,7 +30,11 @@ import { } from "../src/server"; import { handleManagementAPI } from "../src/server/management-api"; import { providerManagementConfigError } from "../src/server/auth-cors"; -import { providerServiceTierConfigError, withProviderServiceTierDTO } from "../src/server/management/provider-capability-config"; +import { + providerServiceTierConfigError, + providerSyntheticMaxConfigError, + withProviderServiceTierDTO, +} from "../src/server/management/provider-capability-config"; import { clearModelCache, markProviderDiscoveryFailed } from "../src/codex/model-cache"; import type { OcxConfig } from "../src/types"; import { fakeChatGptJwt } from "./helpers/fake-chatgpt-jwt"; @@ -304,6 +308,53 @@ describe("provider management validation", () => { expect(dto.providers.relay?.noStructuredOutputModels).toEqual(["deepseek-v4-flash"]); }); + test("validates model-scoped synthetic-max suppression", () => { + const provider = { + adapter: "openai-responses", + baseUrl: "https://relay.example/v1", + modelSuppressSyntheticMax: { "gemini-3.7-flash": true, legacy: false }, + }; + expect(providerSyntheticMaxConfigError("relay", provider)).toBeNull(); + for (const modelSuppressSyntheticMax of [[], { model: "true" }, { "": true }]) { + const invalidProvider = { + ...provider, + modelSuppressSyntheticMax, + }; + expect(providerManagementConfigError("relay", invalidProvider)).toBeNull(); + expect(providerSyntheticMaxConfigError("relay", invalidProvider)).toContain("modelSuppressSyntheticMax"); + } + }); + + test("provider POST redacts token-shaped synthetic-max validation identifiers", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig(config("127.0.0.1")); + const secret = "sk-super-secret-9876"; + + const server = startServer(0); + try { + const response = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: secret, + provider: { + adapter: "openai-responses", + baseUrl: "https://relay.example/v1", + modelSuppressSyntheticMax: { [secret]: "true" }, + }, + }), + }); + const body = await response.text(); + expect(response.status).toBe(400); + expect(body).not.toContain(secret); + expect(body).toContain("[REDACTED]"); + } finally { + await server.stop(true); + } + }); + test("normalizes hand-edited structured-output model opt-outs at load", () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); @@ -642,6 +693,44 @@ describe("provider management validation", () => { } }); + test("provider POST overwrite preserves modelSuppressSyntheticMax when the payload omits it", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig(config("127.0.0.1")); + + const server = startServer(0); + try { + const suppression = { "gemini-3.7-flash": true }; + const create = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "custom-synthetic-max", + provider: { + adapter: "openai-responses", + baseUrl: "https://api.example.test/v1", + modelSuppressSyntheticMax: suppression, + }, + }), + }); + expect(create.status).toBe(200); + + const overwrite = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "custom-synthetic-max", + provider: { adapter: "openai-responses", baseUrl: "https://api.example.test/v1" }, + }), + }); + expect(overwrite.status).toBe(200); + expect(loadConfig().providers["custom-synthetic-max"]?.modelSuppressSyntheticMax).toEqual(suppression); + } finally { + await server.stop(true); + } + }); + // #1409: the add/edit form's payload type has no member for contextWindow or // modelContextWindows, so an overwrite arrives without them. Registry enrichment then fills // the absent fields from the seed and the stored row loses the user's values — for diff --git a/tests/model-rename-migration.test.ts b/tests/model-rename-migration.test.ts index a4648b04ad..84842b66f0 100644 --- a/tests/model-rename-migration.test.ts +++ b/tests/model-rename-migration.test.ts @@ -32,6 +32,7 @@ function staleConfig(): OcxConfig { modelInputModalities: { "qwen3.8-max-preview": ["text", "image"] }, modelReasoningEfforts: { "qwen3.8-max-preview": ["low", "high", "xhigh"] }, modelDefaultReasoningEfforts: { "qwen3.8-max-preview": "xhigh" }, + modelSuppressSyntheticMax: { "qwen3.8-max-preview": true }, preserveReasoningContentModels: ["glm-5.2", "qwen3.8-max-preview", "qwen3.7-max"], thinkingBudgetModels: ["qwen3.8-max-preview", "qwen3.7-max"], }, @@ -54,6 +55,7 @@ describe("registry model rename migration (#1610)", () => { expect(prov.modelInputModalities?.["qwen3.8-max"]).toEqual(["text", "image"]); expect(prov.modelReasoningEfforts?.["qwen3.8-max"]).toEqual(["low", "high", "xhigh"]); expect(prov.modelDefaultReasoningEfforts?.["qwen3.8-max"]).toBe("xhigh"); + expect(prov.modelSuppressSyntheticMax?.["qwen3.8-max"]).toBe(true); expect(prov.preserveReasoningContentModels).toEqual(["glm-5.2", "qwen3.8-max", "qwen3.7-max"]); expect(prov.thinkingBudgetModels).toEqual(["qwen3.8-max", "qwen3.7-max"]); expect(warnings.some(w => w.includes("qwen3.8-max"))).toBe(true); @@ -80,6 +82,7 @@ describe("registry model rename migration (#1610)", () => { prov.modelInputModalities = {}; prov.modelReasoningEfforts = {}; prov.modelDefaultReasoningEfforts = {}; + prov.modelSuppressSyntheticMax = {}; prov.preserveReasoningContentModels = ["qwen3.8-max"]; prov.thinkingBudgetModels = ["qwen3.7-max"]; clean.disabledModels = ["other/model"];