Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs-site/src/content/docs/reference/configuration/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,21 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids.
| `unsafeAllowNativeLocalExec?` | `boolean` | Cursor legacy boolean, equivalent to `nativeLocalExec: "on"` only when the newer field is unset. |
| `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor local-exec policy. `off` is default; `codex-sandbox` currently fails closed like `off`. |

## Codex catalog and root `config.toml` settings

These settings belong in the root of `$CODEX_HOME/config.toml`, alongside
`approvals_reviewer`; they are not provider fields.

| Field | Type | Meaning |
| --- | --- | --- |
| `auto_review_model` | `string` | Public catalog selector in `provider/model` form, for example `opencode-go/deepseek-v4-flash`. After each catalog merge, OpenCodex resolves it against the final catalog and stamps the trimmed value as `auto_review_model_override` on catalog entries. Boundary whitespace is removed; the selector's slash-delimited components are otherwise unchanged. If the value is absent or blank, existing routed overrides are cleared and normal upstream auto-review selection is preserved. If it is syntactically invalid or absent from the final catalog (including after provider/model removal), OpenCodex fails closed for the override only: it clears the dead override, preserves normal upstream behavior, and emits a diagnostic. Re-adding the provider/model on a later sync allows the configured selector to be stamped again. |

The setting is evaluated after provider discovery, model filtering, native/account-row
projection, and merge precedence, so only a selector present in the catalog produced by
that sync can become an override. Native upstream values are preserved when the setting is
cleared or unresolved. The persisted catalog field is read by Codex for the current turn's
model, which is why a valid configured selector is copied to each applicable entry.

### FastWire B1 capability migration

Fast capability and arbitrary Chat caller-tier forwarding are independent after FastWire B1. The
Expand Down
2 changes: 1 addition & 1 deletion src/codex/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, c
export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithMetadata, resolveComboCatalogMember, configuredComboTargetModelsByProvider } from "./catalog/provider-fetch";
export { deriveComboCatalogModel, exactComboCatalogSlugs, getLastComboCatalogOmissions, resetOpenAiApiCatalogWarningStateForTests, uniqueCatalogModelsForPublicList, uniqueCatalogModelsForRawPublicList, buildComboCatalogOmission, comboCatalogOmissionReason, summarizeComboCatalogOmissions } from "./catalog/aggregation";
export type { ComboCatalogOmission, ComboCatalogOmissionReason } from "./catalog/aggregation";
export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, effectiveSubagentRoster, buildCatalogEntries, mergeCatalogEntriesFromObservedState, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache } from "./catalog/sync";
export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, effectiveSubagentRoster, buildCatalogEntries, mergeCatalogEntriesFromObservedState, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache, finalizeAutoReviewModelOverride } from "./catalog/sync";
export type { ObservedCatalogMergeInput } from "./catalog/sync";
export type { SpawnAgentSurface, SubagentRosterExclusionReason, EffectiveSubagentModel, SubagentRosterExclusion, EffectiveSubagentRoster } from "./catalog/sync";
export { accountBoundNativeDisplayName, accountBoundNativeModelSlugs, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./catalog/account-models";
16 changes: 16 additions & 0 deletions src/codex/catalog/parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,22 @@ export function readCodexCatalogPathForHome(codexHome: string): string {
return join(codexHome, "opencodex-catalog.json");
}

/**
* 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);
Expand Down
134 changes: 129 additions & 5 deletions src/codex/catalog/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, readCodexCatalogPathForHome, 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, readCodexCatalogPathForHome, 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 {
Expand Down Expand Up @@ -1403,6 +1403,130 @@ 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 type AutoReviewModelOverrideResult = "absent" | "applied" | "invalid" | "unresolved";

function isRoutedCatalogEntry(entry: RawEntry): boolean {
const slug = typeof entry.slug === "string" ? entry.slug : "";
return slug.includes("/")
|| (typeof entry.description === "string" && entry.description.startsWith("Routed via opencodex → "));
}

function clearAutoReviewModelOverride(
models: readonly RawEntry[],
sourceModels: readonly RawEntry[] = [],
): void {
const observedModels = [...models, ...sourceModels];
const configuredValues = new Set(observedModels.flatMap(entry => {
const value = entry?.auto_review_model_override;
return typeof value === "string" && value.trim() ? [value] : [];
}));
const globalStamp = configuredValues.size === 1
&& observedModels.some(entry => {
const value = entry.auto_review_model_override;
return isRoutedCatalogEntry(entry)
&& typeof value === "string"
&& value.trim().length > 0
&& configuredValues.has(value);
})
&& observedModels.every(entry => {
const value = entry?.auto_review_model_override;
return value === null
|| value === undefined
|| (typeof value === "string" && configuredValues.has(value));
});
for (const entry of models) {
if (!entry || typeof entry !== "object") continue;
const current = entry.auto_review_model_override;
if (isRoutedCatalogEntry(entry)
|| (globalStamp && typeof current === "string" && configuredValues.has(current))) {
entry.auto_review_model_override = null;
}
}
}

function warnAutoReviewModelDiagnostic(
reason: "invalid" | "unresolved",
configured: string,
): void {
const safeConfigured = JSON.stringify(redactSecretString(configured));
const detail = reason === "unresolved"
? "the selector was not found in the final catalog"
: "the selector format is invalid";
console.warn(
`[opencodex] auto_review_model ${detail} (${safeConfigured}); preserving normal upstream auto-review behavior.`,
);
}

function preserveNativeAutoReviewModelOverrides(
models: readonly RawEntry[],
sourceModels: readonly RawEntry[],
): void {
const existing = new Map<string, string | null>();
for (const entry of sourceModels) {
const slug = typeof entry.slug === "string" ? entry.slug : undefined;
const value = entry.auto_review_model_override;
if (!slug || isRoutedCatalogEntry(entry)) continue;
if (typeof value === "string" || value === null) existing.set(slug, value);
}
for (const entry of models) {
const slug = typeof entry.slug === "string" ? entry.slug : undefined;
if (!slug || isRoutedCatalogEntry(entry) || !existing.has(slug)) continue;
entry.auto_review_model_override = existing.get(slug) ?? null;
}
}

export function applyAutoReviewModelOverride(
models: RawEntry[] | undefined,
autoReviewModel: string | null | undefined,
sourceModels: readonly RawEntry[] = [],
): AutoReviewModelOverrideResult {
if (!models || !Array.isArray(models)) return "absent";
if (autoReviewModel === null || autoReviewModel === undefined) {
clearAutoReviewModelOverride(models, sourceModels);
return "absent";
}
const trimmed = autoReviewModel.trim();
if (!trimmed) {
clearAutoReviewModelOverride(models, sourceModels);
return "absent";
}
if (!isValidAutoReviewModel(trimmed)) {
clearAutoReviewModelOverride(models, sourceModels);
warnAutoReviewModelDiagnostic("invalid", trimmed);
return "invalid";
}
if (!configuredCatalogEntry(models, trimmed)) {
clearAutoReviewModelOverride(models, sourceModels);
warnAutoReviewModelDiagnostic("unresolved", trimmed);
return "unresolved";
}
for (const entry of models) {
if (entry && typeof entry === "object") {
entry.auto_review_model_override = trimmed;
}
}
return "applied";
}

/** Apply the root Codex auto-review selector after the final catalog merge. */
export function finalizeAutoReviewModelOverride(
models: RawEntry[] | undefined,
sourceModels: readonly RawEntry[] = [],
): AutoReviewModelOverrideResult {
if (models && sourceModels.length > 0) preserveNativeAutoReviewModelOverrides(models, sourceModels);
return applyAutoReviewModelOverride(models, readConfiguredAutoReviewModel(), sourceModels);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function writeRetainedCatalogSync({
config,
goModels,
Expand Down Expand Up @@ -1596,6 +1720,7 @@ function writeRetainedCatalogSync({
},
});
clampCatalogModelsToCodexSupport(catalog.models);
finalizeAutoReviewModelOverride(catalog.models, catalogModelsForMerge);

const added = goEntries.length + accountBoundEntries.length;
const content = `${JSON.stringify(catalog, null, 2)}\n`;
Expand Down Expand Up @@ -1832,12 +1957,11 @@ export function invalidateCodexModelsCacheWithPermit(
// The catalog-only sync override applies here too so an explicit refresh
// keeps the cache consistent with the catalog it just wrote.
if (!shouldSyncCodexOnStart(loadConfig()) && options?.allowWhenDesiredDisabled !== true) return false;
const catalogPath = readCodexCatalogPathForHome(owningCodexHome);
const cachePath = join(owningCodexHome, "models_cache.json");
const catalogPath = readCodexCatalogPath();
if (!existsSync(catalogPath)) return false;
const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
const models = catalog.models ?? catalog;
const currentCache = readCatalog(cachePath);
const currentCache = readCatalog(activeCodexModelsCachePath());
const existingSlugs = new Set(models.flatMap((entry: RawEntry) =>
typeof entry.slug === "string" ? [entry.slug] : []));
const currentConfig = loadConfig();
Expand Down Expand Up @@ -1865,7 +1989,7 @@ export function invalidateCodexModelsCacheWithPermit(
models: [...models, ...observedAccountModels],
};
replaceCodexModelsCache(permit, owningCodexHome, {
path: cachePath,
path: activeCodexModelsCachePath(),
content: `${JSON.stringify(wrapper, null, 2)}\n`,
});
return true;
Expand Down
2 changes: 2 additions & 0 deletions src/codex/convergence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
import {
buildCatalogEntriesFromObservedState,
CANONICAL_NATIVE_CATALOG_CONTENT_POLICY,
finalizeAutoReviewModelOverride,
mergeCatalogEntriesFromObservedState,
mergeCatalogModelsWithNativeRecovery,
orderForSubagents,
Expand Down Expand Up @@ -369,6 +370,7 @@ function prepareCatalog(
? supportedCodexReasoningEffortsFromObservedCatalog(source.runtimeSupport.catalog)
: null,
);
finalizeAutoReviewModelOverride(mergedModels, catalogModels);
catalog.models = mergedModels;
return catalog;
}
Expand Down
73 changes: 73 additions & 0 deletions tests/codex-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5667,4 +5667,77 @@ 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/deepseek-v4-flash", 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 clears routed state 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" },
{ slug: "opencode-go/glm-5.2", auto_review_model_override: "old-model" },
];

applyAutoReviewModelOverride(entries, null);
expect(entries[0].auto_review_model_override).toBe("existing-model");
expect(entries[1].auto_review_model_override).toBeNull();
applyAutoReviewModelOverride(entries, " ");
expect(entries[0].auto_review_model_override).toBe("existing-model");
expect(entries[1].auto_review_model_override).toBeNull();
});

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");
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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/deepseek-v4-flash", 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).toBeNull();

// 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";
Loading
Loading