Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/codex/catalog/parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ export interface CatalogModel {
* "shell" leaves tool_mode unset so Codex declares top-level shell tools (exec_command).
*/
codexToolMode?: "code_mode_only" | "shell";
/** Codex auto-review (approvals) model override for this routed row. */
autoReviewModelOverride?: string;
/** Normalized upstream capability names retained for management/API consumers (#485 follow-up). */
capabilities?: string[];
/** OpenCodex-only catalog ownership marker; Codex ignores the serialized extension field. */
Expand Down
151 changes: 131 additions & 20 deletions src/codex/catalog/provider-fetch.ts

Large diffs are not rendered by default.

64 changes: 64 additions & 0 deletions src/codex/catalog/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,12 @@ export function deriveEntry(
if (model) applyCatalogMetadata(e, model.provider, model.id, model.contextCap);
applyCatalogModelMetadata(e, model);
if (model?.catalogKind) e.opencodex_catalog_kind = model.catalogKind;
// Codex auto-review (approvals) override: the provider-fetch layer already
// normalized the target to a catalog slug; stamp it verbatim. Rows without
// an override keep the template's null.
if (model?.autoReviewModelOverride) {
e.auto_review_model_override = model.autoReviewModelOverride;
}
} else {
applyNativeOpenAiContextOverride(e, contextCap);
if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(e);
Expand Down Expand Up @@ -394,6 +400,9 @@ export function deriveEntry(
if (model && isRouted) applyCatalogMetadata(entry, model.provider, model.id, model.contextCap);
applyCatalogModelMetadata(entry, model);
if (model?.catalogKind) entry.opencodex_catalog_kind = model.catalogKind;
if (model?.autoReviewModelOverride) {
entry.auto_review_model_override = model.autoReviewModelOverride;
}
if (!isRouted) applyNativeOpenAiContextOverride(entry, contextCap);
return ensureStrictCatalogFields(normalizeServiceTiers(entry), {
preserveExactInputModalities: preserveExact,
Expand Down Expand Up @@ -2007,3 +2016,58 @@ export function invalidateCodexModelsCache(options?: CodexCatalogSyncOptions): b
);
return outcome.kind === "completed" && outcome.value;
}

/**
* Final fail-closed pass over the assembled catalog: every stamped
* auto_review_model_override must name a model that is actually emitted.
* Typo'd, disabled, or allowlisted-away targets are replaced with null (one
* redacted warning per target) so every routed row keeps the same field shape
* as template-cloned entries instead of reaching Codex.
*/
export function validateAutoReviewOverridesAgainstCatalog(entries: readonly RawEntry[]): void {
// Only nonblank string slugs are emitted catalog selectors; a missing or
// malformed slug must never become a matchable "undefined"/"null" string.
const slugs = new Set(entries.flatMap(entry =>
typeof entry.slug === "string" && entry.slug.trim() !== "" ? [entry.slug] : [],
));
const warned = new Set<string>();
for (const entry of entries) {
const override = entry.auto_review_model_override;
// Wrong-shaped values must never persist: JSON.stringify would otherwise
// keep a numeric or object override unchanged. null/undefined already
// serialize as the canonical empty shape, so normalize everything else
// before the native-row preservation branch below (native rows must not
// bypass this fail-closed normalization).
if (override !== undefined && override !== null && typeof override !== "string") {
entry.auto_review_model_override = null;
}
const current = entry.auto_review_model_override;
// Native rows with a valid slug carry upstream-retained values (for example
// a Codex-side "native-upstream" selector) that opencodex must preserve
// verbatim. Preserve only a valid nonblank native string; blank strings and
// residual wrong shapes are normalized to the canonical empty shape instead
// of reaching Codex, without requiring the value to resolve to an emitted
// routed slug. Routed rows and malformed catalog rows with missing/invalid
// slugs still fall through to the fail-closed pass below.
if (!isRoutedCatalogEntry(entry) && typeof entry.slug === "string" && entry.slug.trim() !== "") {
if (typeof current === "string" && current.trim() !== "") continue;
// undefined and null are both canonical empty shapes (undefined is
// omitted by JSON.stringify, null is explicit); only residual blank
// strings and wrong-shaped values are normalized so native rows cannot
// bypass the fail-closed pass.
if (current !== undefined && current !== null) entry.auto_review_model_override = null;
continue;
}
if (typeof current !== "string") continue;
if (!slugs.has(current)) {
if (!warned.has(current)) {
warned.add(current);
console.warn(
"[opencodex] autoReviewModel override " + JSON.stringify(redactSecretString(current))
+ " does not match any catalog model; skipped.",
);
}
entry.auto_review_model_override = null;
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
3 changes: 3 additions & 0 deletions src/codex/convergence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
finalizeAutoReviewModelOverride,
mergeCatalogEntriesFromObservedState,
mergeCatalogModelsWithNativeRecovery,
validateAutoReviewOverridesAgainstCatalog,
orderForSubagents,
} from "./catalog/sync";
import { multiAgentV2EnabledFromConfigText } from "./features";
Expand Down Expand Up @@ -374,6 +375,8 @@ function prepareCatalog(
);
finalizeAutoReviewModelOverride(mergedModels, catalogModels);
catalog.models = mergedModels;
// Fail-closed final pass: an override must name a model actually emitted.
validateAutoReviewOverridesAgainstCatalog(catalog.models as RawEntry[]);
return catalog;
}

Expand Down
12 changes: 12 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
providerBaseUrlConfigError,
providerHeadersConfigError,
reasoningSummaryDeliveryRecordConfigError,
sanitizeAutoReviewOverridesForLoad,
upstreamHttpVersionConfigError,
} from "./config/provider-validation";
import {
Expand Down Expand Up @@ -500,6 +501,11 @@ const providerConfigSchema = z.object({
responsesPath: z.string().min(1).optional(),
statelessResponses: z.boolean().optional(),
requiresAdjacentResponsesToolResults: z.boolean().optional(),
autoReviewModel: z.string().trim().min(1).refine(value => !/\s/.test(value), "must not contain whitespace").optional(),
autoReviewModelOverrides: z.record(
z.string().min(1).refine(key => !/\s/.test(key), "keys must not contain whitespace"),
z.string().trim().min(1).refine(value => !/\s/.test(value), "must not contain whitespace"),
).optional(),
fastWire: fastWireSchema.nullable().optional(),
supportsServiceTier: z.boolean().optional(),
modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(),
Expand Down Expand Up @@ -534,16 +540,21 @@ const providerConfigSchema = z.object({

export { isValidProviderName, hasOwnProvider } from "./config/provider-name";
export {
autoReviewModelConfigError,
apiKeyTransportConfigError,
booleanRecordConfigError,
modelAdapterRecordConfigError,
nonBlankStringArrayConfigError,
normalizeAutoReviewModelField,
normalizeAutoReviewModelFields,
normalizeAutoReviewModelOverridesField,
normalizeNonBlankStringArray,
positiveIntegerConfigError,
positiveIntegerRecordConfigError,
providerBaseUrlConfigError,
providerHeadersConfigError,
reasoningSummaryDeliveryRecordConfigError,
sanitizeAutoReviewOverridesForLoad,
upstreamHttpVersionConfigError,
} from "./config/provider-validation";

Expand Down Expand Up @@ -1817,6 +1828,7 @@ export function loadConfig(): OcxConfig {
sanitizeAliasesForLoad(parsed);
sanitizeRetryOn429ForLoad(parsed);
sanitizeModelCostsForLoad(parsed);
sanitizeAutoReviewOverridesForLoad(parsed);
const result = configSchema.safeParse(parsed);
if (result.success) {
const config = normalizeApiKeyIds(result.data as OcxConfig);
Expand Down
119 changes: 119 additions & 0 deletions src/config/provider-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,125 @@ export function normalizeNonBlankStringArray(value: readonly string[]): string[]
return [...new Set(value.map(entry => entry.trim()))];
}

/**
* Validate the Codex auto-review model override shape at the management write
* boundary. Returns an error string, or null when the fields may be persisted.
*/
export function autoReviewModelConfigError(model: unknown, overrides: unknown): string | null {
if (model !== undefined) {
const trimmed = typeof model === "string" ? model.trim() : "";
if (trimmed === "" || /\s/.test(trimmed)) {
return "autoReviewModel must be a nonblank model id without whitespace";
}
}
if (overrides === undefined) return null;
if (!overrides || typeof overrides !== "object" || Array.isArray(overrides)) {
return "autoReviewModelOverrides must be an object mapping model ids to approval model ids";
}
for (const [key, value] of Object.entries(overrides as Record<string, unknown>)) {
const trimmedKey = key.trim();
if (trimmedKey === "" || /\s/.test(trimmedKey)) {
return "autoReviewModelOverrides keys must be nonblank model ids without whitespace";
}
const trimmedValue = typeof value === "string" ? value.trim() : "";
if (trimmedValue === "" || /\s/.test(trimmedValue)) {
return "autoReviewModelOverrides values must be nonblank model ids without whitespace";
}
}
return null;
}

/** Normalize one autoReviewModel field with PATCH-style null-to-clear semantics. */
export function normalizeAutoReviewModelField(value: unknown):
| { value: string }
| { clear: true }
| { error: string } {
if (value === null) return { clear: true };
if (typeof value !== "string" || value.trim() === "" || /\s/.test(value.trim())) {
return { error: "autoReviewModel must be a nonblank model id without whitespace, or null to clear" };
}
return { value: value.trim() };
}

/** Normalize one autoReviewModelOverrides field with PATCH-style null-to-clear semantics. */
export function normalizeAutoReviewModelOverridesField(value: unknown):
| { value: Record<string, string> }
| { clear: true }
| { error: string } {
if (value === null) return { clear: true };
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { error: "autoReviewModelOverrides must be an object mapping model ids to approval model ids, or null to clear" };
}
const cleaned: Record<string, string> = {};
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
const trimmedKey = key.trim();
const trimmedEntry = typeof entry === "string" ? entry.trim() : "";
if (trimmedKey === "" || /\s/.test(trimmedKey) || trimmedEntry === "" || /\s/.test(trimmedEntry)) {
return { error: "autoReviewModelOverrides entries must be nonblank model ids without whitespace" };
}
cleaned[trimmedKey] = trimmedEntry;
}
return { value: cleaned };
}

/**
* Trim auto-review fields in place on a provider object that already passed
* boundary validation (POST path). Returns an error string only when the
* caller skipped validation; provider-routes always validates first.
*/
export function normalizeAutoReviewModelFields(provider: {
autoReviewModel?: unknown;
autoReviewModelOverrides?: unknown;
}): string | null {
const error = autoReviewModelConfigError(provider.autoReviewModel, provider.autoReviewModelOverrides);
if (error) return error;
if (typeof provider.autoReviewModel === "string") {
provider.autoReviewModel = provider.autoReviewModel.trim();
}
if (provider.autoReviewModelOverrides !== undefined) {
const normalized = normalizeAutoReviewModelOverridesField(provider.autoReviewModelOverrides);
if ("error" in normalized) return normalized.error;
if ("value" in normalized) provider.autoReviewModelOverrides = normalized.value;
}
return null;
}

/**
* Load-time sanitizer for hand-edited configs: malformed auto-review fields are
* trimmed and dropped instead of retiring the whole config. The strict
* management write boundary still rejects bad input before it reaches disk.
*/
export function sanitizeAutoReviewOverridesForLoad(parsed: unknown): void {
if (!parsed || typeof parsed !== "object") return;
const root = parsed as Record<string, unknown>;
const providers = root.providers;
if (!providers || typeof providers !== "object" || Array.isArray(providers)) return;
for (const provider of Object.values(providers as Record<string, unknown>)) {
if (!provider || typeof provider !== "object" || Array.isArray(provider)) continue;
const row = provider as Record<string, unknown>;
if (row.autoReviewModel !== undefined) {
const value = typeof row.autoReviewModel === "string" ? row.autoReviewModel.trim() : "";
row.autoReviewModel = value !== "" && !/\s/.test(value) ? value : undefined;
}
if (row.autoReviewModelOverrides !== undefined) {
const overrides = row.autoReviewModelOverrides;
if (!overrides || typeof overrides !== "object" || Array.isArray(overrides)) {
row.autoReviewModelOverrides = undefined;
continue;
}
const cleaned: Record<string, string> = {};
for (const [key, value] of Object.entries(overrides as Record<string, unknown>)) {
const trimmedKey = key.trim();
if (trimmedKey === "" || /\s/.test(trimmedKey)) continue;
const trimmedValue = typeof value === "string" ? value.trim() : "";
if (trimmedValue === "" || /\s/.test(trimmedValue)) continue;
cleaned[trimmedKey] = trimmedValue;
}
row.autoReviewModelOverrides = Object.keys(cleaned).length > 0 ? cleaned : undefined;
}
}
}

export function booleanRecordConfigError(value: unknown, field: string): string | null {
if (value === undefined) return null;
if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
Expand Down
52 changes: 52 additions & 0 deletions src/providers/derive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,10 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
...(entry.requiresAdjacentResponsesToolResults !== undefined
? { requiresAdjacentResponsesToolResults: entry.requiresAdjacentResponsesToolResults }
: {}),
...(entry.autoReviewModel !== undefined ? { autoReviewModel: entry.autoReviewModel } : {}),
...(entry.autoReviewModelOverrides !== undefined
? { autoReviewModelOverrides: { ...entry.autoReviewModelOverrides } }
: {}),
...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}),
...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}),
...(entry.requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels: [...entry.requiresReasoningPlaceholderModels] } : {}),
Expand Down Expand Up @@ -501,6 +505,12 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
if (prov.requiresAdjacentResponsesToolResults === undefined && seed.requiresAdjacentResponsesToolResults !== undefined) {
prov.requiresAdjacentResponsesToolResults = seed.requiresAdjacentResponsesToolResults;
}
if (prov.autoReviewModel === undefined && seed.autoReviewModel !== undefined) {
prov.autoReviewModel = seed.autoReviewModel;
}
if (prov.autoReviewModelOverrides === undefined && seed.autoReviewModelOverrides !== undefined) {
prov.autoReviewModelOverrides = { ...seed.autoReviewModelOverrides };
}
// Registry-only metadata (never seeded into saved config): backfill straight from
// the entry so an explicit user value stays distinguishable from the default.
if (prov.fastWire === undefined && entry.fastWire !== undefined) {
Expand Down Expand Up @@ -602,6 +612,48 @@ function customPreset(): DerivedProviderPreset {
return { id: "custom", label: "Custom provider", adapter: "openai-chat", baseUrl: "", auth: "key" };
}

/**
* Resolve the Codex auto-review (approvals) model for one routed model id.
* Per-model overrides win over the provider-wide default; both are opt-in and
* trimmed. Returns the configured target (bare id or `provider/model` slug) or
* null when the operator left the session-model behavior untouched.
*/
export function resolveAutoReviewModel(
provider: OcxProviderConfig | undefined,
modelId: string,
): string | null {
if (!provider) return null;
const perModel = autoReviewOverrideForModel(provider.autoReviewModelOverrides, modelId);
if (typeof perModel === "string" && perModel.trim() !== "") return perModel.trim();
if (typeof provider.autoReviewModel === "string" && provider.autoReviewModel.trim() !== "") {
return provider.autoReviewModel.trim();
}
return null;
}

/** Per-model override lookup: exact model, exact :family, folded model, folded :family. */
function autoReviewOverrideForModel(
overrides: Record<string, string> | undefined,
modelId: string,
): string | undefined {
if (!overrides) return undefined;
if (Object.prototype.hasOwnProperty.call(overrides, modelId)) return overrides[modelId];
const colon = modelId.indexOf(":");
const folded = modelId.toLowerCase();
for (const [key, value] of Object.entries(overrides)) {
if (key.toLowerCase() === folded) return value;
}
// The case-insensitive family lookup must run AFTER the folded full-model
// loop: a model-specific override for a colon-suffixed id wins over the
// family default even when both keys differ only in casing.
if (colon > 0) {
const family = modelId.slice(0, colon);
const familyMatch = Object.entries(overrides).find(([key]) => key.toLowerCase() === family.toLowerCase());
if (familyMatch) return familyMatch[1];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return undefined;
}

function formatInitLabel(entry: ProviderRegistryEntry): string {
if (entry.authKind === "forward") return "OpenAI — ChatGPT login (no key; account pool default, Direct selectable)";
if (entry.authKind === "oauth") {
Expand Down
4 changes: 4 additions & 0 deletions src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,10 @@ export interface ProviderRegistryEntry {
* to stay contiguous. This is seeded/backfilled like other fixed wire capabilities.
*/
requiresAdjacentResponsesToolResults?: boolean;
/** Optional registry default for the Codex auto-review model (provider-wide). */
autoReviewModel?: string;
/** Optional registry per-model auto-review defaults (model id -> approval model id). */
autoReviewModelOverrides?: Record<string, string>;
/**
* Registry default for the provider's `service_tier` support; see
* `OcxProviderConfig.supportsServiceTier`. Registry-only: backfilled (never
Expand Down
6 changes: 6 additions & 0 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,12 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
&& registryEntry.requiresAdjacentResponsesToolResults !== undefined
? { requiresAdjacentResponsesToolResults: registryEntry.requiresAdjacentResponsesToolResults }
: {}),
...(provider.autoReviewModel === undefined && registryEntry.autoReviewModel !== undefined
? { autoReviewModel: registryEntry.autoReviewModel }
: {}),
...(provider.autoReviewModelOverrides === undefined && registryEntry.autoReviewModelOverrides !== undefined
? { autoReviewModelOverrides: { ...registryEntry.autoReviewModelOverrides } }
: {}),
...(provider.fastWire === undefined && registryEntry.fastWire !== undefined
? {
fastWire: cloneFastWire(registryEntry.fastWire),
Expand Down
Loading
Loading