Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
762ac39
feat(catalog): select approval reviewers per provider and model
lidge-jun Sep 12, 2026
2340498
fix(catalog): retain reviewer provenance across repeated syncs
lidge-jun Sep 12, 2026
fbb0410
feat(providers): persist explicit per-model capability declarations
lidge-jun Sep 12, 2026
d5d03f8
feat(providers): declare text-only models through CLI and shared capa…
lidge-jun Sep 12, 2026
2617fd1
docs(config): keep schema and load comments beside their owners
lidge-jun Sep 12, 2026
ca15403
test(catalog): verify exact capability modality precedence
lidge-jun Sep 12, 2026
d1dee68
test(providers): cover capability mutation isolation and overwrite pr…
lidge-jun Sep 12, 2026
3f24400
test(cli): invoke text-only edit handler with its declared signature
lidge-jun Sep 12, 2026
ebe3773
fix(catalog): preserve display-name validation and pin reviewer test …
lidge-jun Sep 12, 2026
c28d04c
[skip ci] Merge origin/dev into 60plus-models-capabilities
lidge-jun Sep 13, 2026
8bbfde7
[skip ci] Merge capabilities+dev into 60plus-models-text-only
lidge-jun Sep 13, 2026
8f4cbcf
[skip ci] Merge text-only+dev into 60plus-models-review-model
lidge-jun Sep 13, 2026
fee142c
[skip ci] docs(structure): keep stack notes in their own sections
lidge-jun Sep 13, 2026
2746727
[skip ci] Merge capabilities section placement into 60plus-models-tex…
lidge-jun Sep 13, 2026
38c0048
[skip ci] Merge section placement into 60plus-models-review-model
lidge-jun Sep 13, 2026
d7e4d29
[skip ci] Merge origin/dev audio work into 60plus-models-capabilities
lidge-jun Sep 13, 2026
3ed62aa
[skip ci] Merge capabilities into 60plus-models-text-only
lidge-jun Sep 13, 2026
489f475
[skip ci] Merge text-only into 60plus-models-review-model
lidge-jun Sep 13, 2026
96f38cc
[skip ci] Merge origin/dev 60plus-v2 work into 60plus-models-capabili…
lidge-jun Sep 13, 2026
8c69b11
[skip ci] Merge capabilities into 60plus-models-text-only
lidge-jun Sep 13, 2026
c26daa4
[skip ci] Merge text-only into 60plus-models-review-model
lidge-jun Sep 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,48 @@ dashboard or `custom` in `ocx init` and enter the base URL. See the
[Configuration reference](/reference/configuration/) for every provider field
(`headers`, `noReasoningModels`, `noVisionModels`, `models`, …).

## Approval reviewer per provider

Codex asks a second model to review approval requests, and takes that reviewer from
`auto_review_model_override` on the catalog row of the current turn's model. The root
`auto_review_model` in `$CODEX_HOME/config.toml` applies one reviewer to every row. To give a
routed provider its own — usually cheaper — reviewer, set the selector on that provider row in
`~/.opencodex/config.json`:

```json
{
"providers": {
"blsc": {
"autoReviewModel": "opencode-go/deepseek-v4-flash",
"autoReviewModelOverrides": { "kimi-k3": "gpt-5.6-terra" }
}
}
}
```

`autoReviewModel` covers every routed row of the provider. `autoReviewModelOverrides` targets a
single upstream model id and wins over it. A value is either a bare model id of that same provider
or a public catalog slug such as `opencode-go/deepseek-v4-flash`, and a provider stamp wins over the
root selector on its own rows while the root selector stays the fallback elsewhere.

A bare value resolves against the provider's own rows first and then against a bare catalog row,
which is how a native model such as `gpt-5.6-terra` is named; a value that matches neither is left
unresolved, and a bare value that lands outside the provider prints a note naming the row that
supplies the reviewer. Giving the full slug avoids the question entirely when the reviewer is
another provider's routed model.

Selectors are resolved against the final catalog on the next sync, each one on its own, and each
fails closed by itself: an unresolved `autoReviewModel` prints a diagnostic and stamps no
provider-wide rows, an unresolved `autoReviewModelOverrides` entry prints a diagnostic and stamps
no per-model override, leaving a valid provider-wide target as fallback. Whatever resolves is still applied. Rows without a provider stamp
keep the root selector, or upstream behavior when that is unset. Removing the root selector leaves
provider stamps alone, and removing a provider selector clears only that provider's stamps.

These fields are available through configuration, `PATCH /api/providers?name=<provider>`, and
the dashboard raw JSON provider editor; dedicated form controls are not present. The canonical `openai` provider
rejects them. Field-by-field rules live in the
[provider configuration reference](/reference/configuration/providers/#auto-review-approval-model-selection).

## Rate limits in the providers overview

The **Rate limits** section of the Providers overview shows live utilization
Expand Down
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.
52 changes: 52 additions & 0 deletions docs-site/src/content/docs/reference/configuration/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,49 @@ use `null` to clear a scalar or the whole map. A map entry set to `null` or `""`
entry while preserving other entries. Malformed writes are rejected before saving. A malformed
optional pin in a hand-edited file is ignored on load without discarding the rest of the config.

### Auto-review (approval) model selection

Codex reads `auto_review_model_override` from the catalog row of the current turn's model to
choose the model that reviews approval requests. The root `auto_review_model` setting in
`$CODEX_HOME/config.toml` applies one reviewer to every catalog row; the provider-scoped fields
below override it per provider. The [provider guide](/guides/providers/#approval-reviewer-per-provider)
has the operator workflow and a worked example.

`autoReviewModel` is the provider-wide reviewer target. A value can be a bare model id of that same
provider (the catalog row is normalized to the `provider/model` slug) or a full public catalog
slug such as `opencode-go/deepseek-v4-flash`. A bare value resolves against that provider's rows
first and then against a bare catalog row, which is how a native model such as `gpt-5.6-terra` is
named, and a bare value that lands outside the provider prints a note naming the row that actually
supplies the reviewer; a value that matches neither is left unresolved. `autoReviewModelOverrides`
keys are exact upstream model ids of that provider, or the provider's published alias for one
(`modelAliases`); either spelling names the same routed row, whose slug carries the upstream id. An
entry wins over the provider-wide value for its model. A provider
stamp wins over the root selector on its own routed rows, and the root selector remains the
fallback for native rows and routed rows without a provider stamp. Removing a provider selector
clears only that provider's stamps; removing the root selector never clears provider stamps.
Model ids that contain a slash may be written raw or in their encoded catalog form; both
spellings resolve to the same routed row. Model keys preserve case.
Selectors are resolved against the final catalog on each sync, independently of one another, and
each fails closed on its own: an unresolved `autoReviewModel` emits a diagnostic and stamps no
provider-wide rows, an unresolved `autoReviewModelOverrides` entry emits a diagnostic and stamps
no per-model override, so a valid provider-wide target remains its fallback. Any selector that does resolve is still applied. Rows without a provider
stamp keep the root selector, or normal upstream auto-review behavior when that is unset. The
canonical `openai` provider does not accept these fields.

Removing the root selector clears root stamps from every row, including native rows stamped by
earlier releases that predate OpenCodex's provenance marker. That cleanup recognizes a legacy
stamp by its shape — one value across the whole catalog that a routed row also carries — so a
genuine per-row value matching that shape is cleared with it, and a catalog that has since
diverged from that shape needs one manual sync. Provider stamps are never touched by root removal.

`PATCH /api/providers?name=<provider>` accepts both fields. Use `null` to clear the scalar or
the whole map; use a map entry of `null` or `""` to remove that model while preserving other
entries. Unrelated provider saves preserve previously configured values.

These fields are available in `config.json`, the provider management API, and the Dashboard raw
JSON provider editor. Dedicated form controls are not present. Native root stamps record the
previous value and restore it on removal when the stamped value has not been changed externally.

### Discovered model display names

Use `modelDisplayNames` when a provider returns machine friendly ids but the Codex model picker
Expand Down Expand Up @@ -329,6 +372,7 @@ projection, and merge precedence, so only a selector present in the catalog prod
that sync can become an override. Native upstream values are preserved when the setting is
cleared or unresolved. The persisted catalog field is read by Codex for the current turn's
model, which is why a valid configured selector is copied to each applicable entry.
Provider-scoped selectors (above) are applied before this root fallback and win on routed rows.

### FastWire B1 capability migration

Expand Down Expand Up @@ -1052,3 +1096,11 @@ 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.

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
24 changes: 22 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 @@ -224,6 +230,20 @@ async function handleAdd(args: string[]): Promise<void> {
}

const existingProvider = config.providers[name];
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
5 changes: 4 additions & 1 deletion 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 Expand Up @@ -673,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
Loading
Loading