Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -1052,3 +1052,9 @@ or expiry does not extend the history-recovery contract.
Sender and recipient on routed Responses are context for the receiving model, not a new
machine-readable routing protocol. Tool routing continues to use the existing collaboration
contracts.

### Per-model capability declarations

`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.
3 changes: 3 additions & 0 deletions src/cli/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,9 @@ async function handleAdd(args: string[]): Promise<void> {
}

const existingProvider = config.providers[name];
if (existingProvider?.modelCapabilities !== undefined && provConfig.modelCapabilities === undefined) {
provConfig.modelCapabilities = structuredClone(existingProvider.modelCapabilities);
}
const { initializeProviderModelSelection } = await import("../providers/initial-model-selection");
initializeProviderModelSelection(name, provConfig, existingProvider, config);
config.providers[name] = provConfig;
Expand Down
1 change: 1 addition & 0 deletions src/codex/catalog/provider-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,7 @@ function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Reco
maxOut: prov.modelMaxOutputTokens ?? null,
autoCompact: prov.modelAutoCompactTokenLimits ?? null,
inMod: prov.modelInputModalities ?? null,
capabilities: prov.modelCapabilities ?? null,
re: prov.modelReasoningEfforts ?? null,
defRe: prov.modelDefaultReasoningEfforts ?? null,
rsSum: prov.modelSupportsReasoningSummaries ?? null,
Expand Down
26 changes: 26 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { modelCapabilitiesConfigError, mergeModelCapabilities, sanitizeModelCapabilitiesForLoad } from "./config/provider-validation";
import { createHash } from "node:crypto";
import { chmodSync, constants as fsConstants, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, truncateSync, unlinkSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
Expand Down Expand Up @@ -573,11 +574,17 @@ const modelPinnedEffortsSchema = z.unknown().superRefine((value, ctx) => {
Object.entries(value as Record<string, string>).map(([key, effort]) => [key.trim(), effort]),
));

const modelCapabilitiesSchema = z.unknown().superRefine((value, ctx) => {
const error = modelCapabilitiesConfigError(value);
if (error) ctx.addIssue({ code: "custom", message: error });
}).transform(value => mergeModelCapabilities(undefined, value));

/**
* Zod schema for one provider entry: known fields are validated strictly while unknown
* fields pass through (preserved for runtime extensions).
*/
const providerConfigSchema = z.object({
modelCapabilities: modelCapabilitiesSchema.optional(),
pinnedReasoningEffort: pinnedReasoningEffortSchema.optional(),
modelPinnedReasoningEfforts: modelPinnedEffortsSchema.optional(),
// Validated rather than left to passthrough: an unrecognized strategy would otherwise
Expand Down Expand Up @@ -1901,6 +1908,23 @@ export function retryOn429PolicyConfigError(policy: unknown): string | null {
return `retryOn429.${field} is invalid (${first.message})`;
}

function sanitizeCapabilityDeclarationsForLoad(parsed: unknown): void {
if (!parsed || typeof parsed !== "object") return;
const providers = (parsed as Record<string, unknown>).providers;
if (!providers || typeof providers !== "object" || Array.isArray(providers)) return;
for (const [name, value] of Object.entries(providers)) {
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
const provider = value as Record<string, unknown>;
if (provider.modelCapabilities === undefined) continue;
if (modelCapabilitiesConfigError(provider.modelCapabilities) !== null) {
console.warn(`config.json provider ${JSON.stringify(redactSecretString(name))} has malformed modelCapabilities; retaining valid axes and restricting malformed input modalities to text`);
const repaired = sanitizeModelCapabilitiesForLoad(provider.modelCapabilities);
if (repaired) provider.modelCapabilities = repaired;
else delete provider.modelCapabilities;
}
}
}

/**
* Load-time degradation for `providers.<name>.modelCosts`, mirroring
* {@link sanitizeRetryOn429ForLoad}. A hand-edited malformed display-price row
Expand Down Expand Up @@ -2417,6 +2441,7 @@ export function loadConfig(): OcxConfig {
sanitizeModelDisplayNamesForLoad(parsed);
sanitizeRetryOn429ForLoad(parsed);
sanitizeModelCostsForLoad(parsed);
sanitizeCapabilityDeclarationsForLoad(parsed);
const result = configSchema.safeParse(parsed);
if (result.success) {
const config = normalizeApiKeyIds(result.data as OcxConfig);
Expand Down Expand Up @@ -3062,6 +3087,7 @@ function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics {
sanitizeModelDisplayNamesForLoad(parsed);
sanitizeRetryOn429ForLoad(parsed);
sanitizeModelCostsForLoad(parsed);
sanitizeCapabilityDeclarationsForLoad(parsed);
const result = configSchema.safeParse(parsed);
if (result.success) {
return validFileConfigDiagnostics(normalizeApiKeyIds(result.data as OcxConfig), parsed);
Expand Down
96 changes: 96 additions & 0 deletions src/config/provider-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
REASONING_SUMMARY_DELIVERY_VALUES,
UPSTREAM_HTTP_VERSION_VALUES,
type OcxProviderConfig,
type ModelCapabilities,
} from "../types";

const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
Expand Down Expand Up @@ -295,3 +296,98 @@ export function modelAdapterRecordConfigError(
}
return null;
}


function capabilityRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
&& [Object.prototype, null].includes(Object.getPrototypeOf(value));
}

/** Strict writes; only PATCH may carry deletion tombstones. Model IDs are exact. */
export function modelCapabilitiesConfigError(value: unknown, allowTombstones = false): string | null {
if (value === undefined || (allowTombstones && value === null)) return null;
if (!capabilityRecord(value)) return "modelCapabilities must be a plain object";
if (Object.keys(value).length > MODEL_DISCOVERY_MAX_MODELS) return "modelCapabilities has too many models";
for (const [id, row] of Object.entries(value)) {
if (!isValidModelDiscoveryModelId(id) || ["__proto__", "prototype", "constructor"].includes(id)) {
return "modelCapabilities keys must be exact non-reserved model ids without surrounding whitespace";
}
if (allowTombstones && row === null) continue;
if (!capabilityRecord(row)) return "modelCapabilities rows must be plain objects";
for (const [axis, declaration] of Object.entries(row)) {
if (!["inputModalities", "contextTier", "video"].includes(axis)) return "modelCapabilities contains an unknown axis";
if (allowTombstones && declaration === null) continue;
if (axis === "inputModalities") {
if (!Array.isArray(declaration) || declaration.length === 0
|| declaration.some(item => typeof item !== "string" || !["text", "image", "audio", "video"].includes(item))) {
return "modelCapabilities inputModalities must be a nonempty array of text, image, audio or video";
}
} else if (axis === "contextTier") {
if (declaration !== "default" && declaration !== "long_context") return "modelCapabilities contextTier must be default or long_context";
} else {
if (!capabilityRecord(declaration) || Object.keys(declaration).some(key => key !== "processing")) {
return "modelCapabilities video must be a plain object containing only processing";
}
if (Object.hasOwn(declaration, "processing") && declaration.processing !== "static" && declaration.processing !== "agentic"
&& !(allowTombstones && declaration.processing === null)) return "modelCapabilities video processing must be static or agentic";
}
}
}
return null;
}

/** Merge a validated patch without sharing nested objects with the live provider. */
export function mergeModelCapabilities(
current: Record<string, ModelCapabilities> | undefined,
patch: unknown,
): Record<string, ModelCapabilities> | undefined {
if (patch === null) return undefined;
const next = Object.fromEntries(Object.entries(current ?? {}).map(([id, row]) => [id, structuredClone(row)]));
if (patch !== undefined) for (const [id, raw] of Object.entries(patch as Record<string, Record<string, unknown> | null>)) {
if (raw === null) { delete next[id]; continue; }
const row: ModelCapabilities = Object.hasOwn(next, id) ? next[id]! : {};
for (const [axis, value] of Object.entries(raw)) {
if (axis === "inputModalities") {
if (value === null) delete row.inputModalities;
else row.inputModalities = [...(value as NonNullable<ModelCapabilities["inputModalities"]>)];
} else if (axis === "contextTier") {
if (value === null) delete row.contextTier;
else row.contextTier = value as ModelCapabilities["contextTier"];
} else if (axis === "video") {
if (value === null) delete row.video;
else {
const video = { ...(row.video ?? {}) };
const change = value as { processing?: "static" | "agentic" | null };
if (Object.hasOwn(change, "processing")) {
if (change.processing === null) delete video.processing;
else video.processing = change.processing;
}
if (Object.keys(video).length) row.video = video;
else delete row.video;
}
}
}
if (Object.keys(row).length) next[id] = row;
else delete next[id];
}
return Object.keys(next).length ? next : undefined;
}

/** Load-only repair preserves independent valid axes; malformed explicit modalities restrict to text. */
export function sanitizeModelCapabilitiesForLoad(value: unknown): Record<string, ModelCapabilities> | undefined {
if (!capabilityRecord(value)) return undefined;
const rows: Record<string, ModelCapabilities> = Object.create(null);
for (const [id, raw] of Object.entries(value).slice(0, MODEL_DISCOVERY_MAX_MODELS)) {
if (!capabilityRecord(raw) || !isValidModelDiscoveryModelId(id) || ["__proto__", "prototype", "constructor"].includes(id)) continue;
const row: Record<string, unknown> = {};
for (const axis of ["inputModalities", "contextTier", "video"] as const) {
if (!Object.hasOwn(raw, axis)) continue;
const declaration = raw[axis];
if (modelCapabilitiesConfigError({ [id]: { [axis]: declaration } }) === null) row[axis] = declaration;
else if (axis === "inputModalities") row.inputModalities = ["text"];
}
const normalized = mergeModelCapabilities(undefined, { [id]: row });
if (normalized?.[id]) rows[id] = normalized[id];
}
return Object.keys(rows).length ? rows : undefined;
}
4 changes: 4 additions & 0 deletions src/server/auth-cors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { modelCapabilitiesConfigError } from "../config/provider-validation";
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { initialModelSelection } from "../providers/initial-model-selection";
import { extractAccountId } from "../oauth/chatgpt";
Expand Down Expand Up @@ -646,6 +647,8 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
return "provider must be a plain object";
}
const raw = provider as Record<string, unknown>;
const capabilitiesError = modelCapabilitiesConfigError(raw.modelCapabilities);
if (capabilitiesError) return capabilitiesError;
const pinsError = providerReasoningPinsConfigError(raw);
if (pinsError) return pinsError;
for (const field of FORBIDDEN_PROVIDER_RUNTIME_FIELDS) {
Expand Down Expand Up @@ -891,6 +894,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
contextWindow: "editor",
modelContextWindows: "editor",
modelInputModalities: "editor",
modelCapabilities: "editor",
modelMaxInputTokens: "runtime",
modelAutoCompactTokenLimits: "editor",
defaultMaxOutputTokens: "editor",
Expand Down
18 changes: 18 additions & 0 deletions src/server/management/provider-routes.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { modelCapabilitiesConfigError, mergeModelCapabilities } from "../../config/provider-validation";
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { isDeepStrictEqual } from "node:util";
Expand Down Expand Up @@ -287,6 +288,9 @@ function providerEditorCandidate(
if (provider.modelPinnedReasoningEfforts !== undefined) {
provider.modelPinnedReasoningEfforts = validated.config.providers[name]!.modelPinnedReasoningEfforts;
}
const capabilities = validated.config.providers[name]!.modelCapabilities;
if (capabilities === undefined) delete provider.modelCapabilities;
else provider.modelCapabilities = capabilities;
}
return { ok: true, config: candidate, removedProviders };
}
Expand Down Expand Up @@ -490,6 +494,14 @@ function applyProviderPatchFields(
}
touched = true;
}
if (Object.hasOwn(rawBody, "modelCapabilities")) {
const error = modelCapabilitiesConfigError(rawBody.modelCapabilities, true);
if (error) return { error };
const capabilities = mergeModelCapabilities(next.modelCapabilities, rawBody.modelCapabilities);
if (capabilities === undefined) delete next.modelCapabilities;
else next.modelCapabilities = capabilities;
touched = true;
}
if (Object.hasOwn(rawBody, "modelContextWindows")) {
const value = rawBody.modelContextWindows;
if (value === null) {
Expand Down Expand Up @@ -784,6 +796,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
models: p.models ?? [],
contextWindow: p.contextWindow,
modelContextWindows: p.modelContextWindows,
modelCapabilities: p.modelCapabilities,
pinnedReasoningEffort: p.pinnedReasoningEffort,
modelPinnedReasoningEfforts: p.modelPinnedReasoningEfforts,
modelAutoCompactTokenLimits: p.modelAutoCompactTokenLimits,
Expand Down Expand Up @@ -1117,6 +1130,11 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
// completed during that wait remains authoritative instead of being overwritten by the
// older ownership snapshot used to admit this POST.
restorePersistedAliasOverlays(prov, config.providers[name]);
const capabilities = Object.hasOwn(body.provider, "modelCapabilities")
? mergeModelCapabilities(undefined, prov.modelCapabilities)
: mergeModelCapabilities(config.providers[name]?.modelCapabilities, undefined);
if (capabilities === undefined) delete prov.modelCapabilities;
else prov.modelCapabilities = capabilities;
// The add/edit form omits wire choices. Read after DNS so a concurrent switch
// remains authoritative, including the marker that protects it on the next boot.
if (name === "xai") {
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export type {
TierObservationContext,
TierDecision,
OcxProviderConfig,
ModelCapabilities,
} from "./types/provider";

export { PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS } from "./types/provider";
Expand Down
9 changes: 9 additions & 0 deletions src/types/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,14 @@ export type TierDecision =
* One configured provider entry. `authMode` (default `"key"`) decides whether same-target 429
* retries are allowed; OAuth/forward credentials and local runtimes are never replayed.
*/
/** Explicit per-model operator declarations; absent axes keep legacy behavior. */
export interface ModelCapabilities {
inputModalities?: Array<"text" | "image" | "audio" | "video">;
/** Requested tier only; does not imply an upstream window or activate an unverified wire. */
contextTier?: "default" | "long_context";
video?: { processing?: "static" | "agentic" };
}

export interface OcxProviderConfig {
/** Optional short provider namespace used only at request/catalog presentation time. */
alias?: string;
Expand Down Expand Up @@ -478,6 +486,7 @@ export interface OcxProviderConfig {
modelContextWindows?: Record<string, number>;
/** Model-specific Codex catalog input modalities, e.g. ["text"] or ["text", "image"]. */
modelInputModalities?: Record<string, string[]>;
modelCapabilities?: Record<string, ModelCapabilities>;
/** Model-specific max input token limits. Values cap auto_compact_token_limit. */
modelMaxInputTokens?: Record<string, number>;
/**
Expand Down
2 changes: 2 additions & 0 deletions structure/adapters/registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,5 @@ privately to final dispatch; preliminary route selection does not inject Go-only
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.

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.
2 changes: 2 additions & 0 deletions structure/catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,3 +293,5 @@ Provider `showThinkingSummary` is a Responses request default; it does not rewri

Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch)
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.
2 changes: 2 additions & 0 deletions structure/clients/claude-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,5 @@ The lightweight top-level CLI help counts Cline CLI among the fifteen registered
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.

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.
2 changes: 2 additions & 0 deletions structure/codex-home.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,3 +241,5 @@ A config restoration that was attempted and failed retains its failed artifact i
result; unattempted catalog and history artifacts remain skipped. Successful preimage compensation
preserves config/profile/journal bytes without relabeling the failure as a skipped operation.
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.
Loading
Loading