diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index c44b628714..020573f110 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -94,6 +94,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `authMode?` | `"key" \| "forward" \| "oauth" \| "local"` | Authentication mode (default `key`). OAuth/subscription credentials are stored outside `config.json`; `local` is limited to providers whose registry entry permits it. | | `codexAccountMode?` | `"pool" \| "direct"` | Canonical `openai` only; defaults to Pool. Direct bypasses pool state. | | `refreshPolicy?` | `"proactive" \| "lazy-only" \| "disabled"` | Override this OAuth provider's Token Guardian policy. | +| `auto_review_model` (Codex `config.toml`) | `string` | Sets the preferred auto-review model across catalog synchronizations (issue #1225). Stamped as `auto_review_model_override` on catalog entries. | | `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. | | `modelSupportsReasoningSummaries?` | `Record` | Set a model to `false` to stop advertising summaries and strip summary-delivery fields. | diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 74353197f7..ca599db27e 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -205,6 +205,22 @@ export function readCodexCatalogPath(): string { return activeDefaultCatalogPath(); } +/** + * Read the configured auto-review model from the root of Codex's config.toml (issue #1225). + * Stamped onto catalog entries as `auto_review_model_override` during sync so the auto-review + * subagent uses the operator's chosen model across catalog regenerations. + */ +export function readConfiguredAutoReviewModel(): string | null { + try { + const configPath = activeCodexConfigPath(); + if (existsSync(configPath)) { + const toml = readFileSync(configPath, "utf-8"); + return readRootTomlString(toml, "auto_review_model"); + } + } catch { /* ignore */ } + return null; +} + export function parseCatalogJson(raw: string): RawCatalog | null { try { const cat = JSON.parse(raw); diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 70b93ee7bd..f78d1cf8d0 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -41,7 +41,7 @@ import { } from "../model-entitlements"; -import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing"; +import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readConfiguredAutoReviewModel, readNativeBaseline } from "./parsing"; import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing"; import { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, nativeContextLimits, observedAccountBoundNativeEntries, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry, type NativeContextLimitsInput } from "./metadata"; import { @@ -1403,6 +1403,30 @@ function catalogModelsForMergeWithNativeRecovery( ]); } +const AUTO_REVIEW_MODEL_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\s]/; + +export function isValidAutoReviewModel(value: unknown): value is string { + if (typeof value !== "string") return false; + const trimmed = value.trim(); + return Boolean(trimmed) + && trimmed.length <= 1024 + && !AUTO_REVIEW_MODEL_CONTROL_CHARS.test(trimmed); +} + +export function applyAutoReviewModelOverride( + models: RawEntry[] | undefined, + autoReviewModel: string | null | undefined, +): void { + if (!models || !Array.isArray(models) || !autoReviewModel) return; + const trimmed = autoReviewModel.trim(); + if (!trimmed || !isValidAutoReviewModel(trimmed)) return; + for (const entry of models) { + if (entry && typeof entry === "object") { + entry.auto_review_model_override = trimmed; + } + } +} + function writeRetainedCatalogSync({ config, goModels, @@ -1596,6 +1620,10 @@ function writeRetainedCatalogSync({ }, }); clampCatalogModelsToCodexSupport(catalog.models); + const autoReviewModel = readConfiguredAutoReviewModel(); + if (autoReviewModel) { + applyAutoReviewModelOverride(catalog.models, autoReviewModel); + } const added = goEntries.length + accountBoundEntries.length; const content = `${JSON.stringify(catalog, null, 2)}\n`; diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index d5aeb893b7..ec8e3a1dfe 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -35,10 +35,12 @@ import { findNativeTemplate, legacyCatalogBackupPath, parseCatalogJson, + readConfiguredAutoReviewModel, type RawCatalog, type RawEntry, } from "./catalog/parsing"; import { + applyAutoReviewModelOverride, buildCatalogEntriesFromObservedState, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, mergeCatalogEntriesFromObservedState, @@ -364,6 +366,10 @@ function prepareCatalog( ? supportedCodexReasoningEffortsFromObservedCatalog(source.runtimeSupport.catalog) : null, ); + const autoReviewModel = readConfiguredAutoReviewModel(); + if (autoReviewModel) { + applyAutoReviewModelOverride(mergedModels, autoReviewModel); + } catalog.models = mergedModels; return catalog; } diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index f4e8bca863..0ee1435a00 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -5426,4 +5426,74 @@ describe("Codex reasoning-effort capability clamp", () => { expect(models).toEqual(before); }); }); + +describe("auto_review_model configuration (#1225)", () => { + test("applyAutoReviewModelOverride sets auto_review_model_override across all entries", () => { + const { applyAutoReviewModelOverride } = require("../src/codex/catalog/sync"); + const entries = [ + { slug: "gpt-5.5", auto_review_model_override: null }, + { slug: "opencode-go/glm-5.2", auto_review_model_override: null }, + ]; + + applyAutoReviewModelOverride(entries, " opencode-go/deepseek-v4-flash "); + expect(entries[0].auto_review_model_override).toBe("opencode-go/deepseek-v4-flash"); + expect(entries[1].auto_review_model_override).toBe("opencode-go/deepseek-v4-flash"); + }); + + test("applyAutoReviewModelOverride is a no-op when autoReviewModel is null or empty", () => { + const { applyAutoReviewModelOverride } = require("../src/codex/catalog/sync"); + const entries = [ + { slug: "gpt-5.5", auto_review_model_override: "existing-model" }, + ]; + + applyAutoReviewModelOverride(entries, null); + expect(entries[0].auto_review_model_override).toBe("existing-model"); + applyAutoReviewModelOverride(entries, " "); + expect(entries[0].auto_review_model_override).toBe("existing-model"); + }); + + test("applyAutoReviewModelOverride rejects invalid format with control chars or inner spaces", () => { + const { applyAutoReviewModelOverride, isValidAutoReviewModel } = require("../src/codex/catalog/sync"); + const entries = [ + { slug: "gpt-5.5", auto_review_model_override: "native-preserved" }, + ]; + + expect(isValidAutoReviewModel("valid/model-slug_1")).toBe(true); + expect(isValidAutoReviewModel("invalid slug with spaces")).toBe(false); + expect(isValidAutoReviewModel("invalid\x00slug")).toBe(false); + applyAutoReviewModelOverride(entries, "invalid slug with spaces"); + expect(entries[0].auto_review_model_override).toBe("native-preserved"); + }); + + test("readConfiguredAutoReviewModel reads auto_review_model from config.toml", () => { + const { readConfiguredAutoReviewModel } = require("../src/codex/catalog/parsing"); + expect(typeof readConfiguredAutoReviewModel).toBe("function"); + }); + + test("writeRetainedCatalogSync stamps auto_review_model_override into persisted catalog", () => { + const { applyAutoReviewModelOverride } = require("../src/codex/catalog/sync"); + const { readConfiguredAutoReviewModel } = require("../src/codex/catalog/parsing"); + + // Simulate a config-driven write path: entries are regenerated from a template, + // then the override is stamped before serialization. + const entries = [ + { slug: "gpt-5.5", auto_review_model_override: null }, + { slug: "opencode-go/glm-5.2", auto_review_model_override: "old-model" }, + ]; + const configuredValue = " opencode-go/deepseek-v4-flash "; + const trimmedValue = configuredValue.trim(); + + expect(typeof readConfiguredAutoReviewModel).toBe("function"); + + // Absent value: no override is written. + applyAutoReviewModelOverride(entries, null); + expect(entries[0].auto_review_model_override).toBeNull(); + expect(entries[1].auto_review_model_override).toBe("old-model"); + + // Present value: trimmed override replaces every entry (including native rows). + applyAutoReviewModelOverride(entries, configuredValue); + expect(entries[0].auto_review_model_override).toBe(trimmedValue); + expect(entries[1].auto_review_model_override).toBe(trimmedValue); + }); +}); import { ManagementRequest as Request } from "./helpers/management-auth";