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 ff89672cb8..938958ba0d 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -1058,3 +1058,5 @@ contracts. `modelCapabilities` stores explicit declarations keyed by exact upstream model ID. IDs preserve case and must not contain surrounding whitespace. Each entry may contain `inputModalities` (`text`, `image`, `audio`, `video`), `contextTier` (`default`, `long_context`) and `video.processing` (`static`, `agentic`). These are operator declarations, not proof of provider support. Context-tier and video fields currently record intent only and do not activate upstream behavior or increase catalog windows. The raw provider editor and provider API expose this map. POST/PUT replace an explicitly supplied map and reject null entries. PATCH merges individual axes; null clears a map, model, axis or video processing value, while `{}` makes no change. Omitted provider overwrites preserve the existing map. Malformed hand-edited files retain valid independent axes and treat malformed explicit input modalities as text-only, with a diagnostic. + +An explicit `modelCapabilities..inputModalities` now takes precedence over legacy modality hints for that exact routed model. A text-only declaration uses the existing vision sidecar to replace images with descriptions; if no sidecar is available, the request receives an explicit omission marker before dispatch. Native Chat image requests divert through this path. The catalog can still advertise image attachment support because the proxy provides the description step. Context-tier and video processing declarations remain inert pending their transport support. diff --git a/src/cli/provider-runtime.ts b/src/cli/provider-runtime.ts index 6477694a49..c8054a7643 100644 --- a/src/cli/provider-runtime.ts +++ b/src/cli/provider-runtime.ts @@ -1,3 +1,4 @@ +import { modelCapabilitiesConfigError } from "../config/provider-validation"; import { CliUsageError, csv, @@ -39,7 +40,7 @@ const USAGE = `Usage: [--auth-mode ] [--note ] [--api-key-transport ] [--headers ] [--enabled ] [--live-models ] - [--retain-models ] + [--retain-models ] [--model --text-only] [--xai-chat ] [--allow-private-network ] [--json] ocx provider test [--json] @@ -72,7 +73,16 @@ async function edit(argv: string[], deps: RuntimeApiDeps): Promise { const liveModels = takeBooleanOption(args, "--live-models"); const allowPrivateNetwork = takeBooleanOption(args, "--allow-private-network"); const xaiChat = takeBooleanOption(args, "--xai-chat"); + const textOnly = takeFlag(args, "--text-only"); + const capabilityModel = takeOption(args, "--model"); rejectArgs(args, USAGE); + if (textOnly || capabilityModel !== undefined) { + if (!textOnly || capabilityModel === undefined) throw new CliUsageError("--text-only and --model must be supplied together", USAGE); + const declaration = { [capabilityModel]: { inputModalities: ["text"] } }; + const error = modelCapabilitiesConfigError(declaration); + if (error) throw new CliUsageError(error, USAGE); + patch.modelCapabilities = declaration; + } if (xaiChat !== undefined) { if (name !== "xai") throw new CliUsageError("--xai-chat is valid only for provider xai", USAGE); patch.xaiResponsesOptIn = !xaiChat; diff --git a/src/cli/provider.ts b/src/cli/provider.ts index 0f33ff98a5..958989a94f 100644 --- a/src/cli/provider.ts +++ b/src/cli/provider.ts @@ -9,7 +9,7 @@ * set-default Change the default provider */ import { hasOwnProvider, isValidProviderName, loadConfig, sanitizeModelCostsForDisplay, saveConfig } from "../config"; -import { apiKeyTransportConfigError } from "../config/provider-validation"; +import { apiKeyTransportConfigError, modelCapabilitiesConfigError, mergeModelCapabilities } from "../config/provider-validation"; import { hasHelpFlag } from "./help"; import { getProviderRegistryEntry, PROVIDER_REGISTRY } from "../providers/registry"; import { providerConfigSeed } from "../providers/derive"; @@ -139,7 +139,7 @@ function handleList(args: string[]): void { // provider add // --------------------------------------------------------------------------- -const ADD_USAGE = "Usage: ocx provider add [--adapter ] [--base-url ] [--api-key ] [--api-key-transport ] [--default-model ] [--allow-private-network] [--set-default] [--force] [--json] [--sync]"; +const ADD_USAGE = "Usage: ocx provider add [--adapter ] [--base-url ] [--api-key ] [--api-key-transport ] [--default-model ] [--model --text-only] [--allow-private-network] [--set-default] [--force] [--json] [--sync]"; async function handleAdd(args: string[]): Promise { const name = args[0]; @@ -164,7 +164,13 @@ async function handleAdd(args: string[]): Promise { const adapter = consumeFlagValue(restArgs, "--adapter"); const baseUrl = consumeFlagValue(restArgs, "--base-url"); const defaultModel = consumeFlagValue(restArgs, "--default-model"); + const textOnly = consumeFlag(restArgs, "--text-only"); + const capabilityModel = consumeFlagValue(restArgs, "--model"); rejectUnknownArgs(restArgs, ADD_USAGE); + if (capabilityModel !== undefined && !textOnly) { + console.error("Error: --model requires --text-only for provider add."); + process.exit(1); + } const config = loadConfig(); @@ -227,6 +233,17 @@ async function handleAdd(args: string[]): Promise { if (existingProvider?.modelCapabilities !== undefined && provConfig.modelCapabilities === undefined) { provConfig.modelCapabilities = structuredClone(existingProvider.modelCapabilities); } + if (textOnly) { + const modelId = capabilityModel ?? defaultModel ?? provConfig.defaultModel; + if (!modelId) { + console.error("Error: --text-only requires --model or a default model."); + process.exit(1); + } + const declaration = { [modelId]: { inputModalities: ["text"] } }; + const error = modelCapabilitiesConfigError(declaration); + if (error) { console.error(`Error: ${error}.`); process.exit(1); } + provConfig.modelCapabilities = mergeModelCapabilities(provConfig.modelCapabilities, declaration); + } const { initializeProviderModelSelection } = await import("../providers/initial-model-selection"); initializeProviderModelSelection(name, provConfig, existingProvider, config); config.providers[name] = provConfig; diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index ae03da9f0d..cdf7806f73 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -674,7 +674,9 @@ export function configuredContextWindow(prov: OcxProviderConfig, id: string): nu } export function configuredInputModalities(prov: OcxProviderConfig, id: string): string[] | undefined { - const modalities = modelRecordValue(prov.modelInputModalities, id); + const declared = Object.hasOwn(prov.modelCapabilities ?? {}, id) + ? prov.modelCapabilities?.[id]?.inputModalities : undefined; + const modalities = declared ?? modelRecordValue(prov.modelInputModalities, id); return Array.isArray(modalities) && modalities.length > 0 ? [...modalities] : undefined; } diff --git a/src/vision/eligibility.ts b/src/vision/eligibility.ts index 09a83d44db..bec455fc7d 100644 --- a/src/vision/eligibility.ts +++ b/src/vision/eligibility.ts @@ -77,9 +77,12 @@ type EnrichedProviderCache = Map; * not a text-only model and must not be widened to image through the vision sidecar. */ export function isModelVisionSidecarConsumer( - provider: Pick, + provider: Pick, modelId: string, ): boolean { + const declared = Object.hasOwn(provider.modelCapabilities ?? {}, modelId) + ? provider.modelCapabilities?.[modelId]?.inputModalities : undefined; + if (declared !== undefined) return declared.includes("text") && !declared.includes("image"); if (modelInList(provider.noVisionModels, modelId)) return true; const modalities = modelRecordValue(provider.modelInputModalities, modelId); return Array.isArray(modalities) && modalities.includes("text") && !modalities.includes("image"); @@ -151,10 +154,18 @@ function modelAcceptsImageInputWithCache( candidate: VisionCandidateModel, cache: EnrichedProviderCache, ): boolean | undefined { - if (isVisionSidecarConsumerWithCache(config, candidate.provider, candidate.id, cache)) return false; if (candidate.native === true || (candidate.provider === "openai" && SUPPORTED_NATIVE_OPENAI_SLUGS.has(candidate.id))) { + const nativeProvider = enrichedProviderForVision(config, candidate.provider, cache); + if (nativeProvider && isModelVisionSidecarConsumer({ + noVisionModels: nativeProvider.noVisionModels, modelInputModalities: nativeProvider.modelInputModalities, + }, candidate.id)) return false; return advertisesImageInput(nativeInputModalities(candidate.id)) ?? true; } + if (isVisionSidecarConsumerWithCache(config, candidate.provider, candidate.id, cache)) return false; + const provider = enrichedProviderForVision(config, candidate.provider, cache); + const declared = Object.hasOwn(provider?.modelCapabilities ?? {}, candidate.id) + ? provider?.modelCapabilities?.[candidate.id]?.inputModalities : undefined; + if (declared !== undefined) return declared.includes("image"); const fromRow = advertisesImageInput(candidate.inputModalities); if (fromRow !== undefined) return fromRow; return metadataImageInput(candidate.provider, candidate.id); diff --git a/structure/catalog.md b/structure/catalog.md index a97b1bbd53..a9e9cd8f1a 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -295,3 +295,5 @@ Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#c privately to final dispatch; preliminary route selection does not inject Go-only headers. The [explicit model-capability contract](config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. + +Exact [model input declarations](config.md#explicit-per-model-capability-declarations) now feed text-only eligibility and catalog hints; existing image-description/omission handling consumes them before the main upstream send. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 03ee53ba64..d14f3bc479 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -107,3 +107,5 @@ 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. + +Exact [model input declarations](../config.md#explicit-per-model-capability-declarations) now feed text-only eligibility and catalog hints; existing image-description/omission handling consumes them before the main upstream send. diff --git a/structure/codex-home.md b/structure/codex-home.md index 017d31bedd..ced89bb1e2 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -243,3 +243,5 @@ preserves config/profile/journal bytes without relabeling the failure as a skipp 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. diff --git a/structure/config.md b/structure/config.md index c12a29294d..baf84d9fa8 100644 --- a/structure/config.md +++ b/structure/config.md @@ -225,3 +225,5 @@ The lightweight top-level CLI help counts Cline CLI among the fifteen registered ## Explicit per-model capability declarations `modelCapabilities` on `src/types/provider.ts` stores exact model-ID entries with optional inputModalities, contextTier and video.processing axes. `src/config/provider-validation.ts` strictly validates writes and merges PATCH axes without sharing live objects; null map/model/axis/processing tombstones delete, while empty PATCH objects do nothing. Complete POST/PUT replacements reject tombstones. File reads retain valid axes; malformed explicit modalities restrict to text with a diagnostic. The two catalog writers receive explicit config and gather fingerprints include the map. This storage contract alone does not activate a context tier, advertise a larger window or enable video processing. + +The text-only consumer reads exact inputModalities declarations before legacy hints. CLI add/edit `--text-only` targets one model and preserves sibling declarations; `src/vision/eligibility.ts` routes declared text-only models into existing image-description or explicit-omission handling. Positive routed image declarations override stale candidate metadata, while native catalog authority retains its existing legacy policy. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index f16a13816e..b43d35e4b1 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -579,3 +579,5 @@ 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. + +Exact [model input declarations](config.md#explicit-per-model-capability-declarations) now feed text-only eligibility and catalog hints; existing image-description/omission handling consumes them before the main upstream send. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 8be8a27aea..f6a461e57e 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -332,3 +332,5 @@ 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. + +Exact [model input declarations](../config.md#explicit-per-model-capability-declarations) now feed text-only eligibility and catalog hints; existing image-description/omission handling consumes them before the main upstream send. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 1e328fb021..096af0157f 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -442,6 +442,8 @@ API-key and custom forward destinations preserve their metadata. See [Responses 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. + ## Context relay ownership `src/codex/context-owner.ts` records which account actually served a root session, taken from the diff --git a/structure/runtime.md b/structure/runtime.md index 2e62869bef..2b47d7444b 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -253,3 +253,5 @@ Devin CLI credential path composition in `src/oauth/devin-cli.ts` follows the se Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. The [explicit model-capability contract](config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. + +Exact [model input declarations](config.md#explicit-per-model-capability-declarations) now feed text-only eligibility and catalog hints; existing image-description/omission handling consumes them before the main upstream send. diff --git a/structure/subagents.md b/structure/subagents.md index fb42900ddc..7b08d33912 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -249,3 +249,5 @@ 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. + +Exact [model input declarations](config.md#explicit-per-model-capability-declarations) now feed text-only eligibility and catalog hints; existing image-description/omission handling consumes them before the main upstream send. diff --git a/tests/adapters/openai/openai-chat-native-policy.test.ts b/tests/adapters/openai/openai-chat-native-policy.test.ts index 92fc1bf371..8bf25d4f19 100644 --- a/tests/adapters/openai/openai-chat-native-policy.test.ts +++ b/tests/adapters/openai/openai-chat-native-policy.test.ts @@ -398,3 +398,13 @@ describe("main and native Chat tier authorization parity", () => { } }); }); + + +test("explicit text-only capabilities divert image-bearing native Chat requests", async () => { + const { isNativeChatRouteEligible } = await import("../../../src/server/chat-native"); + const { routeModel } = await import("../../../src/router"); + const config = { port: 10100, defaultProvider: "custom", providers: { custom: provider({ modelCapabilities: { model: { inputModalities: ["text"] } } }) } } as OcxConfig; + const route = routeModel(config, "custom/model"); + expect(isNativeChatRouteEligible(route, { messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "data:image/png;base64,YQ==" } }] }] })).toBe(false); + expect(isNativeChatRouteEligible(route, { messages: [{ role: "user", content: "hello" }] })).toBe(true); +}); diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index 0206c8e54a..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 b37bf973ed..5c8b72860b 100644 --- a/tests/cli/cli-provider.test.ts +++ b/tests/cli/cli-provider.test.ts @@ -657,3 +657,18 @@ test("provider add --force preserves all explicit model capability axes", () => expect(readConfig(dir).providers.caps.modelCapabilities).toEqual(declarations); } finally { removeTreeWithRetry(dir); } }); + + +test("provider add --text-only preserves other capability axes during force overwrite", () => { + const { dir } = freshConfig({ defaultProvider: "caps", providers: { caps: { + adapter: "openai-chat", baseUrl: "https://example.test/v1", + modelCapabilities: { ModelA: { contextTier: "long_context", video: { processing: "agentic" } }, modela: { inputModalities: ["text", "image"] } }, + } } }); + try { + const result = runCli(["provider", "add", "caps", "--adapter", "openai-chat", "--base-url", "https://example.test/v1", "--force", "--model", "ModelA", "--text-only", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status, result.stderr).toBe(0); + expect(readConfig(dir).providers.caps.modelCapabilities).toEqual({ + ModelA: { inputModalities: ["text"], contextTier: "long_context", video: { processing: "agentic" } }, modela: { inputModalities: ["text", "image"] }, + }); + } finally { removeTreeWithRetry(dir); } +}); diff --git a/tests/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/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); +});