From 762ac39a23ef1be0ceb5be33af5d1e620e768cd3 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:55:14 +0900 Subject: [PATCH 01/12] feat(catalog): select approval reviewers per provider and model Carry #4100 with case-preserving keys, normalized editor adoption, and recoverable native root-stamp provenance. Co-authored-by: HarryZhou <2373256746@qq.com> --- .../src/content/docs/guides/providers.md | 42 ++ .../docs/reference/configuration/providers.md | 44 ++ src/codex/catalog/sync.ts | 399 +++++++++++++++++- src/codex/convergence.ts | 2 +- src/config.ts | 62 +++ src/config/provider-validation.ts | 84 +++- src/server/auth-cors.ts | 11 + src/server/management/provider-routes.ts | 97 ++++- src/types/provider.ts | 22 + structure/adapters/registry.md | 1 + structure/catalog.md | 3 + structure/clients/claude-desktop.md | 1 + structure/codex-home.md | 1 + structure/config.md | 1 + structure/data-planes/images.md | 1 + structure/data-planes/inbound-compat.md | 1 + structure/gui-and-management-api.md | 1 + structure/ops/docs-and-release.md | 1 + structure/ops/service-and-sidecars.md | 1 + structure/overview.md | 1 + structure/providers/openai-tiers.md | 1 + structure/providers/xai-grok.md | 1 + structure/runtime.md | 1 + structure/subagents.md | 1 + structure/transports/inventory.md | 1 + structure/transports/responses.md | 1 + structure/transports/streaming-health.md | 1 + tests/codex-integration/codex-catalog.test.ts | 399 ++++++++++++++++++ ...odex-convergence-account-selectors.test.ts | 53 +++ tests/config/config-load-degrade.test.ts | 36 ++ .../management-provider-validation.test.ts | 287 ++++++++++++- 31 files changed, 1534 insertions(+), 24 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index a093f22dc6..3e8d9ef398 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -999,6 +999,48 @@ dashboard or `custom` in `ocx init` and enter the base URL. See the [Configuration reference](/reference/configuration/) for every provider field (`headers`, `noReasoningModels`, `noVisionModels`, `models`, …). +## Approval reviewer per provider + +Codex asks a second model to review approval requests, and takes that reviewer from +`auto_review_model_override` on the catalog row of the current turn's model. The root +`auto_review_model` in `$CODEX_HOME/config.toml` applies one reviewer to every row. To give a +routed provider its own — usually cheaper — reviewer, set the selector on that provider row in +`~/.opencodex/config.json`: + +```json +{ + "providers": { + "blsc": { + "autoReviewModel": "opencode-go/deepseek-v4-flash", + "autoReviewModelOverrides": { "kimi-k3": "gpt-5.6-terra" } + } + } +} +``` + +`autoReviewModel` covers every routed row of the provider. `autoReviewModelOverrides` targets a +single upstream model id and wins over it. A value is either a bare model id of that same provider +or a public catalog slug such as `opencode-go/deepseek-v4-flash`, and a provider stamp wins over the +root selector on its own rows while the root selector stays the fallback elsewhere. + +A bare value resolves against the provider's own rows first and then against a bare catalog row, +which is how a native model such as `gpt-5.6-terra` is named; a value that matches neither is left +unresolved, and a bare value that lands outside the provider prints a note naming the row that +supplies the reviewer. Giving the full slug avoids the question entirely when the reviewer is +another provider's routed model. + +Selectors are resolved against the final catalog on the next sync, each one on its own, and each +fails closed by itself: an unresolved `autoReviewModel` prints a diagnostic and stamps no +provider-wide rows, an unresolved `autoReviewModelOverrides` entry prints a diagnostic and stamps +no per-model override, leaving a valid provider-wide target as fallback. Whatever resolves is still applied. Rows without a provider stamp +keep the root selector, or upstream behavior when that is unset. Removing the root selector leaves +provider stamps alone, and removing a provider selector clears only that provider's stamps. + +These fields are available through configuration, `PATCH /api/providers?name=`, and +the dashboard raw JSON provider editor; dedicated form controls are not present. The canonical `openai` provider +rejects them. Field-by-field rules live in the +[provider configuration reference](/reference/configuration/providers/#auto-review-approval-model-selection). + ## Rate limits in the providers overview The **Rate limits** section of the Providers overview shows live utilization diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 23b7e5e92d..f042b3fedb 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -260,6 +260,49 @@ use `null` to clear a scalar or the whole map. A map entry set to `null` or `""` entry while preserving other entries. Malformed writes are rejected before saving. A malformed optional pin in a hand-edited file is ignored on load without discarding the rest of the config. +### Auto-review (approval) model selection + +Codex reads `auto_review_model_override` from the catalog row of the current turn's model to +choose the model that reviews approval requests. The root `auto_review_model` setting in +`$CODEX_HOME/config.toml` applies one reviewer to every catalog row; the provider-scoped fields +below override it per provider. The [provider guide](/guides/providers/#approval-reviewer-per-provider) +has the operator workflow and a worked example. + +`autoReviewModel` is the provider-wide reviewer target. A value can be a bare model id of that same +provider (the catalog row is normalized to the `provider/model` slug) or a full public catalog +slug such as `opencode-go/deepseek-v4-flash`. A bare value resolves against that provider's rows +first and then against a bare catalog row, which is how a native model such as `gpt-5.6-terra` is +named, and a bare value that lands outside the provider prints a note naming the row that actually +supplies the reviewer; a value that matches neither is left unresolved. `autoReviewModelOverrides` +keys are exact upstream model ids of that provider, or the provider's published alias for one +(`modelAliases`); either spelling names the same routed row, whose slug carries the upstream id. An +entry wins over the provider-wide value for its model. A provider +stamp wins over the root selector on its own routed rows, and the root selector remains the +fallback for native rows and routed rows without a provider stamp. Removing a provider selector +clears only that provider's stamps; removing the root selector never clears provider stamps. +Model ids that contain a slash may be written raw or in their encoded catalog form; both +spellings resolve to the same routed row. Model keys preserve case. +Selectors are resolved against the final catalog on each sync, independently of one another, and +each fails closed on its own: an unresolved `autoReviewModel` emits a diagnostic and stamps no +provider-wide rows, an unresolved `autoReviewModelOverrides` entry emits a diagnostic and stamps +no per-model override, so a valid provider-wide target remains its fallback. Any selector that does resolve is still applied. Rows without a provider +stamp keep the root selector, or normal upstream auto-review behavior when that is unset. The +canonical `openai` provider does not accept these fields. + +Removing the root selector clears root stamps from every row, including native rows stamped by +earlier releases that predate OpenCodex's provenance marker. That cleanup recognizes a legacy +stamp by its shape — one value across the whole catalog that a routed row also carries — so a +genuine per-row value matching that shape is cleared with it, and a catalog that has since +diverged from that shape needs one manual sync. Provider stamps are never touched by root removal. + +`PATCH /api/providers?name=` accepts both fields. Use `null` to clear the scalar or +the whole map; use a map entry of `null` or `""` to remove that model while preserving other +entries. Unrelated provider saves preserve previously configured values. + +These fields are available in `config.json`, the provider management API, and the Dashboard raw +JSON provider editor. Dedicated form controls are not present. Native root stamps record the +previous value and restore it on removal when the stamped value has not been changed externally. + ### Discovered model display names Use `modelDisplayNames` when a provider returns machine friendly ids but the Codex model picker @@ -328,6 +371,7 @@ projection, and merge precedence, so only a selector present in the catalog prod 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. +Provider-scoped selectors (above) are applied before this root fallback and win on routed rows. ### FastWire B1 capability migration diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 2f756663ad..f321cbc1a8 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -16,7 +16,8 @@ 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 { canonicalAutoReviewModelKey, isValidAutoReviewModel as isValidAutoReviewTarget } from "../../config/provider-validation"; import { identifyRoutedModel } from "../../adapters/identity"; import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; @@ -1586,29 +1587,58 @@ function catalogModelsForMergeWithNativeRecovery( ]); } -const AUTO_REVIEW_MODEL_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\s]/; +const AUTO_REVIEW_ROOT_MARKER = "opencodex_auto_review_root"; +interface RootAutoReviewStamp { + slug: string; + original: string | null; + applied: string; +} + +function rootAutoReviewStamp(entry: RawEntry): RootAutoReviewStamp | undefined { + const value = entry[AUTO_REVIEW_ROOT_MARKER]; + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const stamp = value as Record; + if (stamp.slug !== entry.slug || typeof stamp.slug !== "string" + || typeof stamp.applied !== "string" + || (stamp.original !== null && typeof stamp.original !== "string")) return undefined; + return stamp as unknown as RootAutoReviewStamp; +} + + +/** True when the value is a valid Codex catalog auto-review selector. */ 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); + return isValidAutoReviewTarget(value); } export type AutoReviewModelOverrideResult = "absent" | "applied" | "invalid" | "unresolved"; +/** True when a catalog row was synthesized by opencodex instead of coming from upstream. */ 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]; +/** Remove an override and its root-derived provenance marker from one catalog row. */ +function clearAutoReviewOverrideValue(entry: RawEntry): void { + const stamp = rootAutoReviewStamp(entry); + if (stamp) { + if (entry.auto_review_model_override === stamp.applied) entry.auto_review_model_override = stamp.original; + } else { + entry.auto_review_model_override = null; + } + delete entry[AUTO_REVIEW_ROOT_MARKER]; +} + +/** + * Legacy whole-catalog root stamp: releases before AUTO_REVIEW_ROOT_MARKER wrote root stamps that + * are textually identical to an upstream value, so the only way to recognize one is the uniform + * signature the no-provider path relies on — a single value that a routed row also carries. + * Returns the stamped values when the observed rows match that shape. + */ +function legacyRootStampValues(observedModels: readonly RawEntry[]): ReadonlySet | undefined { + if (observedModels.some(entry => entry?.[AUTO_REVIEW_ROOT_MARKER] !== undefined)) return undefined; const configuredValues = new Set(observedModels.flatMap(entry => { const value = entry?.auto_review_model_override; return typeof value === "string" && value.trim() ? [value] : []; @@ -1627,16 +1657,48 @@ function clearAutoReviewModelOverride( || value === undefined || (typeof value === "string" && configuredValues.has(value)); }); + return globalStamp ? configuredValues : undefined; +} + +/** + * Sweep legacy root stamps off the rows a root removal owns, before provider plans land. + * + * Root removal reaches marker-tagged native rows on its own, but a catalog written before the + * marker only carries the legacy signature — and provider stamping rewrites that signature before + * the root pass could read it, so the sweep has to run first. + */ +function clearLegacyRootStamps(models: readonly RawEntry[], sourceModels: readonly RawEntry[] = []): void { + const legacyStamp = legacyRootStampValues([...models, ...sourceModels]); + if (legacyStamp === undefined) return; + for (const entry of models) { + if (!entry || typeof entry !== "object") continue; + const current = entry.auto_review_model_override; + if (entry[AUTO_REVIEW_ROOT_MARKER] === undefined + && typeof current === "string" && legacyStamp.has(current)) clearAutoReviewOverrideValue(entry); + } +} + +/** + * Clear the root selector from every row this path owns: routed rows, rows stamped by a release + * that writes the provenance marker, and the legacy whole-catalog stamp that predates it. + */ +function clearAutoReviewModelOverride( + models: readonly RawEntry[], + sourceModels: readonly RawEntry[] = [], +): void { + const legacyStamp = legacyRootStampValues([...models, ...sourceModels]); 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; + || (entry[AUTO_REVIEW_ROOT_MARKER] === true || rootAutoReviewStamp(entry) !== undefined) + || (legacyStamp !== undefined && typeof current === "string" && legacyStamp.has(current))) { + clearAutoReviewOverrideValue(entry); } } } +/** Warn once about a malformed or unresolvable root auto-review selector. */ function warnAutoReviewModelDiagnostic( reason: "invalid" | "unresolved", configured: string, @@ -1650,24 +1712,88 @@ function warnAutoReviewModelDiagnostic( ); } +/** Warn once about a malformed or unresolvable provider-scoped auto-review selector. */ +function warnProviderAutoReviewModelDiagnostic( + reason: "invalid" | "unresolved", + provider: string, + configured: string, +): void { + const safeProvider = JSON.stringify(redactSecretString(provider)); + 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 for provider ${safeProvider} ${detail} (${safeConfigured}); using the next valid provider/root selector or upstream behavior.`, + ); +} + +/** + * Note once when a bare selector resolves to a row outside the provider it was configured on. + * + * That is how a native model is named as a reviewer, so it stays usable, but a mistyped target must + * not be silent: the operator sees which catalog row actually supplies the reviewer. + */ +function warnProviderAutoReviewForeignTarget(provider: string, configured: string, target: string): void { + const safeProvider = JSON.stringify(redactSecretString(provider)); + const safeConfigured = JSON.stringify(redactSecretString(configured)); + const safeTarget = JSON.stringify(redactSecretString(target)); + console.warn( + `[opencodex] auto_review_model for provider ${safeProvider} (${safeConfigured}) resolved to ${safeTarget}, which is not a row of that provider; that catalog row supplies the reviewer.`, + ); +} + +/** Preserve native upstream overrides and the root-derived provenance marker from source rows. */ function preserveNativeAutoReviewModelOverrides( models: readonly RawEntry[], sourceModels: readonly RawEntry[], ): void { - const existing = new Map(); + const existing = new Map(); 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); + if (typeof value === "string" || value === null) { + existing.set(slug, { value, root: rootAutoReviewStamp(entry) ?? (entry[AUTO_REVIEW_ROOT_MARKER] === true ? true : undefined) }); + } } 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; + const saved = existing.get(slug)!; + entry.auto_review_model_override = saved.value; + if (saved.root) entry[AUTO_REVIEW_ROOT_MARKER] = structuredClone(saved.root); + else delete entry[AUTO_REVIEW_ROOT_MARKER]; + } +} + +/** Stamp a root-derived override and mark native rows so later root removal is durable. */ +function stampRootAutoReviewOverride(entry: RawEntry, target: string): void { + if (!isRoutedCatalogEntry(entry)) { + const previous = rootAutoReviewStamp(entry); + const current = entry.auto_review_model_override; + entry[AUTO_REVIEW_ROOT_MARKER] = { + slug: typeof entry.slug === "string" ? entry.slug : "", + original: previous && current === previous.applied + ? previous.original : typeof current === "string" ? current : null, + applied: target, + } satisfies RootAutoReviewStamp; + } else { + delete entry[AUTO_REVIEW_ROOT_MARKER]; } + entry.auto_review_model_override = target; +} + +/** Stamp a provider-derived override; provider stamps never fall under root removal. */ +function stampProviderAutoReviewOverride(entry: RawEntry, target: string): void { + entry.auto_review_model_override = target; + delete entry[AUTO_REVIEW_ROOT_MARKER]; } +/** + * Apply the root Codex auto-review selector to every catalog row, or clear it when the value is + * absent, blank, malformed, or does not resolve against the assembled catalog. + */ export function applyAutoReviewModelOverride( models: RawEntry[] | undefined, autoReviewModel: string | null | undefined, @@ -1695,21 +1821,254 @@ export function applyAutoReviewModelOverride( } for (const entry of models) { if (entry && typeof entry === "object") { - entry.auto_review_model_override = trimmed; + stampRootAutoReviewOverride(entry, trimmed); + } + } + return "applied"; +} + +/** Validated provider-scoped target with both the configured spelling and catalog slug. */ +interface ValidProviderReviewTarget { + configured: string; + target: string; +} + +/** One provider's resolved provider-wide and per-model auto-review targets. */ +interface ProviderReviewPlan { + wide?: ValidProviderReviewTarget; + perModel: Map; +} + +/** Public provider namespace of a routed catalog row, when it has one. */ +function catalogEntryProviderName(entry: RawEntry): string | undefined { + const slug = typeof entry.slug === "string" ? entry.slug : ""; + const slash = slug.indexOf("/"); + return slash > 0 && isRoutedCatalogEntry(entry) ? slug.slice(0, slash) : undefined; +} + +/** Encoded model-id segment of a routed catalog row, when it has one. */ +function catalogEntryModelSegment(entry: RawEntry): string | undefined { + const slug = typeof entry.slug === "string" ? entry.slug : ""; + const slash = slug.indexOf("/"); + return slash > 0 ? slug.slice(slash + 1) : undefined; +} + +/** Case-preserving encoded key used to match per-model override maps. */ +function providerModelKey(modelId: string): string { + return canonicalAutoReviewModelKey(modelId); +} + +/** + * True when another routed row of this provider already carries `alias` as its own model id. + * + * The alias API validates against whatever ids discovery has reported so far, so on a cold start an + * alias can be persisted that later turns out to name a different row. A key using it is then not + * an alternate spelling of the aliased model — it is that row's id — and must not be propagated. + */ +function aliasNamesAnotherRoutedRow(models: readonly RawEntry[], provider: string, alias: string): boolean { + const encoded = encodeRoutedModelId(alias); + return models.some(entry => isRoutedCatalogEntry(entry) + && catalogEntryProviderName(entry) === provider + && catalogEntryModelSegment(entry) === encoded); +} + +/** Resolve one configured target against the assembled catalog; bare values name a model of the same provider. */ +function resolveProviderReviewTarget( + models: readonly RawEntry[], + provider: string, + configuredRaw: unknown, +): { kind: "valid"; value: ValidProviderReviewTarget; foreign?: boolean } | { kind: "invalid"; configured: string } | { kind: "unresolved"; configured: string } | { kind: "absent" } { + if (typeof configuredRaw !== "string") return { kind: "absent" }; + const configured = configuredRaw.trim(); + if (!configured) return { kind: "absent" }; + if (!isValidAutoReviewModel(configured)) return { kind: "invalid", configured }; + const prefix = `${provider}/`; + let match: RawEntry | undefined; + const sameProviderCandidate = (rawModelId: string): RawEntry | undefined => models.find(entry => { + if (!isRoutedCatalogEntry(entry) || typeof entry.slug !== "string" || !entry.slug.startsWith(prefix)) return false; + const segment = catalogEntryModelSegment(entry); + return segment !== undefined && segment === encodeRoutedModelId(rawModelId); + }); + // A bare selector names a model of this provider. A full selector that resolves in the + // assembled catalog already names the exact row, including a same-provider encoded slug. + if (!configured.includes("/")) { + match = sameProviderCandidate(configured); + } + match ??= configuredCatalogEntry(models, configured); + if (!match && configured.startsWith(prefix)) { + match = sameProviderCandidate(configured.slice(prefix.length)); + } + if (!match) { + // A raw model id may itself contain "/" (for example zenmux moonshotai/kimi-k3). + // After the full-selector lookup misses, try that spelling as a same-provider id. + match = sameProviderCandidate(configured); + } + if (!match) return { kind: "unresolved", configured }; + const target = typeof match.slug === "string" ? match.slug : configured; + // A qualified selector may name another provider's row on purpose; only a bare value that lands + // outside this provider is worth reporting. + const foreign = !configured.includes("/") && catalogEntryProviderName(match) !== provider; + return { kind: "valid", value: { configured, target }, ...(foreign ? { foreign: true } : {}) }; +} + +/** Build resolved per-provider plans and emit one diagnostic per bad selector. */ +function buildProviderReviewPlans( + models: readonly RawEntry[], + config: Pick, +): { plans: Map; failure?: "invalid" | "unresolved" } { + const plans = new Map(); + let failure: "invalid" | "unresolved" | undefined; + const warned = new Set(); + const recordFailure = (kind: "invalid" | "unresolved", provider: string, configured: string): void => { + const signature = `${provider}\u0000${configured}`; + if (warned.has(signature)) return; + warned.add(signature); + warnProviderAutoReviewModelDiagnostic(kind, provider, configured); + failure ??= kind; + }; + const recordForeignTarget = (provider: string, configured: string, target: string): void => { + const signature = `${provider}\u0000foreign\u0000${configured}`; + if (warned.has(signature)) return; + warned.add(signature); + warnProviderAutoReviewForeignTarget(provider, configured, target); + }; + for (const [name, provider] of Object.entries(config.providers ?? {})) { + if (provider.autoReviewModel === undefined && provider.autoReviewModelOverrides === undefined) continue; + const plan: ProviderReviewPlan = { perModel: new Map() }; + if (provider.autoReviewModel !== undefined) { + const resolved = resolveProviderReviewTarget(models, name, provider.autoReviewModel); + if (resolved.kind === "valid") { + plan.wide = resolved.value; + if (resolved.foreign) recordForeignTarget(name, resolved.value.configured, resolved.value.target); + } + else if (resolved.kind !== "absent") recordFailure(resolved.kind, name, resolved.configured); + } + if (provider.autoReviewModelOverrides !== undefined) { + for (const [modelId, rawTarget] of Object.entries(provider.autoReviewModelOverrides)) { + const resolved = resolveProviderReviewTarget(models, name, rawTarget); + if (resolved.kind === "valid") { + plan.perModel.set(providerModelKey(modelId), resolved.value); + if (resolved.foreign) recordForeignTarget(name, resolved.value.configured, resolved.value.target); + } else if (resolved.kind !== "absent") { + recordFailure(resolved.kind, name, resolved.configured); + } + } + } + // `modelAliases` publishes a second public name for a model id, and a routed row's slug always + // carries the upstream id — so accept an override key written in either spelling. + for (const [modelId, alias] of Object.entries(provider.modelAliases ?? {})) { + if (typeof alias !== "string" || !alias.trim()) continue; + if (aliasNamesAnotherRoutedRow(models, name, alias)) continue; + const idKey = providerModelKey(modelId); + const aliasKey = providerModelKey(alias); + if (idKey === aliasKey) continue; + const fromId = plan.perModel.get(idKey); + const fromAlias = plan.perModel.get(aliasKey); + if (fromId !== undefined && fromAlias === undefined) plan.perModel.set(aliasKey, fromId); + else if (fromAlias !== undefined && fromId === undefined) plan.perModel.set(idKey, fromAlias); } + if (plan.wide !== undefined || plan.perModel.size > 0) plans.set(name, plan); + } + return { plans, failure }; +} + +/** Apply or clear the root selector only on rows without a provider stamp. */ +function applyRootSelectorToRemaining( + models: readonly RawEntry[], + rootValue: string | null | undefined, + providerStamped: ReadonlySet, +): AutoReviewModelOverrideResult { + const clearRemaining = (): void => { + for (const entry of models) { + if (!entry || providerStamped.has(entry)) continue; + // Native rows written by releases before the root marker cannot be told apart from upstream + // values once provider stamps diverge. clearLegacyRootStamps sweeps the ones the legacy + // uniform signature still recognizes before provider plans land, because provider stamping + // destroys that signature; a catalog that no longer matches it needs a one-off manual sync. + if (isRoutedCatalogEntry(entry) || entry[AUTO_REVIEW_ROOT_MARKER] === true || rootAutoReviewStamp(entry)) clearAutoReviewOverrideValue(entry); + } + }; + if (rootValue === null || rootValue === undefined) { + clearRemaining(); + return "absent"; + } + const trimmed = rootValue.trim(); + if (!trimmed) { + clearRemaining(); + return "absent"; + } + if (!isValidAutoReviewModel(trimmed)) { + clearRemaining(); + warnAutoReviewModelDiagnostic("invalid", trimmed); + return "invalid"; + } + if (!configuredCatalogEntry(models, trimmed)) { + clearRemaining(); + warnAutoReviewModelDiagnostic("unresolved", trimmed); + return "unresolved"; + } + for (const entry of models) { + if (!entry || providerStamped.has(entry)) continue; + stampRootAutoReviewOverride(entry, trimmed); } return "applied"; } +/** Provider-aware variant: provider rows win and the root selector is the fallback. */ +export function applyConfiguredAutoReviewModelOverride( + models: RawEntry[] | undefined, + rootAutoReviewModel: string | null | undefined, + config: Pick, + sourceModels: readonly RawEntry[] = [], +): AutoReviewModelOverrideResult { + if (!models || !Array.isArray(models)) return "absent"; + // Runs unconditionally because the sweep only fires on the uniform legacy signature. A resolved + // root selector restamps every row it touches below, so the call is behavior-preserving there; + // with the root absent, invalid, or unresolved those clears are final — which is the point, and + // also the limit: the legacy heuristic cannot tell a root stamp from an identical upstream value. + clearLegacyRootStamps(models, sourceModels); + const { plans, failure } = buildProviderReviewPlans(models, config); + const providerStamped = new Set(); + for (const entry of models) { + if (!entry || typeof entry !== "object") continue; + const provider = catalogEntryProviderName(entry); + if (!provider) continue; + const plan = plans.get(provider); + if (!plan) continue; + const modelSegment = catalogEntryModelSegment(entry); + const perModel = modelSegment === undefined ? undefined : plan.perModel.get(providerModelKey(modelSegment)); + const selected = perModel ?? plan.wide; + if (!selected) continue; + stampProviderAutoReviewOverride(entry, selected.target); + providerStamped.add(entry); + } + const rootResult = applyRootSelectorToRemaining(models, rootAutoReviewModel, providerStamped); + const providerApplied = [...providerStamped].some(entry => typeof entry.auto_review_model_override === "string"); + if (providerApplied) { + if (rootResult === "invalid" || rootResult === "unresolved") return rootResult; + return failure ?? "applied"; + } + return failure ?? rootResult; +} + +/** True when any provider row configures a provider-scoped auto-review selector. */ +function configHasProviderAutoReview(config: Pick): boolean { + return Object.values(config.providers ?? {}).some(provider => + provider.autoReviewModel !== undefined || provider.autoReviewModelOverrides !== undefined); +} + /** Apply the root Codex auto-review selector after the final catalog merge. */ export function finalizeAutoReviewModelOverride( models: RawEntry[] | undefined, sourceModels: readonly RawEntry[] = [], + config?: Pick, ): AutoReviewModelOverrideResult { if (models && sourceModels.length > 0) preserveNativeAutoReviewModelOverrides(models, sourceModels); + if (config && configHasProviderAutoReview(config)) { + return applyConfiguredAutoReviewModelOverride(models, readConfiguredAutoReviewModel(), config, sourceModels); + } return applyAutoReviewModelOverride(models, readConfiguredAutoReviewModel(), sourceModels); } - /** * Why an account-gated native model stopped being offered, but only when the answer is one the * operator can act on. @@ -2028,7 +2387,7 @@ function writeRetainedCatalogSync({ }, }); clampCatalogModelsToCodexSupport(catalog.models); - finalizeAutoReviewModelOverride(catalog.models, catalogModelsForMerge); + finalizeAutoReviewModelOverride(catalog.models, catalogModelsForMerge, config); 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 2b8a8512c6..9c844fad4a 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -385,7 +385,7 @@ function prepareCatalog( ? supportedCodexReasoningEffortsFromObservedCatalog(source.runtimeSupport.catalog) : null, ); - finalizeAutoReviewModelOverride(mergedModels, catalogModels); + finalizeAutoReviewModelOverride(mergedModels, catalogModels, config); catalog.models = mergedModels; return catalog; } diff --git a/src/config.ts b/src/config.ts index 5e81a7e5f1..8c658b0845 100644 --- a/src/config.ts +++ b/src/config.ts @@ -14,8 +14,11 @@ import { pinnedReasoningEffortConfigError, modelAdapterRecordConfigError, modelDisplayNamesConfigError, + autoReviewModelOverridesConfigError, + autoReviewModelTargetConfigError, nonBlankStringArrayConfigError, normalizeNonBlankStringArray, + normalizeAutoReviewModelOverrides, positiveIntegerConfigError, positiveIntegerRecordConfigError, providerBaseUrlConfigError, @@ -573,6 +576,20 @@ const modelPinnedEffortsSchema = z.unknown().superRefine((value, ctx) => { Object.entries(value as Record).map(([key, effort]) => [key.trim(), effort]), )); +const autoReviewModelSchema = z.unknown().superRefine((value, ctx) => { + const error = autoReviewModelTargetConfigError(value, "autoReviewModel", true); + if (error) ctx.addIssue({ code: "custom", message: error }); +}).transform(value => { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +}); + +const autoReviewModelOverridesSchema = z.unknown().superRefine((value, ctx) => { + const error = autoReviewModelOverridesConfigError(value, "autoReviewModelOverrides", true); + if (error) ctx.addIssue({ code: "custom", message: error }); +}).transform(value => normalizeAutoReviewModelOverrides(value)); + /** * Zod schema for one provider entry: known fields are validated strictly while unknown * fields pass through (preserved for runtime extensions). @@ -584,6 +601,8 @@ const providerConfigSchema = z.object({ // load silently and then be ignored at selection time, which reads as a broken feature // rather than a rejected setting. apiKeyPoolStrategy: z.enum(["round-robin", "fill-first", "quota"]).optional(), + autoReviewModel: autoReviewModelSchema.optional(), + autoReviewModelOverrides: autoReviewModelOverridesSchema.optional(), adapter: z.string().min(1), baseUrl: z.string().min(1), alias: z.string().optional(), @@ -665,9 +684,12 @@ export { apiKeyTransportConfigError, booleanRecordConfigError, modelAdapterRecordConfigError, + autoReviewModelOverridesConfigError, + autoReviewModelTargetConfigError, modelDisplayNamesConfigError, nonBlankStringArrayConfigError, normalizeNonBlankStringArray, + normalizeAutoReviewModelOverrides, positiveIntegerConfigError, positiveIntegerRecordConfigError, providerBaseUrlConfigError, @@ -1946,6 +1968,44 @@ function sanitizeModelCostsForLoad(parsed: unknown): void { } } +/** + * Load-time degradation for provider-scoped auto-review selectors. A malformed + * hand edit must not fail the whole config parse; the management boundary stays + * strict and rejects the same shapes before they can be written. + */ +function sanitizeAutoReviewForLoad(parsed: unknown): void { + if (!parsed || typeof parsed !== "object") return; + const root = parsed as Record; + const providers = root.providers; + if (!providers || typeof providers !== "object" || Array.isArray(providers)) return; + for (const [name, providerValue] of Object.entries(providers as Record)) { + if (!providerValue || typeof providerValue !== "object" || Array.isArray(providerValue)) continue; + const provider = providerValue as Record; + const safeProviderName = JSON.stringify(redactSecretString(name)); + if (name === "openai") { + delete provider.autoReviewModel; + delete provider.autoReviewModelOverrides; + continue; + } + if (provider.autoReviewModel !== undefined + && autoReviewModelTargetConfigError(provider.autoReviewModel, "autoReviewModel", true) !== null) { + console.warn(`⚠️ config.json providers.${safeProviderName}.autoReviewModel is invalid — ignoring the selector`); + delete provider.autoReviewModel; + } + if (provider.autoReviewModelOverrides !== undefined) { + const overridesError = autoReviewModelOverridesConfigError( + provider.autoReviewModelOverrides, + "autoReviewModelOverrides", + true, + ); + if (overridesError) { + console.warn(`⚠️ config.json providers.${safeProviderName}.autoReviewModelOverrides is invalid — ignoring the map`); + delete provider.autoReviewModelOverrides; + } + } + } +} + /** * Companion to {@link warnDegradedStreamMode} for a blank persisted `hostname`. The bind * falls back to loopback, which is the safe direction but not what the file asked for — @@ -2401,6 +2461,7 @@ export function loadConfig(): OcxConfig { sanitizeAliasesForLoad(parsed); sanitizeReasoningPinsForLoad(parsed); sanitizeModelDisplayNamesForLoad(parsed); + sanitizeAutoReviewForLoad(parsed); sanitizeRetryOn429ForLoad(parsed); sanitizeModelCostsForLoad(parsed); const result = configSchema.safeParse(parsed); @@ -3025,6 +3086,7 @@ function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics { // schema and send the caller a default-config fallback (the config command could then // persist that fallback over the user's providers/keys). sanitizeModelDisplayNamesForLoad(parsed); + sanitizeAutoReviewForLoad(parsed); sanitizeRetryOn429ForLoad(parsed); sanitizeModelCostsForLoad(parsed); const result = configSchema.safeParse(parsed); diff --git a/src/config/provider-validation.ts b/src/config/provider-validation.ts index c1e60033e8..66fa926a62 100644 --- a/src/config/provider-validation.ts +++ b/src/config/provider-validation.ts @@ -5,6 +5,7 @@ import { MODEL_DISCOVERY_MAX_MODELS, } from "../providers/model-discovery-limits"; import { isDeclaredReasoningEffort, modelRecordValue } from "../reasoning-effort"; +import { encodeRoutedModelId } from "../providers/slug-codec"; import { isWirePinnedModel, MODEL_ADAPTER_OVERRIDE_ALLOWED, @@ -216,7 +217,9 @@ export function modelDisplayNamesConfigError( return `${field} must contain at most ${MODEL_DISCOVERY_MAX_MODELS} entries`; } for (const [modelId, displayName] of entries) { - if (!isValidModelDiscoveryModelId(modelId)) return `${field} keys must be valid model ids`; + if (!isValidModelDiscoveryModelId(modelId) || ["__proto__", "prototype", "constructor"].includes(modelId)) { + return `${field} keys must be valid non-reserved model ids`; + } const safeModelId = JSON.stringify(redactSecretString(modelId)); if (typeof displayName !== "string") return `${field}.${safeModelId} must be a string`; const trimmed = displayName.trim(); @@ -233,6 +236,85 @@ export function modelDisplayNamesConfigError( return null; } +/** Characters that make a Codex catalog selector ambiguous or unrepresentable. */ +export const AUTO_REVIEW_MODEL_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\s]/; + +/** Validate one auto-review target (provider-wide value or map value). */ +export function autoReviewModelTargetConfigError( + value: unknown, + field = "autoReviewModel", + allowClear = false, +): string | null { + if (value === undefined || (allowClear && (value === null || value === ""))) return null; + if (typeof value !== "string") return `${field} must be a string`; + const trimmed = value.trim(); + if (!trimmed) return `${field} must be nonblank`; + if (trimmed.length > 1024 || AUTO_REVIEW_MODEL_CONTROL_CHARS.test(trimmed)) { + return `${field} must be a catalog selector without whitespace or control characters`; + } + return null; +} + +/** True when the value is a valid Codex catalog auto-review selector. */ +export function isValidAutoReviewModel(value: unknown): value is string { + return typeof value === "string" && autoReviewModelTargetConfigError(value) === null; +} + +/** Canonical model key used for map matching, duplicate detection, and route tombstones. */ +export function canonicalAutoReviewModelKey(modelId: string): string { + return encodeRoutedModelId(modelId.trim()); +} + +/** Validate a per-model auto-review override map. */ +export function autoReviewModelOverridesConfigError( + value: unknown, + field = "autoReviewModelOverrides", + allowTombstones = false, +): string | null { + if (value === undefined) return null; + if (value === null && allowTombstones) return null; + if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + return `${field} must be a plain object with own properties`; + } + const entries = Object.entries(value); + if (entries.length > MODEL_DISCOVERY_MAX_MODELS) { + return `${field} must contain at most ${MODEL_DISCOVERY_MAX_MODELS} entries`; + } + const canonicalKeys = new Set(); + for (const [modelId, target] of entries) { + if (!isValidModelDiscoveryModelId(modelId) || ["__proto__", "prototype", "constructor"].includes(modelId)) { + return `${field} keys must be valid non-reserved model ids`; + } + const safeModelId = JSON.stringify(redactSecretString(modelId)); + const canonicalKey = canonicalAutoReviewModelKey(modelId); + if (canonicalKeys.has(canonicalKey)) { + return `${field} keys must be unique after trimming and slash normalization`; + } + canonicalKeys.add(canonicalKey); + if (allowTombstones && (target === null || target === "")) continue; + const targetError = autoReviewModelTargetConfigError(target, `${field}.${safeModelId}`); + if (targetError) return targetError; + } + return null; +} + +/** Normalize a persisted auto-review override map (trim, drop blanks, keep insertion order). */ +export function normalizeAutoReviewModelOverrides(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const out = Object.create(null) as Record; + for (const [modelId, target] of Object.entries(value)) { + const key = modelId.trim(); + if (!key) continue; + if (target === null || typeof target !== "string") continue; + const trimmed = target.trim(); + if (!trimmed) continue; + out[key] = trimmed; + } + return Object.keys(out).length > 0 ? out : undefined; +} + /** Validate the management DTO boundary for the opt-in empty-tool-output annotation. */ export function providerEmptyToolOutputConfigError(name: string, provider: unknown): string | null { const raw = provider as Record | null | undefined; diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 0910698a0c..d9cd3c57b1 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -13,6 +13,8 @@ import { } from "../config"; import { apiKeyTransportConfigError, + autoReviewModelOverridesConfigError, + autoReviewModelTargetConfigError, booleanRecordConfigError, providerReasoningPinsConfigError, modelAdapterRecordConfigError, @@ -590,6 +592,13 @@ export function providerManagementConfigError(name: unknown, provider: unknown): const raw = provider as Record; const pinsError = providerReasoningPinsConfigError(raw); if (pinsError) return pinsError; + if (name === "openai" && (Object.hasOwn(raw, "autoReviewModel") || Object.hasOwn(raw, "autoReviewModelOverrides"))) { + return "provider openai must not include autoReviewModel or autoReviewModelOverrides"; + } + const autoReviewTargetError = autoReviewModelTargetConfigError(raw.autoReviewModel, "autoReviewModel", true); + if (autoReviewTargetError) return autoReviewTargetError; + const autoReviewMapError = autoReviewModelOverridesConfigError(raw.autoReviewModelOverrides, "autoReviewModelOverrides", true); + if (autoReviewMapError) return autoReviewMapError; for (const field of FORBIDDEN_PROVIDER_RUNTIME_FIELDS) { if (Object.hasOwn(raw, field)) return `provider ${name} must not include runtime field "${field}"`; } @@ -855,6 +864,8 @@ const PROVIDER_CONFIG_FIELD_POLICY = { modelDefaultReasoningEfforts: "editor", pinnedReasoningEffort: "editor", modelPinnedReasoningEfforts: "editor", + autoReviewModel: "editor", + autoReviewModelOverrides: "editor", modelSupportsReasoningSummaries: "editor", modelSupportsVerbosity: "editor", supportsVerbosity: "editor", diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 6977a17783..b319d1d35c 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -15,6 +15,7 @@ import { mutatePersistedConfig, nonBlankStringArrayConfigError, normalizeNonBlankStringArray, + normalizeAutoReviewModelOverrides, providerBaseUrlConfigError, providerHeadersConfigError, requestPacingConfigError, @@ -34,7 +35,7 @@ import { upsertOAuthProvider, } from "../../oauth"; import { captureConfigTopLevelRollback } from "../../config/rebase-provenance"; -import { mergeModelPinnedEfforts, modelPinnedEffortsConfigError, pinnedReasoningEffortConfigError } from "../../config/provider-validation"; +import { canonicalAutoReviewModelKey, mergeModelPinnedEfforts, modelPinnedEffortsConfigError, pinnedReasoningEffortConfigError } from "../../config/provider-validation"; import { replaceProviderAccountSet } from "../../oauth/store"; import { providerDestinationResolvedError } from "../../lib/destination-policy"; import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; @@ -287,6 +288,11 @@ function providerEditorCandidate( if (provider.modelPinnedReasoningEfforts !== undefined) { provider.modelPinnedReasoningEfforts = validated.config.providers[name]!.modelPinnedReasoningEfforts; } + const normalized = validated.config.providers[name]!; + if (normalized.autoReviewModel === undefined) delete provider.autoReviewModel; + else provider.autoReviewModel = normalized.autoReviewModel; + if (normalized.autoReviewModelOverrides === undefined) delete provider.autoReviewModelOverrides; + else provider.autoReviewModelOverrides = normalized.autoReviewModelOverrides; } return { ok: true, config: candidate, removedProviders }; } @@ -518,6 +524,61 @@ function applyProviderPatchFields( if (error) return { error }; touched = true; } + if (Object.hasOwn(rawBody, "autoReviewModel")) { + const value = rawBody.autoReviewModel; + if (value === null || value === "") { + delete next.autoReviewModel; + } else if (typeof value === "string" && value.trim()) { + next.autoReviewModel = value.trim(); + } else { + return { error: "autoReviewModel must be a catalog selector string or null" }; + } + touched = true; + } + if (Object.hasOwn(rawBody, "autoReviewModelOverrides")) { + const value = rawBody.autoReviewModelOverrides; + if (value === null) { + delete next.autoReviewModelOverrides; + } else if (isPlainRecord(value)) { + const merged: Record = { ...(next.autoReviewModelOverrides ?? {}) }; + const existingByCanonical = new Map(); + for (const existingKey of Object.keys(merged)) { + existingByCanonical.set(canonicalAutoReviewModelKey(existingKey), existingKey); + } + const submittedCanonicalKeys = new Set(); + for (const [model, target] of Object.entries(value)) { + const key = model.trim(); + if (["__proto__", "prototype", "constructor"].includes(key)) { + return { error: "autoReviewModelOverrides keys must be non-reserved model ids" }; + } + const canonicalKey = canonicalAutoReviewModelKey(model); + // Uniqueness is enforced before the tombstone branch: a clear and a set that normalize to + // the same key would otherwise resolve in object order instead of being rejected. + if (submittedCanonicalKeys.has(canonicalKey)) { + return { error: "autoReviewModelOverrides keys must be unique after trimming and slash normalization" }; + } + submittedCanonicalKeys.add(canonicalKey); + if (target === null || target === "") { + const previousKey = existingByCanonical.get(canonicalKey); + if (previousKey !== undefined) delete merged[previousKey]; + if (Object.hasOwn(merged, key)) delete merged[key]; + continue; + } + if (typeof target !== "string" || !target.trim()) { + return { error: "autoReviewModelOverrides values must be catalog selectors, null, or empty to remove" }; + } + const previousKey = existingByCanonical.get(canonicalKey); + if (previousKey !== undefined && previousKey !== key) delete merged[previousKey]; + merged[key] = target.trim(); + existingByCanonical.set(canonicalKey, key); + } + if (Object.keys(merged).length > 0) next.autoReviewModelOverrides = merged; + else delete next.autoReviewModelOverrides; + } else { + return { error: "autoReviewModelOverrides must be a plain object or null" }; + } + touched = true; + } if (Object.hasOwn(rawBody, "modelAutoCompactTokenLimits")) { const value = rawBody.modelAutoCompactTokenLimits; const error = modelAutoCompactTokenLimitsConfigError(value, { @@ -996,6 +1057,11 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise; + /** + * Provider-wide auto-review (approval) model for routed models of this provider. + * + * The value is a catalog selector: either a bare model id of this provider + * (for example `deepseek-v4-flash`) or a full public slug (for example + * `opencode-go/deepseek-v4-flash`). During catalog synchronization the + * selector is resolved against the final catalog and stamped as + * `auto_review_model_override` on each routed row of this provider that has + * no per-model override. The root Codex `auto_review_model` remains the + * fallback for every row without a provider stamp. Null or blank clears the + * provider-wide stamp; see `autoReviewModelOverrides` for per-model targets. + */ + autoReviewModel?: string; + /** + * Per-model auto-review (approval) overrides for routed models of this + * provider. Keys are exact upstream model ids under this provider (either + * spelling of a slash-containing id is accepted). Each value is a catalog + * selector with the same meaning as `autoReviewModel`; an entry wins over + * the provider-wide value for its model. Null or blank entries remove the + * model from the map while preserving other entries. + */ + autoReviewModelOverrides?: Record; headers?: Record; /** Default provider-routing preferences for models sent through the canonical OpenRouter API. */ openRouterRouting?: OpenRouterProviderRouting; diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index a4dc21adbf..61a2a82937 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -66,3 +66,4 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. +Provider-scoped approval reviewer settings are projected by the [catalog owner](../catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. diff --git a/structure/catalog.md b/structure/catalog.md index 0ba4acca3e..6e6aa5ce7e 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -278,3 +278,6 @@ see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing- Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. +## Provider-scoped approval reviewer + +`src/codex/catalog/sync.ts` resolves exact case-preserving provider/model reviewer selectors against the final catalog in both retained sync and `src/codex/convergence.ts`. Valid per-model selection wins over valid provider-wide selection, then the root selector supplies fallback. Native root stamps retain the observed original value and applied selector bound to their slug; removal restores the original only while the applied value is unchanged. Ambiguous legacy unmarked catalogs retain their existing heuristic cleanup. Provider stamps do not change routing or credentials. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 2914823958..78ee607525 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -91,3 +91,4 @@ The explicit sync coordinator also accepts Cline CLI as a separate file integrat `claudeCode.stabilizePromptCache` is a default-off operator setting for [translated instruction stabilization](../data-planes/inbound-compat.md#opt-in-claude-instruction-stabilization). Config JSON preserves the boolean; only literal true activates the role-changing transform. +Provider-scoped approval reviewer settings are projected by the [catalog owner](../catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. diff --git a/structure/codex-home.md b/structure/codex-home.md index b11ddd3f1a..cb063eb890 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -236,3 +236,4 @@ Injection preflights affected history using the normalized config candidate befo The legacy external writer is now refused for affected rows in any store whose schema includes history_mode, even while their row mode is still legacy. This deliberately sacrifices automatic relabeling on migration-capable stores rather than racing native conversion. Synchronous/asynchronous restore, inline journal restore, and direct config removal preserve all artifacts on the same refusal. Native restore preflight also checks manifest-owned targets whose rows already returned to `openai`, including interrupted restores. Preimage capture distinguishes absent files from unreadable artifacts and aborts before mutation when a complete snapshot cannot be read. +Provider-scoped approval reviewer settings are projected by the [catalog owner](catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. diff --git a/structure/config.md b/structure/config.md index 80bb62bc73..0000eef7f7 100644 --- a/structure/config.md +++ b/structure/config.md @@ -205,3 +205,4 @@ The Cline client keeps connection settings and models in a separate native file `claudeCode.stabilizePromptCache` is a default-off operator setting for [translated instruction stabilization](data-planes/inbound-compat.md#opt-in-claude-instruction-stabilization). Config JSON preserves the boolean; only literal true activates the role-changing transform. +Provider `autoReviewModel` and `autoReviewModelOverrides` accept validated final-catalog selectors. Per-model keys preserve case and accept the existing raw/encoded slash equivalence. File-load degradation removes malformed optional selectors only; management writes reject malformed shapes. Omitted provider saves preserve selectors, explicit clears remove them, and raw editor candidates adopt normalized values before persistence and live replacement. See [catalog ownership](catalog.md#provider-scoped-approval-reviewer). diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 25646c7de4..64dd76c46b 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -79,3 +79,4 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. +Provider-scoped approval reviewer settings are projected by the [catalog owner](../catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 2d17c11875..eee11db535 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -128,3 +128,4 @@ changes prompt roles, not conversation identity, and cannot guarantee upstream c Instruction notice extraction scans fence ranges once and walks original lines backwards with a decreasing cursor. It accepts exactly one ASCII space inside the token notice, preserves unmatched prefix bytes, and does not repeatedly scan or copy shrinking prompt prefixes. +Provider-scoped approval reviewer settings are projected by the [catalog owner](../catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 73090d646e..efaf26b0b2 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -543,3 +543,4 @@ integration IO adapter. Its snapshot fingerprint cannot be checked against provi [the integration contract](clients/integrations.md#cline-paired-files) defines recovery. The existing dashboard file-client maps include Cline CLI and reuse its committed color mark. The export panel labels its download as a settings/catalog bundle; all locales explain that Undo restores both original files. +The raw provider editor round-trips `autoReviewModel` and `autoReviewModelOverrides` through editor-owned DTO fields. POST/PATCH/PUT share validation; PUT copies schema-normalized values into the persisted and live candidate before adoption. Canonical `openai` rejects these fields, including clear forms. Existing authentication, origin checks and stale-baseline protection still govern the writes. See [reviewer projection](catalog.md#provider-scoped-approval-reviewer). diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 827540194a..4a514e1854 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -314,3 +314,4 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. The integrations guide documents Cline CLI as a two-file, loopback-only integration. Hosted CI validates its source-backed fixtures; the packaged dashboard exposes it through the existing client list. +Provider-scoped approval reviewer settings are projected by the [catalog owner](../catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 39dc9a82da..8152787033 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -142,3 +142,4 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. +Provider-scoped approval reviewer settings are projected by the [catalog owner](../catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. diff --git a/structure/overview.md b/structure/overview.md index d5d1a2207a..88abfa6297 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -108,3 +108,4 @@ The management quota DTO keeps Combo editing aligned with scoped inference evide see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). Cline CLI is a managed file integration: its provider settings and catalog share one recoverable journal operation. The [paired-file contract](clients/integrations.md#cline-paired-files) defines its stop/restart requirement. +Provider-scoped approval reviewer settings are projected by the [catalog owner](catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index a44edff557..31b7ffaf12 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -402,3 +402,4 @@ successful main usage refresh clears the runtime mark. ## Paginated history writer boundary `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. +Provider-scoped approval reviewer settings are projected by the [catalog owner](../catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 5b149ac6a2..49458e9762 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -65,3 +65,4 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. +Provider-scoped approval reviewer settings are projected by the [catalog owner](../catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. diff --git a/structure/runtime.md b/structure/runtime.md index 522e5cabb9..1f4790dcd1 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -225,3 +225,4 @@ Cline CLI joins the existing export/client integration registries. Explicit CLI `claudeCode.stabilizePromptCache` is a default-off operator setting for [translated instruction stabilization](data-planes/inbound-compat.md#opt-in-claude-instruction-stabilization). Config JSON preserves the boolean; only literal true activates the role-changing transform. +Provider-scoped approval reviewer settings are projected by the [catalog owner](catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. diff --git a/structure/subagents.md b/structure/subagents.md index f190aab084..99734661ff 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -214,3 +214,4 @@ see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing- Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. +Provider-scoped approval reviewer settings are projected by the [catalog owner](catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index b2fc3b3fae..5ba11f8093 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -70,3 +70,4 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. +Provider-scoped approval reviewer settings are projected by the [catalog owner](../catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 2321d78dd6..d7c924b132 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -523,3 +523,4 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. +Provider-scoped approval reviewer settings are projected by the [catalog owner](../catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index 68093843ea..a305c20e28 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -199,3 +199,4 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. +Provider-scoped approval reviewer settings are projected by the [catalog owner](../catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index 794a92bd50..aaf66bbd15 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -7293,6 +7293,405 @@ describe("auto_review_model configuration (#1225)", () => { expect(entries[1].auto_review_model_override).toBe(trimmedValue); }); }); + +describe("provider-level auto_review_model overrides", () => { + const { applyConfiguredAutoReviewModelOverride } = require("../../src/codex/catalog/sync"); + + function entries(): Array> { + return [ + { slug: "gpt-5.6-terra", auto_review_model_override: null }, + { slug: "blsc/glm-5.2", auto_review_model_override: null }, + { slug: "blsc/kimi-k3", auto_review_model_override: null }, + { slug: "opencode-go/deepseek-v4-flash", auto_review_model_override: null }, + ]; + } + + function config(providerOverrides?: Record): { providers: Record> } { + return { + providers: { + blsc: { + adapter: "openai-chat", + baseUrl: "https://example.invalid/v1", + ...providerOverrides, + }, + }, + }; + } + + test("provider-wide selector stamps every routed row of that provider", () => { + const models = entries(); + const result = applyConfiguredAutoReviewModelOverride( + models, + null, + config({ autoReviewModel: "opencode-go/deepseek-v4-flash" }), + ); + expect(result).toBe("applied"); + expect(models.find(row => row.slug === "blsc/glm-5.2")?.auto_review_model_override) + .toBe("opencode-go/deepseek-v4-flash"); + expect(models.find(row => row.slug === "blsc/kimi-k3")?.auto_review_model_override) + .toBe("opencode-go/deepseek-v4-flash"); + expect(models.find(row => row.slug === "gpt-5.6-terra")?.auto_review_model_override).toBeNull(); + expect(models.find(row => row.slug === "opencode-go/deepseek-v4-flash")?.auto_review_model_override).toBeNull(); + }); + + test("bare same-provider selector is normalized to the provider/model row", () => { + const models = [...entries(), { slug: "other/glm-5.2", auto_review_model_override: null }]; + const result = applyConfiguredAutoReviewModelOverride(models, null, config({ + autoReviewModel: "glm-5.2", + })); + expect(result).toBe("applied"); + expect(models.find(row => row.slug === "blsc/glm-5.2")?.auto_review_model_override) + .toBe("blsc/glm-5.2"); + expect(models.find(row => row.slug === "blsc/kimi-k3")?.auto_review_model_override) + .toBe("blsc/glm-5.2"); + expect(models.find(row => row.slug === "other/glm-5.2")?.auto_review_model_override).toBeNull(); + }); + + test("per-model override wins over the provider-wide target", () => { + const models = entries(); + const result = applyConfiguredAutoReviewModelOverride(models, null, config({ + autoReviewModel: "gpt-5.6-terra", + autoReviewModelOverrides: { + "kimi-k3": "opencode-go/deepseek-v4-flash", + }, + })); + expect(result).toBe("applied"); + expect(models.find(row => row.slug === "blsc/kimi-k3")?.auto_review_model_override) + .toBe("opencode-go/deepseek-v4-flash"); + expect(models.find(row => row.slug === "blsc/glm-5.2")?.auto_review_model_override) + .toBe("gpt-5.6-terra"); + }); + + test("a raw model id containing a slash resolves against the same provider row", () => { + const models = [ + { slug: "zenmux/moonshotai-kimi-k3", auto_review_model_override: null }, + { slug: "zenmux/other-model", auto_review_model_override: null }, + { slug: "blsc/moonshotai-kimi-k3", auto_review_model_override: null }, + ]; + const result = applyConfiguredAutoReviewModelOverride(models, null, { + providers: { + zenmux: { + adapter: "openai-chat", + baseUrl: "https://zenmux.example.test/v1", + autoReviewModel: "moonshotai/kimi-k3", + }, + }, + }); + expect(result).toBe("applied"); + expect(models.find(row => row.slug === "zenmux/moonshotai-kimi-k3")?.auto_review_model_override) + .toBe("zenmux/moonshotai-kimi-k3"); + expect(models.find(row => row.slug === "zenmux/other-model")?.auto_review_model_override) + .toBe("zenmux/moonshotai-kimi-k3"); + expect(models.find(row => row.slug === "blsc/moonshotai-kimi-k3")?.auto_review_model_override).toBeNull(); + }); + + test("partial provider failure is reported even when valid stamps are applied", () => { + const models = entries(); + const result = applyConfiguredAutoReviewModelOverride(models, null, config({ + autoReviewModel: "opencode-go/deepseek-v4-flash", + autoReviewModelOverrides: { "kimi-k3": "missing/reviewer" }, + })); + expect(result).toBe("unresolved"); + expect(models.find(row => row.slug === "blsc/glm-5.2")?.auto_review_model_override) + .toBe("opencode-go/deepseek-v4-flash"); + }); + + test("root selector remains the fallback and provider stamps survive root removal", () => { + const models = entries(); + const providerConfig = config({ autoReviewModel: "opencode-go/deepseek-v4-flash" }); + applyConfiguredAutoReviewModelOverride(models, "gpt-5.6-terra", providerConfig); + expect(models.find(row => row.slug === "gpt-5.6-terra")?.auto_review_model_override) + .toBe("gpt-5.6-terra"); + expect(models.find(row => row.slug === "blsc/glm-5.2")?.auto_review_model_override) + .toBe("opencode-go/deepseek-v4-flash"); + expect(models.find(row => row.slug === "opencode-go/deepseek-v4-flash")?.auto_review_model_override) + .toBe("gpt-5.6-terra"); + + applyConfiguredAutoReviewModelOverride(models, null, providerConfig); + expect(models.find(row => row.slug === "gpt-5.6-terra")?.auto_review_model_override).toBeNull(); + expect(models.find(row => row.slug === "blsc/glm-5.2")?.auto_review_model_override) + .toBe("opencode-go/deepseek-v4-flash"); + expect(models.find(row => row.slug === "opencode-go/deepseek-v4-flash")?.auto_review_model_override).toBeNull(); + }); + + test("removing the provider selector clears its previous stamps", () => { + const models = entries(); + const providerConfig = config({ autoReviewModel: "opencode-go/deepseek-v4-flash" }); + applyConfiguredAutoReviewModelOverride(models, null, providerConfig); + expect(models.find(row => row.slug === "blsc/glm-5.2")?.auto_review_model_override) + .toBe("opencode-go/deepseek-v4-flash"); + applyConfiguredAutoReviewModelOverride(models, null, config()); + expect(models.find(row => row.slug === "blsc/glm-5.2")?.auto_review_model_override).toBeNull(); + expect(models.find(row => row.slug === "blsc/kimi-k3")?.auto_review_model_override).toBeNull(); + }); + + test("unresolved provider selector clears overrides when no root is configured", () => { + const models = entries(); + const result = applyConfiguredAutoReviewModelOverride(models, null, config({ + autoReviewModel: "missing/reviewer", + })); + expect(result).toBe("unresolved"); + expect(models.every(row => row.auto_review_model_override === null)).toBe(true); + }); + + test("invalid provider targets use the valid root, and invalid model targets use the provider", () => { + const models = entries(); + applyConfiguredAutoReviewModelOverride(models, "gpt-5.6-terra", config({ autoReviewModel: "missing/reviewer" })); + expect(models.find(row => row.slug === "blsc/glm-5.2")?.auto_review_model_override).toBe("gpt-5.6-terra"); + applyConfiguredAutoReviewModelOverride(models, "gpt-5.6-terra", config({ + autoReviewModel: "opencode-go/deepseek-v4-flash", + autoReviewModelOverrides: { "glm-5.2": "missing/reviewer" }, + })); + expect(models.find(row => row.slug === "blsc/glm-5.2")?.auto_review_model_override).toBe("opencode-go/deepseek-v4-flash"); + }); + + test("case-distinct model overrides remain distinct", () => { + const models: Array> = [ + { slug: "blsc/ModelA" }, { slug: "blsc/modela" }, { slug: "blsc/reviewer" }, { slug: "gpt-5.6-terra" }, + ]; + applyConfiguredAutoReviewModelOverride(models, null, config({ + autoReviewModelOverrides: { ModelA: "reviewer", modela: "gpt-5.6-terra" }, + })); + expect(models[0]!.auto_review_model_override).toBe("blsc/reviewer"); + expect(models[1]!.auto_review_model_override).toBe("gpt-5.6-terra"); + }); + + test("native root provenance restores the original and respects external edits", () => { + const models: Array> = [ + { slug: "gpt-5.6-terra", auto_review_model_override: "native-original" }, + { slug: "blsc/reviewer" }, + ]; + applyConfiguredAutoReviewModelOverride(models, "blsc/reviewer", config()); + applyConfiguredAutoReviewModelOverride(models, "gpt-5.6-terra", config()); + applyConfiguredAutoReviewModelOverride(models, null, config()); + expect(models[0]!.auto_review_model_override).toBe("native-original"); + applyConfiguredAutoReviewModelOverride(models, "blsc/reviewer", config()); + models[0]!.auto_review_model_override = "external-reviewer"; + applyConfiguredAutoReviewModelOverride(models, null, config()); + expect(models[0]!.auto_review_model_override).toBe("external-reviewer"); + }); + + test("legacy root stamps are swept when provider configuration replaces the root in one step", () => { + // Catalogs written before the provenance marker carry root stamps that look exactly like + // upstream values, so removing the root while adding a provider selector has to fall back to + // the legacy whole-catalog signature — and read it before provider stamping rewrites it. + const sourceModels: Array> = [ + { slug: "gpt-5.6-terra", auto_review_model_override: "gpt-5.6-terra" }, + { slug: "gpt-5.5", auto_review_model_override: "gpt-5.6-terra" }, + { slug: "blsc/glm-5.2", auto_review_model_override: "gpt-5.6-terra" }, + { slug: "opencode-go/deepseek-v4-flash", auto_review_model_override: "gpt-5.6-terra" }, + ]; + const models: Array> = [ + { slug: "gpt-5.6-terra", auto_review_model_override: "gpt-5.6-terra" }, + { slug: "gpt-5.5", auto_review_model_override: "gpt-5.6-terra" }, + { slug: "blsc/glm-5.2", auto_review_model_override: null }, + { slug: "opencode-go/deepseek-v4-flash", auto_review_model_override: null }, + ]; + const result = applyConfiguredAutoReviewModelOverride( + models, + null, + config({ autoReviewModel: "opencode-go/deepseek-v4-flash" }), + sourceModels, + ); + expect(result).toBe("applied"); + expect(models.find(row => row.slug === "gpt-5.6-terra")?.auto_review_model_override).toBeNull(); + expect(models.find(row => row.slug === "gpt-5.5")?.auto_review_model_override).toBeNull(); + expect(models.find(row => row.slug === "blsc/glm-5.2")?.auto_review_model_override) + .toBe("opencode-go/deepseek-v4-flash"); + expect(models.find(row => row.slug === "opencode-go/deepseek-v4-flash")?.auto_review_model_override).toBeNull(); + }); + + test("a catalog with mixed values is not mistaken for a legacy root stamp", () => { + const sourceModels: Array> = [ + { slug: "gpt-5.6-terra", auto_review_model_override: "native-reviewer" }, + { slug: "blsc/glm-5.2", auto_review_model_override: "legacy-root" }, + ]; + const models: Array> = [ + { slug: "gpt-5.6-terra", auto_review_model_override: "native-reviewer" }, + { slug: "blsc/glm-5.2", auto_review_model_override: null }, + ]; + applyConfiguredAutoReviewModelOverride( + models, + null, + config({ autoReviewModel: "opencode-go/deepseek-v4-flash" }), + sourceModels, + ); + expect(models.find(row => row.slug === "gpt-5.6-terra")?.auto_review_model_override) + .toBe("native-reviewer"); + }); + + test("a uniform value no routed row carries is not treated as a legacy root stamp", () => { + const sourceModels: Array> = [ + { slug: "gpt-5.6-terra", auto_review_model_override: "user-pinned-reviewer" }, + { slug: "gpt-5.5", auto_review_model_override: "user-pinned-reviewer" }, + ]; + const models: Array> = [ + { slug: "gpt-5.6-terra", auto_review_model_override: "user-pinned-reviewer" }, + { slug: "gpt-5.5", auto_review_model_override: "user-pinned-reviewer" }, + { slug: "blsc/glm-5.2", auto_review_model_override: null }, + ]; + applyConfiguredAutoReviewModelOverride( + models, + null, + config({ autoReviewModel: "opencode-go/deepseek-v4-flash" }), + sourceModels, + ); + expect(models.find(row => row.slug === "gpt-5.6-terra")?.auto_review_model_override) + .toBe("user-pinned-reviewer"); + }); + + test("an override key written with a slash matches the encoded routed row", () => { + const models: Array> = [ + { slug: "gpt-5.6-terra", auto_review_model_override: null }, + { slug: "zenmux/moonshotai-kimi-k3", auto_review_model_override: null }, + { slug: "zenmux/other-model", auto_review_model_override: null }, + ]; + const result = applyConfiguredAutoReviewModelOverride(models, null, { + providers: { + zenmux: { + adapter: "openai-chat", + baseUrl: "https://zenmux.example.test/v1", + autoReviewModel: "other-model", + autoReviewModelOverrides: { "moonshotai/kimi-k3": "gpt-5.6-terra" }, + }, + }, + }); + expect(result).toBe("applied"); + expect(models.find(row => row.slug === "zenmux/moonshotai-kimi-k3")?.auto_review_model_override) + .toBe("gpt-5.6-terra"); + expect(models.find(row => row.slug === "zenmux/other-model")?.auto_review_model_override) + .toBe("zenmux/other-model"); + }); + + test("a bare selector never resolves to another provider's routed row", () => { + const models: Array> = [ + { slug: "other/glm-5.2", auto_review_model_override: null }, + { slug: "blsc/kimi-k3", auto_review_model_override: null }, + ]; + const result = applyConfiguredAutoReviewModelOverride(models, null, config({ autoReviewModel: "glm-5.2" })); + // Bare selectors name this provider's model or a bare catalog row, never a sibling provider's + // encoded slug; an unresolvable one fails closed instead of borrowing the other row. + expect(result).toBe("unresolved"); + expect(models.every(row => row.auto_review_model_override === null)).toBe(true); + }); + + test("the legacy sweep leaves no trace while a root selector resolves", () => { + const sourceModels: Array> = [ + { slug: "gpt-5.6-terra", auto_review_model_override: "legacy-root" }, + { slug: "blsc/glm-5.2", auto_review_model_override: "legacy-root" }, + ]; + const models: Array> = [ + { slug: "gpt-5.6-terra", auto_review_model_override: "legacy-root" }, + { slug: "blsc/glm-5.2", auto_review_model_override: null }, + { slug: "opencode-go/deepseek-v4-flash", auto_review_model_override: null }, + ]; + const result = applyConfiguredAutoReviewModelOverride( + models, + "gpt-5.6-terra", + config({ autoReviewModel: "opencode-go/deepseek-v4-flash" }), + sourceModels, + ); + expect(result).toBe("applied"); + // Every non-provider row ends on the root value and each provider row on the plan target, so + // the sweep is invisible once the root selector resolves. + expect(models.find(row => row.slug === "gpt-5.6-terra")?.auto_review_model_override).toBe("gpt-5.6-terra"); + expect(models.find(row => row.slug === "opencode-go/deepseek-v4-flash")?.auto_review_model_override) + .toBe("gpt-5.6-terra"); + expect(models.find(row => row.slug === "blsc/glm-5.2")?.auto_review_model_override) + .toBe("opencode-go/deepseek-v4-flash"); + expect(models.some(row => row.auto_review_model_override === "legacy-root")).toBe(false); + }); + + test("a bare target that resolves outside the provider is used but reported", () => { + const models = entries(); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const result = applyConfiguredAutoReviewModelOverride(models, null, config({ autoReviewModel: "gpt-5.6-terra" })); + expect(result).toBe("applied"); + expect(models.find(row => row.slug === "blsc/glm-5.2")?.auto_review_model_override).toBe("gpt-5.6-terra"); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0]?.[0])).toContain("auto_review_model for provider \"blsc\""); + } finally { + warn.mockRestore(); + } + }); + + test("a bare target that resolves inside the provider stays silent", () => { + const models = entries(); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const result = applyConfiguredAutoReviewModelOverride(models, null, config({ autoReviewModel: "glm-5.2" })); + expect(result).toBe("applied"); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + test("an override key written as the provider alias selects the same row", () => { + const models: Array> = [ + { slug: "gpt-5.6-terra", auto_review_model_override: null }, + { slug: "blsc/pin-model", auto_review_model_override: null }, + { slug: "blsc/other-model", auto_review_model_override: null }, + ]; + const result = applyConfiguredAutoReviewModelOverride(models, null, { + providers: { + blsc: { + adapter: "openai-chat", + baseUrl: "https://blsc.example.test/v1", + modelAliases: { "pin-model": "friendly" }, + autoReviewModelOverrides: { friendly: "gpt-5.6-terra" }, + }, + }, + }); + expect(result).toBe("applied"); + expect(models.find(row => row.slug === "blsc/pin-model")?.auto_review_model_override).toBe("gpt-5.6-terra"); + expect(models.find(row => row.slug === "blsc/other-model")?.auto_review_model_override).toBeNull(); + }); + + test("an alias does not displace an override keyed by the upstream id", () => { + const models: Array> = [ + { slug: "gpt-5.6-terra", auto_review_model_override: null }, + { slug: "blsc/pin-model", auto_review_model_override: null }, + ]; + const result = applyConfiguredAutoReviewModelOverride(models, null, { + providers: { + blsc: { + adapter: "openai-chat", + baseUrl: "https://blsc.example.test/v1", + modelAliases: { "pin-model": "friendly" }, + autoReviewModelOverrides: { "pin-model": "gpt-5.6-terra" }, + }, + }, + }); + expect(result).toBe("applied"); + expect(models.find(row => row.slug === "blsc/pin-model")?.auto_review_model_override).toBe("gpt-5.6-terra"); + }); + + test("an alias that names another routed row is not propagated", () => { + const models: Array> = [ + { slug: "gpt-5.6-terra", auto_review_model_override: null }, + { slug: "blsc/pin-model", auto_review_model_override: null }, + { slug: "blsc/friendly", auto_review_model_override: null }, + ]; + const result = applyConfiguredAutoReviewModelOverride(models, null, { + providers: { + blsc: { + adapter: "openai-chat", + baseUrl: "https://blsc.example.test/v1", + // Persisted on a cold start, before discovery reported the row that owns "friendly". + modelAliases: { "pin-model": "friendly" }, + autoReviewModelOverrides: { friendly: "gpt-5.6-terra" }, + }, + }, + }); + expect(result).toBe("applied"); + // The key names the row that literally carries it; the colliding alias is not propagated to it. + expect(models.find(row => row.slug === "blsc/friendly")?.auto_review_model_override).toBe("gpt-5.6-terra"); + expect(models.find(row => row.slug === "blsc/pin-model")?.auto_review_model_override).toBeNull(); + }); +}); + import { ManagementRequest as Request } from "../helpers/management-auth"; describe("#2465 model preset management routes", () => { diff --git a/tests/codex-integration/codex-convergence-account-selectors.test.ts b/tests/codex-integration/codex-convergence-account-selectors.test.ts index 1586978e46..3414f4ab58 100644 --- a/tests/codex-integration/codex-convergence-account-selectors.test.ts +++ b/tests/codex-integration/codex-convergence-account-selectors.test.ts @@ -736,6 +736,59 @@ test("retained and convergence writers resolve, clear, reject, and recover auto- } }); +test("provider-scoped auto-review overrides win on routed rows in both writers", async () => { + primeCodexRuntimeFixture(); + + for (const writer of ["retained", "convergence"] as const) { + const write = async (nextConfig: OcxConfig): Promise => { + if (writer === "retained") { + const result = await syncCatalogModels(nextConfig); + expect(result.catalogWritten).toBe(true); + } else { + const disposition = await convergeCatalogDisposition(nextConfig); + expect(disposition).toMatchObject({ status: "committed" }); + } + return JSON.parse(readFileSync(catalogPath, "utf8")) as RawCatalog; + }; + + const nextConfig = autoReviewConfig(["deepseek-v4-flash", "glm-5.2"]); + nextConfig.providers.static!.autoReviewModel = "deepseek-v4-flash"; + nextConfig.providers.static!.autoReviewModelOverrides = { + "glm-5.2": "gpt-5.5", + }; + + writeAutoReviewModel("gpt-5.5"); + writeCatalog([ + { ...nativeEntry(), auto_review_model_override: "native-upstream" }, + generatedRoutedEntry("static/deepseek-v4-flash"), + generatedRoutedEntry("static/glm-5.2"), + ]); + let catalog = await write(nextConfig); + expect(catalog.models?.find(entry => entry.slug === "static/deepseek-v4-flash")) + .toHaveProperty("auto_review_model_override", "static/deepseek-v4-flash"); + expect(catalog.models?.find(entry => entry.slug === "static/glm-5.2")) + .toHaveProperty("auto_review_model_override", "gpt-5.5"); + expect(catalog.models?.find(entry => entry.slug === "gpt-5.5")) + .toHaveProperty("auto_review_model_override", "gpt-5.5"); + + // Removing the provider-scoped fields restores root fallback: routed rows get the + // root selector again instead of a stale provider stamp. + delete nextConfig.providers.static!.autoReviewModel; + delete nextConfig.providers.static!.autoReviewModelOverrides; + catalog = await write(nextConfig); + expect(catalog.models?.find(entry => entry.slug === "static/deepseek-v4-flash")) + .toHaveProperty("auto_review_model_override", "gpt-5.5"); + expect(catalog.models?.find(entry => entry.slug === "static/glm-5.2")) + .toHaveProperty("auto_review_model_override", "gpt-5.5"); + writeAutoReviewModel(undefined); + catalog = await write(nextConfig); + expect(catalog.models?.find(entry => entry.slug === "gpt-5.5")) + .toHaveProperty("auto_review_model_override", "native-upstream"); + expect(catalog.models?.find(entry => entry.slug === "static/glm-5.2")) + .toHaveProperty("auto_review_model_override", null); + } +}); + test("degraded preservation still honors explicit routed visibility policy", async () => { writeCatalog([ nativeEntry(), diff --git a/tests/config/config-load-degrade.test.ts b/tests/config/config-load-degrade.test.ts index 4d8cca68f7..c85ab5f9cb 100644 --- a/tests/config/config-load-degrade.test.ts +++ b/tests/config/config-load-degrade.test.ts @@ -54,6 +54,23 @@ function writeCandidate(modelDisplayNames: unknown, provider = "xai"): void { writeFileSync(getConfigPath(), JSON.stringify(config), "utf8"); } +function writeAutoReviewConfig(autoReviewModel: unknown, autoReviewModelOverrides: unknown): void { + const defaults = getDefaultConfig(); + writeFileSync(getConfigPath(), JSON.stringify({ + ...defaults, + defaultProvider: "xai", + providers: { + xai: { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + note: "keep me", + autoReviewModel, + autoReviewModelOverrides, + }, + }, + }), "utf8"); +} + test("config validation accepts only safe provider model display names", () => { const valid = validateConfigCandidate(candidate({ "grok-4.6": "Grok 4.6", @@ -129,6 +146,25 @@ test("load warnings never reveal display values or secret shaped provider names" }); +test("load ignores malformed auto-review selectors without dropping the provider", () => { + writeAutoReviewConfig("bad selector", { model: "bad selector" }); + + const loaded = loadConfig(); + + expect(loaded.providers.xai).toMatchObject({ note: "keep me" }); + expect(loaded.providers.xai.autoReviewModel).toBeUndefined(); + expect(loaded.providers.xai.autoReviewModelOverrides).toBeUndefined(); +}); + +test("load preserves valid auto-review selectors and trims boundary whitespace", () => { + writeAutoReviewConfig(" openai/gpt-test ", { "glm-5.2": " gpt-test " }); + + const loaded = loadConfig(); + + expect(loaded.providers.xai.autoReviewModel).toBe("openai/gpt-test"); + expect(loaded.providers.xai.autoReviewModelOverrides).toEqual({ "glm-5.2": "gpt-test" }); +}); + test("Fast rows default on for fresh and omitted config; explicit false and malformed values disable", () => { expect(getDefaultConfig().fastRows).toBe(true); for (const [value, expected] of [[undefined, true], [true, true], [false, false], ["invalid", false]] as const) { diff --git a/tests/server/management-provider-validation.test.ts b/tests/server/management-provider-validation.test.ts index 949791f2d6..dcdc28a1c5 100644 --- a/tests/server/management-provider-validation.test.ts +++ b/tests/server/management-provider-validation.test.ts @@ -30,7 +30,11 @@ import { } from "../../src/server"; import { handleManagementAPI } from "../../src/server/management-api"; import { providerEditorConfigDTO, providerManagementConfigError } from "../../src/server/auth-cors"; -import { providerEmptyToolOutputConfigError } from "../../src/config/provider-validation"; +import { + autoReviewModelOverridesConfigError, + autoReviewModelTargetConfigError, + providerEmptyToolOutputConfigError, +} from "../../src/config/provider-validation"; import { providerServiceTierConfigError, withProviderServiceTierDTO } from "../../src/server/management/provider-capability-config"; import { clearModelCache, markProviderDiscoveryFailed, markProviderDiscoveryOk } from "../../src/codex/model-cache"; import { @@ -675,6 +679,187 @@ describe("provider management validation", () => { } }); + test("provider management validates and patches auto-review selectors", async () => { + expect(autoReviewModelTargetConfigError(" opencode-go/deepseek-v4-flash ")).toBeNull(); + expect(autoReviewModelTargetConfigError("bad slug")).toContain("autoReviewModel"); + expect(autoReviewModelOverridesConfigError({ " model": "gpt-test" })).toContain("keys"); + + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const liveConfig: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "relay", + providers: { + relay: { adapter: "openai-chat", baseUrl: "https://relay.example/v1" }, + }, + }; + saveConfig(liveConfig); + const request = async (path: string, init?: RequestInit) => { + const req = new Request(`http://127.0.0.1${path}`, init); + return handleManagementAPI(req, new URL(req.url), liveConfig, { + createManagementConvergeCodex: catalogConvergenceFactory(), + }); + }; + + const reject = await request("/api/providers?name=relay", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ autoReviewModelOverrides: { "glm-5.2": "bad target" } }), + }); + expect(reject?.status).toBe(400); + expect(await reject?.json()).toMatchObject({ error: expect.stringContaining("autoReviewModelOverrides") }); + + const set = await request("/api/providers?name=relay", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + autoReviewModel: "openai/gpt-test", + autoReviewModelOverrides: { "glm-5.2": "gpt-test" }, + }), + }); + expect(set?.status).toBe(200); + expect(liveConfig.providers.relay?.autoReviewModel).toBe("openai/gpt-test"); + expect(liveConfig.providers.relay?.autoReviewModelOverrides).toEqual({ "glm-5.2": "gpt-test" }); + expect(loadConfig().providers.relay?.autoReviewModelOverrides).toEqual({ "glm-5.2": "gpt-test" }); + + const update = await request("/api/providers?name=relay", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ autoReviewModelOverrides: { "GLM-5.2": "gpt-5.6-terra" } }), + }); + expect(update?.status).toBe(200); + expect(liveConfig.providers.relay?.autoReviewModelOverrides).toEqual({ "GLM-5.2": "gpt-5.6-terra" }); + + const remove = await request("/api/providers?name=relay", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ autoReviewModelOverrides: { "glm-5.2": null } }), + }); + expect(remove?.status).toBe(200); + expect(liveConfig.providers.relay?.autoReviewModelOverrides).toBeUndefined(); + + const clear = await request("/api/providers?name=relay", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ autoReviewModel: null, autoReviewModelOverrides: null }), + }); + expect(clear?.status).toBe(200); + expect(liveConfig.providers.relay).not.toHaveProperty("autoReviewModel"); + expect(liveConfig.providers.relay).not.toHaveProperty("autoReviewModelOverrides"); + }); + + test("a clear sharing a normalized key with a set is rejected instead of racing on order", async () => { + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const liveConfig: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "relay", + providers: { + relay: { adapter: "openai-chat", baseUrl: "https://relay.example/v1" }, + }, + }; + saveConfig(liveConfig); + const request = async (body: Record) => { + const req = new Request("http://127.0.0.1/api/providers?name=relay", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + return handleManagementAPI(req, new URL(req.url), liveConfig, { + createManagementConvergeCodex: catalogConvergenceFactory(), + }); + }; + + for (const overrides of [ + { "glm-5.2": null, "GLM-5.2": "gpt-test" }, + { "GLM-5.2": "gpt-test", "glm-5.2": null }, + { "glm-5.2": null, "GLM-5.2": null }, + ]) { + const response = await request({ autoReviewModelOverrides: overrides }); + expect(response?.status).toBe(400); + expect(await response?.json()).toMatchObject({ error: expect.stringContaining("unique") }); + } + expect(liveConfig.providers.relay).not.toHaveProperty("autoReviewModelOverrides"); + }); + + test("canonical openai provider rejects auto-review fields", async () => { + expect(providerManagementConfigError("openai", { + ...canonicalDirect, + codexAccountMode: "pool", + autoReviewModel: "gpt-test", + })).toContain("autoReviewModel"); + }); + + test("provider POST overwrite preserves auto-review selectors when omitted", async () => { + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const liveConfig: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "relay", + providers: { + relay: { + adapter: "openai-chat", + baseUrl: "https://relay.example/v1", + autoReviewModel: "openai/gpt-test", + autoReviewModelOverrides: { "glm-5.2": "gpt-test" }, + }, + }, + }; + saveConfig(liveConfig); + const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + try { + const req = new Request("http://127.0.0.1/api/providers", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "relay", + provider: { adapter: "openai-chat", baseUrl: "https://relay.example/v1" }, + }), + }); + const response = await handleManagementAPI( + req, + new URL(req.url), + liveConfig, + { createManagementConvergeCodex: catalogConvergenceFactory() }, + ); + expect(response?.status).toBe(200); + expect(liveConfig.providers.relay?.autoReviewModel).toBe("openai/gpt-test"); + expect(liveConfig.providers.relay?.autoReviewModelOverrides).toEqual({ "glm-5.2": "gpt-test" }); + expect(loadConfig().providers.relay?.autoReviewModelOverrides).toEqual({ "glm-5.2": "gpt-test" }); + + const blankReq = new Request("http://127.0.0.1/api/providers", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "relay", + provider: { + adapter: "openai-chat", + baseUrl: "https://relay.example/v1", + autoReviewModel: "", + autoReviewModelOverrides: {}, + }, + }), + }); + const blankResponse = await handleManagementAPI( + blankReq, + new URL(blankReq.url), + liveConfig, + { createManagementConvergeCodex: catalogConvergenceFactory() }, + ); + expect(blankResponse?.status).toBe(200); + expect(liveConfig.providers.relay).not.toHaveProperty("autoReviewModel"); + expect(liveConfig.providers.relay).not.toHaveProperty("autoReviewModelOverrides"); + } finally { + resolvedError.mockRestore(); + } + }); + test("provider management rejects modelCosts rows with extra fields", () => { const error = providerManagementConfigError("blsc", { adapter: "openai-chat", @@ -1339,6 +1524,59 @@ describe("provider management validation", () => { } }); + test("canonical openai PATCH and POST reject auto-review fields in every clear form", async () => { + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig({ + port: 0, + openaiProviderTierVersion: 2, + defaultProvider: "openai", + providers: { openai: { ...canonicalDirect } }, + } as OcxConfig); + const server = startServer(0); + try { + const before = readFileSync(join(TEST_DIR, "config.json")); + // A clear or no-op value would otherwise delete the field from the merged row before the + // canonical-openai guard sees it, answering 200 for a field the provider may not carry. + for (const body of [ + { autoReviewModel: "gpt-test" }, + { autoReviewModel: null }, + { autoReviewModel: "" }, + { autoReviewModelOverrides: null }, + { autoReviewModelOverrides: {} }, + { autoReviewModelOverrides: { "glm-5.2": "" } }, + { autoReviewModelOverrides: { "glm-5.2": null } }, + ]) { + const response = await fetch(new URL("/api/providers?name=openai", server.url), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: expect.stringContaining("autoReviewModel") }); + } + // POST carries the same prohibition: the clear forms are normalized away before the + // merged-row guard, so they have to be rejected on the submitted body instead. + for (const body of [ + { autoReviewModel: null }, + { autoReviewModelOverrides: {} }, + { autoReviewModelOverrides: { "glm-5.2": null } }, + ]) { + const response = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "openai", provider: { ...canonicalDirect, ...body } }), + }); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: expect.stringContaining("autoReviewModel") }); + } + expect(readFileSync(join(TEST_DIR, "config.json"))).toEqual(before); + } finally { + await server.stop(true); + } + }); + test("malformed alias overlays return bounded 4xx without config persistence", async () => { if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); mkdirSync(TEST_DIR, { recursive: true }); @@ -5005,3 +5243,50 @@ describe("remembered provider context selections", () => { } }); }); + + +test("raw provider editor normalizes reviewer clears before live adoption", async () => { + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const live: OcxConfig = { + port: 0, defaultProvider: "review-fixture", + providers: { "review-fixture": { + adapter: "openai-chat", baseUrl: "https://example.test/v1", liveModels: false, + models: ["ModelA", "modela", "reviewer"], + autoReviewModel: "reviewer", autoReviewModelOverrides: { ModelA: "reviewer", modela: "ModelA" }, + } }, + }; + saveConfig(live); + const request = async (method: string, body?: unknown) => { + const url = new URL("http://localhost/api/providers"); + return (await handleManagementAPI(new Request(url, { + method, headers: { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }), url, live, { createManagementConvergeCodex: catalogConvergenceFactory() }))!; + }; + for (const fields of [ + { autoReviewModel: " reviewer ", autoReviewModelOverrides: { ModelA: " reviewer ", modela: null } }, + { autoReviewModel: null, autoReviewModelOverrides: null }, + ]) { + const baseline = providerEditorConfigDTO(loadConfig()); + const next = structuredClone(baseline); + Object.assign(next.providers["review-fixture"]!, fields); + const response = await request("PUT", { baseline, next }); + expect(response.status, await response.text()).toBe(200); + const persisted = loadConfig().providers["review-fixture"]!; + expect(live.providers["review-fixture"]!.autoReviewModel).toEqual(persisted.autoReviewModel); + expect(live.providers["review-fixture"]!.autoReviewModelOverrides).toEqual(persisted.autoReviewModelOverrides); + if (fields.autoReviewModel === null) { + expect(live.providers["review-fixture"]!.autoReviewModel).toBeUndefined(); + expect(live.providers["review-fixture"]!.autoReviewModelOverrides).toBeUndefined(); + } else { + expect(live.providers["review-fixture"]!.autoReviewModel).toBe("reviewer"); + expect(live.providers["review-fixture"]!.autoReviewModelOverrides).toEqual({ ModelA: "reviewer" }); + } + expect((await request("GET")).status).toBe(200); + const updated = providerEditorConfigDTO(loadConfig()); + const unrelated = structuredClone(updated); + unrelated.providers["review-fixture"]!.note = "after normalization"; + expect((await request("PUT", { baseline: updated, next: unrelated })).status).toBe(200); + } +}); From 23404983637698fc40b31f6cbe5c36f5aafe138d Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:58:27 +0900 Subject: [PATCH 02/12] fix(catalog): retain reviewer provenance across repeated syncs --- src/codex/catalog/sync.ts | 4 ++-- structure/catalog.md | 2 +- tests/codex-integration/codex-catalog.test.ts | 12 ++++++++++++ .../codex-convergence-account-selectors.test.ts | 11 +++++++++-- tests/server/management-provider-validation.test.ts | 10 +++++----- 5 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index f321cbc1a8..94aa3e6e02 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -1620,15 +1620,15 @@ function isRoutedCatalogEntry(entry: RawEntry): boolean { || (typeof entry.description === "string" && entry.description.startsWith("Routed via opencodex → ")); } -/** Remove an override and its root-derived provenance marker from one catalog row. */ +/** Restore an owned native value, retaining provenance to avoid legacy reclassification. */ function clearAutoReviewOverrideValue(entry: RawEntry): void { const stamp = rootAutoReviewStamp(entry); if (stamp) { if (entry.auto_review_model_override === stamp.applied) entry.auto_review_model_override = stamp.original; } else { entry.auto_review_model_override = null; + delete entry[AUTO_REVIEW_ROOT_MARKER]; } - delete entry[AUTO_REVIEW_ROOT_MARKER]; } /** diff --git a/structure/catalog.md b/structure/catalog.md index 6e6aa5ce7e..91d2659712 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -280,4 +280,4 @@ Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#c privately to final dispatch; preliminary route selection does not inject Go-only headers. ## Provider-scoped approval reviewer -`src/codex/catalog/sync.ts` resolves exact case-preserving provider/model reviewer selectors against the final catalog in both retained sync and `src/codex/convergence.ts`. Valid per-model selection wins over valid provider-wide selection, then the root selector supplies fallback. Native root stamps retain the observed original value and applied selector bound to their slug; removal restores the original only while the applied value is unchanged. Ambiguous legacy unmarked catalogs retain their existing heuristic cleanup. Provider stamps do not change routing or credentials. +`src/codex/catalog/sync.ts` resolves exact case-preserving provider/model reviewer selectors against the final catalog in both retained sync and `src/codex/convergence.ts`. Valid per-model selection wins over valid provider-wide selection, then the root selector supplies fallback. Native root stamps retain the observed original value and applied selector bound to their slug; removal restores the original only while the applied value is unchanged. The native provenance remains after restoration so an equal provider reviewer cannot trigger legacy reclassification on the next sync. Ambiguous legacy unmarked catalogs retain their existing heuristic cleanup. Provider stamps do not change routing or credentials. diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index aaf66bbd15..6c6050d92b 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -7471,6 +7471,18 @@ describe("provider-level auto_review_model overrides", () => { expect(models[0]!.auto_review_model_override).toBe("external-reviewer"); }); + test("restored native values are not later misclassified as uniform legacy stamps", () => { + const models: Array> = [ + { slug: "gpt-5.6-terra", auto_review_model_override: "blsc/reviewer" }, + { slug: "blsc/reviewer" }, + ]; + const providerConfig = config({ autoReviewModel: "reviewer" }); + applyConfiguredAutoReviewModelOverride(models, "gpt-5.6-terra", providerConfig); + applyConfiguredAutoReviewModelOverride(models, null, providerConfig); + applyConfiguredAutoReviewModelOverride(models, null, providerConfig); + expect(models[0]!.auto_review_model_override).toBe("blsc/reviewer"); + }); + test("legacy root stamps are swept when provider configuration replaces the root in one step", () => { // Catalogs written before the provenance marker carry root stamps that look exactly like // upstream values, so removing the root while adding a provider selector has to fall back to diff --git a/tests/codex-integration/codex-convergence-account-selectors.test.ts b/tests/codex-integration/codex-convergence-account-selectors.test.ts index 3414f4ab58..a7661b60d2 100644 --- a/tests/codex-integration/codex-convergence-account-selectors.test.ts +++ b/tests/codex-integration/codex-convergence-account-selectors.test.ts @@ -759,7 +759,7 @@ test("provider-scoped auto-review overrides win on routed rows in both writers", writeAutoReviewModel("gpt-5.5"); writeCatalog([ - { ...nativeEntry(), auto_review_model_override: "native-upstream" }, + { ...nativeEntry(), auto_review_model_override: "static/deepseek-v4-flash" }, generatedRoutedEntry("static/deepseek-v4-flash"), generatedRoutedEntry("static/glm-5.2"), ]); @@ -783,9 +783,16 @@ test("provider-scoped auto-review overrides win on routed rows in both writers", writeAutoReviewModel(undefined); catalog = await write(nextConfig); expect(catalog.models?.find(entry => entry.slug === "gpt-5.5")) - .toHaveProperty("auto_review_model_override", "native-upstream"); + .toHaveProperty("auto_review_model_override", "static/deepseek-v4-flash"); expect(catalog.models?.find(entry => entry.slug === "static/glm-5.2")) .toHaveProperty("auto_review_model_override", null); + nextConfig.providers.static!.autoReviewModel = "deepseek-v4-flash"; + catalog = await write(nextConfig); + // Force a real changed write with the same reviewer provenance, without reseeding. + nextConfig.providers.static!.modelDisplayNames = { "glm-5.2": "Updated label" }; + catalog = await write(nextConfig); + expect(catalog.models?.find(entry => entry.slug === "gpt-5.5")) + .toHaveProperty("auto_review_model_override", "static/deepseek-v4-flash"); } }); diff --git a/tests/server/management-provider-validation.test.ts b/tests/server/management-provider-validation.test.ts index dcdc28a1c5..eb81364839 100644 --- a/tests/server/management-provider-validation.test.ts +++ b/tests/server/management-provider-validation.test.ts @@ -730,7 +730,7 @@ describe("provider management validation", () => { body: JSON.stringify({ autoReviewModelOverrides: { "GLM-5.2": "gpt-5.6-terra" } }), }); expect(update?.status).toBe(200); - expect(liveConfig.providers.relay?.autoReviewModelOverrides).toEqual({ "GLM-5.2": "gpt-5.6-terra" }); + expect(liveConfig.providers.relay?.autoReviewModelOverrides).toEqual({ "glm-5.2": "gpt-test", "GLM-5.2": "gpt-5.6-terra" }); const remove = await request("/api/providers?name=relay", { method: "PATCH", @@ -738,7 +738,7 @@ describe("provider management validation", () => { body: JSON.stringify({ autoReviewModelOverrides: { "glm-5.2": null } }), }); expect(remove?.status).toBe(200); - expect(liveConfig.providers.relay?.autoReviewModelOverrides).toBeUndefined(); + expect(liveConfig.providers.relay?.autoReviewModelOverrides).toEqual({ "GLM-5.2": "gpt-5.6-terra" }); const clear = await request("/api/providers?name=relay", { method: "PATCH", @@ -775,9 +775,9 @@ describe("provider management validation", () => { }; for (const overrides of [ - { "glm-5.2": null, "GLM-5.2": "gpt-test" }, - { "GLM-5.2": "gpt-test", "glm-5.2": null }, - { "glm-5.2": null, "GLM-5.2": null }, + { "vendor/model": null, "vendor-model": "gpt-test" }, + { "vendor-model": "gpt-test", "vendor/model": null }, + { "vendor/model": null, "vendor-model": null }, ]) { const response = await request({ autoReviewModelOverrides: overrides }); expect(response?.status).toBe(400); From b342eb5c73f736c18613c46e21e4b3390480c404 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:11:48 +0900 Subject: [PATCH 03/12] fix(providers): inherit reasoning tables for renamed key destinations Refs #4308. Fill missing metadata through existing destination matching while preserving explicit provider and model overrides. --- .../docs/reference/configuration/providers.md | 4 ++ src/providers/derive.ts | 12 ++++-- structure/catalog.md | 4 ++ structure/ops/docs-and-release.md | 2 + structure/providers/xai-grok.md | 2 + structure/runtime.md | 2 + structure/subagents.md | 2 + structure/transports/inventory.md | 2 + tests/config/client-config-export.test.ts | 33 ++++++++++++++ .../provider-registry-parity.test.ts | 43 +++++++++++++++++++ 10 files changed, 103 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 23b7e5e92d..d02a95506b 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -1043,3 +1043,7 @@ or expiry does not extend the history-recovery contract. Sender and recipient on routed Responses are context for the receiving model, not a new machine-readable routing protocol. Tool routing continues to use the existing collaboration contracts. + +### Renamed API-key presets + +A provider saved under another name, such as `CommandCode`, inherits missing reasoning-effort metadata when its adapter and fixed API-key endpoint match a registry preset. Your explicit per-model lists, including `[]`, remain authoritative. An omitted provider-wide list inherits the preset default; an explicit list remains unchanged. This does not match OAuth, unrelated endpoints, or templated/custom endpoint presets. diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 72a662aee4..08434f1f93 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -441,13 +441,19 @@ function applyVerbosityDefaults(prov: OcxProviderConfig, entry: ProviderRegistry * was skipped and the reasoning ladder was advertised without summary support — exactly the * inconsistency that makes Codex drop the inbound reasoning object. * - * Deliberately narrow: only the reasoning-summary map, and only via + * Deliberately narrow: reasoning-summary and effort metadata only, via * `registryEntryForProviderDestination`, which matches fixed key destinations and refuses * templated or overridable base URLs. A custom row keeps its own identity for everything else. */ -function enrichReasoningSummariesByDestination(prov: OcxProviderConfig): void { +function enrichReasoningMetadataByDestination(prov: OcxProviderConfig): void { const destination = registryEntryForProviderDestination(prov); applyReasoningSummaryDefaults(prov, destination?.modelSupportsReasoningSummaries); + if (destination?.modelReasoningEfforts) { + prov.modelReasoningEfforts = fillRecordOfArrays(destination.modelReasoningEfforts, prov.modelReasoningEfforts); + } + if (prov.reasoningEfforts === undefined && destination?.reasoningEfforts !== undefined) { + prov.reasoningEfforts = [...destination.reasoningEfforts]; + } } /** Repair the exact low-only ClinePass ladder generated by older key-login presets. */ @@ -467,7 +473,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig // `registryEntryForProviderDestination` answers the question that actually matters here — // which vendor endpoint is this row talking to — and is already restricted to fixed key // destinations, so a templated or overridable base URL cannot be claimed by it. - enrichReasoningSummariesByDestination(prov); + enrichReasoningMetadataByDestination(prov); applyServiceTierModelDefaults(prov, serviceTierModelDefaultsFor(registryEntryForProviderDestination(prov), prov)); applyVerbosityDefaults(prov, registryEntryForProviderDestination(prov)); return; diff --git a/structure/catalog.md b/structure/catalog.md index 0ba4acca3e..f71af030cc 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -278,3 +278,7 @@ see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing- Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +## Renamed destination reasoning metadata + +`src/providers/derive.ts` fills missing reasoning tables for renamed providers accepted by the existing fixed-key destination matcher. Model entries are cloned and explicit user entries (including empty arrays) win. Provider-wide effort defaults fill only when undefined; Command Code unknown models therefore keep the registry's empty picker policy unless overridden. Identity, transport and other capability axes are unchanged. The gathered row drives client exports; this metadata contract does not prove arbitrary gateway routing. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 827540194a..1c936b4062 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -314,3 +314,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. The integrations guide documents Cline CLI as a two-file, loopback-only integration. Hosted CI validates its source-backed fixtures; the packaged dashboard exposes it through the existing client list. + +Renamed fixed-key providers receive [missing reasoning metadata](../catalog.md#renamed-destination-reasoning-metadata) during derivation; explicit per-model entries and provider defaults retain precedence. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 5b149ac6a2..4fc5998c2b 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -65,3 +65,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Renamed fixed-key providers receive [missing reasoning metadata](../catalog.md#renamed-destination-reasoning-metadata) during derivation; explicit per-model entries and provider defaults retain precedence. diff --git a/structure/runtime.md b/structure/runtime.md index 522e5cabb9..458c466fbe 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -225,3 +225,5 @@ Cline CLI joins the existing export/client integration registries. Explicit CLI `claudeCode.stabilizePromptCache` is a default-off operator setting for [translated instruction stabilization](data-planes/inbound-compat.md#opt-in-claude-instruction-stabilization). Config JSON preserves the boolean; only literal true activates the role-changing transform. + +Renamed fixed-key providers receive [missing reasoning metadata](catalog.md#renamed-destination-reasoning-metadata) during derivation; explicit per-model entries and provider defaults retain precedence. diff --git a/structure/subagents.md b/structure/subagents.md index f190aab084..87d5bd174b 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -214,3 +214,5 @@ see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing- Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Renamed fixed-key providers receive [missing reasoning metadata](catalog.md#renamed-destination-reasoning-metadata) during derivation; explicit per-model entries and provider defaults retain precedence. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index b2fc3b3fae..db3d67c873 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -70,3 +70,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Renamed fixed-key providers receive [missing reasoning metadata](../catalog.md#renamed-destination-reasoning-metadata) during derivation; explicit per-model entries and provider defaults retain precedence. diff --git a/tests/config/client-config-export.test.ts b/tests/config/client-config-export.test.ts index 53e47c6276..50b3b5a5a2 100644 --- a/tests/config/client-config-export.test.ts +++ b/tests/config/client-config-export.test.ts @@ -1105,3 +1105,36 @@ describe("EXPORT_CLIENTS registry", () => { expect(piConfig(empty).providers.opencodex!.models).toEqual([]); }); }); + + +test("renamed CommandCode gathered effort tables reach DSH and ZCode exports", async () => { + const { gatherRoutedModels } = await import("../../src/codex/catalog"); + const { clearModelCache } = await import("../../src/codex/model-cache"); + const { installIsolatedCodexHome } = await import("../helpers/isolated-codex-home"); + const isolated = installIsolatedCodexHome("ocx-renamed-provider-export-"); + const known = "deepseek/deepseek-v4-flash"; + const overridden = "deepseek/deepseek-v4.1-flash"; + const config = cfg({ defaultProvider: "CommandCode", providers: { CommandCode: { + adapter: "openai-chat", authMode: "key", baseUrl: "https://api.commandcode.ai/provider/v1", + liveModels: false, models: [known, overridden, "unknown-model"], + modelReasoningEfforts: { [overridden]: ["low"] }, + } } }); + try { + clearModelCache(); + const gathered = await gatherRoutedModels(config); + const rows = gathered.map(row => ({ ...row, namespaced: `CommandCode/${row.id}` })); + const models = exportModelsFromProxyRows(rows, config); + const context = ctx({ models, config }); + const dshConfig = dsh.buildDshClientConfig(context); + const dshModels = Object.values(dshConfig["llm-pi-ai"].providers).flatMap(provider => provider.models); + expect(dshModels.find(model => model.id === `CommandCode/${known}`)?.reasoningEfforts).toEqual({ high: "high", max: "max" }); + expect(dshModels.find(model => model.id === `CommandCode/${overridden}`)?.reasoningEfforts).toEqual({ low: "low" }); + expect(dshModels.find(model => model.id === "CommandCode/unknown-model")?.reasoningEfforts).toBeUndefined(); + const zcodeModels = Object.values(zcode.buildZcodeClientConfig(context).provider).flatMap(provider => Object.values(provider.models)); + expect(zcodeModels.filter(model => model.reasoning?.variants.includes("high"))).toHaveLength(1); + expect(zcodeModels.filter(model => model.reasoning?.variants.includes("low"))).toHaveLength(1); + } finally { + clearModelCache(); + isolated.restore(); + } +}); diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index bfe4532edb..0e8ae09b58 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -1,3 +1,4 @@ +import { configuredReasoningEfforts } from "../../src/reasoning-effort"; import { describe, expect, spyOn, test } from "bun:test"; import { buildCatalogEntries } from "../../src/codex/catalog"; import { CURSOR_NO_VISION_MODELS } from "../../src/adapters/cursor/discovery"; @@ -1527,3 +1528,45 @@ describe("free-provider directory isolation", () => { } }); }); + + +describe("renamed fixed-key destination reasoning metadata", () => { + const known = "deepseek/deepseek-v4-flash"; + const newer = "deepseek/deepseek-v4.1-flash"; + const make = (overrides: Partial = {}): OcxProviderConfig => ({ + adapter: "openai-chat", authMode: "key", baseUrl: "https://api.commandcode.ai/provider/v1", ...overrides, + }); + test("fills known model tables and unknown-model default for CommandCode", () => { + const provider = make(); + enrichProviderFromRegistry("CommandCode", provider); + expect(configuredReasoningEfforts(provider, known)).toEqual(["high", "max"]); + expect(configuredReasoningEfforts(provider, newer)).toEqual(["high", "max"]); + expect(configuredReasoningEfforts(provider, "unknown-model")).toEqual([]); + }); + test("preserves explicit entries and clones arrays without losing other table rows", () => { + const caller = ["low"]; + const provider = make({ reasoningEfforts: ["medium"], modelReasoningEfforts: { [known]: caller, custom: [] } }); + const registry = registryEntryForProviderDestination(provider)!; + const registryBefore = structuredClone(registry.modelReasoningEfforts); + enrichProviderFromRegistry("CommandCode", provider); + const once = structuredClone(provider); + enrichProviderFromRegistry("CommandCode", provider); + expect(provider).toEqual(once); + expect(configuredReasoningEfforts(provider, known)).toEqual(["low"]); + expect(configuredReasoningEfforts(provider, newer)).toEqual(["high", "max"]); + expect(configuredReasoningEfforts(provider, "custom")).toEqual([]); + expect(configuredReasoningEfforts(provider, "unknown-model")).toEqual(["medium"]); + provider.modelReasoningEfforts![known]!.push("high"); + provider.modelReasoningEfforts![newer]!.push("low"); + expect(caller).toEqual(["low"]); + expect(registry.modelReasoningEfforts).toEqual(registryBefore); + }); + test("does not infer metadata for a different adapter, OAuth, or unrelated endpoint", () => { + for (const override of [{ adapter: "openai-responses" }, { authMode: "oauth" as const }, { baseUrl: "https://example.test/v1" }]) { + const provider = make(override); + enrichProviderFromRegistry("CommandCode", provider); + expect(provider.modelReasoningEfforts).toBeUndefined(); + expect(provider.reasoningEfforts).toBeUndefined(); + } + }); +}); From 6c29ffa0aae6298cd2ae438b6ae019c2680d83a3 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:13:21 +0900 Subject: [PATCH 04/12] test(providers): bind renamed-provider export assertions to model rows --- tests/config/client-config-export.test.ts | 13 ++++++++++--- tests/providers/provider-registry-parity.test.ts | 6 ++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/tests/config/client-config-export.test.ts b/tests/config/client-config-export.test.ts index 50b3b5a5a2..1884dc941c 100644 --- a/tests/config/client-config-export.test.ts +++ b/tests/config/client-config-export.test.ts @@ -1130,9 +1130,16 @@ test("renamed CommandCode gathered effort tables reach DSH and ZCode exports", a expect(dshModels.find(model => model.id === `CommandCode/${known}`)?.reasoningEfforts).toEqual({ high: "high", max: "max" }); expect(dshModels.find(model => model.id === `CommandCode/${overridden}`)?.reasoningEfforts).toEqual({ low: "low" }); expect(dshModels.find(model => model.id === "CommandCode/unknown-model")?.reasoningEfforts).toBeUndefined(); - const zcodeModels = Object.values(zcode.buildZcodeClientConfig(context).provider).flatMap(provider => Object.values(provider.models)); - expect(zcodeModels.filter(model => model.reasoning?.variants.includes("high"))).toHaveLength(1); - expect(zcodeModels.filter(model => model.reasoning?.variants.includes("low"))).toHaveLength(1); + for (const id of [known, overridden, "unknown-model"]) { + expect(dshModels.find(model => model.id === `CommandCode/${id}`)).toBeDefined(); + } + const zcodeModels = Object.assign({}, ...Object.values(zcode.buildZcodeClientConfig(context).provider).map(provider => provider.models)) as Record; + expect(zcodeModels[`CommandCode/${known}`]).toBeDefined(); + expect(zcodeModels[`CommandCode/${known}`]!.reasoning?.variants).toEqual(["high", "max"]); + expect(zcodeModels[`CommandCode/${overridden}`]).toBeDefined(); + expect(zcodeModels[`CommandCode/${overridden}`]!.reasoning?.variants).toEqual(["low"]); + expect(zcodeModels["CommandCode/unknown-model"]).toBeDefined(); + expect(zcodeModels["CommandCode/unknown-model"]!.reasoning).toBeUndefined(); } finally { clearModelCache(); isolated.restore(); diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index 0e8ae09b58..091a141e62 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -1561,6 +1561,12 @@ describe("renamed fixed-key destination reasoning metadata", () => { expect(caller).toEqual(["low"]); expect(registry.modelReasoningEfforts).toEqual(registryBefore); }); + test("explicit empty model declaration overrides a seeded ladder", () => { + const provider = make({ modelReasoningEfforts: { [known]: [] } }); + enrichProviderFromRegistry("CommandCode", provider); + expect(configuredReasoningEfforts(provider, known)).toEqual([]); + expect(configuredReasoningEfforts(provider, newer)).toEqual(["high", "max"]); + }); test("does not infer metadata for a different adapter, OAuth, or unrelated endpoint", () => { for (const override of [{ adapter: "openai-responses" }, { authMode: "oauth" as const }, { baseUrl: "https://example.test/v1" }]) { const provider = make(override); From fbb04105a62b47f9cf5f9e9d5c82d7086f9d32e8 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:19:59 +0900 Subject: [PATCH 05/12] feat(providers): persist explicit per-model capability declarations Refs #3377. Add strict management writes, per-axis load recovery, exact model IDs, and declaration-preserving DTO/mutation paths. Upstream tier/video activation remains evidence-gated. --- .../docs/reference/configuration/providers.md | 6 ++ src/cli/provider.ts | 3 + src/codex/catalog/provider-fetch.ts | 1 + src/config.ts | 26 +++++ src/config/provider-validation.ts | 96 +++++++++++++++++++ src/server/auth-cors.ts | 4 + src/server/management/provider-routes.ts | 18 ++++ src/types.ts | 1 + src/types/provider.ts | 9 ++ structure/adapters/registry.md | 2 + structure/catalog.md | 2 + structure/clients/claude-desktop.md | 2 + structure/codex-home.md | 2 + structure/config.md | 4 + structure/data-planes/images.md | 2 + structure/data-planes/inbound-compat.md | 2 + structure/gui-and-management-api.md | 2 + structure/ops/docs-and-release.md | 2 + structure/ops/service-and-sidecars.md | 2 + structure/overview.md | 2 + structure/providers/openai-tiers.md | 2 + structure/providers/xai-grok.md | 2 + structure/runtime.md | 2 + structure/subagents.md | 2 + structure/transports/inventory.md | 2 + structure/transports/responses.md | 2 + structure/transports/streaming-health.md | 2 + .../codex-gather-authority.test.ts | 41 ++++++++ tests/config/config-load-degrade.test.ts | 34 +++++++ .../oauth-upsert-preserves-api-key.test.ts | 11 +++ .../management-provider-validation.test.ts | 43 +++++++++ 31 files changed, 331 insertions(+) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 23b7e5e92d..2e713e58d2 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -1043,3 +1043,9 @@ or expiry does not extend the history-recovery contract. Sender and recipient on routed Responses are context for the receiving model, not a new machine-readable routing protocol. Tool routing continues to use the existing collaboration contracts. + +### Per-model capability declarations + +`modelCapabilities` stores explicit declarations keyed by exact upstream model ID. IDs preserve case and must not contain surrounding whitespace. Each entry may contain `inputModalities` (`text`, `image`, `audio`, `video`), `contextTier` (`default`, `long_context`) and `video.processing` (`static`, `agentic`). These are operator declarations, not proof of provider support. Context-tier and video fields currently record intent only and do not activate upstream behavior or increase catalog windows. + +The raw provider editor and provider API expose this map. POST/PUT replace an explicitly supplied map and reject null entries. PATCH merges individual axes; null clears a map, model, axis or video processing value, while `{}` makes no change. Omitted provider overwrites preserve the existing map. Malformed hand-edited files retain valid independent axes and treat malformed explicit input modalities as text-only, with a diagnostic. diff --git a/src/cli/provider.ts b/src/cli/provider.ts index 47f23fee62..0f33ff98a5 100644 --- a/src/cli/provider.ts +++ b/src/cli/provider.ts @@ -224,6 +224,9 @@ async function handleAdd(args: string[]): Promise { } const existingProvider = config.providers[name]; + if (existingProvider?.modelCapabilities !== undefined && provConfig.modelCapabilities === undefined) { + provConfig.modelCapabilities = structuredClone(existingProvider.modelCapabilities); + } const { initializeProviderModelSelection } = await import("../providers/initial-model-selection"); initializeProviderModelSelection(name, provConfig, existingProvider, config); config.providers[name] = provConfig; diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 68910ce0eb..ae03da9f0d 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -600,6 +600,7 @@ function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Reco maxOut: prov.modelMaxOutputTokens ?? null, autoCompact: prov.modelAutoCompactTokenLimits ?? null, inMod: prov.modelInputModalities ?? null, + capabilities: prov.modelCapabilities ?? null, re: prov.modelReasoningEfforts ?? null, defRe: prov.modelDefaultReasoningEfforts ?? null, rsSum: prov.modelSupportsReasoningSummaries ?? null, diff --git a/src/config.ts b/src/config.ts index 5e81a7e5f1..1bcd06de03 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,3 +1,4 @@ +import { modelCapabilitiesConfigError, mergeModelCapabilities, sanitizeModelCapabilitiesForLoad } from "./config/provider-validation"; import { createHash } from "node:crypto"; import { chmodSync, constants as fsConstants, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; @@ -577,7 +578,13 @@ const modelPinnedEffortsSchema = z.unknown().superRefine((value, ctx) => { * Zod schema for one provider entry: known fields are validated strictly while unknown * fields pass through (preserved for runtime extensions). */ +const modelCapabilitiesSchema = z.unknown().superRefine((value, ctx) => { + const error = modelCapabilitiesConfigError(value); + if (error) ctx.addIssue({ code: "custom", message: error }); +}).transform(value => mergeModelCapabilities(undefined, value)); + const providerConfigSchema = z.object({ + modelCapabilities: modelCapabilitiesSchema.optional(), pinnedReasoningEffort: pinnedReasoningEffortSchema.optional(), modelPinnedReasoningEfforts: modelPinnedEffortsSchema.optional(), // Validated rather than left to passthrough: an unrecognized strategy would otherwise @@ -1907,6 +1914,23 @@ export function retryOn429PolicyConfigError(policy: unknown): string | null { * with a warning; strict rejection stays at the management/write boundary * (providerManagementConfigError). */ +function sanitizeCapabilityDeclarationsForLoad(parsed: unknown): void { + if (!parsed || typeof parsed !== "object") return; + const providers = (parsed as Record).providers; + if (!providers || typeof providers !== "object" || Array.isArray(providers)) return; + for (const [name, value] of Object.entries(providers)) { + if (!value || typeof value !== "object" || Array.isArray(value)) continue; + const provider = value as Record; + if (provider.modelCapabilities === undefined) continue; + if (modelCapabilitiesConfigError(provider.modelCapabilities) !== null) { + console.warn(`config.json provider ${JSON.stringify(redactSecretString(name))} has malformed modelCapabilities; retaining valid axes and restricting malformed input modalities to text`); + const repaired = sanitizeModelCapabilitiesForLoad(provider.modelCapabilities); + if (repaired) provider.modelCapabilities = repaired; + else delete provider.modelCapabilities; + } + } +} + function sanitizeModelCostsForLoad(parsed: unknown): void { if (!parsed || typeof parsed !== "object") return; const root = parsed as Record; @@ -2403,6 +2427,7 @@ export function loadConfig(): OcxConfig { sanitizeModelDisplayNamesForLoad(parsed); sanitizeRetryOn429ForLoad(parsed); sanitizeModelCostsForLoad(parsed); + sanitizeCapabilityDeclarationsForLoad(parsed); const result = configSchema.safeParse(parsed); if (result.success) { const config = normalizeApiKeyIds(result.data as OcxConfig); @@ -3027,6 +3052,7 @@ function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics { sanitizeModelDisplayNamesForLoad(parsed); sanitizeRetryOn429ForLoad(parsed); sanitizeModelCostsForLoad(parsed); + sanitizeCapabilityDeclarationsForLoad(parsed); const result = configSchema.safeParse(parsed); if (result.success) { return validFileConfigDiagnostics(normalizeApiKeyIds(result.data as OcxConfig), parsed); diff --git a/src/config/provider-validation.ts b/src/config/provider-validation.ts index c1e60033e8..76e2f86b94 100644 --- a/src/config/provider-validation.ts +++ b/src/config/provider-validation.ts @@ -11,6 +11,7 @@ import { REASONING_SUMMARY_DELIVERY_VALUES, UPSTREAM_HTTP_VERSION_VALUES, type OcxProviderConfig, + type ModelCapabilities, } from "../types"; const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; @@ -295,3 +296,98 @@ export function modelAdapterRecordConfigError( } return null; } + + +function capabilityRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value) + && [Object.prototype, null].includes(Object.getPrototypeOf(value)); +} + +/** Strict writes; only PATCH may carry deletion tombstones. Model IDs are exact. */ +export function modelCapabilitiesConfigError(value: unknown, allowTombstones = false): string | null { + if (value === undefined || (allowTombstones && value === null)) return null; + if (!capabilityRecord(value)) return "modelCapabilities must be a plain object"; + if (Object.keys(value).length > MODEL_DISCOVERY_MAX_MODELS) return "modelCapabilities has too many models"; + for (const [id, row] of Object.entries(value)) { + if (!isValidModelDiscoveryModelId(id) || ["__proto__", "prototype", "constructor"].includes(id)) { + return "modelCapabilities keys must be exact non-reserved model ids without surrounding whitespace"; + } + if (allowTombstones && row === null) continue; + if (!capabilityRecord(row)) return "modelCapabilities rows must be plain objects"; + for (const [axis, declaration] of Object.entries(row)) { + if (!["inputModalities", "contextTier", "video"].includes(axis)) return "modelCapabilities contains an unknown axis"; + if (allowTombstones && declaration === null) continue; + if (axis === "inputModalities") { + if (!Array.isArray(declaration) || declaration.length === 0 + || declaration.some(item => typeof item !== "string" || !["text", "image", "audio", "video"].includes(item))) { + return "modelCapabilities inputModalities must be a nonempty array of text, image, audio or video"; + } + } else if (axis === "contextTier") { + if (declaration !== "default" && declaration !== "long_context") return "modelCapabilities contextTier must be default or long_context"; + } else { + if (!capabilityRecord(declaration) || Object.keys(declaration).some(key => key !== "processing")) { + return "modelCapabilities video must be a plain object containing only processing"; + } + if (Object.hasOwn(declaration, "processing") && declaration.processing !== "static" && declaration.processing !== "agentic" + && !(allowTombstones && declaration.processing === null)) return "modelCapabilities video processing must be static or agentic"; + } + } + } + return null; +} + +/** Merge a validated patch without sharing nested objects with the live provider. */ +export function mergeModelCapabilities( + current: Record | undefined, + patch: unknown, +): Record | undefined { + if (patch === null) return undefined; + const next = Object.fromEntries(Object.entries(current ?? {}).map(([id, row]) => [id, structuredClone(row)])); + if (patch !== undefined) for (const [id, raw] of Object.entries(patch as Record | null>)) { + if (raw === null) { delete next[id]; continue; } + const row: ModelCapabilities = Object.hasOwn(next, id) ? next[id]! : {}; + for (const [axis, value] of Object.entries(raw)) { + if (axis === "inputModalities") { + if (value === null) delete row.inputModalities; + else row.inputModalities = [...(value as NonNullable)]; + } else if (axis === "contextTier") { + if (value === null) delete row.contextTier; + else row.contextTier = value as ModelCapabilities["contextTier"]; + } else if (axis === "video") { + if (value === null) delete row.video; + else { + const video = { ...(row.video ?? {}) }; + const change = value as { processing?: "static" | "agentic" | null }; + if (Object.hasOwn(change, "processing")) { + if (change.processing === null) delete video.processing; + else video.processing = change.processing; + } + if (Object.keys(video).length) row.video = video; + else delete row.video; + } + } + } + if (Object.keys(row).length) next[id] = row; + else delete next[id]; + } + return Object.keys(next).length ? next : undefined; +} + +/** Load-only repair preserves independent valid axes; malformed explicit modalities restrict to text. */ +export function sanitizeModelCapabilitiesForLoad(value: unknown): Record | undefined { + if (!capabilityRecord(value)) return undefined; + const rows: Record = Object.create(null); + for (const [id, raw] of Object.entries(value).slice(0, MODEL_DISCOVERY_MAX_MODELS)) { + if (!capabilityRecord(raw) || !isValidModelDiscoveryModelId(id) || ["__proto__", "prototype", "constructor"].includes(id)) continue; + const row: Record = {}; + for (const axis of ["inputModalities", "contextTier", "video"] as const) { + if (!Object.hasOwn(raw, axis)) continue; + const declaration = raw[axis]; + if (modelCapabilitiesConfigError({ [id]: { [axis]: declaration } }) === null) row[axis] = declaration; + else if (axis === "inputModalities") row.inputModalities = ["text"]; + } + const normalized = mergeModelCapabilities(undefined, { [id]: row }); + if (normalized?.[id]) rows[id] = normalized[id]; + } + return Object.keys(rows).length ? rows : undefined; +} diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 0910698a0c..972b415f1f 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -1,3 +1,4 @@ +import { modelCapabilitiesConfigError } from "../config/provider-validation"; import { timingSafeEqual } from "node:crypto"; import { initialModelSelection } from "../providers/initial-model-selection"; import { extractAccountId } from "../oauth/chatgpt"; @@ -588,6 +589,8 @@ export function providerManagementConfigError(name: unknown, provider: unknown): return "provider must be a plain object"; } const raw = provider as Record; + const capabilitiesError = modelCapabilitiesConfigError(raw.modelCapabilities); + if (capabilitiesError) return capabilitiesError; const pinsError = providerReasoningPinsConfigError(raw); if (pinsError) return pinsError; for (const field of FORBIDDEN_PROVIDER_RUNTIME_FIELDS) { @@ -833,6 +836,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { contextWindow: "editor", modelContextWindows: "editor", modelInputModalities: "editor", + modelCapabilities: "editor", modelMaxInputTokens: "runtime", modelAutoCompactTokenLimits: "editor", defaultMaxOutputTokens: "editor", diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 6977a17783..863dbd79ed 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -1,3 +1,4 @@ +import { modelCapabilitiesConfigError, mergeModelCapabilities } from "../../config/provider-validation"; import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import { isDeepStrictEqual } from "node:util"; @@ -287,6 +288,9 @@ function providerEditorCandidate( if (provider.modelPinnedReasoningEfforts !== undefined) { provider.modelPinnedReasoningEfforts = validated.config.providers[name]!.modelPinnedReasoningEfforts; } + const capabilities = validated.config.providers[name]!.modelCapabilities; + if (capabilities === undefined) delete provider.modelCapabilities; + else provider.modelCapabilities = capabilities; } return { ok: true, config: candidate, removedProviders }; } @@ -490,6 +494,14 @@ function applyProviderPatchFields( } touched = true; } + if (Object.hasOwn(rawBody, "modelCapabilities")) { + const error = modelCapabilitiesConfigError(rawBody.modelCapabilities, true); + if (error) return { error }; + const capabilities = mergeModelCapabilities(next.modelCapabilities, rawBody.modelCapabilities); + if (capabilities === undefined) delete next.modelCapabilities; + else next.modelCapabilities = capabilities; + touched = true; + } if (Object.hasOwn(rawBody, "modelContextWindows")) { const value = rawBody.modelContextWindows; if (value === null) { @@ -784,6 +796,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise; + /** Requested tier only; does not imply an upstream window or activate an unverified wire. */ + contextTier?: "default" | "long_context"; + video?: { processing?: "static" | "agentic" }; +} + export interface OcxProviderConfig { /** Optional short provider namespace used only at request/catalog presentation time. */ alias?: string; @@ -478,6 +486,7 @@ export interface OcxProviderConfig { modelContextWindows?: Record; /** Model-specific Codex catalog input modalities, e.g. ["text"] or ["text", "image"]. */ modelInputModalities?: Record; + modelCapabilities?: Record; /** Model-specific max input token limits. Values cap auto_compact_token_limit. */ modelMaxInputTokens?: Record; /** diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index a4dc21adbf..c321306fd1 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -66,3 +66,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. diff --git a/structure/catalog.md b/structure/catalog.md index 0ba4acca3e..3913e08dd3 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -278,3 +278,5 @@ see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing- Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +The [explicit model-capability contract](config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 2914823958..bc988164fb 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -91,3 +91,5 @@ The explicit sync coordinator also accepts Cline CLI as a separate file integrat `claudeCode.stabilizePromptCache` is a default-off operator setting for [translated instruction stabilization](../data-planes/inbound-compat.md#opt-in-claude-instruction-stabilization). Config JSON preserves the boolean; only literal true activates the role-changing transform. + +The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. diff --git a/structure/codex-home.md b/structure/codex-home.md index b11ddd3f1a..8faf8f1c04 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -236,3 +236,5 @@ Injection preflights affected history using the normalized config candidate befo The legacy external writer is now refused for affected rows in any store whose schema includes history_mode, even while their row mode is still legacy. This deliberately sacrifices automatic relabeling on migration-capable stores rather than racing native conversion. Synchronous/asynchronous restore, inline journal restore, and direct config removal preserve all artifacts on the same refusal. Native restore preflight also checks manifest-owned targets whose rows already returned to `openai`, including interrupted restores. Preimage capture distinguishes absent files from unreadable artifacts and aborts before mutation when a complete snapshot cannot be read. + +The [explicit model-capability contract](config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. diff --git a/structure/config.md b/structure/config.md index 80bb62bc73..00f763efae 100644 --- a/structure/config.md +++ b/structure/config.md @@ -205,3 +205,7 @@ The Cline client keeps connection settings and models in a separate native file `claudeCode.stabilizePromptCache` is a default-off operator setting for [translated instruction stabilization](data-planes/inbound-compat.md#opt-in-claude-instruction-stabilization). Config JSON preserves the boolean; only literal true activates the role-changing transform. + +## Explicit per-model capability declarations + +`modelCapabilities` on `src/types/provider.ts` stores exact model-ID entries with optional inputModalities, contextTier and video.processing axes. `src/config/provider-validation.ts` strictly validates writes and merges PATCH axes without sharing live objects; null map/model/axis/processing tombstones delete, while empty PATCH objects do nothing. Complete POST/PUT replacements reject tombstones. File reads retain valid axes; malformed explicit modalities restrict to text with a diagnostic. The two catalog writers receive explicit config and gather fingerprints include the map. This storage contract alone does not activate a context tier, advertise a larger window or enable video processing. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 25646c7de4..4ce8c300e7 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -79,3 +79,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 2d17c11875..3065f7e212 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -128,3 +128,5 @@ changes prompt roles, not conversation identity, and cannot guarantee upstream c Instruction notice extraction scans fence ranges once and walks original lines backwards with a decreasing cursor. It accepts exactly one ASCII space inside the token notice, preserves unmatched prefix bytes, and does not repeatedly scan or copy shrinking prompt prefixes. + +The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 73090d646e..3352286201 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -543,3 +543,5 @@ integration IO adapter. Its snapshot fingerprint cannot be checked against provi [the integration contract](clients/integrations.md#cline-paired-files) defines recovery. The existing dashboard file-client maps include Cline CLI and reuse its committed color mark. The export panel labels its download as a settings/catalog bundle; all locales explain that Undo restores both original files. + +The [explicit model-capability contract](config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 827540194a..232a57e650 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -314,3 +314,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. The integrations guide documents Cline CLI as a two-file, loopback-only integration. Hosted CI validates its source-backed fixtures; the packaged dashboard exposes it through the existing client list. + +The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 39dc9a82da..cd4e35d6ca 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -142,3 +142,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. diff --git a/structure/overview.md b/structure/overview.md index d5d1a2207a..c378aafe89 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -108,3 +108,5 @@ The management quota DTO keeps Combo editing aligned with scoped inference evide see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). Cline CLI is a managed file integration: its provider settings and catalog share one recoverable journal operation. The [paired-file contract](clients/integrations.md#cline-paired-files) defines its stop/restart requirement. + +The [explicit model-capability contract](config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index a44edff557..3cbacac96b 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -402,3 +402,5 @@ successful main usage refresh clears the runtime mark. ## Paginated history writer boundary `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. + +The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 5b149ac6a2..5555e3f158 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -65,3 +65,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. diff --git a/structure/runtime.md b/structure/runtime.md index 522e5cabb9..bb175079a4 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -225,3 +225,5 @@ Cline CLI joins the existing export/client integration registries. Explicit CLI `claudeCode.stabilizePromptCache` is a default-off operator setting for [translated instruction stabilization](data-planes/inbound-compat.md#opt-in-claude-instruction-stabilization). Config JSON preserves the boolean; only literal true activates the role-changing transform. + +The [explicit model-capability contract](config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. diff --git a/structure/subagents.md b/structure/subagents.md index f190aab084..e60d77a76c 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -214,3 +214,5 @@ see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing- Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +The [explicit model-capability contract](config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index b2fc3b3fae..96a724e4fd 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -70,3 +70,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 2321d78dd6..8034aeb76a 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -523,3 +523,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index 68093843ea..d63b97bb6b 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -199,3 +199,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. diff --git a/tests/codex-integration/codex-gather-authority.test.ts b/tests/codex-integration/codex-gather-authority.test.ts index ae3204a0a7..5e8f287d01 100644 --- a/tests/codex-integration/codex-gather-authority.test.ts +++ b/tests/codex-integration/codex-gather-authority.test.ts @@ -365,3 +365,44 @@ describe("catalog gather discovery-policy authority", () => { } }); }); + + +test("overlapping gathers with different explicit capability declarations stay isolated", async () => { + clearModelCache("together"); + clearGatherRoutedModelsInflight(); + const arrived = [deferred(), deferred()]; + const release = deferred(); + let count = 0; + globalThis.fetch = (async () => { + const index = count++; + arrived[index]?.resolve(); + await release.promise; + return Response.json({ data: [{ id: `cap-model-${index}` }] }); + }) as typeof fetch; + const a = togetherConfig(); + const b = togetherConfig(); + a.providers.together!.modelCapabilities = { model: { contextTier: "default" } }; + b.providers.together!.modelCapabilities = { model: { contextTier: "long_context" } }; + const first = gatherRoutedModels(a); + let second: ReturnType | undefined; + try { + await arrived[0]!.promise; + second = gatherRoutedModels(b); + let timeout: ReturnType | undefined; + try { + await Promise.race([arrived[1]!.promise, new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error("second capability gather joined the first flight")), 10_000); + })]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } + expect(count).toBe(2); + release.resolve(); + const [firstRows, secondRows] = await Promise.all([first, second]); + expect(firstRows.some(row => row.id === "cap-model-0")).toBe(true); + expect(secondRows.some(row => row.id === "cap-model-1")).toBe(true); + } finally { + release.resolve(); + await Promise.allSettled([first, ...(second ? [second] : [])]); + } +}, 20_000); diff --git a/tests/config/config-load-degrade.test.ts b/tests/config/config-load-degrade.test.ts index 4d8cca68f7..93a36e845d 100644 --- a/tests/config/config-load-degrade.test.ts +++ b/tests/config/config-load-degrade.test.ts @@ -139,3 +139,37 @@ test("Fast rows default on for fresh and omitted config; explicit false and malf expect(loaded.providers.xai.note).toBe("keep me"); } }); + + +test("model capability writes stay strict while load preserves independent restrictions", () => { + const raw = { ...candidate(undefined), providers: { xai: { + ...candidate(undefined).providers.xai, + apiKey: "fixture-key", modelCapabilities: { + ModelA: { inputModalities: ["text"] }, + modela: { contextTier: "long_context" }, + broken: { inputModalities: "image", contextTier: "typo" }, + }, + } } }; + expect(validateConfigCandidate(raw).ok).toBe(false); + writeFileSync(getConfigPath(), JSON.stringify(raw), "utf8"); + const loaded = loadConfig(); + expect(loaded.providers.xai.modelCapabilities).toEqual({ + ModelA: { inputModalities: ["text"] }, modela: { contextTier: "long_context" }, + broken: { inputModalities: ["text"] }, + }); + expect(loaded.providers.xai.apiKey).toBe("fixture-key"); + expect(validateConfigCandidate(loaded).ok).toBe(true); +}); + +test("model capabilities round-trip all explicit axes without expanding inference", () => { + const raw = { ...candidate(undefined), providers: { xai: { + ...candidate(undefined).providers.xai, + modelCapabilities: { ModelA: { inputModalities: ["text", "image"], contextTier: "long_context", video: { processing: "agentic" } } }, + } } }; + const validated = validateConfigCandidate(raw); + expect(validated.ok).toBe(true); + if (!validated.ok) return; + saveConfig(validated.config); + expect(loadConfig().providers.xai.modelCapabilities).toEqual(raw.providers.xai.modelCapabilities); + expect(loadConfig().providers.xai.modelContextWindows).toBeUndefined(); +}); diff --git a/tests/oauth/oauth-upsert-preserves-api-key.test.ts b/tests/oauth/oauth-upsert-preserves-api-key.test.ts index a22239dd69..710089a7ad 100644 --- a/tests/oauth/oauth-upsert-preserves-api-key.test.ts +++ b/tests/oauth/oauth-upsert-preserves-api-key.test.ts @@ -582,3 +582,14 @@ describe("upsertOAuthProvider credential preservation", () => { expect(config.providers["command-code"]!.note).toBe("operator-note"); }); }); + + +test("OAuth upsert preserves explicit per-model capabilities", () => { + const config: OcxConfig = { port: 10100, defaultProvider: "xai", providers: { xai: { + adapter: "openai-responses", baseUrl: "https://api.x.ai/v1", + modelCapabilities: { "grok-4.6": { inputModalities: ["text"], contextTier: "default", video: { processing: "static" } } }, + } } }; + const before = structuredClone(config.providers.xai!.modelCapabilities); + upsertOAuthProvider(config, "xai"); + expect(config.providers.xai!.modelCapabilities).toEqual(before); +}); diff --git a/tests/server/management-provider-validation.test.ts b/tests/server/management-provider-validation.test.ts index 949791f2d6..c9d3832bca 100644 --- a/tests/server/management-provider-validation.test.ts +++ b/tests/server/management-provider-validation.test.ts @@ -5005,3 +5005,46 @@ describe("remembered provider context selections", () => { } }); }); + + +test("model capability PATCH merges axes while strict replacement and DTO state agree", async () => { + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const live: OcxConfig = { port: 0, defaultProvider: "caps", providers: { caps: { + adapter: "openai-chat", baseUrl: "https://example.test/v1", liveModels: false, models: ["ModelA", "modela"], + modelCapabilities: { ModelA: { inputModalities: ["text"], contextTier: "long_context", video: { processing: "agentic" } }, modela: { inputModalities: ["text", "image"] } }, + } } }; + saveConfig(live); + const request = async (method: string, body?: unknown) => { + const url = new URL(method === "PATCH" ? "http://localhost/api/providers?name=caps" : "http://localhost/api/providers"); + return (await handleManagementAPI(new Request(url, { method, headers: { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }), url, live, { createManagementConvergeCodex: catalogConvergenceFactory() }))!; + }; + const before = structuredClone(live.providers.caps!.modelCapabilities); + expect((await request("PATCH", { modelCapabilities: { ModelA: { contextTier: "default", video: { processing: null } } } })).status).toBe(200); + expect(live.providers.caps!.modelCapabilities).toEqual({ + ModelA: { inputModalities: ["text"], contextTier: "default" }, modela: { inputModalities: ["text", "image"] }, + }); + expect(before!.ModelA!.video).toEqual({ processing: "agentic" }); + expect(loadConfig().providers.caps!.modelCapabilities).toEqual(live.providers.caps!.modelCapabilities); + const listed = await (await request("GET")).json() as Array<{ name: string; modelCapabilities?: unknown }>; + expect(listed.find(row => row.name === "caps")!.modelCapabilities).toEqual(live.providers.caps!.modelCapabilities); + expect(providerEditorConfigDTO(live).providers.caps!.modelCapabilities).toEqual(live.providers.caps!.modelCapabilities); + for (const patch of [{ " ModelA ": {} }, { ModelA: { unknown: true } }, { ModelA: { inputModalities: [] } }]) { + expect((await request("PATCH", { modelCapabilities: patch })).status).toBe(400); + } + const baseline = providerEditorConfigDTO(loadConfig()); + const invalidNext = structuredClone(baseline); + Object.assign(invalidNext.providers.caps!, { modelCapabilities: null }); + expect((await request("PUT", { baseline, next: invalidNext })).status).toBe(400); + const next = structuredClone(baseline); + next.providers.caps!.modelCapabilities = {}; + expect((await request("PUT", { baseline, next })).status).toBe(200); + expect(live.providers.caps!.modelCapabilities).toBeUndefined(); + expect(loadConfig().providers.caps!.modelCapabilities).toBeUndefined(); + const newBaseline = providerEditorConfigDTO(loadConfig()); + const followup = structuredClone(newBaseline); + followup.providers.caps!.note = "fresh baseline"; + expect((await request("PUT", { baseline: newBaseline, next: followup })).status).toBe(200); +}); From d5d03f805cc048c4171e132365a8ca6fe7441412 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:34:31 +0900 Subject: [PATCH 06/12] feat(providers): declare text-only models through CLI and shared capabilities Refs #3377 and original text-only request #3268 by @turin-dev. Reuse existing vision description/omission handling, preserve exact model keys and legacy fallback. --- .../docs/reference/cli/providers-accounts.md | 4 ++++ .../docs/reference/configuration/providers.md | 2 ++ src/cli/provider-runtime.ts | 12 +++++++++- src/cli/provider.ts | 21 ++++++++++++++++-- src/codex/catalog/provider-fetch.ts | 4 +++- src/vision/eligibility.ts | 15 +++++++++++-- structure/catalog.md | 2 ++ structure/clients/claude-desktop.md | 2 ++ structure/codex-home.md | 2 ++ structure/config.md | 2 ++ structure/gui-and-management-api.md | 2 ++ structure/ops/docs-and-release.md | 2 ++ structure/providers/openai-tiers.md | 2 ++ structure/runtime.md | 2 ++ structure/subagents.md | 2 ++ .../openai/openai-chat-native-policy.test.ts | 10 +++++++++ tests/cli/cli-headless-parity.test.ts | 22 +++++++++++++++++++ tests/cli/cli-provider.test.ts | 15 +++++++++++++ tests/vision/vision-eligibility.test.ts | 11 ++++++++++ tests/vision/vision-routed.test.ts | 9 ++++++++ tests/vision/vision-sidecar-e2e.test.ts | 18 +++++++-------- .../vision/vision-text-only-predicate.test.ts | 11 ++++++++++ 22 files changed, 157 insertions(+), 15 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index f7e94a1833..5c9a8b6769 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -581,3 +581,7 @@ otherwise look routed. and rejects an entire catalog containing any other value, so `add`, `edit`, and the management API all refuse the bad value rather than storing something the catalog writer would have to strip later (#759). + +### Mark one model text-only + +Use `ocx provider add mine --adapter openai-chat --base-url https://example.com/v1 --default-model model-a --text-only` when registering a provider, or `ocx provider edit mine --model model-a --text-only` for an existing provider. Add can use `--model` or its default model; edit requires `--model`. The flag updates only that exact model's `modelCapabilities.inputModalities` to `["text"]`, preserving other models and axes. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 2e713e58d2..6760588be3 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -1049,3 +1049,5 @@ contracts. `modelCapabilities` stores explicit declarations keyed by exact upstream model ID. IDs preserve case and must not contain surrounding whitespace. Each entry may contain `inputModalities` (`text`, `image`, `audio`, `video`), `contextTier` (`default`, `long_context`) and `video.processing` (`static`, `agentic`). These are operator declarations, not proof of provider support. Context-tier and video fields currently record intent only and do not activate upstream behavior or increase catalog windows. The raw provider editor and provider API expose this map. POST/PUT replace an explicitly supplied map and reject null entries. PATCH merges individual axes; null clears a map, model, axis or video processing value, while `{}` makes no change. Omitted provider overwrites preserve the existing map. Malformed hand-edited files retain valid independent axes and treat malformed explicit input modalities as text-only, with a diagnostic. + +An explicit `modelCapabilities..inputModalities` now takes precedence over legacy modality hints for that exact routed model. A text-only declaration uses the existing vision sidecar to replace images with descriptions; if no sidecar is available, the request receives an explicit omission marker before dispatch. Native Chat image requests divert through this path. The catalog can still advertise image attachment support because the proxy provides the description step. Context-tier and video processing declarations remain inert pending their transport support. diff --git a/src/cli/provider-runtime.ts b/src/cli/provider-runtime.ts index 6477694a49..c8054a7643 100644 --- a/src/cli/provider-runtime.ts +++ b/src/cli/provider-runtime.ts @@ -1,3 +1,4 @@ +import { modelCapabilitiesConfigError } from "../config/provider-validation"; import { CliUsageError, csv, @@ -39,7 +40,7 @@ const USAGE = `Usage: [--auth-mode ] [--note ] [--api-key-transport ] [--headers ] [--enabled ] [--live-models ] - [--retain-models ] + [--retain-models ] [--model --text-only] [--xai-chat ] [--allow-private-network ] [--json] ocx provider test [--json] @@ -72,7 +73,16 @@ async function edit(argv: string[], deps: RuntimeApiDeps): Promise { const liveModels = takeBooleanOption(args, "--live-models"); const allowPrivateNetwork = takeBooleanOption(args, "--allow-private-network"); const xaiChat = takeBooleanOption(args, "--xai-chat"); + const textOnly = takeFlag(args, "--text-only"); + const capabilityModel = takeOption(args, "--model"); rejectArgs(args, USAGE); + if (textOnly || capabilityModel !== undefined) { + if (!textOnly || capabilityModel === undefined) throw new CliUsageError("--text-only and --model must be supplied together", USAGE); + const declaration = { [capabilityModel]: { inputModalities: ["text"] } }; + const error = modelCapabilitiesConfigError(declaration); + if (error) throw new CliUsageError(error, USAGE); + patch.modelCapabilities = declaration; + } if (xaiChat !== undefined) { if (name !== "xai") throw new CliUsageError("--xai-chat is valid only for provider xai", USAGE); patch.xaiResponsesOptIn = !xaiChat; diff --git a/src/cli/provider.ts b/src/cli/provider.ts index 0f33ff98a5..958989a94f 100644 --- a/src/cli/provider.ts +++ b/src/cli/provider.ts @@ -9,7 +9,7 @@ * set-default Change the default provider */ import { hasOwnProvider, isValidProviderName, loadConfig, sanitizeModelCostsForDisplay, saveConfig } from "../config"; -import { apiKeyTransportConfigError } from "../config/provider-validation"; +import { apiKeyTransportConfigError, modelCapabilitiesConfigError, mergeModelCapabilities } from "../config/provider-validation"; import { hasHelpFlag } from "./help"; import { getProviderRegistryEntry, PROVIDER_REGISTRY } from "../providers/registry"; import { providerConfigSeed } from "../providers/derive"; @@ -139,7 +139,7 @@ function handleList(args: string[]): void { // provider add // --------------------------------------------------------------------------- -const ADD_USAGE = "Usage: ocx provider add [--adapter ] [--base-url ] [--api-key ] [--api-key-transport ] [--default-model ] [--allow-private-network] [--set-default] [--force] [--json] [--sync]"; +const ADD_USAGE = "Usage: ocx provider add [--adapter ] [--base-url ] [--api-key ] [--api-key-transport ] [--default-model ] [--model --text-only] [--allow-private-network] [--set-default] [--force] [--json] [--sync]"; async function handleAdd(args: string[]): Promise { const name = args[0]; @@ -164,7 +164,13 @@ async function handleAdd(args: string[]): Promise { const adapter = consumeFlagValue(restArgs, "--adapter"); const baseUrl = consumeFlagValue(restArgs, "--base-url"); const defaultModel = consumeFlagValue(restArgs, "--default-model"); + const textOnly = consumeFlag(restArgs, "--text-only"); + const capabilityModel = consumeFlagValue(restArgs, "--model"); rejectUnknownArgs(restArgs, ADD_USAGE); + if (capabilityModel !== undefined && !textOnly) { + console.error("Error: --model requires --text-only for provider add."); + process.exit(1); + } const config = loadConfig(); @@ -227,6 +233,17 @@ async function handleAdd(args: string[]): Promise { if (existingProvider?.modelCapabilities !== undefined && provConfig.modelCapabilities === undefined) { provConfig.modelCapabilities = structuredClone(existingProvider.modelCapabilities); } + if (textOnly) { + const modelId = capabilityModel ?? defaultModel ?? provConfig.defaultModel; + if (!modelId) { + console.error("Error: --text-only requires --model or a default model."); + process.exit(1); + } + const declaration = { [modelId]: { inputModalities: ["text"] } }; + const error = modelCapabilitiesConfigError(declaration); + if (error) { console.error(`Error: ${error}.`); process.exit(1); } + provConfig.modelCapabilities = mergeModelCapabilities(provConfig.modelCapabilities, declaration); + } const { initializeProviderModelSelection } = await import("../providers/initial-model-selection"); initializeProviderModelSelection(name, provConfig, existingProvider, config); config.providers[name] = provConfig; diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index ae03da9f0d..cdf7806f73 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -674,7 +674,9 @@ export function configuredContextWindow(prov: OcxProviderConfig, id: string): nu } export function configuredInputModalities(prov: OcxProviderConfig, id: string): string[] | undefined { - const modalities = modelRecordValue(prov.modelInputModalities, id); + const declared = Object.hasOwn(prov.modelCapabilities ?? {}, id) + ? prov.modelCapabilities?.[id]?.inputModalities : undefined; + const modalities = declared ?? modelRecordValue(prov.modelInputModalities, id); return Array.isArray(modalities) && modalities.length > 0 ? [...modalities] : undefined; } diff --git a/src/vision/eligibility.ts b/src/vision/eligibility.ts index 09a83d44db..bec455fc7d 100644 --- a/src/vision/eligibility.ts +++ b/src/vision/eligibility.ts @@ -77,9 +77,12 @@ type EnrichedProviderCache = Map; * not a text-only model and must not be widened to image through the vision sidecar. */ export function isModelVisionSidecarConsumer( - provider: Pick, + provider: Pick, modelId: string, ): boolean { + const declared = Object.hasOwn(provider.modelCapabilities ?? {}, modelId) + ? provider.modelCapabilities?.[modelId]?.inputModalities : undefined; + if (declared !== undefined) return declared.includes("text") && !declared.includes("image"); if (modelInList(provider.noVisionModels, modelId)) return true; const modalities = modelRecordValue(provider.modelInputModalities, modelId); return Array.isArray(modalities) && modalities.includes("text") && !modalities.includes("image"); @@ -151,10 +154,18 @@ function modelAcceptsImageInputWithCache( candidate: VisionCandidateModel, cache: EnrichedProviderCache, ): boolean | undefined { - if (isVisionSidecarConsumerWithCache(config, candidate.provider, candidate.id, cache)) return false; if (candidate.native === true || (candidate.provider === "openai" && SUPPORTED_NATIVE_OPENAI_SLUGS.has(candidate.id))) { + const nativeProvider = enrichedProviderForVision(config, candidate.provider, cache); + if (nativeProvider && isModelVisionSidecarConsumer({ + noVisionModels: nativeProvider.noVisionModels, modelInputModalities: nativeProvider.modelInputModalities, + }, candidate.id)) return false; return advertisesImageInput(nativeInputModalities(candidate.id)) ?? true; } + if (isVisionSidecarConsumerWithCache(config, candidate.provider, candidate.id, cache)) return false; + const provider = enrichedProviderForVision(config, candidate.provider, cache); + const declared = Object.hasOwn(provider?.modelCapabilities ?? {}, candidate.id) + ? provider?.modelCapabilities?.[candidate.id]?.inputModalities : undefined; + if (declared !== undefined) return declared.includes("image"); const fromRow = advertisesImageInput(candidate.inputModalities); if (fromRow !== undefined) return fromRow; return metadataImageInput(candidate.provider, candidate.id); diff --git a/structure/catalog.md b/structure/catalog.md index 3913e08dd3..0180980c1d 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -280,3 +280,5 @@ Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#c privately to final dispatch; preliminary route selection does not inject Go-only headers. The [explicit model-capability contract](config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. + +Exact [model input declarations](config.md#explicit-per-model-capability-declarations) now feed text-only eligibility and catalog hints; existing image-description/omission handling consumes them before the main upstream send. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index bc988164fb..802272130d 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -93,3 +93,5 @@ The explicit sync coordinator also accepts Cline CLI as a separate file integrat Config JSON preserves the boolean; only literal true activates the role-changing transform. The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. + +Exact [model input declarations](../config.md#explicit-per-model-capability-declarations) now feed text-only eligibility and catalog hints; existing image-description/omission handling consumes them before the main upstream send. diff --git a/structure/codex-home.md b/structure/codex-home.md index 8faf8f1c04..3fb9b723b0 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -238,3 +238,5 @@ The legacy external writer is now refused for affected rows in any store whose s Native restore preflight also checks manifest-owned targets whose rows already returned to `openai`, including interrupted restores. Preimage capture distinguishes absent files from unreadable artifacts and aborts before mutation when a complete snapshot cannot be read. The [explicit model-capability contract](config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. + +Exact [model input declarations](config.md#explicit-per-model-capability-declarations) now feed text-only eligibility and catalog hints; existing image-description/omission handling consumes them before the main upstream send. diff --git a/structure/config.md b/structure/config.md index 00f763efae..f6d2aa4e0f 100644 --- a/structure/config.md +++ b/structure/config.md @@ -209,3 +209,5 @@ Config JSON preserves the boolean; only literal true activates the role-changing ## Explicit per-model capability declarations `modelCapabilities` on `src/types/provider.ts` stores exact model-ID entries with optional inputModalities, contextTier and video.processing axes. `src/config/provider-validation.ts` strictly validates writes and merges PATCH axes without sharing live objects; null map/model/axis/processing tombstones delete, while empty PATCH objects do nothing. Complete POST/PUT replacements reject tombstones. File reads retain valid axes; malformed explicit modalities restrict to text with a diagnostic. The two catalog writers receive explicit config and gather fingerprints include the map. This storage contract alone does not activate a context tier, advertise a larger window or enable video processing. + +The text-only consumer reads exact inputModalities declarations before legacy hints. CLI add/edit `--text-only` targets one model and preserves sibling declarations; `src/vision/eligibility.ts` routes declared text-only models into existing image-description or explicit-omission handling. Positive routed image declarations override stale candidate metadata, while native catalog authority retains its existing legacy policy. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 3352286201..ae5d3c091e 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -545,3 +545,5 @@ integration IO adapter. Its snapshot fingerprint cannot be checked against provi The existing dashboard file-client maps include Cline CLI and reuse its committed color mark. The export panel labels its download as a settings/catalog bundle; all locales explain that Undo restores both original files. The [explicit model-capability contract](config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. + +Exact [model input declarations](config.md#explicit-per-model-capability-declarations) now feed text-only eligibility and catalog hints; existing image-description/omission handling consumes them before the main upstream send. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 232a57e650..3a6eb74772 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -316,3 +316,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi The integrations guide documents Cline CLI as a two-file, loopback-only integration. Hosted CI validates its source-backed fixtures; the packaged dashboard exposes it through the existing client list. The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. + +Exact [model input declarations](../config.md#explicit-per-model-capability-declarations) now feed text-only eligibility and catalog hints; existing image-description/omission handling consumes them before the main upstream send. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 3cbacac96b..77b1391943 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -404,3 +404,5 @@ successful main usage refresh clears the runtime mark. `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. + +Exact [model input declarations](../config.md#explicit-per-model-capability-declarations) now feed text-only eligibility and catalog hints; existing image-description/omission handling consumes them before the main upstream send. diff --git a/structure/runtime.md b/structure/runtime.md index bb175079a4..421d7e2281 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -227,3 +227,5 @@ Cline CLI joins the existing export/client integration registries. Explicit CLI Config JSON preserves the boolean; only literal true activates the role-changing transform. The [explicit model-capability contract](config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. + +Exact [model input declarations](config.md#explicit-per-model-capability-declarations) now feed text-only eligibility and catalog hints; existing image-description/omission handling consumes them before the main upstream send. diff --git a/structure/subagents.md b/structure/subagents.md index e60d77a76c..6887111491 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -216,3 +216,5 @@ Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#c privately to final dispatch; preliminary route selection does not inject Go-only headers. The [explicit model-capability contract](config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. + +Exact [model input declarations](config.md#explicit-per-model-capability-declarations) now feed text-only eligibility and catalog hints; existing image-description/omission handling consumes them before the main upstream send. diff --git a/tests/adapters/openai/openai-chat-native-policy.test.ts b/tests/adapters/openai/openai-chat-native-policy.test.ts index 92fc1bf371..8bf25d4f19 100644 --- a/tests/adapters/openai/openai-chat-native-policy.test.ts +++ b/tests/adapters/openai/openai-chat-native-policy.test.ts @@ -398,3 +398,13 @@ describe("main and native Chat tier authorization parity", () => { } }); }); + + +test("explicit text-only capabilities divert image-bearing native Chat requests", async () => { + const { isNativeChatRouteEligible } = await import("../../../src/server/chat-native"); + const { routeModel } = await import("../../../src/router"); + const config = { port: 10100, defaultProvider: "custom", providers: { custom: provider({ modelCapabilities: { model: { inputModalities: ["text"] } } }) } } as OcxConfig; + const route = routeModel(config, "custom/model"); + expect(isNativeChatRouteEligible(route, { messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "data:image/png;base64,YQ==" } }] }] })).toBe(false); + expect(isNativeChatRouteEligible(route, { messages: [{ role: "user", content: "hello" }] })).toBe(true); +}); diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index 0206c8e54a..c7b90ea7a7 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -1113,3 +1113,25 @@ describe("Aside CLI recovery metadata", () => { } }); }); + + +test("provider edit sends a model-scoped text-only capability patch", async () => { + const { requests, deps } = fakeRuntime(); + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await handleProviderRuntimeCommand(["edit", "mine", "--model", "ModelA", "--text-only", "--json"], deps)).toBe(0); + expect(requests).toEqual([{ path: "/api/providers?name=mine", method: "PATCH", body: { modelCapabilities: { ModelA: { inputModalities: ["text"] } } } }]); + } finally { log.mockRestore(); } +}); + + +test("provider edit rejects incomplete text-only targeting before contacting the server", async () => { + const { requests, deps } = fakeRuntime(); + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + for (const flags of [["--text-only"], ["--model", "ModelA"], ["--model", " ModelA ", "--text-only"]]) { + expect(await handleProviderRuntimeCommand(["edit", "mine", ...flags], deps)).not.toBe(0); + } + expect(requests).toHaveLength(0); + } finally { error.mockRestore(); } +}); diff --git a/tests/cli/cli-provider.test.ts b/tests/cli/cli-provider.test.ts index b37bf973ed..5c8b72860b 100644 --- a/tests/cli/cli-provider.test.ts +++ b/tests/cli/cli-provider.test.ts @@ -657,3 +657,18 @@ test("provider add --force preserves all explicit model capability axes", () => expect(readConfig(dir).providers.caps.modelCapabilities).toEqual(declarations); } finally { removeTreeWithRetry(dir); } }); + + +test("provider add --text-only preserves other capability axes during force overwrite", () => { + const { dir } = freshConfig({ defaultProvider: "caps", providers: { caps: { + adapter: "openai-chat", baseUrl: "https://example.test/v1", + modelCapabilities: { ModelA: { contextTier: "long_context", video: { processing: "agentic" } }, modela: { inputModalities: ["text", "image"] } }, + } } }); + try { + const result = runCli(["provider", "add", "caps", "--adapter", "openai-chat", "--base-url", "https://example.test/v1", "--force", "--model", "ModelA", "--text-only", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status, result.stderr).toBe(0); + expect(readConfig(dir).providers.caps.modelCapabilities).toEqual({ + ModelA: { inputModalities: ["text"], contextTier: "long_context", video: { processing: "agentic" } }, modela: { inputModalities: ["text", "image"] }, + }); + } finally { removeTreeWithRetry(dir); } +}); diff --git a/tests/vision/vision-eligibility.test.ts b/tests/vision/vision-eligibility.test.ts index 25fdc63d42..fc43a0d2f3 100644 --- a/tests/vision/vision-eligibility.test.ts +++ b/tests/vision/vision-eligibility.test.ts @@ -293,3 +293,14 @@ describe("vision eligibility core", () => { expect(withRouted.some((o) => o.value === "cursor/cursor-vision-capable" && o.backend === "routed")).toBe(true); }); }); + + +test("explicit routed image declarations outrank stale candidate metadata", () => { + const config = configWithProviders({ custom: { + adapter: "openai-chat", baseUrl: "https://example.test/v1", noVisionModels: ["ModelA"], + modelCapabilities: { ModelA: { inputModalities: ["text", "image"] } }, + } }); + expect(modelAcceptsImageInput(config, { provider: "custom", id: "ModelA", inputModalities: ["text"] })).toBe(true); + expect(modelAcceptsImageInput(config, { provider: "custom", id: "modela", inputModalities: ["text"] })).toBe(false); + expect(modelAcceptsImageInput(config, { provider: "custom", id: "ModelA:variant", inputModalities: ["text"] })).toBe(false); +}); diff --git a/tests/vision/vision-routed.test.ts b/tests/vision/vision-routed.test.ts index d742345d8f..c1182a0953 100644 --- a/tests/vision/vision-routed.test.ts +++ b/tests/vision/vision-routed.test.ts @@ -375,3 +375,12 @@ describe("chat-surface recursion fence (full path)", () => { } }); }); + + +test("explicit routed image capability enables a describer despite stale legacy metadata", () => { + const main: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://main.test/v1", modelCapabilities: { blind: { inputModalities: ["text"] } } }; + const helper: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://helper.test/v1", noVisionModels: ["vision"], modelCapabilities: { vision: { inputModalities: ["text", "image"] } } }; + const parsed = parseRequest({ model: "main/blind", input: [{ role: "user", content: [{ type: "input_image", image_url: PNG_DATA_URL }] }] }); + const plan = planVisionSidecar({ port: 10100, defaultProvider: "main", providers: { main, helper }, visionSidecar: { enabled: true, backend: "routed", model: "helper/vision" } } as OcxConfig, main, "blind", parsed); + expect(plan?.backend).toBe("routed"); +}); diff --git a/tests/vision/vision-sidecar-e2e.test.ts b/tests/vision/vision-sidecar-e2e.test.ts index ee8a3d1522..d88b0070df 100644 --- a/tests/vision/vision-sidecar-e2e.test.ts +++ b/tests/vision/vision-sidecar-e2e.test.ts @@ -150,7 +150,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { expect(JSON.stringify(parsed._rawBody)).toContain("[image omitted:"); }); - test("noVisionModels request fires the sidecar and forwards the caption instead of the image", async () => { + test.each(["legacy", "capabilities", "developer"] as const)("noVisionModels request fires the sidecar and forwards the caption instead of the image (%s)", async declaration => { let upstreamBody = ""; let sidecarBody = ""; let sidecarAuth: string | null = null; @@ -183,7 +183,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { baseUrl: `http://127.0.0.1:${upstream.port}/v1`, allowPrivateNetwork: true, apiKey: "key-alpha-000111222333", - noVisionModels: ["blind-model"], + ...(declaration === "legacy" ? { noVisionModels: ["blind-model"] } : { modelCapabilities: { "blind-model": { inputModalities: ["text"] } } }), }, openai: { adapter: "openai-responses", @@ -204,7 +204,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { authorization: `Bearer ${token}`, "chatgpt-account-id": "acct-vision-sidecar", }, - body: JSON.stringify(baseRequest("textonly/blind-model")), + body: JSON.stringify({ ...baseRequest("textonly/blind-model"), input: baseRequest("textonly/blind-model").input.map(item => ({ ...item, role: declaration === "developer" ? "developer" : "user" })) }), }); expect(res.status).toBe(200); @@ -225,7 +225,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { } }); - test("Responses passthrough removes every raw image when fewer captions than images are produced", async () => { + test.each(["legacy", "capabilities"] as const)("Responses passthrough removes every raw image when fewer captions than images are produced (%s)", async declaration => { let upstreamBody = ""; let sidecarHits = 0; upstream = serveResponsesUpstream(b => { upstreamBody = b; }); @@ -250,7 +250,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { responsesPath: "/responses", allowPrivateNetwork: true, apiKey: "key-alpha-000111222333", - noVisionModels: ["blind-model"], + ...(declaration === "legacy" ? { noVisionModels: ["blind-model"] } : { modelCapabilities: { "blind-model": { inputModalities: ["text"] } } }), }, openai: { adapter: "openai-responses", @@ -285,7 +285,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { } }); - test("Responses passthrough replaces an image returned by a client tool", async () => { + test.each(["legacy", "capabilities"] as const)("Responses passthrough replaces an image returned by a client tool (%s)", async declaration => { let upstreamBody = ""; let sidecarHits = 0; upstream = serveResponsesUpstream(b => { upstreamBody = b; }); @@ -310,7 +310,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { responsesPath: "/responses", allowPrivateNetwork: true, apiKey: "key-alpha-000111222333", - noVisionModels: ["blind-model"], + ...(declaration === "legacy" ? { noVisionModels: ["blind-model"] } : { modelCapabilities: { "blind-model": { inputModalities: ["text"] } } }), }, openai: { adapter: "openai-responses", @@ -343,7 +343,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { } }); - test("Responses passthrough strips images when no vision sidecar is available", async () => { + test.each(["legacy", "capabilities"] as const)("Responses passthrough strips images when no vision sidecar is available (%s)", async declaration => { let upstreamBody = ""; upstream = serveResponsesUpstream(b => { upstreamBody = b; }); const config: OcxConfig = { @@ -356,7 +356,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { responsesPath: "/responses", allowPrivateNetwork: true, apiKey: "key-alpha-000111222333", - noVisionModels: ["blind-model"], + ...(declaration === "legacy" ? { noVisionModels: ["blind-model"] } : { modelCapabilities: { "blind-model": { inputModalities: ["text"] } } }), }, }, } as OcxConfig; diff --git a/tests/vision/vision-text-only-predicate.test.ts b/tests/vision/vision-text-only-predicate.test.ts index 6a78872a04..e6f8c6e719 100644 --- a/tests/vision/vision-text-only-predicate.test.ts +++ b/tests/vision/vision-text-only-predicate.test.ts @@ -42,3 +42,14 @@ describe("isModelTextOnly (#1024)", () => { expect(isModelTextOnly(provider({ modelInputModalities: { "base-model": ["text"] } }), "base-model:extended")).toBe(true); }); }); + + +test("explicit capability keys are exact and take precedence over legacy declarations", () => { + const config = provider({ noVisionModels: ["ModelA"], modelCapabilities: { ModelA: { inputModalities: ["text", "image"] }, model: { inputModalities: ["text"] } } }); + expect(isModelTextOnly(config, "ModelA")).toBe(false); + expect(isModelTextOnly(config, "model")).toBe(true); + expect(isModelTextOnly(config, "MODEL")).toBe(false); + expect(isModelTextOnly(config, "model:variant")).toBe(false); + delete config.modelCapabilities!.ModelA; + expect(isModelTextOnly(config, "ModelA")).toBe(true); +}); From 2617fd157f058503a025e55ef5c523ecea8486d3 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:20:47 +0900 Subject: [PATCH 07/12] docs(config): keep schema and load comments beside their owners --- src/config.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/config.ts b/src/config.ts index 1bcd06de03..b7a3fc145a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -574,15 +574,15 @@ const modelPinnedEffortsSchema = z.unknown().superRefine((value, ctx) => { Object.entries(value as Record).map(([key, effort]) => [key.trim(), effort]), )); -/** - * Zod schema for one provider entry: known fields are validated strictly while unknown - * fields pass through (preserved for runtime extensions). - */ const modelCapabilitiesSchema = z.unknown().superRefine((value, ctx) => { const error = modelCapabilitiesConfigError(value); if (error) ctx.addIssue({ code: "custom", message: error }); }).transform(value => mergeModelCapabilities(undefined, value)); +/** + * Zod schema for one provider entry: known fields are validated strictly while unknown + * fields pass through (preserved for runtime extensions). + */ const providerConfigSchema = z.object({ modelCapabilities: modelCapabilitiesSchema.optional(), pinnedReasoningEffort: pinnedReasoningEffortSchema.optional(), @@ -1905,15 +1905,6 @@ export function retryOn429PolicyConfigError(policy: unknown): string | null { return `retryOn429.${field} is invalid (${first.message})`; } -/** - * Load-time degradation for `providers..modelCosts`, mirroring - * {@link sanitizeRetryOn429ForLoad}. A hand-edited malformed display-price row - * must not fail the whole config parse — that would back up config.json and - * fall back to defaults, dropping otherwise valid providers and the default - * route for a typo in a non-runtime display field. Invalid rows are dropped - * with a warning; strict rejection stays at the management/write boundary - * (providerManagementConfigError). - */ function sanitizeCapabilityDeclarationsForLoad(parsed: unknown): void { if (!parsed || typeof parsed !== "object") return; const providers = (parsed as Record).providers; @@ -1931,6 +1922,15 @@ function sanitizeCapabilityDeclarationsForLoad(parsed: unknown): void { } } +/** + * Load-time degradation for `providers..modelCosts`, mirroring + * {@link sanitizeRetryOn429ForLoad}. A hand-edited malformed display-price row + * must not fail the whole config parse — that would back up config.json and + * fall back to defaults, dropping otherwise valid providers and the default + * route for a typo in a non-runtime display field. Invalid rows are dropped + * with a warning; strict rejection stays at the management/write boundary + * (providerManagementConfigError). + */ function sanitizeModelCostsForLoad(parsed: unknown): void { if (!parsed || typeof parsed !== "object") return; const root = parsed as Record; From ca15403262d4e0f7500437f95e6e44217eb2717f Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:36:57 +0900 Subject: [PATCH 08/12] test(catalog): verify exact capability modality precedence --- .../catalog-vision-sidecar-modalities.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/codex-integration/catalog-vision-sidecar-modalities.test.ts b/tests/codex-integration/catalog-vision-sidecar-modalities.test.ts index 3e51ac0d01..6df91ab4e5 100644 --- a/tests/codex-integration/catalog-vision-sidecar-modalities.test.ts +++ b/tests/codex-integration/catalog-vision-sidecar-modalities.test.ts @@ -410,3 +410,20 @@ describe("Cursor native vs sidecar vision registry", () => { } }); }); + + +test("exact capability modalities override legacy catalog hints and clear back to inference", () => { + const provider: OcxProviderConfig = { + adapter: "openai-chat", baseUrl: "https://example.test/v1", + modelInputModalities: { ModelA: ["audio"] }, + modelCapabilities: { ModelA: { inputModalities: ["text", "image"] } }, + }; + const hint = (id: string) => applyProviderConfigHints("custom", provider, { provider: "custom", id, inputModalities: ["text"] }).inputModalities; + expect(hint("ModelA")).toEqual(["text", "image"]); + expect(hint("modela")).toEqual(["audio"]); + expect(hint("ModelA:variant")).toEqual(["audio"]); + delete provider.modelCapabilities!.ModelA; + expect(hint("ModelA")).toEqual(["audio"]); + provider.modelCapabilities!.ModelA = { inputModalities: ["text"] }; + expect(hint("ModelA")).toEqual(["text", "image"]); +}); From d1dee687ff627bb3d106c474b39d45b91d68f662 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:23:18 +0900 Subject: [PATCH 09/12] test(providers): cover capability mutation isolation and overwrite preservation --- tests/cli/cli-provider.test.ts | 13 +++++++ .../management-provider-validation.test.ts | 38 ++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/tests/cli/cli-provider.test.ts b/tests/cli/cli-provider.test.ts index ea29c5535f..b37bf973ed 100644 --- a/tests/cli/cli-provider.test.ts +++ b/tests/cli/cli-provider.test.ts @@ -644,3 +644,16 @@ describe("ocx provider add --sync", () => { } }); }); + + +test("provider add --force preserves all explicit model capability axes", () => { + const declarations = { ModelA: { inputModalities: ["text"], contextTier: "long_context", video: { processing: "agentic" } }, modela: { inputModalities: ["text", "image"] } }; + const { dir } = freshConfig({ defaultProvider: "caps", providers: { caps: { + adapter: "openai-chat", baseUrl: "https://example.test/v1", modelCapabilities: declarations, + } } }); + try { + const result = runCli(["provider", "add", "caps", "--adapter", "openai-chat", "--base-url", "https://example.test/v1", "--force", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status, result.stderr).toBe(0); + expect(readConfig(dir).providers.caps.modelCapabilities).toEqual(declarations); + } finally { removeTreeWithRetry(dir); } +}); diff --git a/tests/server/management-provider-validation.test.ts b/tests/server/management-provider-validation.test.ts index c9d3832bca..dc69f46b79 100644 --- a/tests/server/management-provider-validation.test.ts +++ b/tests/server/management-provider-validation.test.ts @@ -5021,12 +5021,18 @@ test("model capability PATCH merges axes while strict replacement and DTO state ...(body === undefined ? {} : { body: JSON.stringify(body) }), }), url, live, { createManagementConvergeCodex: catalogConvergenceFactory() }))!; }; - const before = structuredClone(live.providers.caps!.modelCapabilities); + const before = live.providers.caps!.modelCapabilities; + const originalRow = before!.ModelA!; + const originalVideo = originalRow.video; expect((await request("PATCH", { modelCapabilities: { ModelA: { contextTier: "default", video: { processing: null } } } })).status).toBe(200); expect(live.providers.caps!.modelCapabilities).toEqual({ ModelA: { inputModalities: ["text"], contextTier: "default" }, modela: { inputModalities: ["text", "image"] }, }); expect(before!.ModelA!.video).toEqual({ processing: "agentic" }); + expect(originalRow.contextTier).toBe("long_context"); + expect(originalVideo).toEqual({ processing: "agentic" }); + expect(live.providers.caps!.modelCapabilities).not.toBe(before); + expect(live.providers.caps!.modelCapabilities!.ModelA).not.toBe(originalRow); expect(loadConfig().providers.caps!.modelCapabilities).toEqual(live.providers.caps!.modelCapabilities); const listed = await (await request("GET")).json() as Array<{ name: string; modelCapabilities?: unknown }>; expect(listed.find(row => row.name === "caps")!.modelCapabilities).toEqual(live.providers.caps!.modelCapabilities); @@ -5034,6 +5040,36 @@ test("model capability PATCH merges axes while strict replacement and DTO state for (const patch of [{ " ModelA ": {} }, { ModelA: { unknown: true } }, { ModelA: { inputModalities: [] } }]) { expect((await request("PATCH", { modelCapabilities: patch })).status).toBe(400); } + const admitted = structuredClone(live.providers.caps!.modelCapabilities); + const diskBeforeReject = readFileSync(join(TEST_DIR, "config.json"), "utf8"); + for (const patch of [JSON.parse('{"__proto__":null}'), { ModelA: { video: [] } }, { ModelA: { video: { processing: "invalid" } } }]) { + expect((await request("PATCH", { modelCapabilities: patch })).status).toBe(400); + expect(live.providers.caps!.modelCapabilities).toEqual(admitted); + expect(readFileSync(join(TEST_DIR, "config.json"), "utf8")).toBe(diskBeforeReject); + } + const resolved = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + try { + const replacement = { adapter: "openai-chat", baseUrl: "https://example.test/v1", liveModels: false, models: ["ModelA", "modela"] }; + expect((await request("POST", { name: "caps", provider: replacement })).status).toBe(200); + expect(live.providers.caps!.modelCapabilities).toEqual(admitted); + expect((await request("POST", { name: "caps", provider: { ...replacement, modelCapabilities: {} } })).status).toBe(200); + expect(live.providers.caps!.modelCapabilities).toBeUndefined(); + expect((await request("PATCH", { modelCapabilities: admitted })).status).toBe(200); + const stale = providerEditorConfigDTO(loadConfig()); + expect((await request("PATCH", { modelCapabilities: { ModelA: { contextTier: null } } })).status).toBe(200); + expect(live.providers.caps!.modelCapabilities!.ModelA).toEqual({ inputModalities: ["text"] }); + expect(live.providers.caps!.modelCapabilities!.modela).toEqual({ inputModalities: ["text", "image"] }); + const staleEdit = structuredClone(stale); + staleEdit.providers.caps!.note = "stale"; + expect((await request("PUT", { baseline: stale, next: staleEdit })).status).toBe(409); + expect((await request("PATCH", { modelCapabilities: { ModelA: null } })).status).toBe(200); + expect(live.providers.caps!.modelCapabilities).toEqual({ modela: { inputModalities: ["text", "image"] } }); + expect((await request("PATCH", { modelCapabilities: null })).status).toBe(200); + expect(live.providers.caps!.modelCapabilities).toBeUndefined(); + expect((await request("PATCH", { modelCapabilities: admitted })).status).toBe(200); + } finally { + resolved.mockRestore(); + } const baseline = providerEditorConfigDTO(loadConfig()); const invalidNext = structuredClone(baseline); Object.assign(invalidNext.providers.caps!, { modelCapabilities: null }); From 3f24400c949e78841d4fb3f0147f8a5c762ed3c7 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 21:19:41 +0900 Subject: [PATCH 10/12] test(cli): invoke text-only edit handler with its declared signature --- tests/cli/cli-headless-parity.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index c7b90ea7a7..fa33b804a2 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -1119,7 +1119,7 @@ test("provider edit sends a model-scoped text-only capability patch", async () = const { requests, deps } = fakeRuntime(); const log = spyOn(console, "log").mockImplementation(() => {}); try { - expect(await handleProviderRuntimeCommand(["edit", "mine", "--model", "ModelA", "--text-only", "--json"], deps)).toBe(0); + expect(await handleProviderRuntimeCommand("edit", ["mine", "--model", "ModelA", "--text-only", "--json"], deps)).toBe(0); expect(requests).toEqual([{ path: "/api/providers?name=mine", method: "PATCH", body: { modelCapabilities: { ModelA: { inputModalities: ["text"] } } } }]); } finally { log.mockRestore(); } }); @@ -1130,7 +1130,7 @@ test("provider edit rejects incomplete text-only targeting before contacting the const error = spyOn(console, "error").mockImplementation(() => {}); try { for (const flags of [["--text-only"], ["--model", "ModelA"], ["--model", " ModelA ", "--text-only"]]) { - expect(await handleProviderRuntimeCommand(["edit", "mine", ...flags], deps)).not.toBe(0); + expect(await handleProviderRuntimeCommand("edit", ["mine", ...flags], deps)).toBe(2); } expect(requests).toHaveLength(0); } finally { error.mockRestore(); } From ebe3773638556c5e7ba9801bf1a7e8d550c2e8eb Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 21:23:20 +0900 Subject: [PATCH 11/12] fix(catalog): preserve display-name validation and pin reviewer test identity --- src/config/provider-validation.ts | 4 +--- structure/config.md | 2 ++ .../codex-convergence-account-selectors.test.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/config/provider-validation.ts b/src/config/provider-validation.ts index 66fa926a62..265a20cf20 100644 --- a/src/config/provider-validation.ts +++ b/src/config/provider-validation.ts @@ -217,9 +217,7 @@ export function modelDisplayNamesConfigError( return `${field} must contain at most ${MODEL_DISCOVERY_MAX_MODELS} entries`; } for (const [modelId, displayName] of entries) { - if (!isValidModelDiscoveryModelId(modelId) || ["__proto__", "prototype", "constructor"].includes(modelId)) { - return `${field} keys must be valid non-reserved model ids`; - } + if (!isValidModelDiscoveryModelId(modelId)) return `${field} keys must be valid model ids`; const safeModelId = JSON.stringify(redactSecretString(modelId)); if (typeof displayName !== "string") return `${field}.${safeModelId} must be a string`; const trimmed = displayName.trim(); diff --git a/structure/config.md b/structure/config.md index 0000eef7f7..eaeed37c6d 100644 --- a/structure/config.md +++ b/structure/config.md @@ -206,3 +206,5 @@ The Cline client keeps connection settings and models in a separate native file [translated instruction stabilization](data-planes/inbound-compat.md#opt-in-claude-instruction-stabilization). Config JSON preserves the boolean; only literal true activates the role-changing transform. Provider `autoReviewModel` and `autoReviewModelOverrides` accept validated final-catalog selectors. Per-model keys preserve case and accept the existing raw/encoded slash equivalence. File-load degradation removes malformed optional selectors only; management writes reject malformed shapes. Omitted provider saves preserve selectors, explicit clears remove them, and raw editor candidates adopt normalized values before persistence and live replacement. See [catalog ownership](catalog.md#provider-scoped-approval-reviewer). + +Display-name validation retains prototype-shaped model IDs as data; reviewer-target map validation remains separate and rejects its reserved keys. diff --git a/tests/codex-integration/codex-convergence-account-selectors.test.ts b/tests/codex-integration/codex-convergence-account-selectors.test.ts index a7661b60d2..edfa49eef8 100644 --- a/tests/codex-integration/codex-convergence-account-selectors.test.ts +++ b/tests/codex-integration/codex-convergence-account-selectors.test.ts @@ -759,7 +759,7 @@ test("provider-scoped auto-review overrides win on routed rows in both writers", writeAutoReviewModel("gpt-5.5"); writeCatalog([ - { ...nativeEntry(), auto_review_model_override: "static/deepseek-v4-flash" }, + { ...nativeEntry(), slug: "gpt-5.5", auto_review_model_override: "static/deepseek-v4-flash" }, generatedRoutedEntry("static/deepseek-v4-flash"), generatedRoutedEntry("static/glm-5.2"), ]); From fee142cd69444ba7c8efdae1f4883609beefb2b0 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 10:59:43 +0900 Subject: [PATCH 12/12] [skip ci] docs(structure): keep stack notes in their own sections The dev merge appended this layer's cross-reference notes after sections origin/dev had added, so they rendered under Context relay ownership, the OAuth Fast Tier section, and the capability section. Move them back beside the prose they describe; no wording changes. --- structure/config.md | 4 ++-- structure/providers/openai-tiers.md | 4 ++-- structure/providers/xai-grok.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/structure/config.md b/structure/config.md index 2d225d7228..e72d06bfe6 100644 --- a/structure/config.md +++ b/structure/config.md @@ -213,8 +213,8 @@ The Cline client keeps connection settings and models in a separate native file [translated instruction stabilization](data-planes/inbound-compat.md#opt-in-claude-instruction-stabilization). Config JSON preserves the boolean; only literal true activates the role-changing transform. +The lightweight top-level CLI help counts Cline CLI among the fifteen registered export clients; registry parity remains covered by the client help and integration tests. + ## Explicit per-model capability declarations `modelCapabilities` on `src/types/provider.ts` stores exact model-ID entries with optional inputModalities, contextTier and video.processing axes. `src/config/provider-validation.ts` strictly validates writes and merges PATCH axes without sharing live objects; null map/model/axis/processing tombstones delete, while empty PATCH objects do nothing. Complete POST/PUT replacements reject tombstones. File reads retain valid axes; malformed explicit modalities restrict to text with a diagnostic. The two catalog writers receive explicit config and gather fingerprints include the map. This storage contract alone does not activate a context tier, advertise a larger window or enable video processing. - -The lightweight top-level CLI help counts Cline CLI among the fifteen registered export clients; registry parity remains covered by the client help and integration tests. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index f626214544..72121b4654 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -421,6 +421,8 @@ successful main usage refresh clears the runtime mark. `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before and after config/profile/journal changes, including successful journal and fallback restores, and compensates detected migration. Failed config restore stops later catalog/history work and rolls back a coordinated remove transition. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. +The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. + ## Context relay ownership `src/codex/context-owner.ts` records which account actually served a root session, taken from the @@ -453,5 +455,3 @@ separately, nothing is dispatched upstream after either, and notes writes are ne Context relay dispatch rechecks the native experimental opt-in after body and credential waits. A disabled gate prevents upstream dispatch even when the request entered while enabled. Final materialized headers pass the proxy-credential exclusion check before owner matching. - -The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 67fcebf9c8..edd8f168e9 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -70,6 +70,8 @@ Devin CLI credential path composition in `src/oauth/devin-cli.ts` follows the se Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. +The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. + ### OAuth Fast Tier (Priority Processing) xAI's Priority Processing (`service_tier: "priority"` on Chat Completions and Responses, @@ -90,5 +92,3 @@ The upstream tier echo relays to the client on every Chat Completions delivery s (`src/chat/outbound.ts` projections and `src/server/chat-native-sse.ts` chunks), matching what the Responses lane already relayed for responses-wire upstreams; the responses-lane assembly for chat-wire upstreams tracks the echo in attempt telemetry only. - -The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior.