diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 24bc7c5f3d..0a333acd0b 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/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 eb61194c94..a832326d59 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -261,6 +261,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 @@ -329,6 +372,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 @@ -1052,3 +1096,15 @@ 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. + +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. + +### 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/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 47f23fee62..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(); @@ -224,6 +230,20 @@ async function handleAdd(args: string[]): Promise { } const existingProvider = config.providers[name]; + 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 68910ce0eb..cdf7806f73 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, @@ -673,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/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 6b32ad9f13..bb0a778993 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"; @@ -1588,29 +1589,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]; +/** 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]; + } +} + +/** + * 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] : []; @@ -1629,16 +1659,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, @@ -1652,24 +1714,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, @@ -1697,21 +1823,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. @@ -2030,7 +2389,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 1b513bf2d4..9f518dbcd0 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"; @@ -14,8 +15,11 @@ import { pinnedReasoningEffortConfigError, modelAdapterRecordConfigError, modelDisplayNamesConfigError, + autoReviewModelOverridesConfigError, + autoReviewModelTargetConfigError, nonBlankStringArrayConfigError, normalizeNonBlankStringArray, + normalizeAutoReviewModelOverrides, positiveIntegerConfigError, positiveIntegerRecordConfigError, providerBaseUrlConfigError, @@ -573,17 +577,39 @@ 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)); + +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(), modelPinnedReasoningEfforts: modelPinnedEffortsSchema.optional(), // Validated rather than left to passthrough: an unrecognized strategy would otherwise // 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 +691,12 @@ export { apiKeyTransportConfigError, booleanRecordConfigError, modelAdapterRecordConfigError, + autoReviewModelOverridesConfigError, + autoReviewModelTargetConfigError, modelDisplayNamesConfigError, nonBlankStringArrayConfigError, normalizeNonBlankStringArray, + normalizeAutoReviewModelOverrides, positiveIntegerConfigError, positiveIntegerRecordConfigError, providerBaseUrlConfigError, @@ -1903,6 +1932,23 @@ export function retryOn429PolicyConfigError(policy: unknown): string | null { return `retryOn429.${field} is invalid (${first.message})`; } +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; + } + } +} + /** * Load-time degradation for `providers..modelCosts`, mirroring * {@link sanitizeRetryOn429ForLoad}. A hand-edited malformed display-price row @@ -1951,6 +1997,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 — @@ -2437,8 +2521,10 @@ export function loadConfig(): OcxConfig { sanitizeAliasesForLoad(parsed); sanitizeReasoningPinsForLoad(parsed); sanitizeModelDisplayNamesForLoad(parsed); + sanitizeAutoReviewForLoad(parsed); sanitizeRetryOn429ForLoad(parsed); sanitizeModelCostsForLoad(parsed); + sanitizeCapabilityDeclarationsForLoad(parsed); const result = configSchema.safeParse(parsed); if (result.success) { const config = normalizeApiKeyIds(result.data as OcxConfig); @@ -3086,8 +3172,10 @@ 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); + 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..cf66a6eab6 100644 --- a/src/config/provider-validation.ts +++ b/src/config/provider-validation.ts @@ -5,12 +5,14 @@ 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, REASONING_SUMMARY_DELIVERY_VALUES, UPSTREAM_HTTP_VERSION_VALUES, type OcxProviderConfig, + type ModelCapabilities, } from "../types"; const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; @@ -233,6 +235,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; @@ -295,3 +376,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/providers/derive.ts b/src/providers/derive.ts index 3ab1d01500..02b3ba20dd 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -444,13 +444,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. */ @@ -470,7 +476,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/src/server/auth-cors.ts b/src/server/auth-cors.ts index 820943ab04..be09daa64c 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -1,3 +1,4 @@ +import { modelCapabilitiesConfigError } from "../config/provider-validation"; import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { initialModelSelection } from "../providers/initial-model-selection"; import { extractAccountId } from "../oauth/chatgpt"; @@ -13,6 +14,8 @@ import { } from "../config"; import { apiKeyTransportConfigError, + autoReviewModelOverridesConfigError, + autoReviewModelTargetConfigError, booleanRecordConfigError, providerReasoningPinsConfigError, modelAdapterRecordConfigError, @@ -646,8 +649,17 @@ 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; + 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}"`; } @@ -891,6 +903,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { contextWindow: "editor", modelContextWindows: "editor", modelInputModalities: "editor", + modelCapabilities: "editor", modelMaxInputTokens: "runtime", modelAutoCompactTokenLimits: "editor", defaultMaxOutputTokens: "editor", @@ -913,6 +926,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..2c24ae791f 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"; @@ -15,6 +16,7 @@ import { mutatePersistedConfig, nonBlankStringArrayConfigError, normalizeNonBlankStringArray, + normalizeAutoReviewModelOverrides, providerBaseUrlConfigError, providerHeadersConfigError, requestPacingConfigError, @@ -34,7 +36,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 +289,13 @@ 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; + if (normalized.modelCapabilities === undefined) delete provider.modelCapabilities; + else provider.modelCapabilities = normalized.modelCapabilities; } return { ok: true, config: candidate, removedProviders }; } @@ -490,6 +499,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) { @@ -518,6 +535,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, { @@ -784,6 +856,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; /** @@ -501,6 +510,28 @@ export interface OcxProviderConfig { * all-zero entry means "not billable here" and falls through to the catalogs. */ modelCosts?: Record; + /** + * 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/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/adapters/registry.md b/structure/adapters/registry.md index d44c3b99ac..ca3582958d 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -92,12 +92,14 @@ Native Chat applies qualifying effort ceilings independently of model pins; pin Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat removes effort only for an explicit empty declaration or no-reasoning model. - Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. - Translated Chat request construction uses the [inline-image budget](../transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached. +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. + +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. + ## SWE-2 model effort selection `src/adapters/devin.ts` resolves an explicit SWE-2 reasoning effort to the native diff --git a/structure/catalog.md b/structure/catalog.md index 68fb9b9bb3..4a6ef7befe 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -299,3 +299,15 @@ 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. Live sideband admission and its bounded upstream handshake follow the [runtime contract](runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. + +## 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. 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. + +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. + +## 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/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 95abdfeb29..35b77c081b 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -103,7 +103,6 @@ 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 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. 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. @@ -113,3 +112,9 @@ Combo child requests normalize effort and thinking controls against the selected Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. OpenCode is a separate launcher: its management catalog read retains local admin authority in the parent, while generated provider blocks reference only the child admission environment. It does not change Desktop configuration ownership. + +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. + +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 a25eb8d326..2883167368 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -236,8 +236,13 @@ 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. - A config restoration that was attempted and failed retains its failed artifact in the restore result; unattempted catalog and history artifacts remain skipped. Successful preimage compensation preserves config/profile/journal bytes without relabeling the failure as a skipped operation. Incomplete compensation still raises the explicit partial-write error. + +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. + +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 abafd78404..b8e63caf00 100644 --- a/structure/config.md +++ b/structure/config.md @@ -225,3 +225,13 @@ Config JSON preserves the boolean; only literal true activates the role-changing 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. The OpenCode launcher resolves the existing local management origin from the live bind and configured hub ingress. Its admin credential comes from the existing admin environment/file policy; an absent credential fails without substituting a data key. + +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. + +## 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/data-planes/images.md b/structure/data-planes/images.md index 730080408a..21e2a8d6e1 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -95,5 +95,8 @@ Native Chat applies qualifying effort ceilings independently of model pins; pin Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat removes effort only for an explicit empty declaration or no-reasoning model. - Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. + +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. + +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 bf457f3839..e41a645fea 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -207,8 +207,10 @@ a decreasing cursor. It accepts exactly one ASCII space inside the token notice, unmatched prefix bytes, and does not repeatedly scan or copy shrinking prompt prefixes. 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. - Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. - Translated Chat request construction uses the [inline-image budget](../transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached. + +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. + +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 47bf09b643..63543dfe5f 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -584,8 +584,12 @@ Native Chat applies qualifying effort ceilings independently of model pins; pin Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](transports/responses.md) documents this boundary, and native Chat removes effort only for an explicit empty declaration or no-reasoning model. - Live sideband admission and its bounded upstream handshake follow the [runtime contract](runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. - Dashboard overview polling observes authorization failures independently of stalled or rejected peer requests, cancels remaining child requests after a decisive result, and exposes resource-level deadline failures without rewriting them as authentication failures. + +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. + +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 a4275374cd..2096a41775 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -330,7 +330,6 @@ Provider configuration documents distinguish actual summaries from raw reasoning Hub/browser pairing instructions distinguish machine enrollment, session authentication, permission denial and network failure. The hosted dashboard preview is the render artifact used to review these states. 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 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. 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. @@ -340,3 +339,11 @@ Combo child requests normalize effort and thinking controls against the selected Translated Chat request construction uses the [inline-image budget](../transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached. OpenCode launcher verification distinguishes the local management catalog request from the inference child. Its transport regressions cover proxy environment, redirects, endpoint validation, credential precedence and child-env separation on hosted CI. + +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. + +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. + +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/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 93806c89ed..e14a9a47cb 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -158,5 +158,8 @@ Native Chat applies qualifying effort ceilings independently of model pins; pin Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat removes effort only for an explicit empty declaration or no-reasoning model. - Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. + +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. + +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 9d0273b86d..e39f32fc62 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -122,3 +122,7 @@ Connected-browser pairing and dashboard failure meanings follow the [management 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. Translated Chat request construction uses the [inline-image budget](transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached. + +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. + +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 9bac0dc9b5..018775d352 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -442,6 +442,12 @@ Listener startup diagnostics follow [the runtime lifecycle contract](../runtime. `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. + +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. + +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. + ## Context relay ownership `src/codex/context-owner.ts` records which account actually served a root session, taken from the diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index f67fa76e9c..b08992fc73 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -88,6 +88,12 @@ Native Chat applies qualifying effort ceilings independently of model pins; pin Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat removes effort only for an explicit empty declaration or no-reasoning model. +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. + +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. + +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. + ### OAuth Fast Tier (Priority Processing) xAI's Priority Processing (`service_tier: "priority"` on Chat Completions and Responses, diff --git a/structure/runtime.md b/structure/runtime.md index 6cec43426f..fea11a7ada 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -262,7 +262,6 @@ 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 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. Devin CLI credential path composition in `src/oauth/devin-cli.ts` follows the selected platform: Windows uses Win32 APPDATA paths, other platforms use POSIX XDG-data paths. The explicit absolute override remains verbatim; credential parsing and login behavior are unchanged. @@ -272,3 +271,11 @@ Native Chat applies qualifying effort ceilings independently of model pins; pin Translated Chat request construction uses the [inline-image budget](transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached. OpenCode catalog discovery in `src/cli/opencode.ts` uses the local admin credential and a validated numeric-loopback management origin. It dials through `src/server/direct-local-http.ts`, rejects redirects and preserves the request/body deadline. Hub ingress selection stays separate from exported inference settings. + +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. + +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. + +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 cea31404e5..65be64eedd 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -265,5 +265,12 @@ Native Chat applies qualifying effort ceilings independently of model pins; pin Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](transports/responses.md) documents this boundary, and native Chat removes effort only for an explicit empty declaration or no-reasoning model. - Live sideband admission and its bounded upstream handshake follow the [runtime contract](runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. + +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. + +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. + +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 61e9c2ca75..e1ac70d3f2 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -85,7 +85,6 @@ CCA Gemini summary provenance and request opt-in are specified in [Google provid 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. - Devin CLI credential path composition in `src/oauth/devin-cli.ts` follows the selected platform: Windows uses Win32 APPDATA paths, other platforms use POSIX XDG-data paths. The explicit absolute override remains verbatim; credential parsing and login behavior are unchanged. 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. @@ -93,3 +92,9 @@ Native Chat applies qualifying effort ceilings independently of model pins; pin Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. Translated Chat request construction uses the [inline-image budget](streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached. + +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. + +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. + +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/responses.md b/structure/transports/responses.md index f56be38160..cc802d00b0 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -589,8 +589,10 @@ Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.m privately to final dispatch; preliminary route selection does not inject Go-only headers. 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. - Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. - Translated Chat request construction uses the [inline-image budget](streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached. + +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. + +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 18f603ae5a..86e55d2bf0 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -215,10 +215,12 @@ Native Chat applies qualifying effort ceilings independently of model pins; pin Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](responses.md) documents this boundary, and native Chat removes effort only for an explicit empty declaration or no-reasoning model. - Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. - ## Translated Chat inline-image budget `src/adapters/openai-chat-images.ts` reuses the shared image normalization ladder for translated Chat bodies above a 3.5 MiB base64-image budget. This is best effort, not a whole-request ceiling. Remote URLs are not fetched; unprocessable and terminal images remain attached, and retained bytes continue to count during demotion. Under-budget construction stays synchronous; delegating MiMo awaits conditional asynchronous construction. Native Chat passthrough and Anthropic-only 413 retry policy retain their existing behavior. + +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. + +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/adapters/openai/openai-chat-native-policy.test.ts b/tests/adapters/openai/openai-chat-native-policy.test.ts index 3a7530d784..ac6fb80b12 100644 --- a/tests/adapters/openai/openai-chat-native-policy.test.ts +++ b/tests/adapters/openai/openai-chat-native-policy.test.ts @@ -448,3 +448,13 @@ describe("main and native Chat tier authorization parity", () => { for (const part of parts) expect(part.image_url.url).toBe(url); }); }); + + +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..fa33b804a2 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)).toBe(2); + } + expect(requests).toHaveLength(0); + } finally { error.mockRestore(); } +}); diff --git a/tests/cli/cli-provider.test.ts b/tests/cli/cli-provider.test.ts index ea29c5535f..5c8b72860b 100644 --- a/tests/cli/cli-provider.test.ts +++ b/tests/cli/cli-provider.test.ts @@ -644,3 +644,31 @@ 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); } +}); + + +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/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"]); +}); diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index cc3136222a..989b17d07c 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -7311,6 +7311,417 @@ 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("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 + // 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..edfa49eef8 100644 --- a/tests/codex-integration/codex-convergence-account-selectors.test.ts +++ b/tests/codex-integration/codex-convergence-account-selectors.test.ts @@ -736,6 +736,66 @@ 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(), slug: "gpt-5.5", auto_review_model_override: "static/deepseek-v4-flash" }, + 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", "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"); + } +}); + test("degraded preservation still honors explicit routed visibility policy", async () => { writeCatalog([ nativeEntry(), 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/client-config-export.test.ts b/tests/config/client-config-export.test.ts index 53e47c6276..1884dc941c 100644 --- a/tests/config/client-config-export.test.ts +++ b/tests/config/client-config-export.test.ts @@ -1105,3 +1105,43 @@ 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(); + 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/config/config-load-degrade.test.ts b/tests/config/config-load-degrade.test.ts index 2ca6ab258a..ae68346a0c 100644 --- a/tests/config/config-load-degrade.test.ts +++ b/tests/config/config-load-degrade.test.ts @@ -55,6 +55,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", @@ -130,6 +147,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) { @@ -142,6 +178,39 @@ test("Fast rows default on for fresh and omitted config; explicit false and malf }); +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(); +}); + test.each([ { enabled: "true" }, { enabled: true, port: 70000 }, @@ -219,5 +288,4 @@ test("valid ingress is not blamed when a malformed hub sibling disables the hub expect(diagnostics.warnings?.join("\n")).toContain("hub.dataPublicOrigin"); expect(diagnostics.warnings?.join("\n")).not.toContain("hub.managementIngress ignored"); expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); - } finally { warn.mockRestore(); } -}); + } finally { warn.mockRestore(); }}); 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/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index d33d10f4c5..8911bc4a1f 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"; @@ -1528,3 +1529,51 @@ 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("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); + enrichProviderFromRegistry("CommandCode", provider); + expect(provider.modelReasoningEfforts).toBeUndefined(); + expect(provider.reasoningEfforts).toBeUndefined(); + } + }); +}); diff --git a/tests/server/management-provider-validation.test.ts b/tests/server/management-provider-validation.test.ts index 949791f2d6..10747c6a12 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-test", "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).toEqual({ "GLM-5.2": "gpt-5.6-terra" }); + + 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 [ + { "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); + 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,128 @@ 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); + } +}); + +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 = 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); + 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 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 }); + 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); +}); 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); +});