diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index ada6821e078..624a736c8a2 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -1128,6 +1128,20 @@ The raw provider editor and provider API expose this map. POST/PUT replace an ex 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. +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 --model --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. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ac1a90a8c17..cf6d4159951 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -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", diff --git a/src/vision/eligibility.ts b/src/vision/eligibility.ts index c8bcb815963..d772c8f9769 100644 --- a/src/vision/eligibility.ts +++ b/src/vision/eligibility.ts @@ -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, + 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, + 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"; @@ -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 & { customModels?: OcxConfig["customModels"] }; + export interface VisionModelOption { value: string; label: string; @@ -142,20 +198,31 @@ function enrichedProviderForVision( } function isVisionSidecarConsumerWithCache( - config: Pick, + 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, providerName: string, modelId: string): boolean { +export function isVisionSidecarConsumer(config: VisionCapabilityConfig, providerName: string, modelId: string): boolean { return isVisionSidecarConsumerWithCache(config, providerName, modelId, new Map()); } @@ -165,23 +232,30 @@ export function isVisionSidecarConsumer(config: Pick, pr * which callers treat as eligible. */ export function modelAcceptsImageInput( - config: Pick, + config: VisionCapabilityConfig, candidate: VisionCandidateModel, ): boolean | undefined { return modelAcceptsImageInputWithCache(config, candidate, new Map()); } function modelAcceptsImageInputWithCache( - config: Pick, + 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; @@ -189,6 +263,11 @@ function modelAcceptsImageInputWithCache( 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; const configuredModalities = provider ? modelRecordValue(provider.modelInputModalities, candidate.id) : undefined; const fromConfiguredModalities = advertisesImageInput(configuredModalities); if (fromConfiguredModalities !== undefined) return fromConfiguredModalities; @@ -206,14 +285,14 @@ function modelAcceptsImageInputWithCache( /** Eligible = not a sidecar consumer, and not positively known to be text-only. */ export function isVisionEligibleModel( - config: Pick, + config: VisionCapabilityConfig, candidate: VisionCandidateModel, ): boolean { return isVisionEligibleModelWithCache(config, candidate, new Map()); } function isVisionEligibleModelWithCache( - config: Pick, + config: VisionCapabilityConfig, candidate: VisionCandidateModel, cache: EnrichedProviderCache, ): boolean { @@ -274,7 +353,7 @@ function baselineCandidate( * backend, and only `value` reaches the client, so first-wins costs nothing. */ export function visionEligibleModelOptions( - config: Pick, + config: VisionCapabilityConfig, candidates: readonly VisionCandidateModel[], enabledBackends: readonly VisionSidecarBackend[], anthropicProviderName?: string, diff --git a/src/vision/plan.ts b/src/vision/plan.ts index 166d6bb3b03..1283896959f 100644 --- a/src/vision/plan.ts +++ b/src/vision/plan.ts @@ -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"; @@ -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, + config: Pick & { customModels?: OcxConfig["customModels"] }, provider: Pick, 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"); @@ -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( diff --git a/structure/config.md b/structure/config.md index a6eb45aff94..b7dc281b418 100644 --- a/structure/config.md +++ b/structure/config.md @@ -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. diff --git a/structure/runtime.md b/structure/runtime.md index c34925f78a3..2e1e3283b1b 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -412,7 +412,7 @@ declare `modelInputModalities: ["text", "image"]` per model for the nine Claude explicit operator overrides; unknown models receive no new declaration. Client eligibility filters and Anthropic image wire handling remain unchanged. -`src/vision/plan.ts` prevents raw image bytes from reaching any target whose effective capability is positively known to exclude image input. Evidence from the resolved runtime provider and explicit operator declarations takes precedence, followed by backend-specific/registry/vendor metadata. A proven text-only target is preprocessed through the configured Vision Sidecar; a positively image-capable target receives the image directly. Genuinely unknown custom models retain the existing compatibility path rather than being guessed text-only. +`src/vision/plan.ts` prevents raw image bytes from reaching any target whose effective capability is positively known to exclude image input. Evidence is consulted highest-first: `modelCapabilities`, an explicit custom row for the same routed identity, `noVisionModels`, an explicit per-model modality list without `image`, then backend-specific/registry/vendor metadata. A proven text-only target is preprocessed through the configured Vision Sidecar; a positively image-capable target receives the image directly. Genuinely unknown custom models retain the existing compatibility path rather than being guessed text-only. Canonical ChatGPT Codex forwarding uses the generated `openai-codex` capability bundle rather than the public `openai` bundle. This matters when the two backends differ: for example, the vendored metadata records `gpt-5.3-codex-spark` as text-only on `openai-codex` while the public OpenAI row lists image input. The native Chat fast path and web-search image verbalization consume the same effective-capability decision. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d677aea796a..0d84562cd81 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1288,6 +1288,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", diff --git a/tests/vision/vision-custom-row-precedence.test.ts b/tests/vision/vision-custom-row-precedence.test.ts new file mode 100644 index 00000000000..740c46c6295 --- /dev/null +++ b/tests/vision/vision-custom-row-precedence.test.ts @@ -0,0 +1,169 @@ +/** + * An explicit custom row outranks the provider-level vision hints. + * + * The reported defect: a model the operator had added as a manual custom row with + * `inputModalities: ["text", "image"]` still had every attachment replaced by the omission + * marker. The catalog half was already correct — `src/codex/catalog/routed-gather.ts` copies + * `customModels[].inputModalities` onto the advertised row, which is why the dashboard showed + * "text, image" — while the request path consulted only `providers[].noVisionModels` and + * `modelInputModalities` and concluded text-only. One config, two answers. + * + * These tests pin the precedence at the request-path seam (`requiresVisionPreprocessing`) and at + * the shared capability predicate (`modelAcceptsImageInput` / `isVisionSidecarConsumer`), which + * is what the sidecar picker and the web-search verbalizer read. + */ +import { describe, expect, test } from "bun:test"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { + isVisionSidecarConsumer, + modelAcceptsImageInput, +} from "../../src/vision/eligibility"; +import { planVisionSidecar, requiresVisionPreprocessing } from "../../src/vision/plan"; +import { parseRequest } from "../../src/responses/parser"; + +const PROVIDER = "zen-go"; +const MODEL = "deepseek-v4.1-flash"; + +/** The reported shape: the provider lists the model as text-only, the custom row says otherwise. */ +const provider: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://opencode.ai/zen/go/v1", + noVisionModels: [MODEL], + modelInputModalities: { [MODEL]: ["text"] }, +}; + +function configWith( + customModels?: OcxConfig["customModels"], + providerOverrides: Partial = {}, +): OcxConfig { + return { + port: 10100, + defaultProvider: PROVIDER, + providers: { [PROVIDER]: { ...provider, ...providerOverrides } }, + ...(customModels ? { customModels } : {}), + } as OcxConfig; +} + +const imageRow: NonNullable[number] = { + id: "custom-image-row", + provider: PROVIDER, + modelId: MODEL, + inputModalities: ["text", "image"], +}; + +describe("custom row outranks provider vision hints", () => { + test("the provider hints alone are what strip the image (the reported defect)", () => { + const config = configWith(); + expect(requiresVisionPreprocessing(config, config.providers[PROVIDER]!, MODEL, PROVIDER)).toBe(true); + expect(modelAcceptsImageInput(config, { provider: PROVIDER, id: MODEL })).toBe(false); + }); + + test("an explicit image declaration on the custom row turns preprocessing off", () => { + const config = configWith([imageRow]); + expect(requiresVisionPreprocessing(config, config.providers[PROVIDER]!, MODEL, PROVIDER)).toBe(false); + expect(modelAcceptsImageInput(config, { provider: PROVIDER, id: MODEL })).toBe(true); + }); + + test("the shared consumer predicate agrees, so the picker and verbalizer follow", () => { + expect(isVisionSidecarConsumer(configWith([imageRow]), PROVIDER, MODEL)).toBe(false); + expect(isVisionSidecarConsumer(configWith(), PROVIDER, MODEL)).toBe(true); + }); + + test("an explicit text-only custom row outranks provider hints that advertise image", () => { + // The mirror direction: the row must be able to declare text-only for a model the provider + // row happens to describe as image-capable, or the override would only work one way. + const config = configWith( + [{ ...imageRow, inputModalities: ["text"] }], + { noVisionModels: [], modelInputModalities: { [MODEL]: ["text", "image"] } }, + ); + expect(requiresVisionPreprocessing(config, config.providers[PROVIDER]!, MODEL, PROVIDER)).toBe(true); + expect(modelAcceptsImageInput(config, { provider: PROVIDER, id: MODEL })).toBe(false); + }); + + test("a custom row without a modality declaration stays silent instead of claiming text-only", () => { + const config = configWith( + [{ id: "custom-row", provider: PROVIDER, modelId: MODEL, contextWindow: 1_048_576 }], + { noVisionModels: [], modelInputModalities: { [MODEL]: ["text", "image"] } }, + ); + expect(requiresVisionPreprocessing(config, config.providers[PROVIDER]!, MODEL, PROVIDER)).toBe(false); + expect(modelAcceptsImageInput(config, { provider: PROVIDER, id: MODEL })).toBe(true); + }); + + test("an audio-only custom row is image-incapable in both predicates", () => { + // The declaration answers "can this model take an image", not "is it a text model". A row + // that lists only audio excludes image input exactly as `["text"]` does, so the wider + // `includes("text")` test this branch used to apply made the two predicates disagree and + // sent the attachment to a model that cannot read it. + const config = configWith([{ ...imageRow, inputModalities: ["audio"] }]); + expect(requiresVisionPreprocessing(config, config.providers[PROVIDER]!, MODEL, PROVIDER)).toBe(true); + expect(modelAcceptsImageInput(config, { provider: PROVIDER, id: MODEL })).toBe(false); + }); + + test("a custom row lets a provider-declared text-only model serve as the routed describer", () => { + // `usableRoutedVisionModel` used to AND a provider-only `isModelTextOnly` on top of + // `modelAcceptsImageInput`. That second check never saw a custom row, so a describer the + // operator had declared image-capable was refused for the very provider hint the row overrides. + const request = parseRequest({ + model: "main/blind", + input: [{ type: "message", role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,aGVsbG8taW1hZ2U=" }] }], + }); + const build = (customModels?: OcxConfig["customModels"]): OcxConfig => ({ + port: 10100, + defaultProvider: "main", + providers: { + main: { adapter: "openai-chat", baseUrl: "https://main.test/v1", noVisionModels: ["blind"] }, + [PROVIDER]: { ...provider }, + }, + visionSidecar: { enabled: true, backend: "routed", model: `${PROVIDER}/${MODEL}` }, + ...(customModels ? { customModels } : {}), + } as OcxConfig); + + const withRow = build([imageRow]); + const plan = planVisionSidecar(withRow, withRow.providers["main"]!, "blind", request, undefined, { providerName: "main" }); + expect(plan?.backend).toBe("routed"); + expect(plan?.routedModel).toBe(`${PROVIDER}/${MODEL}`); + + // Without the row the provider hint stands, the describer is refused, and nothing else can + // describe the image on this config — so the whole plan is absent. + const withoutRow = build(); + expect(planVisionSidecar(withoutRow, withoutRow.providers["main"]!, "blind", request, undefined, { providerName: "main" })).toBeUndefined(); + }); + + test("modelCapabilities remains the top slot when it contradicts the custom row", () => { + // `modelCapabilities` is the dedicated capability axis and what `ocx provider edit --text-only` + // writes. Two explicit declarations that disagree resolve to the more specific axis. + const config = configWith([imageRow]); + config.providers[PROVIDER]!.modelCapabilities = { [MODEL]: { inputModalities: ["text"] } }; + expect(requiresVisionPreprocessing(config, config.providers[PROVIDER]!, MODEL, PROVIDER)).toBe(true); + expect(modelAcceptsImageInput(config, { provider: PROVIDER, id: MODEL })).toBe(false); + }); + + test("the shared consumer predicate reads modelCapabilities ahead of the custom row", () => { + // The precedence is one contract, not one per predicate: a reader that consults the custom + // row first would answer the opposite of `modelAcceptsImageInput` for the same config. + const config = configWith([imageRow]); + config.providers[PROVIDER]!.modelCapabilities = { [MODEL]: { inputModalities: ["text"] } }; + expect(isVisionSidecarConsumer(config, PROVIDER, MODEL)).toBe(true); + }); + + test("an explicit custom row outranks a noVisionModels listing on a native candidate", () => { + // The native arm used to check the sidecar hints before the custom row, so a listing the + // catalog had already overridden still forced text-only on the request path. + const config = configWith([imageRow]); + expect(modelAcceptsImageInput(config, { provider: PROVIDER, id: MODEL, native: true })).toBe(true); + }); + + test("a custom row for a different provider or model id does not leak", () => { + const otherProvider = configWith([{ ...imageRow, provider: "other-provider" }]); + expect(requiresVisionPreprocessing(otherProvider, otherProvider.providers[PROVIDER]!, MODEL, PROVIDER)).toBe(true); + const otherModel = configWith([{ ...imageRow, modelId: "another-model" }]); + expect(requiresVisionPreprocessing(otherModel, otherModel.providers[PROVIDER]!, MODEL, PROVIDER)).toBe(true); + }); + + test("without a providerName the custom row is not consulted, preserving legacy callers", () => { + // The provider-only fallback exists for unit callers that never resolved a route. It must not + // start guessing which provider a bare model id belongs to. + const config = configWith([imageRow]); + expect(requiresVisionPreprocessing(config, config.providers[PROVIDER]!, MODEL)).toBe(true); + }); +});