From c7af382c3da9b98d2c994166d372ea45f10d7003 Mon Sep 17 00:00:00 2001 From: anni kock Date: Fri, 21 Aug 2026 14:15:46 +0800 Subject: [PATCH 1/7] feat(catalog): allow per-model synthetic max suppression --- .../docs/reference/configuration/providers.md | 1 + src/codex/catalog/effort.ts | 7 +- src/codex/catalog/parsing.ts | 2 + src/codex/catalog/provider-fetch.ts | 12 +- src/codex/catalog/sync.ts | 35 +++- src/codex/convergence.ts | 2 + src/config.ts | 12 ++ src/providers/model-rename-migration.ts | 1 + src/server/auth-cors.ts | 2 + src/server/management/provider-routes.ts | 4 + src/types/provider.ts | 2 + structure/03_catalog-and-subagents.md | 9 + tests/codex-catalog.test.ts | 182 ++++++++++++++++++ tests/codex-v2-gate.test.ts | 1 + tests/config.test.ts | 31 +++ tests/management-provider-validation.test.ts | 53 +++++ tests/model-rename-migration.test.ts | 3 + 17 files changed, 353 insertions(+), 6 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index c44b628714..6332d3e9a9 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 Codex validation for an opted-in model before wire clamping 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..945eff832e 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -206,6 +206,7 @@ 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` @@ -214,10 +215,12 @@ export function applyReasoningLevels( // 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 (!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; } 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..3297b7fcf8 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -336,6 +336,7 @@ export function deriveEntry( model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact || codexForwardNativeCapabilityAlias !== null, + 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. @@ -385,7 +386,13 @@ 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, + ); } else { applyReasoningLevels(entry, isGpt56NativeSlug(slug) ? undefined : ["low", "medium", "high", "xhigh"]); @@ -770,6 +777,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 +809,7 @@ export function mergeCatalogEntriesFromObservedState({ includeNativeOpenAi, accountBoundEntries, suppressedBareNativeSlugs = new Set(), + syntheticMaxSuppressedSlugs, policy, openaiContextCap, }: ObservedCatalogMergeInput): RawEntry[] { @@ -813,6 +822,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,7 +1108,9 @@ 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. - if (!exactCombo) { + const suppressSyntheticMax = typeof e.slug === "string" + && syntheticMaxSuppressedKeys.has(slugEquivalenceKey(e.slug)); + if (!exactCombo && !suppressSyntheticMax) { const levels = Array.isArray(e.supported_reasoning_levels) ? e.supported_reasoning_levels as Array<{ effort?: string }> : []; @@ -1160,6 +1174,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 +1210,7 @@ export function mergeCatalogEntriesForSync( includeNativeOpenAi, accountBoundEntries, suppressedBareNativeSlugs, + syntheticMaxSuppressedSlugs, openaiContextCap, policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, @@ -1203,6 +1219,20 @@ export function mergeCatalogEntriesForSync( }); } +/** Current config policy, kept explicit so degraded discovery cannot resurrect synthetic max. */ +export function syntheticMaxSuppressedCatalogSlugs( + config: Pick, +): Set { + const slugs = new Set(); + for (const [provider, entry] of Object.entries(config.providers)) { + if (entry.disabled === true) continue; + for (const [model, suppress] of Object.entries(entry.modelSuppressSyntheticMax ?? {})) { + if (suppress === true) slugs.add(routedSlug(provider, model)); + } + } + return slugs; +} + interface RetainedCatalogSyncRead { readonly catalogPath: string; readonly catalog: RawCatalog; @@ -1588,6 +1618,7 @@ function writeRetainedCatalogSync({ includeNativeOpenAi, accountBoundEntries, suppressedBareNativeSlugs, + syntheticMaxSuppressedSlugs: syntheticMaxSuppressedCatalogSlugs(config), openaiContextCap, policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index d5aeb893b7..0f632baeae 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,7 @@ function prepareCatalog( includeNativeOpenAi, accountBoundEntries, suppressedBareNativeSlugs, + syntheticMaxSuppressedSlugs: syntheticMaxSuppressedCatalogSlugs(config), 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/auth-cors.ts b/src/server/auth-cors.ts index 77ffa085c2..74095352d1 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -607,6 +607,8 @@ export function providerManagementConfigError(name: unknown, provider: unknown): if (maxInputError) return `provider ${name} ${maxInputError}`; 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, diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 47909e50f0..8c88f2c731 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -537,6 +537,7 @@ 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..2dca677a6b 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -255,6 +255,15 @@ 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. 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..11c8f73409 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,131 @@ 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"]); + }); + + 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 still applies current synthetic-max policy", () => { + const slug = "google/gemini-3.7-flash"; + const existing = { + slug, + owned_by: "google", + input_modalities: ["text"], + supported_reasoning_levels: ["low", "high", "ultra"].map(effort => ({ effort })), + }; + const preserved = mergeObservedForTest({ + catalogModels: [existing], + routedEntries: [], + template: null, + gatheredProviderNames: new Set(["google"]), + degradedProviderNames: new Set(["google"]), + syntheticMaxSuppressedSlugs: new Set([slug]), + }).find(entry => entry.slug === slug); + + expect((preserved?.supported_reasoning_levels as Array<{ effort: string }>).map(level => level.effort)) + .toEqual(["low", "high", "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("does not reuse a routed native alias as the native catalog template", () => { const routedAlias = { ...nativeTemplate(), @@ -5024,6 +5182,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: [{ 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..81bf8950a6 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -304,6 +304,21 @@ 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(providerManagementConfigError("relay", provider)).toBeNull(); + for (const modelSuppressSyntheticMax of [[], { model: "true" }, { "": true }]) { + expect(providerManagementConfigError("relay", { + ...provider, + modelSuppressSyntheticMax, + })).toContain("modelSuppressSyntheticMax"); + } + }); + 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 +657,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"]; From e9bd28619489c55cbb5d8ddfb49b0944fc65dc77 Mon Sep 17 00:00:00 2001 From: anni kock Date: Fri, 21 Aug 2026 15:34:22 +0800 Subject: [PATCH 2/7] fix(management): isolate synthetic max validation --- src/server/auth-cors.ts | 2 -- .../management/provider-capability-config.ts | 11 +++++++++++ src/server/management/provider-routes.ts | 11 ++++++++++- tests/management-provider-validation.test.ts | 14 ++++++++++---- 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 74095352d1..77ffa085c2 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -607,8 +607,6 @@ export function providerManagementConfigError(name: unknown, provider: unknown): if (maxInputError) return `provider ${name} ${maxInputError}`; 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, diff --git a/src/server/management/provider-capability-config.ts b/src/server/management/provider-capability-config.ts index ce58966df3..9ffe7041b5 100644 --- a/src/server/management/provider-capability-config.ts +++ b/src/server/management/provider-capability-config.ts @@ -17,6 +17,17 @@ 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 ${name} ${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 8c88f2c731..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 { baseUrl: "https://relay.example/v1", modelSuppressSyntheticMax: { "gemini-3.7-flash": true, legacy: false }, }; - expect(providerManagementConfigError("relay", provider)).toBeNull(); + expect(providerSyntheticMaxConfigError("relay", provider)).toBeNull(); for (const modelSuppressSyntheticMax of [[], { model: "true" }, { "": true }]) { - expect(providerManagementConfigError("relay", { + const invalidProvider = { ...provider, modelSuppressSyntheticMax, - })).toContain("modelSuppressSyntheticMax"); + }; + expect(providerManagementConfigError("relay", invalidProvider)).toBeNull(); + expect(providerSyntheticMaxConfigError("relay", invalidProvider)).toContain("modelSuppressSyntheticMax"); } }); From 8098407764458cfb85b9b7998e2b7783098531d8 Mon Sep 17 00:00:00 2001 From: anni kock Date: Fri, 21 Aug 2026 15:49:35 +0800 Subject: [PATCH 3/7] fix(catalog): track synthetic max provenance --- src/codex/catalog/effort.ts | 16 +++++++- src/codex/catalog/sync.ts | 22 ++++++++--- structure/03_catalog-and-subagents.md | 4 +- tests/codex-catalog.test.ts | 56 +++++++++++++++++++++------ 4 files changed, 78 insertions(+), 20 deletions(-) diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index 945eff832e..4df54901b5 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; @@ -208,7 +218,8 @@ export function applyReasoningLevels( 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, @@ -218,6 +229,9 @@ export function applyReasoningLevels( // 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 (!suppressSyntheticMax && !efforts.includes("max")) additions.push("max"); diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 3297b7fcf8..5007775d53 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -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, clampCatalogModelsToCodexSupport, ensureGpt56ReasoningLevels, ensureUltraReasoningLevel, isGpt56NativeSlug, MAX_REASONING_PROVENANCE_FIELD, stampMaxReasoningProvenance } from "./effort"; import { clearGatherRoutedModelsInflight, filterCatalogVisibleModels, @@ -338,6 +338,7 @@ export function deriveEntry( 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) { @@ -393,6 +394,7 @@ export function deriveEntry( preserveExact, model?.suppressSyntheticMax === true, ); + stampMaxReasoningProvenance(entry, model?.reasoningEfforts); } else { applyReasoningLevels(entry, isGpt56NativeSlug(slug) ? undefined : ["low", "medium", "high", "xhigh"]); @@ -1110,14 +1112,22 @@ export function mergeCatalogEntriesFromObservedState({ // 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 && !suppressSyntheticMax) { - const levels = Array.isArray(e.supported_reasoning_levels) + if (!exactCombo) { + 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]; + } + if (levels.length > 0 && !levels.some(level => level.effort === "max")) { + if (!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] = "synthetic"; + } } } if (wsEnabled) e.supports_websockets = true; diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index 2dca677a6b..b6cf09ea1d 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -260,7 +260,9 @@ explicit `spawn_agent` effort overrides against catalog membership before the re 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. The catalog has no hidden spawn-only rung, so an +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. diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 11c8f73409..e9634e9f60 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -2479,6 +2479,14 @@ describe("Codex catalog routed normalization", () => { }]).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", () => { @@ -2537,25 +2545,49 @@ describe("Codex catalog routed normalization", () => { .toEqual(["low", "medium", "high", "max", "ultra"]); }); - test("degraded preservation still applies current synthetic-max policy", () => { + test("degraded preservation removes only a previously synthesized max", () => { const slug = "google/gemini-3.7-flash"; - const existing = { - slug, - owned_by: "google", - input_modalities: ["text"], - supported_reasoning_levels: ["low", "high", "ultra"].map(effort => ({ effort })), - }; - const preserved = mergeObservedForTest({ - catalogModels: [existing], + const preserve = (entry: Record, suppress: boolean) => mergeObservedForTest({ + catalogModels: [entry], routedEntries: [], template: null, gatheredProviderNames: new Set(["google"]), degradedProviderNames: new Set(["google"]), - syntheticMaxSuppressedSlugs: new Set([slug]), - }).find(entry => entry.slug === slug); + 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"); - expect((preserved?.supported_reasoning_levels as Array<{ effort: string }>).map(level => level.effort)) + const suppressed = preserve(priorSynthetic, true); + expect((suppressed?.supported_reasoning_levels as Array<{ effort: string }>).map(level => level.effort)) .toEqual(["low", "high", "ultra"]); + 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 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", () => { From 03c8df12c06d9971403659b37725d93821c443fa Mon Sep 17 00:00:00 2001 From: anni kock Date: Fri, 21 Aug 2026 15:58:23 +0800 Subject: [PATCH 4/7] fix(catalog): restore clamped provider max --- src/codex/catalog/sync.ts | 5 +++-- tests/codex-catalog.test.ts | 10 ++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 5007775d53..55c36fdad3 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -1122,11 +1122,12 @@ export function mergeCatalogEntriesFromObservedState({ delete e[MAX_REASONING_PROVENANCE_FIELD]; } if (levels.length > 0 && !levels.some(level => level.effort === "max")) { - if (!suppressSyntheticMax) { + 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] = "synthetic"; + e[MAX_REASONING_PROVENANCE_FIELD] = providerDeclaredMax ? "provider" : "synthetic"; } } } diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index e9634e9f60..b3059feb57 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -2583,6 +2583,16 @@ describe("Codex catalog routed normalization", () => { .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); From 427839fb622a0add95d25979f1a0cbcf26df769e Mon Sep 17 00:00:00 2001 From: anni kock Date: Fri, 21 Aug 2026 16:20:25 +0800 Subject: [PATCH 5/7] fix(catalog): converge clamped synthetic max --- src/codex/catalog/effort.ts | 4 ++++ tests/codex-catalog.test.ts | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index 4df54901b5..8bb4be9ac6 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -341,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/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index b3059feb57..85521c1b41 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -5587,7 +5587,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); @@ -5600,6 +5603,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); }); From 5d00f31e475ff1fe3519b44230eac76328ace920 Mon Sep 17 00:00:00 2001 From: anni kock Date: Fri, 21 Aug 2026 19:39:34 +0800 Subject: [PATCH 6/7] fix(catalog): close synthetic max review gaps --- src/codex/catalog/sync.ts | 30 +++++++++++++++--- src/codex/convergence.ts | 6 +++- .../management/provider-capability-config.ts | 5 ++- tests/codex-catalog.test.ts | 31 +++++++++++++++++++ tests/management-provider-validation.test.ts | 30 ++++++++++++++++++ 5 files changed, 96 insertions(+), 6 deletions(-) diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 55c36fdad3..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, MAX_REASONING_PROVENANCE_FIELD, stampMaxReasoningProvenance } from "./effort"; +import { applyCatalogModelMetadata, applyReasoningLevels, catalogEntryEfforts, clampedDefaultEffort, clampCatalogModelsToCodexSupport, ensureGpt56ReasoningLevels, ensureUltraReasoningLevel, isGpt56NativeSlug, MAX_REASONING_PROVENANCE_FIELD, stampMaxReasoningProvenance } from "./effort"; import { clearGatherRoutedModelsInflight, filterCatalogVisibleModels, @@ -1120,6 +1120,12 @@ export function mergeCatalogEntriesFromObservedState({ 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"; @@ -1233,13 +1239,25 @@ 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; - for (const [model, suppress] of Object.entries(entry.modelSuppressSyntheticMax ?? {})) { + 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; } @@ -1629,7 +1647,11 @@ function writeRetainedCatalogSync({ includeNativeOpenAi, accountBoundEntries, suppressedBareNativeSlugs, - syntheticMaxSuppressedSlugs: syntheticMaxSuppressedCatalogSlugs(config), + 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 0f632baeae..01b0f0adb3 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -353,7 +353,11 @@ function prepareCatalog( includeNativeOpenAi, accountBoundEntries, suppressedBareNativeSlugs, - syntheticMaxSuppressedSlugs: syntheticMaxSuppressedCatalogSlugs(config), + syntheticMaxSuppressedSlugs: syntheticMaxSuppressedCatalogSlugs(config, [ + ...catalogModels, + ...baselineCatalogModels, + ...routedEntries, + ]), policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, nativeBackfillSlugs: [...availableBareNativeSlugs, ...observedNativeSlugs], diff --git a/src/server/management/provider-capability-config.ts b/src/server/management/provider-capability-config.ts index 9ffe7041b5..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"; /** @@ -25,7 +26,9 @@ export function providerSyntheticMaxConfigError(name: unknown, provider: unknown (provider as { modelSuppressSyntheticMax?: unknown }).modelSuppressSyntheticMax, "modelSuppressSyntheticMax", ); - return error ? `provider ${name} ${error}` : null; + return error + ? `provider ${JSON.stringify(redactSecretString(name))} ${redactSecretString(error)}` + : null; } function publicServiceTierRecord(value: unknown): Record | undefined { diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 85521c1b41..a7fd66053c 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -2565,10 +2565,12 @@ describe("Codex catalog routed normalization", () => { 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, [], [{ @@ -2625,6 +2627,35 @@ describe("Codex catalog routed normalization", () => { ])); }); + 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(), diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 8aba7eb24c..4b961b07dd 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -325,6 +325,36 @@ describe("provider management validation", () => { } }); + 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 }); From 6f129c1961f9d253ab83a5d7bf6d6446003ea938 Mon Sep 17 00:00:00 2001 From: anni kock Date: Fri, 21 Aug 2026 20:24:14 +0800 Subject: [PATCH 7/7] docs(catalog): clarify client-side max validation --- docs-site/src/content/docs/reference/configuration/providers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 6332d3e9a9..52908471ff 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -96,7 +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 Codex validation for an opted-in model before wire clamping runs. This is an advanced config-file setting; Dashboard provider edits preserve it but do not expose an editor. | +| `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. |