From d5d03f805cc048c4171e132365a8ca6fe7441412 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:34:31 +0900 Subject: [PATCH 1/3] feat(providers): declare text-only models through CLI and shared capabilities Refs #3377 and original text-only request #3268 by @turin-dev. Reuse existing vision description/omission handling, preserve exact model keys and legacy fallback. --- .../docs/reference/cli/providers-accounts.md | 4 ++++ .../docs/reference/configuration/providers.md | 2 ++ src/cli/provider-runtime.ts | 12 +++++++++- src/cli/provider.ts | 21 ++++++++++++++++-- src/codex/catalog/provider-fetch.ts | 4 +++- src/vision/eligibility.ts | 15 +++++++++++-- structure/catalog.md | 2 ++ structure/clients/claude-desktop.md | 2 ++ structure/codex-home.md | 2 ++ structure/config.md | 2 ++ structure/gui-and-management-api.md | 2 ++ structure/ops/docs-and-release.md | 2 ++ structure/providers/openai-tiers.md | 2 ++ structure/runtime.md | 2 ++ structure/subagents.md | 2 ++ .../openai/openai-chat-native-policy.test.ts | 10 +++++++++ tests/cli/cli-headless-parity.test.ts | 22 +++++++++++++++++++ tests/cli/cli-provider.test.ts | 15 +++++++++++++ tests/vision/vision-eligibility.test.ts | 11 ++++++++++ tests/vision/vision-routed.test.ts | 9 ++++++++ tests/vision/vision-sidecar-e2e.test.ts | 18 +++++++-------- .../vision/vision-text-only-predicate.test.ts | 11 ++++++++++ 22 files changed, 157 insertions(+), 15 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index f7e94a1833..5c9a8b6769 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -581,3 +581,7 @@ otherwise look routed. and rejects an entire catalog containing any other value, so `add`, `edit`, and the management API all refuse the bad value rather than storing something the catalog writer would have to strip later (#759). + +### Mark one model text-only + +Use `ocx provider add mine --adapter openai-chat --base-url https://example.com/v1 --default-model model-a --text-only` when registering a provider, or `ocx provider edit mine --model model-a --text-only` for an existing provider. Add can use `--model` or its default model; edit requires `--model`. The flag updates only that exact model's `modelCapabilities.inputModalities` to `["text"]`, preserving other models and axes. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 2e713e58d2..6760588be3 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -1049,3 +1049,5 @@ contracts. `modelCapabilities` stores explicit declarations keyed by exact upstream model ID. IDs preserve case and must not contain surrounding whitespace. Each entry may contain `inputModalities` (`text`, `image`, `audio`, `video`), `contextTier` (`default`, `long_context`) and `video.processing` (`static`, `agentic`). These are operator declarations, not proof of provider support. Context-tier and video fields currently record intent only and do not activate upstream behavior or increase catalog windows. The raw provider editor and provider API expose this map. POST/PUT replace an explicitly supplied map and reject null entries. PATCH merges individual axes; null clears a map, model, axis or video processing value, while `{}` makes no change. Omitted provider overwrites preserve the existing map. Malformed hand-edited files retain valid independent axes and treat malformed explicit input modalities as text-only, with a diagnostic. + +An explicit `modelCapabilities..inputModalities` now takes precedence over legacy modality hints for that exact routed model. A text-only declaration uses the existing vision sidecar to replace images with descriptions; if no sidecar is available, the request receives an explicit omission marker before dispatch. Native Chat image requests divert through this path. The catalog can still advertise image attachment support because the proxy provides the description step. Context-tier and video processing declarations remain inert pending their transport support. diff --git a/src/cli/provider-runtime.ts b/src/cli/provider-runtime.ts index 6477694a49..c8054a7643 100644 --- a/src/cli/provider-runtime.ts +++ b/src/cli/provider-runtime.ts @@ -1,3 +1,4 @@ +import { modelCapabilitiesConfigError } from "../config/provider-validation"; import { CliUsageError, csv, @@ -39,7 +40,7 @@ const USAGE = `Usage: [--auth-mode ] [--note ] [--api-key-transport ] [--headers ] [--enabled ] [--live-models ] - [--retain-models ] + [--retain-models ] [--model --text-only] [--xai-chat ] [--allow-private-network ] [--json] ocx provider test [--json] @@ -72,7 +73,16 @@ async function edit(argv: string[], deps: RuntimeApiDeps): Promise { const liveModels = takeBooleanOption(args, "--live-models"); const allowPrivateNetwork = takeBooleanOption(args, "--allow-private-network"); const xaiChat = takeBooleanOption(args, "--xai-chat"); + const textOnly = takeFlag(args, "--text-only"); + const capabilityModel = takeOption(args, "--model"); rejectArgs(args, USAGE); + if (textOnly || capabilityModel !== undefined) { + if (!textOnly || capabilityModel === undefined) throw new CliUsageError("--text-only and --model must be supplied together", USAGE); + const declaration = { [capabilityModel]: { inputModalities: ["text"] } }; + const error = modelCapabilitiesConfigError(declaration); + if (error) throw new CliUsageError(error, USAGE); + patch.modelCapabilities = declaration; + } if (xaiChat !== undefined) { if (name !== "xai") throw new CliUsageError("--xai-chat is valid only for provider xai", USAGE); patch.xaiResponsesOptIn = !xaiChat; diff --git a/src/cli/provider.ts b/src/cli/provider.ts index 0f33ff98a5..958989a94f 100644 --- a/src/cli/provider.ts +++ b/src/cli/provider.ts @@ -9,7 +9,7 @@ * set-default Change the default provider */ import { hasOwnProvider, isValidProviderName, loadConfig, sanitizeModelCostsForDisplay, saveConfig } from "../config"; -import { apiKeyTransportConfigError } from "../config/provider-validation"; +import { apiKeyTransportConfigError, modelCapabilitiesConfigError, mergeModelCapabilities } from "../config/provider-validation"; import { hasHelpFlag } from "./help"; import { getProviderRegistryEntry, PROVIDER_REGISTRY } from "../providers/registry"; import { providerConfigSeed } from "../providers/derive"; @@ -139,7 +139,7 @@ function handleList(args: string[]): void { // provider add // --------------------------------------------------------------------------- -const ADD_USAGE = "Usage: ocx provider add [--adapter ] [--base-url ] [--api-key ] [--api-key-transport ] [--default-model ] [--allow-private-network] [--set-default] [--force] [--json] [--sync]"; +const ADD_USAGE = "Usage: ocx provider add [--adapter ] [--base-url ] [--api-key ] [--api-key-transport ] [--default-model ] [--model --text-only] [--allow-private-network] [--set-default] [--force] [--json] [--sync]"; async function handleAdd(args: string[]): Promise { const name = args[0]; @@ -164,7 +164,13 @@ async function handleAdd(args: string[]): Promise { const adapter = consumeFlagValue(restArgs, "--adapter"); const baseUrl = consumeFlagValue(restArgs, "--base-url"); const defaultModel = consumeFlagValue(restArgs, "--default-model"); + const textOnly = consumeFlag(restArgs, "--text-only"); + const capabilityModel = consumeFlagValue(restArgs, "--model"); rejectUnknownArgs(restArgs, ADD_USAGE); + if (capabilityModel !== undefined && !textOnly) { + console.error("Error: --model requires --text-only for provider add."); + process.exit(1); + } const config = loadConfig(); @@ -227,6 +233,17 @@ async function handleAdd(args: string[]): Promise { if (existingProvider?.modelCapabilities !== undefined && provConfig.modelCapabilities === undefined) { provConfig.modelCapabilities = structuredClone(existingProvider.modelCapabilities); } + if (textOnly) { + const modelId = capabilityModel ?? defaultModel ?? provConfig.defaultModel; + if (!modelId) { + console.error("Error: --text-only requires --model or a default model."); + process.exit(1); + } + const declaration = { [modelId]: { inputModalities: ["text"] } }; + const error = modelCapabilitiesConfigError(declaration); + if (error) { console.error(`Error: ${error}.`); process.exit(1); } + provConfig.modelCapabilities = mergeModelCapabilities(provConfig.modelCapabilities, declaration); + } const { initializeProviderModelSelection } = await import("../providers/initial-model-selection"); initializeProviderModelSelection(name, provConfig, existingProvider, config); config.providers[name] = provConfig; diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index ae03da9f0d..cdf7806f73 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -674,7 +674,9 @@ export function configuredContextWindow(prov: OcxProviderConfig, id: string): nu } export function configuredInputModalities(prov: OcxProviderConfig, id: string): string[] | undefined { - const modalities = modelRecordValue(prov.modelInputModalities, id); + const declared = Object.hasOwn(prov.modelCapabilities ?? {}, id) + ? prov.modelCapabilities?.[id]?.inputModalities : undefined; + const modalities = declared ?? modelRecordValue(prov.modelInputModalities, id); return Array.isArray(modalities) && modalities.length > 0 ? [...modalities] : undefined; } diff --git a/src/vision/eligibility.ts b/src/vision/eligibility.ts index 09a83d44db..bec455fc7d 100644 --- a/src/vision/eligibility.ts +++ b/src/vision/eligibility.ts @@ -77,9 +77,12 @@ type EnrichedProviderCache = Map; * not a text-only model and must not be widened to image through the vision sidecar. */ export function isModelVisionSidecarConsumer( - provider: Pick, + provider: Pick, modelId: string, ): boolean { + const declared = Object.hasOwn(provider.modelCapabilities ?? {}, modelId) + ? provider.modelCapabilities?.[modelId]?.inputModalities : undefined; + if (declared !== undefined) return declared.includes("text") && !declared.includes("image"); if (modelInList(provider.noVisionModels, modelId)) return true; const modalities = modelRecordValue(provider.modelInputModalities, modelId); return Array.isArray(modalities) && modalities.includes("text") && !modalities.includes("image"); @@ -151,10 +154,18 @@ function modelAcceptsImageInputWithCache( candidate: VisionCandidateModel, cache: EnrichedProviderCache, ): boolean | undefined { - if (isVisionSidecarConsumerWithCache(config, candidate.provider, candidate.id, cache)) return false; if (candidate.native === true || (candidate.provider === "openai" && SUPPORTED_NATIVE_OPENAI_SLUGS.has(candidate.id))) { + const nativeProvider = enrichedProviderForVision(config, candidate.provider, cache); + if (nativeProvider && isModelVisionSidecarConsumer({ + noVisionModels: nativeProvider.noVisionModels, modelInputModalities: nativeProvider.modelInputModalities, + }, candidate.id)) return false; return advertisesImageInput(nativeInputModalities(candidate.id)) ?? true; } + if (isVisionSidecarConsumerWithCache(config, candidate.provider, candidate.id, cache)) return false; + const provider = enrichedProviderForVision(config, candidate.provider, cache); + const declared = Object.hasOwn(provider?.modelCapabilities ?? {}, candidate.id) + ? provider?.modelCapabilities?.[candidate.id]?.inputModalities : undefined; + if (declared !== undefined) return declared.includes("image"); const fromRow = advertisesImageInput(candidate.inputModalities); if (fromRow !== undefined) return fromRow; return metadataImageInput(candidate.provider, candidate.id); diff --git a/structure/catalog.md b/structure/catalog.md index 3913e08dd3..0180980c1d 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -280,3 +280,5 @@ Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#c privately to final dispatch; preliminary route selection does not inject Go-only headers. The [explicit model-capability contract](config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. + +Exact [model input declarations](config.md#explicit-per-model-capability-declarations) now feed text-only eligibility and catalog hints; existing image-description/omission handling consumes them before the main upstream send. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index bc988164fb..802272130d 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -93,3 +93,5 @@ The explicit sync coordinator also accepts Cline CLI as a separate file integrat Config JSON preserves the boolean; only literal true activates the role-changing transform. The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. + +Exact [model input declarations](../config.md#explicit-per-model-capability-declarations) now feed text-only eligibility and catalog hints; existing image-description/omission handling consumes them before the main upstream send. diff --git a/structure/codex-home.md b/structure/codex-home.md index 8faf8f1c04..3fb9b723b0 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -238,3 +238,5 @@ The legacy external writer is now refused for affected rows in any store whose s Native restore preflight also checks manifest-owned targets whose rows already returned to `openai`, including interrupted restores. Preimage capture distinguishes absent files from unreadable artifacts and aborts before mutation when a complete snapshot cannot be read. The [explicit model-capability contract](config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. + +Exact [model input declarations](config.md#explicit-per-model-capability-declarations) now feed text-only eligibility and catalog hints; existing image-description/omission handling consumes them before the main upstream send. diff --git a/structure/config.md b/structure/config.md index 00f763efae..f6d2aa4e0f 100644 --- a/structure/config.md +++ b/structure/config.md @@ -209,3 +209,5 @@ Config JSON preserves the boolean; only literal true activates the role-changing ## Explicit per-model capability declarations `modelCapabilities` on `src/types/provider.ts` stores exact model-ID entries with optional inputModalities, contextTier and video.processing axes. `src/config/provider-validation.ts` strictly validates writes and merges PATCH axes without sharing live objects; null map/model/axis/processing tombstones delete, while empty PATCH objects do nothing. Complete POST/PUT replacements reject tombstones. File reads retain valid axes; malformed explicit modalities restrict to text with a diagnostic. The two catalog writers receive explicit config and gather fingerprints include the map. This storage contract alone does not activate a context tier, advertise a larger window or enable video processing. + +The text-only consumer reads exact inputModalities declarations before legacy hints. CLI add/edit `--text-only` targets one model and preserves sibling declarations; `src/vision/eligibility.ts` routes declared text-only models into existing image-description or explicit-omission handling. Positive routed image declarations override stale candidate metadata, while native catalog authority retains its existing legacy policy. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 3352286201..ae5d3c091e 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -545,3 +545,5 @@ integration IO adapter. Its snapshot fingerprint cannot be checked against provi The existing dashboard file-client maps include Cline CLI and reuse its committed color mark. The export panel labels its download as a settings/catalog bundle; all locales explain that Undo restores both original files. The [explicit model-capability contract](config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. + +Exact [model input declarations](config.md#explicit-per-model-capability-declarations) now feed text-only eligibility and catalog hints; existing image-description/omission handling consumes them before the main upstream send. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 232a57e650..3a6eb74772 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -316,3 +316,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi The integrations guide documents Cline CLI as a two-file, loopback-only integration. Hosted CI validates its source-backed fixtures; the packaged dashboard exposes it through the existing client list. The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. + +Exact [model input declarations](../config.md#explicit-per-model-capability-declarations) now feed text-only eligibility and catalog hints; existing image-description/omission handling consumes them before the main upstream send. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 3cbacac96b..77b1391943 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -404,3 +404,5 @@ successful main usage refresh clears the runtime mark. `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. + +Exact [model input declarations](../config.md#explicit-per-model-capability-declarations) now feed text-only eligibility and catalog hints; existing image-description/omission handling consumes them before the main upstream send. diff --git a/structure/runtime.md b/structure/runtime.md index bb175079a4..421d7e2281 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -227,3 +227,5 @@ Cline CLI joins the existing export/client integration registries. Explicit CLI Config JSON preserves the boolean; only literal true activates the role-changing transform. The [explicit model-capability contract](config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. + +Exact [model input declarations](config.md#explicit-per-model-capability-declarations) now feed text-only eligibility and catalog hints; existing image-description/omission handling consumes them before the main upstream send. diff --git a/structure/subagents.md b/structure/subagents.md index e60d77a76c..6887111491 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -216,3 +216,5 @@ Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#c privately to final dispatch; preliminary route selection does not inject Go-only headers. The [explicit model-capability contract](config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. + +Exact [model input declarations](config.md#explicit-per-model-capability-declarations) now feed text-only eligibility and catalog hints; existing image-description/omission handling consumes them before the main upstream send. diff --git a/tests/adapters/openai/openai-chat-native-policy.test.ts b/tests/adapters/openai/openai-chat-native-policy.test.ts index 92fc1bf371..8bf25d4f19 100644 --- a/tests/adapters/openai/openai-chat-native-policy.test.ts +++ b/tests/adapters/openai/openai-chat-native-policy.test.ts @@ -398,3 +398,13 @@ describe("main and native Chat tier authorization parity", () => { } }); }); + + +test("explicit text-only capabilities divert image-bearing native Chat requests", async () => { + const { isNativeChatRouteEligible } = await import("../../../src/server/chat-native"); + const { routeModel } = await import("../../../src/router"); + const config = { port: 10100, defaultProvider: "custom", providers: { custom: provider({ modelCapabilities: { model: { inputModalities: ["text"] } } }) } } as OcxConfig; + const route = routeModel(config, "custom/model"); + expect(isNativeChatRouteEligible(route, { messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "data:image/png;base64,YQ==" } }] }] })).toBe(false); + expect(isNativeChatRouteEligible(route, { messages: [{ role: "user", content: "hello" }] })).toBe(true); +}); diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index 0206c8e54a..c7b90ea7a7 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -1113,3 +1113,25 @@ describe("Aside CLI recovery metadata", () => { } }); }); + + +test("provider edit sends a model-scoped text-only capability patch", async () => { + const { requests, deps } = fakeRuntime(); + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await handleProviderRuntimeCommand(["edit", "mine", "--model", "ModelA", "--text-only", "--json"], deps)).toBe(0); + expect(requests).toEqual([{ path: "/api/providers?name=mine", method: "PATCH", body: { modelCapabilities: { ModelA: { inputModalities: ["text"] } } } }]); + } finally { log.mockRestore(); } +}); + + +test("provider edit rejects incomplete text-only targeting before contacting the server", async () => { + const { requests, deps } = fakeRuntime(); + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + for (const flags of [["--text-only"], ["--model", "ModelA"], ["--model", " ModelA ", "--text-only"]]) { + expect(await handleProviderRuntimeCommand(["edit", "mine", ...flags], deps)).not.toBe(0); + } + expect(requests).toHaveLength(0); + } finally { error.mockRestore(); } +}); diff --git a/tests/cli/cli-provider.test.ts b/tests/cli/cli-provider.test.ts index b37bf973ed..5c8b72860b 100644 --- a/tests/cli/cli-provider.test.ts +++ b/tests/cli/cli-provider.test.ts @@ -657,3 +657,18 @@ test("provider add --force preserves all explicit model capability axes", () => expect(readConfig(dir).providers.caps.modelCapabilities).toEqual(declarations); } finally { removeTreeWithRetry(dir); } }); + + +test("provider add --text-only preserves other capability axes during force overwrite", () => { + const { dir } = freshConfig({ defaultProvider: "caps", providers: { caps: { + adapter: "openai-chat", baseUrl: "https://example.test/v1", + modelCapabilities: { ModelA: { contextTier: "long_context", video: { processing: "agentic" } }, modela: { inputModalities: ["text", "image"] } }, + } } }); + try { + const result = runCli(["provider", "add", "caps", "--adapter", "openai-chat", "--base-url", "https://example.test/v1", "--force", "--model", "ModelA", "--text-only", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status, result.stderr).toBe(0); + expect(readConfig(dir).providers.caps.modelCapabilities).toEqual({ + ModelA: { inputModalities: ["text"], contextTier: "long_context", video: { processing: "agentic" } }, modela: { inputModalities: ["text", "image"] }, + }); + } finally { removeTreeWithRetry(dir); } +}); diff --git a/tests/vision/vision-eligibility.test.ts b/tests/vision/vision-eligibility.test.ts index 25fdc63d42..fc43a0d2f3 100644 --- a/tests/vision/vision-eligibility.test.ts +++ b/tests/vision/vision-eligibility.test.ts @@ -293,3 +293,14 @@ describe("vision eligibility core", () => { expect(withRouted.some((o) => o.value === "cursor/cursor-vision-capable" && o.backend === "routed")).toBe(true); }); }); + + +test("explicit routed image declarations outrank stale candidate metadata", () => { + const config = configWithProviders({ custom: { + adapter: "openai-chat", baseUrl: "https://example.test/v1", noVisionModels: ["ModelA"], + modelCapabilities: { ModelA: { inputModalities: ["text", "image"] } }, + } }); + expect(modelAcceptsImageInput(config, { provider: "custom", id: "ModelA", inputModalities: ["text"] })).toBe(true); + expect(modelAcceptsImageInput(config, { provider: "custom", id: "modela", inputModalities: ["text"] })).toBe(false); + expect(modelAcceptsImageInput(config, { provider: "custom", id: "ModelA:variant", inputModalities: ["text"] })).toBe(false); +}); diff --git a/tests/vision/vision-routed.test.ts b/tests/vision/vision-routed.test.ts index d742345d8f..c1182a0953 100644 --- a/tests/vision/vision-routed.test.ts +++ b/tests/vision/vision-routed.test.ts @@ -375,3 +375,12 @@ describe("chat-surface recursion fence (full path)", () => { } }); }); + + +test("explicit routed image capability enables a describer despite stale legacy metadata", () => { + const main: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://main.test/v1", modelCapabilities: { blind: { inputModalities: ["text"] } } }; + const helper: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://helper.test/v1", noVisionModels: ["vision"], modelCapabilities: { vision: { inputModalities: ["text", "image"] } } }; + const parsed = parseRequest({ model: "main/blind", input: [{ role: "user", content: [{ type: "input_image", image_url: PNG_DATA_URL }] }] }); + const plan = planVisionSidecar({ port: 10100, defaultProvider: "main", providers: { main, helper }, visionSidecar: { enabled: true, backend: "routed", model: "helper/vision" } } as OcxConfig, main, "blind", parsed); + expect(plan?.backend).toBe("routed"); +}); diff --git a/tests/vision/vision-sidecar-e2e.test.ts b/tests/vision/vision-sidecar-e2e.test.ts index ee8a3d1522..d88b0070df 100644 --- a/tests/vision/vision-sidecar-e2e.test.ts +++ b/tests/vision/vision-sidecar-e2e.test.ts @@ -150,7 +150,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { expect(JSON.stringify(parsed._rawBody)).toContain("[image omitted:"); }); - test("noVisionModels request fires the sidecar and forwards the caption instead of the image", async () => { + test.each(["legacy", "capabilities", "developer"] as const)("noVisionModels request fires the sidecar and forwards the caption instead of the image (%s)", async declaration => { let upstreamBody = ""; let sidecarBody = ""; let sidecarAuth: string | null = null; @@ -183,7 +183,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { baseUrl: `http://127.0.0.1:${upstream.port}/v1`, allowPrivateNetwork: true, apiKey: "key-alpha-000111222333", - noVisionModels: ["blind-model"], + ...(declaration === "legacy" ? { noVisionModels: ["blind-model"] } : { modelCapabilities: { "blind-model": { inputModalities: ["text"] } } }), }, openai: { adapter: "openai-responses", @@ -204,7 +204,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { authorization: `Bearer ${token}`, "chatgpt-account-id": "acct-vision-sidecar", }, - body: JSON.stringify(baseRequest("textonly/blind-model")), + body: JSON.stringify({ ...baseRequest("textonly/blind-model"), input: baseRequest("textonly/blind-model").input.map(item => ({ ...item, role: declaration === "developer" ? "developer" : "user" })) }), }); expect(res.status).toBe(200); @@ -225,7 +225,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { } }); - test("Responses passthrough removes every raw image when fewer captions than images are produced", async () => { + test.each(["legacy", "capabilities"] as const)("Responses passthrough removes every raw image when fewer captions than images are produced (%s)", async declaration => { let upstreamBody = ""; let sidecarHits = 0; upstream = serveResponsesUpstream(b => { upstreamBody = b; }); @@ -250,7 +250,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { responsesPath: "/responses", allowPrivateNetwork: true, apiKey: "key-alpha-000111222333", - noVisionModels: ["blind-model"], + ...(declaration === "legacy" ? { noVisionModels: ["blind-model"] } : { modelCapabilities: { "blind-model": { inputModalities: ["text"] } } }), }, openai: { adapter: "openai-responses", @@ -285,7 +285,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { } }); - test("Responses passthrough replaces an image returned by a client tool", async () => { + test.each(["legacy", "capabilities"] as const)("Responses passthrough replaces an image returned by a client tool (%s)", async declaration => { let upstreamBody = ""; let sidecarHits = 0; upstream = serveResponsesUpstream(b => { upstreamBody = b; }); @@ -310,7 +310,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { responsesPath: "/responses", allowPrivateNetwork: true, apiKey: "key-alpha-000111222333", - noVisionModels: ["blind-model"], + ...(declaration === "legacy" ? { noVisionModels: ["blind-model"] } : { modelCapabilities: { "blind-model": { inputModalities: ["text"] } } }), }, openai: { adapter: "openai-responses", @@ -343,7 +343,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { } }); - test("Responses passthrough strips images when no vision sidecar is available", async () => { + test.each(["legacy", "capabilities"] as const)("Responses passthrough strips images when no vision sidecar is available (%s)", async declaration => { let upstreamBody = ""; upstream = serveResponsesUpstream(b => { upstreamBody = b; }); const config: OcxConfig = { @@ -356,7 +356,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { responsesPath: "/responses", allowPrivateNetwork: true, apiKey: "key-alpha-000111222333", - noVisionModels: ["blind-model"], + ...(declaration === "legacy" ? { noVisionModels: ["blind-model"] } : { modelCapabilities: { "blind-model": { inputModalities: ["text"] } } }), }, }, } as OcxConfig; diff --git a/tests/vision/vision-text-only-predicate.test.ts b/tests/vision/vision-text-only-predicate.test.ts index 6a78872a04..e6f8c6e719 100644 --- a/tests/vision/vision-text-only-predicate.test.ts +++ b/tests/vision/vision-text-only-predicate.test.ts @@ -42,3 +42,14 @@ describe("isModelTextOnly (#1024)", () => { expect(isModelTextOnly(provider({ modelInputModalities: { "base-model": ["text"] } }), "base-model:extended")).toBe(true); }); }); + + +test("explicit capability keys are exact and take precedence over legacy declarations", () => { + const config = provider({ noVisionModels: ["ModelA"], modelCapabilities: { ModelA: { inputModalities: ["text", "image"] }, model: { inputModalities: ["text"] } } }); + expect(isModelTextOnly(config, "ModelA")).toBe(false); + expect(isModelTextOnly(config, "model")).toBe(true); + expect(isModelTextOnly(config, "MODEL")).toBe(false); + expect(isModelTextOnly(config, "model:variant")).toBe(false); + delete config.modelCapabilities!.ModelA; + expect(isModelTextOnly(config, "ModelA")).toBe(true); +}); From ca15403262d4e0f7500437f95e6e44217eb2717f Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:36:57 +0900 Subject: [PATCH 2/3] test(catalog): verify exact capability modality precedence --- .../catalog-vision-sidecar-modalities.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/codex-integration/catalog-vision-sidecar-modalities.test.ts b/tests/codex-integration/catalog-vision-sidecar-modalities.test.ts index 3e51ac0d01..6df91ab4e5 100644 --- a/tests/codex-integration/catalog-vision-sidecar-modalities.test.ts +++ b/tests/codex-integration/catalog-vision-sidecar-modalities.test.ts @@ -410,3 +410,20 @@ describe("Cursor native vs sidecar vision registry", () => { } }); }); + + +test("exact capability modalities override legacy catalog hints and clear back to inference", () => { + const provider: OcxProviderConfig = { + adapter: "openai-chat", baseUrl: "https://example.test/v1", + modelInputModalities: { ModelA: ["audio"] }, + modelCapabilities: { ModelA: { inputModalities: ["text", "image"] } }, + }; + const hint = (id: string) => applyProviderConfigHints("custom", provider, { provider: "custom", id, inputModalities: ["text"] }).inputModalities; + expect(hint("ModelA")).toEqual(["text", "image"]); + expect(hint("modela")).toEqual(["audio"]); + expect(hint("ModelA:variant")).toEqual(["audio"]); + delete provider.modelCapabilities!.ModelA; + expect(hint("ModelA")).toEqual(["audio"]); + provider.modelCapabilities!.ModelA = { inputModalities: ["text"] }; + expect(hint("ModelA")).toEqual(["text", "image"]); +}); From 3f24400c949e78841d4fb3f0147f8a5c762ed3c7 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 21:19:41 +0900 Subject: [PATCH 3/3] test(cli): invoke text-only edit handler with its declared signature --- tests/cli/cli-headless-parity.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index c7b90ea7a7..fa33b804a2 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -1119,7 +1119,7 @@ test("provider edit sends a model-scoped text-only capability patch", async () = const { requests, deps } = fakeRuntime(); const log = spyOn(console, "log").mockImplementation(() => {}); try { - expect(await handleProviderRuntimeCommand(["edit", "mine", "--model", "ModelA", "--text-only", "--json"], deps)).toBe(0); + expect(await handleProviderRuntimeCommand("edit", ["mine", "--model", "ModelA", "--text-only", "--json"], deps)).toBe(0); expect(requests).toEqual([{ path: "/api/providers?name=mine", method: "PATCH", body: { modelCapabilities: { ModelA: { inputModalities: ["text"] } } } }]); } finally { log.mockRestore(); } }); @@ -1130,7 +1130,7 @@ test("provider edit rejects incomplete text-only targeting before contacting the const error = spyOn(console, "error").mockImplementation(() => {}); try { for (const flags of [["--text-only"], ["--model", "ModelA"], ["--model", " ModelA ", "--text-only"]]) { - expect(await handleProviderRuntimeCommand(["edit", "mine", ...flags], deps)).not.toBe(0); + expect(await handleProviderRuntimeCommand("edit", ["mine", ...flags], deps)).toBe(2); } expect(requests).toHaveLength(0); } finally { error.mockRestore(); }