Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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.<id>.inputModalities` now takes precedence over legacy modality hints for that exact routed model. A text-only declaration uses the existing vision sidecar to replace images with descriptions; if no sidecar is available, the request receives an explicit omission marker before dispatch. Native Chat image requests divert through this path. The catalog can still advertise image attachment support because the proxy provides the description step. Context-tier and video processing declarations remain inert pending their transport support.
12 changes: 11 additions & 1 deletion src/cli/provider-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { modelCapabilitiesConfigError } from "../config/provider-validation";
import {
CliUsageError,
csv,
Expand Down Expand Up @@ -39,7 +40,7 @@ const USAGE = `Usage:
[--auth-mode <key|forward|oauth|local|->] [--note <text|->]
[--api-key-transport <x-api-key|bearer|->]
[--headers <json>] [--enabled <on|off>] [--live-models <on|off>]
[--retain-models <id,id|->]
[--retain-models <id,id|->] [--model <id> --text-only]
[--xai-chat <on|off>]
[--allow-private-network <on|off>] [--json]
ocx provider test <name> [--json]
Expand Down Expand Up @@ -72,7 +73,16 @@ async function edit(argv: string[], deps: RuntimeApiDeps): Promise<void> {
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;
Expand Down
21 changes: 19 additions & 2 deletions src/cli/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* set-default <name> 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";
Expand Down Expand Up @@ -139,7 +139,7 @@ function handleList(args: string[]): void {
// provider add
// ---------------------------------------------------------------------------

const ADD_USAGE = "Usage: ocx provider add <name> [--adapter <adapter>] [--base-url <url>] [--api-key <key>] [--api-key-transport <x-api-key|bearer>] [--default-model <model>] [--allow-private-network] [--set-default] [--force] [--json] [--sync]";
const ADD_USAGE = "Usage: ocx provider add <name> [--adapter <adapter>] [--base-url <url>] [--api-key <key>] [--api-key-transport <x-api-key|bearer>] [--default-model <model>] [--model <id> --text-only] [--allow-private-network] [--set-default] [--force] [--json] [--sync]";

async function handleAdd(args: string[]): Promise<void> {
const name = args[0];
Expand All @@ -164,7 +164,13 @@ async function handleAdd(args: string[]): Promise<void> {
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();

Expand Down Expand Up @@ -227,6 +233,17 @@ async function handleAdd(args: string[]): Promise<void> {
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;
Expand Down
4 changes: 3 additions & 1 deletion src/codex/catalog/provider-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
15 changes: 13 additions & 2 deletions src/vision/eligibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,12 @@ type EnrichedProviderCache = Map<string, OcxProviderConfig>;
* not a text-only model and must not be widened to image through the vision sidecar.
*/
export function isModelVisionSidecarConsumer(
provider: Pick<OcxProviderConfig, "noVisionModels" | "modelInputModalities">,
provider: Pick<OcxProviderConfig, "noVisionModels" | "modelInputModalities" | "modelCapabilities">,
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");
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions structure/catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions structure/clients/claude-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions structure/codex-home.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions structure/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions structure/gui-and-management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions structure/ops/docs-and-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions structure/providers/openai-tiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions structure/subagents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
10 changes: 10 additions & 0 deletions tests/adapters/openai/openai-chat-native-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
22 changes: 22 additions & 0 deletions tests/cli/cli-headless-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(); }
});
Loading
Loading