diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index eb61194c94..ff89672cb8 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -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. diff --git a/src/cli/provider.ts b/src/cli/provider.ts index 47f23fee62..0f33ff98a5 100644 --- a/src/cli/provider.ts +++ b/src/cli/provider.ts @@ -224,6 +224,9 @@ async function handleAdd(args: string[]): Promise { } 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; diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 68910ce0eb..ae03da9f0d 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -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, diff --git a/src/config.ts b/src/config.ts index 932c26d8d8..90cf8d5639 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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"; @@ -573,11 +574,17 @@ const modelPinnedEffortsSchema = z.unknown().superRefine((value, ctx) => { Object.entries(value as Record).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 @@ -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).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; + 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..modelCosts`, mirroring * {@link sanitizeRetryOn429ForLoad}. A hand-edited malformed display-price row @@ -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); @@ -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); diff --git a/src/config/provider-validation.ts b/src/config/provider-validation.ts index c1e60033e8..76e2f86b94 100644 --- a/src/config/provider-validation.ts +++ b/src/config/provider-validation.ts @@ -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-]+$/; @@ -295,3 +296,98 @@ export function modelAdapterRecordConfigError( } return null; } + + +function capabilityRecord(value: unknown): value is Record { + 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 | undefined, + patch: unknown, +): Record | 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 | 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)]; + } 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 | undefined { + if (!capabilityRecord(value)) return undefined; + const rows: Record = 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 = {}; + 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; +} diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 820943ab04..16182f94cf 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -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"; @@ -646,6 +647,8 @@ export function providerManagementConfigError(name: unknown, provider: unknown): return "provider must be a plain object"; } const raw = provider as Record; + 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) { @@ -891,6 +894,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { contextWindow: "editor", modelContextWindows: "editor", modelInputModalities: "editor", + modelCapabilities: "editor", modelMaxInputTokens: "runtime", modelAutoCompactTokenLimits: "editor", defaultMaxOutputTokens: "editor", diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 6977a17783..863dbd79ed 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -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"; @@ -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 }; } @@ -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) { @@ -784,6 +796,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise; + /** 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; @@ -478,6 +486,7 @@ export interface OcxProviderConfig { modelContextWindows?: Record; /** Model-specific Codex catalog input modalities, e.g. ["text"] or ["text", "image"]. */ modelInputModalities?: Record; + modelCapabilities?: Record; /** Model-specific max input token limits. Values cap auto_compact_token_limit. */ modelMaxInputTokens?: Record; /** diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index fe6f7cb994..0d02278c6d 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -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. diff --git a/structure/catalog.md b/structure/catalog.md index a6a6250275..a97b1bbd53 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -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. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index e512b469fc..03ee53ba64 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -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. diff --git a/structure/codex-home.md b/structure/codex-home.md index a25eb8d326..017d31bedd 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -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. diff --git a/structure/config.md b/structure/config.md index 7001e54c92..c12a29294d 100644 --- a/structure/config.md +++ b/structure/config.md @@ -221,3 +221,7 @@ The Cline client keeps connection settings and models in a separate native file Config JSON preserves the boolean; only literal true activates the role-changing transform. The lightweight top-level CLI help counts Cline CLI among the fifteen registered export clients; registry parity remains covered by the client help and integration tests. + +## 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. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 0799866f21..7557d390cb 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -90,3 +90,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. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 7784cc6ebe..40b1a471c5 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -202,3 +202,5 @@ Instruction notice extraction scans fence ranges once and walks original lines b a decreasing cursor. It accepts exactly one ASCII space inside the token notice, preserves unmatched prefix bytes, and does not repeatedly scan or copy shrinking prompt prefixes. 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. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 938468f018..f16a13816e 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -577,3 +577,5 @@ The existing dashboard file-client maps include Cline CLI and reuse its committe 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. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 625fec16b2..8be8a27aea 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -330,3 +330,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. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 8b7a745165..4acff9eb4a 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -153,3 +153,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. diff --git a/structure/overview.md b/structure/overview.md index dbc6c88360..6f75856688 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -116,3 +116,5 @@ its defaults and exclusions are owned by [Responses transport](transports/respon Raw reasoning content and provider-authored summaries remain distinct on the Responses wire. See [reasoning presentation](providers/chat-compat.md). Cline CLI is a managed file integration: its provider settings and catalog share one recoverable journal operation. The [paired-file contract](clients/integrations.md#cline-paired-files) defines its stop/restart requirement. + +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. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 87617de47d..1e328fb021 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -440,6 +440,8 @@ API-key and custom forward destinations preserve their metadata. See [Responses `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 and after config/profile/journal changes, including successful journal and fallback restores, and compensates detected migration. Failed config restore stops later catalog/history work and rolls back a coordinated remove transition. 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. + ## Context relay ownership `src/codex/context-owner.ts` records which account actually served a root session, taken from the diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 8280356d3a..ab1c9793bc 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -80,6 +80,8 @@ 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. + ### OAuth Fast Tier (Priority Processing) xAI's Priority Processing (`service_tier: "priority"` on Chat Completions and Responses, diff --git a/structure/runtime.md b/structure/runtime.md index 6edc2cbd41..2e62869bef 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -251,3 +251,5 @@ The lightweight top-level CLI help counts Cline CLI among the fifteen registered Devin CLI credential path composition in `src/oauth/devin-cli.ts` follows the selected platform: Windows uses Win32 APPDATA paths, other platforms use POSIX XDG-data paths. The explicit absolute override remains verbatim; credential parsing and login behavior are unchanged. 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. diff --git a/structure/subagents.md b/structure/subagents.md index 7560c235ff..fb42900ddc 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -247,3 +247,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. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 0cdb54b0a1..573cc83a40 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -85,3 +85,5 @@ privately to final dispatch; preliminary route selection does not inject Go-only Devin CLI credential path composition in `src/oauth/devin-cli.ts` follows the selected platform: Windows uses Win32 APPDATA paths, other platforms use POSIX XDG-data paths. The explicit absolute override remains verbatim; credential parsing and login behavior are unchanged. 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. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index a2860c09c0..ea5331f71a 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -584,3 +584,5 @@ WebSocket metadata and compact are excluded. This does not disable upstream safe 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. 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. diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index 71cb6cd72e..041852398f 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -210,3 +210,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](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. diff --git a/tests/cli/cli-provider.test.ts b/tests/cli/cli-provider.test.ts index ea29c5535f..b37bf973ed 100644 --- a/tests/cli/cli-provider.test.ts +++ b/tests/cli/cli-provider.test.ts @@ -644,3 +644,16 @@ describe("ocx provider add --sync", () => { } }); }); + + +test("provider add --force preserves all explicit model capability axes", () => { + const declarations = { ModelA: { inputModalities: ["text"], contextTier: "long_context", video: { processing: "agentic" } }, modela: { inputModalities: ["text", "image"] } }; + const { dir } = freshConfig({ defaultProvider: "caps", providers: { caps: { + adapter: "openai-chat", baseUrl: "https://example.test/v1", modelCapabilities: declarations, + } } }); + try { + const result = runCli(["provider", "add", "caps", "--adapter", "openai-chat", "--base-url", "https://example.test/v1", "--force", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status, result.stderr).toBe(0); + expect(readConfig(dir).providers.caps.modelCapabilities).toEqual(declarations); + } finally { removeTreeWithRetry(dir); } +}); diff --git a/tests/codex-integration/codex-gather-authority.test.ts b/tests/codex-integration/codex-gather-authority.test.ts index ae3204a0a7..5e8f287d01 100644 --- a/tests/codex-integration/codex-gather-authority.test.ts +++ b/tests/codex-integration/codex-gather-authority.test.ts @@ -365,3 +365,44 @@ describe("catalog gather discovery-policy authority", () => { } }); }); + + +test("overlapping gathers with different explicit capability declarations stay isolated", async () => { + clearModelCache("together"); + clearGatherRoutedModelsInflight(); + const arrived = [deferred(), deferred()]; + const release = deferred(); + let count = 0; + globalThis.fetch = (async () => { + const index = count++; + arrived[index]?.resolve(); + await release.promise; + return Response.json({ data: [{ id: `cap-model-${index}` }] }); + }) as typeof fetch; + const a = togetherConfig(); + const b = togetherConfig(); + a.providers.together!.modelCapabilities = { model: { contextTier: "default" } }; + b.providers.together!.modelCapabilities = { model: { contextTier: "long_context" } }; + const first = gatherRoutedModels(a); + let second: ReturnType | undefined; + try { + await arrived[0]!.promise; + second = gatherRoutedModels(b); + let timeout: ReturnType | undefined; + try { + await Promise.race([arrived[1]!.promise, new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error("second capability gather joined the first flight")), 10_000); + })]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } + expect(count).toBe(2); + release.resolve(); + const [firstRows, secondRows] = await Promise.all([first, second]); + expect(firstRows.some(row => row.id === "cap-model-0")).toBe(true); + expect(secondRows.some(row => row.id === "cap-model-1")).toBe(true); + } finally { + release.resolve(); + await Promise.allSettled([first, ...(second ? [second] : [])]); + } +}, 20_000); diff --git a/tests/config/config-load-degrade.test.ts b/tests/config/config-load-degrade.test.ts index 4d8cca68f7..93a36e845d 100644 --- a/tests/config/config-load-degrade.test.ts +++ b/tests/config/config-load-degrade.test.ts @@ -139,3 +139,37 @@ test("Fast rows default on for fresh and omitted config; explicit false and malf expect(loaded.providers.xai.note).toBe("keep me"); } }); + + +test("model capability writes stay strict while load preserves independent restrictions", () => { + const raw = { ...candidate(undefined), providers: { xai: { + ...candidate(undefined).providers.xai, + apiKey: "fixture-key", modelCapabilities: { + ModelA: { inputModalities: ["text"] }, + modela: { contextTier: "long_context" }, + broken: { inputModalities: "image", contextTier: "typo" }, + }, + } } }; + expect(validateConfigCandidate(raw).ok).toBe(false); + writeFileSync(getConfigPath(), JSON.stringify(raw), "utf8"); + const loaded = loadConfig(); + expect(loaded.providers.xai.modelCapabilities).toEqual({ + ModelA: { inputModalities: ["text"] }, modela: { contextTier: "long_context" }, + broken: { inputModalities: ["text"] }, + }); + expect(loaded.providers.xai.apiKey).toBe("fixture-key"); + expect(validateConfigCandidate(loaded).ok).toBe(true); +}); + +test("model capabilities round-trip all explicit axes without expanding inference", () => { + const raw = { ...candidate(undefined), providers: { xai: { + ...candidate(undefined).providers.xai, + modelCapabilities: { ModelA: { inputModalities: ["text", "image"], contextTier: "long_context", video: { processing: "agentic" } } }, + } } }; + const validated = validateConfigCandidate(raw); + expect(validated.ok).toBe(true); + if (!validated.ok) return; + saveConfig(validated.config); + expect(loadConfig().providers.xai.modelCapabilities).toEqual(raw.providers.xai.modelCapabilities); + expect(loadConfig().providers.xai.modelContextWindows).toBeUndefined(); +}); diff --git a/tests/oauth/oauth-upsert-preserves-api-key.test.ts b/tests/oauth/oauth-upsert-preserves-api-key.test.ts index a22239dd69..710089a7ad 100644 --- a/tests/oauth/oauth-upsert-preserves-api-key.test.ts +++ b/tests/oauth/oauth-upsert-preserves-api-key.test.ts @@ -582,3 +582,14 @@ describe("upsertOAuthProvider credential preservation", () => { expect(config.providers["command-code"]!.note).toBe("operator-note"); }); }); + + +test("OAuth upsert preserves explicit per-model capabilities", () => { + const config: OcxConfig = { port: 10100, defaultProvider: "xai", providers: { xai: { + adapter: "openai-responses", baseUrl: "https://api.x.ai/v1", + modelCapabilities: { "grok-4.6": { inputModalities: ["text"], contextTier: "default", video: { processing: "static" } } }, + } } }; + const before = structuredClone(config.providers.xai!.modelCapabilities); + upsertOAuthProvider(config, "xai"); + expect(config.providers.xai!.modelCapabilities).toEqual(before); +}); diff --git a/tests/server/management-provider-validation.test.ts b/tests/server/management-provider-validation.test.ts index 949791f2d6..dc69f46b79 100644 --- a/tests/server/management-provider-validation.test.ts +++ b/tests/server/management-provider-validation.test.ts @@ -5005,3 +5005,82 @@ describe("remembered provider context selections", () => { } }); }); + + +test("model capability PATCH merges axes while strict replacement and DTO state agree", async () => { + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const live: OcxConfig = { port: 0, defaultProvider: "caps", providers: { caps: { + adapter: "openai-chat", baseUrl: "https://example.test/v1", liveModels: false, models: ["ModelA", "modela"], + modelCapabilities: { ModelA: { inputModalities: ["text"], contextTier: "long_context", video: { processing: "agentic" } }, modela: { inputModalities: ["text", "image"] } }, + } } }; + saveConfig(live); + const request = async (method: string, body?: unknown) => { + const url = new URL(method === "PATCH" ? "http://localhost/api/providers?name=caps" : "http://localhost/api/providers"); + return (await handleManagementAPI(new Request(url, { method, headers: { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }), url, live, { createManagementConvergeCodex: catalogConvergenceFactory() }))!; + }; + const before = live.providers.caps!.modelCapabilities; + const originalRow = before!.ModelA!; + const originalVideo = originalRow.video; + expect((await request("PATCH", { modelCapabilities: { ModelA: { contextTier: "default", video: { processing: null } } } })).status).toBe(200); + expect(live.providers.caps!.modelCapabilities).toEqual({ + ModelA: { inputModalities: ["text"], contextTier: "default" }, modela: { inputModalities: ["text", "image"] }, + }); + expect(before!.ModelA!.video).toEqual({ processing: "agentic" }); + expect(originalRow.contextTier).toBe("long_context"); + expect(originalVideo).toEqual({ processing: "agentic" }); + expect(live.providers.caps!.modelCapabilities).not.toBe(before); + expect(live.providers.caps!.modelCapabilities!.ModelA).not.toBe(originalRow); + expect(loadConfig().providers.caps!.modelCapabilities).toEqual(live.providers.caps!.modelCapabilities); + const listed = await (await request("GET")).json() as Array<{ name: string; modelCapabilities?: unknown }>; + expect(listed.find(row => row.name === "caps")!.modelCapabilities).toEqual(live.providers.caps!.modelCapabilities); + expect(providerEditorConfigDTO(live).providers.caps!.modelCapabilities).toEqual(live.providers.caps!.modelCapabilities); + for (const patch of [{ " ModelA ": {} }, { ModelA: { unknown: true } }, { ModelA: { inputModalities: [] } }]) { + expect((await request("PATCH", { modelCapabilities: patch })).status).toBe(400); + } + const admitted = structuredClone(live.providers.caps!.modelCapabilities); + const diskBeforeReject = readFileSync(join(TEST_DIR, "config.json"), "utf8"); + for (const patch of [JSON.parse('{"__proto__":null}'), { ModelA: { video: [] } }, { ModelA: { video: { processing: "invalid" } } }]) { + expect((await request("PATCH", { modelCapabilities: patch })).status).toBe(400); + expect(live.providers.caps!.modelCapabilities).toEqual(admitted); + expect(readFileSync(join(TEST_DIR, "config.json"), "utf8")).toBe(diskBeforeReject); + } + const resolved = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + try { + const replacement = { adapter: "openai-chat", baseUrl: "https://example.test/v1", liveModels: false, models: ["ModelA", "modela"] }; + expect((await request("POST", { name: "caps", provider: replacement })).status).toBe(200); + expect(live.providers.caps!.modelCapabilities).toEqual(admitted); + expect((await request("POST", { name: "caps", provider: { ...replacement, modelCapabilities: {} } })).status).toBe(200); + expect(live.providers.caps!.modelCapabilities).toBeUndefined(); + expect((await request("PATCH", { modelCapabilities: admitted })).status).toBe(200); + const stale = providerEditorConfigDTO(loadConfig()); + expect((await request("PATCH", { modelCapabilities: { ModelA: { contextTier: null } } })).status).toBe(200); + expect(live.providers.caps!.modelCapabilities!.ModelA).toEqual({ inputModalities: ["text"] }); + expect(live.providers.caps!.modelCapabilities!.modela).toEqual({ inputModalities: ["text", "image"] }); + const staleEdit = structuredClone(stale); + staleEdit.providers.caps!.note = "stale"; + expect((await request("PUT", { baseline: stale, next: staleEdit })).status).toBe(409); + expect((await request("PATCH", { modelCapabilities: { ModelA: null } })).status).toBe(200); + expect(live.providers.caps!.modelCapabilities).toEqual({ modela: { inputModalities: ["text", "image"] } }); + expect((await request("PATCH", { modelCapabilities: null })).status).toBe(200); + expect(live.providers.caps!.modelCapabilities).toBeUndefined(); + expect((await request("PATCH", { modelCapabilities: admitted })).status).toBe(200); + } finally { + resolved.mockRestore(); + } + const baseline = providerEditorConfigDTO(loadConfig()); + const invalidNext = structuredClone(baseline); + Object.assign(invalidNext.providers.caps!, { modelCapabilities: null }); + expect((await request("PUT", { baseline, next: invalidNext })).status).toBe(400); + const next = structuredClone(baseline); + next.providers.caps!.modelCapabilities = {}; + expect((await request("PUT", { baseline, next })).status).toBe(200); + expect(live.providers.caps!.modelCapabilities).toBeUndefined(); + expect(loadConfig().providers.caps!.modelCapabilities).toBeUndefined(); + const newBaseline = providerEditorConfigDTO(loadConfig()); + const followup = structuredClone(newBaseline); + followup.providers.caps!.note = "fresh baseline"; + expect((await request("PUT", { baseline: newBaseline, next: followup })).status).toBe(200); +});