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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs-site/src/content/docs/reference/configuration/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -1128,6 +1128,20 @@ The raw provider editor and provider API expose this map. POST/PUT replace an ex

An explicit `modelCapabilities.<id>.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.

An input-modality declaration on a **custom model row** — the Input modalities checkboxes in the
Models tab, or `inputModalities` on `customModels[]` — also takes precedence over the provider's
legacy vision hints (`noVisionModels`, `modelInputModalities`) for that exact provider and model
id. Declare `text, image` on a custom row and images reach that model even when the provider row
lists it as text-only; declare only `text` and it is routed through the vision sidecar. This is
the same precedence the catalog already applied, so the advertised row and the request now agree.
`modelCapabilities` stays above it, because that is the dedicated per-model capability axis —
including the write made by `ocx provider edit <provider> --model <id> --text-only`. A custom row
that leaves the modalities blank inherits the provider row instead of claiming text-only.
Every consumer that answers "can this model take an image" applies one rule to the declaration:
image is absent from the list. A row declaring only `audio` or `video` therefore counts as
image-incapable, rather than being treated as a text model by one predicate and an image target
by the other.

### 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.
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1459,6 +1459,7 @@
"vision-anthropic.test.ts": "vision",
"vision-backend-union.test.ts": "vision",
"vision-cache.test.ts": "vision",
"vision-custom-row-precedence.test.ts": "vision",
"vision-eligibility.test.ts": "vision",
"vision-fail-closed.test.ts": "vision",
"vision-reasoning-contract.test.ts": "vision",
Expand Down
97 changes: 88 additions & 9 deletions src/vision/eligibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,54 @@ import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers-dest
*/
export type VisionSidecarBackend = "openai" | "anthropic" | "routed";

/**
* Input modalities an explicit custom row declares for one routed model.
*
* A custom row is the operator's own definition of that model, so its declaration outranks the
* provider-level hints the predicates below read. Without this the two halves of one config
* disagreed in production: the catalog overlay in `src/codex/catalog/routed-gather.ts` copies
* `customModels[].inputModalities` onto the row and the dashboard showed "text, image", while
* the request path consulted only `providers[].noVisionModels` / `modelInputModalities` and
* stripped the image before dispatch. A model the operator had declared image-capable therefore
* received an omission marker instead of its attachment.
*
* Precedence, highest first: `modelCapabilities` (the documented per-model capability
* declaration), then this custom-row declaration, then `noVisionModels`, then
* `modelInputModalities`, then registry/vendor metadata. `modelCapabilities` stays on top
* because it is the dedicated capability axis and the CLI `--text-only` flag writes it; when the
* two explicit forms contradict each other the more specific axis wins.
*
* Matching is exact on the routed identity — provider name and native model id, the pair the
* custom-model API keys rows by. A row that declares no modalities returns `undefined` rather
* than `["text"]`, so it stays silent instead of turning into a text-only claim.
*/
export function customRowInputModalities(
config: Pick<OcxConfig, "customModels">,
providerName: string,
modelId: string,
): string[] | undefined {
for (const row of config.customModels ?? []) {
if (row.provider !== providerName || row.modelId !== modelId) continue;
if (Array.isArray(row.inputModalities) && row.inputModalities.length > 0) {
return [...row.inputModalities];
}
}
return undefined;
}

/**
* The custom row's verdict for one model, in the shape the predicates need:
* `true`/`false` when the row declares modalities, `undefined` when it declares none.
*/
function customRowAcceptsImageInput(
config: Pick<OcxConfig, "customModels">,
providerName: string,
modelId: string,
): boolean | undefined {
const declared = customRowInputModalities(config, providerName, modelId);
return declared === undefined ? undefined : declared.includes("image");
}

/** The two sides every deployment has; also the empty-auth fallback set. */
export type UniversalVisionBackend = "openai" | "anthropic";

Expand All @@ -60,6 +108,14 @@ export interface VisionCandidateModel {
native?: boolean;
}

/**
* The config slice the capability predicates need. `customModels` is optional so provider-only
* callers and unit tests keep compiling; a caller that omits it is simply silent about custom
* rows rather than wrong about them.
*/
export type VisionCapabilityConfig =
Pick<OcxConfig, "providers"> & { customModels?: OcxConfig["customModels"] };

export interface VisionModelOption {
value: string;
label: string;
Expand Down Expand Up @@ -142,20 +198,31 @@ function enrichedProviderForVision(
}

function isVisionSidecarConsumerWithCache(
config: Pick<OcxConfig, "providers">,
config: VisionCapabilityConfig,
providerName: string,
modelId: string,
cache: EnrichedProviderCache,
): boolean {
const provider = enrichedProviderForVision(config, providerName, cache);
return provider !== undefined && isModelVisionSidecarConsumer(provider, modelId);
if (provider === undefined) return false;
// Documented precedence, highest first: the dedicated per-model capability axis, then the
// operator's own custom row, then the provider-level hints. Reading the custom row ahead of
// `modelCapabilities` would let it override the more specific axis.
const capabilityDeclared = Object.hasOwn(provider.modelCapabilities ?? {}, modelId)
? provider.modelCapabilities?.[modelId]?.inputModalities : undefined;
if (capabilityDeclared !== undefined) {
return capabilityDeclared.includes("text") && !capabilityDeclared.includes("image");
}
const customDeclared = customRowInputModalities(config, providerName, modelId);
if (customDeclared !== undefined) return customDeclared.includes("text") && !customDeclared.includes("image");
return isModelVisionSidecarConsumer(provider, modelId);
}

/**
* Is this model listed as one the sidecar describes FOR? Such a model cannot be
* the describer, and its advertised modalities are untrustworthy.
*/
export function isVisionSidecarConsumer(config: Pick<OcxConfig, "providers">, providerName: string, modelId: string): boolean {
export function isVisionSidecarConsumer(config: VisionCapabilityConfig, providerName: string, modelId: string): boolean {
return isVisionSidecarConsumerWithCache(config, providerName, modelId, new Map());
}

Expand All @@ -165,30 +232,42 @@ export function isVisionSidecarConsumer(config: Pick<OcxConfig, "providers">, pr
* which callers treat as eligible.
*/
export function modelAcceptsImageInput(
config: Pick<OcxConfig, "providers">,
config: VisionCapabilityConfig,
candidate: VisionCandidateModel,
): boolean | undefined {
return modelAcceptsImageInputWithCache(config, candidate, new Map());
}

function modelAcceptsImageInputWithCache(
config: Pick<OcxConfig, "providers">,
config: VisionCapabilityConfig,
candidate: VisionCandidateModel,
cache: EnrichedProviderCache,
): boolean | undefined {
if (candidate.native === true || (candidate.provider === "openai" && SUPPORTED_NATIVE_OPENAI_SLUGS.has(candidate.id))) {
const nativeProvider = enrichedProviderForVision(config, candidate.provider, cache);
if (nativeProvider && isModelVisionSidecarConsumer(nativeProvider, candidate.id)) return false;
const declared = Object.hasOwn(nativeProvider?.modelCapabilities ?? {}, candidate.id)
? nativeProvider?.modelCapabilities?.[candidate.id]?.inputModalities : undefined;
if (declared !== undefined) return declared.includes("image");
// The catalog already lets an explicit custom row replace the native modality list for the
// same slug; consult it here too or the row would advertise what the request path ignores.
const fromCustomRow = customRowAcceptsImageInput(config, candidate.provider, candidate.id);
if (fromCustomRow !== undefined) return fromCustomRow;
// The sidecar hints come after both explicit declarations, matching the documented order.
// Ahead of them a `noVisionModels` membership would short-circuit to text-only before the
// operator's own row was read.
if (nativeProvider && isModelVisionSidecarConsumer(nativeProvider, 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");
// Ahead of the provider's own hints: this row is the operator's definition of this exact model,
// and the catalog overlay reads the same field. Behind modelCapabilities, which is the dedicated
// capability axis the CLI `--text-only` writes.
const fromCustomRow = customRowAcceptsImageInput(config, candidate.provider, candidate.id);
if (fromCustomRow !== undefined) return fromCustomRow;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const configuredModalities = provider ? modelRecordValue(provider.modelInputModalities, candidate.id) : undefined;
const fromConfiguredModalities = advertisesImageInput(configuredModalities);
if (fromConfiguredModalities !== undefined) return fromConfiguredModalities;
Expand All @@ -206,14 +285,14 @@ function modelAcceptsImageInputWithCache(

/** Eligible = not a sidecar consumer, and not positively known to be text-only. */
export function isVisionEligibleModel(
config: Pick<OcxConfig, "providers">,
config: VisionCapabilityConfig,
candidate: VisionCandidateModel,
): boolean {
return isVisionEligibleModelWithCache(config, candidate, new Map());
}

function isVisionEligibleModelWithCache(
config: Pick<OcxConfig, "providers">,
config: VisionCapabilityConfig,
candidate: VisionCandidateModel,
cache: EnrichedProviderCache,
): boolean {
Expand Down Expand Up @@ -274,7 +353,7 @@ function baselineCandidate(
* backend, and only `value` reaches the client, so first-wins costs nothing.
*/
export function visionEligibleModelOptions(
config: Pick<OcxConfig, "providers">,
config: VisionCapabilityConfig,
candidates: readonly VisionCandidateModel[],
enabledBackends: readonly VisionSidecarBackend[],
anthropicProviderName?: string,
Expand Down
44 changes: 34 additions & 10 deletions src/vision/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar";
import type { CodexAuthPolicyConfig } from "../codex/auth-context";
import { isCodexReserveRequestEligible } from "../codex/loopback-target";
import type { DataPlaneAdmission } from "../server/auth-cors";
import { isModelVisionSidecarConsumer as isModelTextOnly, modelAcceptsImageInput } from "./eligibility";
import {
customRowInputModalities,
isModelVisionSidecarConsumer as isModelTextOnly,
modelAcceptsImageInput,
} from "./eligibility";
import { normalizeVisionReasoningForModel } from "./reasoning";
import { resolveSidecarAuth } from "../sidecar/auth";
import { DEFAULT_VISION_TIMEOUT_MS, MAX_VISION_TIMEOUT_MS, MIN_VISION_TIMEOUT_MS } from "./timeout-bounds";
Expand Down Expand Up @@ -95,23 +99,40 @@ function messagesHaveImage(parsed: OcxParsedRequest): boolean {
/**
* Direct-image admission for a routed target. Returns true when capability evidence proves the
* target cannot accept image input, so the caller must describe or strip the image first.
* Explicit text-only config, an explicit per-model modality list without `image`, and
* proven-negative registry/vendor metadata each require the vision preprocessor. A genuinely
* unknown custom model is NOT guessed blind: it keeps the established pass-through behaviour.
* The provider-only fallback keeps legacy unit callers stable; production dispatch always
* supplies providerName so the complete capability chain is consulted.
* Evidence is consulted highest-first: `modelCapabilities` (the dedicated per-model capability
* axis), an explicit custom row for the same routed identity, `noVisionModels`, an explicit
* per-model modality list without `image`, and finally proven-negative registry/vendor metadata.
* Any of them can require the vision preprocessor. A genuinely unknown custom model is NOT
* guessed blind: it keeps the established pass-through behaviour. The provider-only fallback
* keeps legacy unit callers stable; production dispatch always supplies providerName so the
* complete capability chain is consulted.
*/
export function requiresVisionPreprocessing(
config: Pick<OcxConfig, "providers">,
config: Pick<OcxConfig, "providers"> & { customModels?: OcxConfig["customModels"] },
provider: Pick<OcxProviderConfig, "noVisionModels" | "modelInputModalities" | "modelCapabilities">,
modelId: string,
providerName?: string,
): boolean {
if (isModelTextOnly(provider, modelId)) return true;
const runtimeDeclared = Object.hasOwn(provider.modelCapabilities ?? {}, modelId)
? provider.modelCapabilities?.[modelId]?.inputModalities
: undefined;
// `modelCapabilities` is the dedicated per-model capability axis, so it outranks everything
// below — including an explicit custom row that disagrees. The CLI `--text-only` flag writes it.
if (runtimeDeclared !== undefined) return !runtimeDeclared.includes("image");
// An explicit custom row outranks the provider-level hints below, mirroring the catalog overlay
// in `src/codex/catalog/routed-gather.ts` that already copies this same declaration onto the
// advertised row. Without it the dashboard advertised "text, image" from the operator's own row
// while this predicate stripped the image before dispatch.
const customDeclared = providerName === undefined
? undefined
: customRowInputModalities(config, providerName, modelId);
// Same rule as the `modelCapabilities` branch above: the declaration answers "can this model
// take an image", not "is it a text model". A row that lists only `audio` or `video` excludes
// image input just as `["text"]` does, and `modelAcceptsImageInput` already answers "no image"
// for it; the narrower `includes("text")` test here made the two predicates disagree and sent
// the attachment to a model that cannot read it.
if (customDeclared !== undefined) return !customDeclared.includes("image");
if (isModelTextOnly(provider, modelId)) return true;
const runtimeModalities = modelRecordValue(provider.modelInputModalities, modelId);
if (Array.isArray(runtimeModalities) && runtimeModalities.length > 0) {
return !runtimeModalities.includes("image");
Expand All @@ -129,9 +150,12 @@ function usableRoutedVisionModel(config: OcxConfig): string | undefined {
if (!routedModel || sep <= 0) return undefined;
const targetProvider = routedModel.slice(0, sep);
const targetId = routedModel.slice(sep + 1);
const targetProviderConfig = config.providers?.[targetProvider];
// `modelAcceptsImageInput` is the one predicate here: it resolves the whole capability chain,
// custom row included. The provider-only `isModelTextOnly` that used to be ANDed in could only
// subtract — it never saw a custom row, so a describer the operator had declared image-capable
// was refused for a provider hint that same row overrides.
return modelAcceptsImageInput(config, { provider: targetProvider, id: targetId }) !== false
&& !(targetProviderConfig && isModelTextOnly(targetProviderConfig, targetId)) ? routedModel : undefined;
? routedModel : undefined;
}

export function shouldResolveOpenAiVisionSidecar(
Expand Down
16 changes: 16 additions & 0 deletions structure/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,22 @@ Display-name validation retains prototype-shaped model IDs as data; reviewer-tar

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.

An explicit custom row is the operator's own definition of one routed model, so its
`customModels[].inputModalities` outranks the provider-level vision hints
(`noVisionModels`, `modelInputModalities`) for that exact `provider`/`modelId` identity.
`modelCapabilities` keeps the top slot as the dedicated capability axis, including for the
`ocx provider edit --text-only` write. The catalog overlay in
`src/codex/catalog/routed-gather.ts` copies that declaration onto the advertised row directly,
and the request-path predicates in `src/vision/eligibility.ts` and `src/vision/plan.ts` read the
same field through `customRowInputModalities`, so an advertised row and the dispatch decision can
no longer disagree about one model. A custom row that declares no modalities stays silent rather
than becoming a text-only claim.

Every consumer that answers "can this model take an image" applies one rule to the declaration:
image is absent from the list. A row declaring only `audio` or `video` therefore counts as
image-incapable in both `requiresVisionPreprocessing` and `modelAcceptsImageInput`, rather than
being treated as a text model by one and an image target by the other.

## Catalog auto-refresh

`catalogAutoRefresh` on `src/types/config.ts` stores an optional `enabled` / `intervalMinutes` section that defaults off: an absent key, an explicit false, and a malformed value all leave the scheduler dormant. `src/config/feature-flags.ts` resolves the cadence; an explicit `intervalMinutes: 0` keeps the unref'd timer idle, and any other value is clamped up to 15 minutes because upstream `/models` caches have not moved below that and a shorter tick only multiplies rate-limit exposure. `src/codex/catalog-auto-refresh.ts` is the module-singleton interval `src/server/background-lifecycle.ts` starts beside the quota reset poller; a tick that is enabled and non-dormant drives the same catalog-only converge funnel management mutations drive. The last-outcome record lives in `src/codex/catalog-refresh-status.ts` (when the tick finished, the normalized `CatalogDisposition`, whether the served model set changed, consecutive failures) and carries no provider or account detail.
Expand Down
Loading
Loading