Combos: failover and load balancing
A combo is one virtual model that fronts an ordered list of real provider/model targets. Your
client requests combo/<id>; opencodex chooses a target, rewrites the request to that concrete
provider/model, and can try another target when the first one has a retryable failure.
This is useful when you want either:
- Failover: prefer one model, but keep backups ready.
- Load balancing: spread successful requests across models or providers in weighted batches.
Combos sit in front of normal provider routing. Read Model Routing first
if provider/model selectors are new to you.
60-second quickstart
Section titled “60-second quickstart”This example creates combo/main with Anthropic first and OpenAI second. Both providers must
already exist and be enabled.
ocx combo set main --targets anthropic/claude-opus-4-8,openai/gpt-5.6-solThe default strategy is failover, so a normal request goes to
anthropic/claude-opus-4-8. If that attempt has a retryable failure, opencodex can hop to
openai/gpt-5.6-sol.
Use the virtual model anywhere you would normally provide a model id:
{ "model": "combo/main", "input": "Explain why the sky looks blue."}Confirm the saved definition:
ocx combo show mainHow combo names work
Section titled “How combo names work”The combo id in ocx combo set <id> must start with a letter or number. It may then contain
letters, numbers, ., _, or -, up to 64 characters total. Its canonical model id is always
combo/<id>; for example, id main becomes combo/main.
The combo/ namespace is reserved while combos are configured. A provider named combo cannot
occupy it, and a combo id cannot duplicate a configured provider name.
An optional alias gives the combo a different public model name. An alias:
- uses the same characters as an id;
- may be bare, such as
daily-fast, or contain one/, such asteam/daily-fast; - cannot be
comboor start withcombo/; - cannot duplicate another combo alias; and
- cannot normally be a bare native OpenAI-family name beginning with
gpt-,o1-,o3-,o4-, orcodex-. The explicit Desktop compatibility mode below is the only exception.
Even when an alias is set, the canonical combo/<id> form still resolves. Canonical lookup runs
before alias matching, so an alias cannot take over another combo’s canonical id.
Compaction after switching combos
Section titled “Compaction after switching combos”When a client compacts using a bare model name after switching combos, opencodex can recall the combo that most recently completed successfully on that conversation lane. The model must match the completed response, and the combo and its target must still exist in the current configuration. The request then follows normal combo selection and failover.
Explicit provider/combo selectors and configured combo aliases take precedence over this recall. Failed, incomplete, or cancelled responses do not replace the last successful selection. Recall is process-local and bounded to 256 conversations for 30 minutes, and to 1 KiB per remembered model name and 64 KiB in total; expired entries are also cleaned up in the background. A response whose model name is too large to retain leaves the previous selection untouched rather than clearing it. Recall does not store account credentials. Without usable conversation identity or valid remembered state, normal compaction routing applies. A restart clears the remembered state.
Codex Desktop native-allowlist compatibility
Section titled “Codex Desktop native-allowlist compatibility”Some Codex Desktop releases apply a remote native-only available_models allowlist after the
app-server has already loaded model_catalog_json. Normal routed ids such as
Nova1/codex-gpt-5.6-sol are then usable by the CLI but absent from the Desktop picker. This is the
upstream Codex Desktop bug tracked by
opencodex #241.
When you control an equivalent routed target, a combo can explicitly take over one native slug:
ocx combo set nova-sol \ --targets Nova1/codex/gpt-5.6-sol \ --alias gpt-5.6-sol \ --native-alias \ --display-name 'Nova1 - codex-gpt-5.6-sol'This mode is deliberately opt-in and requires both --native-alias and a non-empty display label.
The alias must be one of the native model ids supported by this opencodex release; a native-family
prefix alone is not accepted because removal must be able to restore authoritative metadata.
When the routed target’s discovery response supplies only a model id, the compatibility row fills
missing context, modality, and reasoning metadata from the native id it replaces. Explicit target
limits still win, so this fallback never raises a context cap or overrides declared capabilities.
It changes exact routing precedence: requests for gpt-5.6-sol resolve to combo/nova-sol before
the canonical OpenAI native-family route. The catalog contains one bare row with the configured
display label, not duplicate native and combo rows. Only the bare gpt-5.6-sol slug is captured.
Account-qualified rows such as main/gpt-5.6-sol and provider-qualified rows such as
openai-apikey/gpt-5.6-sol remain distinct OpenAI routes; the provider-qualified API-key route
never falls through to the native alias.
Visibility keys stay unambiguous:
combo/nova-solhides the compatibility combo from discovery.- The bare
gpt-5.6-solentry indisabledModelscontinues to mean the dormant native OpenAI row; it does not hide the combo that currently owns that public slug. - While at least one native alias is configured, disabled bare native rows are omitted from the
effective Codex catalog instead of retained as
visibility: "hide". This prevents Desktop’s allowlist from resurrecting rows it should not show. The Models page still lists unshadowed native switches, and re-enabling one restores its preserved or current native metadata.
Choose a strategy
Section titled “Choose a strategy”Failover: ordered primary and backups
Section titled “Failover: ordered primary and backups”failover selects the first eligible target in configuration order. A target is eligible when its
provider exists, is enabled, is not cooling down, and can handle any special request constraint.
Weights and stickyLimit do not affect this strategy.
Given this order:
anthropic/claude-opus-4-8openai/gpt-5.6-solgoogle/gemini-3-pro
each request starts with Anthropic. A retryable Anthropic failure moves that request to OpenAI; a retryable OpenAI failure can move it to Google. A terminal error stops immediately instead of trying the remaining targets.
Round-robin: smooth weighted batches
Section titled “Round-robin: smooth weighted batches”round-robin uses smooth weighted round-robin. A larger target weight gives that target a larger
share over time without sending all of its share as one long block. stickyLimit controls how many
successful requests stay on the selected target before the next weighted selection.
Create a 2:1 combo with batches of two successful requests:
ocx combo set balanced \ --targets anthropic/claude-opus-4-8:2,openai/gpt-5.6-sol:1 \ --strategy round-robin \ --sticky 2Calling the targets A (weight 2) and B (weight 1), the first six weighted selections are
A, B, A, A, B, A. Because stickyLimit is 2, each selection stays active for two successful
requests:
| Successful request | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | | — | — | — | — | — | — | — | — | — | — | — | — | — | — | | Target | A | A | B | B | A | A | A | A | B | B | A | A |
The long-run share is still 2:1. A retryable failure ends the current sticky batch, cools that target, and selects another eligible target for the same request.
Random: weighted draw per request
Section titled “Random: weighted draw per request”random draws one eligible target per request, with odds proportional to weight. Every request
is an independent draw, so traffic spreads across targets without the deterministic pattern or
stickiness of round-robin. stickyLimit does not affect this strategy.
Least-used: favor the target with fewest successes
Section titled “Least-used: favor the target with fewest successes”least-used routes each request to the eligible target with the fewest successful requests
recorded by this opencodex process. Counts start at zero on restart, and ties keep configuration
order. Weights and stickyLimit do not affect this strategy.
Reset-window: follow the soonest quota reset
Section titled “Reset-window: follow the soonest quota reset”reset-window routes each request to the eligible target whose cached provider quota snapshot
shows the soonest upcoming window reset (five-hour, weekly, monthly, or custom). This spends the
provider that refreshes first. Targets without fresh quota data, and ties, keep configuration
order. Weights and stickyLimit do not affect this strategy.
This ranking and provider exclusion before dispatch require fresh model-inference limits that apply to the current single API key as a whole. OAuth/current-account summaries, caller-forward routes, multiple keys, and snapshots with changed credentials or destinations are display-only for this early decision. The same applies when Authorization, x-api-key, or x-goog-api-key headers override credentials; search-only and MCP-only windows are excluded. If no eligible target has an applicable reset, configuration order wins. Account selection and retries still enforce their normal limits.
What happens when a target fails
Section titled “What happens when a target fails”Combo failures are divided into hop failures and terminal failures.
| Result | Behavior |
|---|---|
| HTTP 401, 403, 404, 408, 429, or any 5xx | Cool the target and hop to the next eligible target. |
| HTTP 410 with an explicit model end-of-life, retired, deprecated, sunset, decommissioned, or no-longer-available signal | Cool that target and hop. Unrelated 410 responses remain terminal. |
| Classified authentication, subscription, quota, rate-limit, overload, or upstream-server error | Cool the target and hop, even when the status alone is not sufficient. |
Client cancellation (499), origin_rejected, cyber-policy refusal, context overflow, or other invalid request |
Stop and return the error; another target would not make the request valid. |
Structured HTTP 400 rejecting optional user, an unsupported reasoning effort, or model-scoped image input |
Hop before output commitment without cooling; see request-local target compatibility below. |
| Any other unclassified error | Stop and return the error. |
When cooldownMs is unset, a hopped target uses an upstream fallback: 5 seconds for request-rate
429s with upstream code 1302 or 1305, and 60 seconds otherwise. When it is set, cooldownMs
applies whenever no usable upstream Retry-After or Codex reset signal exists, including those
request-rate 429s. Numeric Retry-After seconds and HTTP-date values are accepted, and every
cooldown is capped at 10 minutes. The precedence is, from strongest to weakest, explicit
Retry-After → Codex reset headers (x-codex-primary-reset-at, x-codex-secondary-reset-at, or
x-codex-tertiary-reset-at) → the combo’s cooldownMs (when set) → the 5-second request-rate
fallback for upstream rate-limit codes 1302/1305 → the 60-second default. A valid immediate
Retry-After: 0 remains an immediate upstream directive rather than being replaced by a configured
cooldown.
The current request never retries the same attempted target. Later requests skip a cooled target until its
cooldown expires; request-local compatibility rejections do not cool the target. A Retry-After HTTP-date that is already in the past is also preserved as an
immediate upstream directive, just like Retry-After: 0. Set waitForCooldownMs to allow a later
request to wait for the earliest eligible target cooldown, up to that cap on each selection attempt,
and then make one fresh selection. A request may therefore wait up to hops × waitForCooldownMs
across multiple failover hops. The default is 0, which fails closed immediately with HTTP 503 when
every eligible target is cooling; that combo_unavailable 503 carries a Retry-After header equal
to the earliest remaining cooldown, rounded up to whole seconds with a minimum of 1. Waits are not jittered, so
synchronized wake-ups are possible. An aborted request cancels this wait and returns the normal
client_cancelled response; it does not dispatch a backup target after cancellation. A combo target
cooldown is process-local per-combo state and is separate from the account-level Codex quota cooldown
used by native account routing.
For streaming requests, the upstream HTTP status is not the final decision. OpenCodex buffers a
bounded pre-output prefix of the selected child’s Responses SSE. If the stream reports a retryable
response.failed terminal before any text, reasoning, tool call, or other output event, the child
is recorded as failed and the combo may try its next eligible target. Once any output event begins,
the target is committed: a later stream failure is returned to the client and is never replayed on
another provider, which prevents duplicate text and tool execution. If the pre-output buffer reaches
its safety cap without a terminal or output boundary, OpenCodex also commits the current target
instead of growing memory without a bound.
Request-local target compatibility
Section titled “Request-local target compatibility”When routing Claude Code to the canonical ChatGPT Codex backend, OpenCodex removes the unsupported top-level user metadata field without changing the session/cache key, input messages, tool schemas, or safety identifiers. Public Responses API and noncanonical forward gateways keep that field.
A combo can also advance after an intact HTTP 400 invalid_request_error that specifically rejects user, reports unsupported_value for reasoning.effort/reasoning_effort, or reports param: input with an exact model-scoped does not support image inputs rejection. This is a mismatch for that request, not evidence that the target is unhealthy, so it records no cooldown. This compatibility recovery does not silently change none into a different effort or broaden this exception to arbitrary invalid requests. Policy refusals, cancellation and already-committed output remain non-replayable. A single-target request still returns an unresolved upstream rejection.
Default reasoning effort
Section titled “Default reasoning effort”defaultEffort supplies a configured effort when the selected target has a known, nonempty supported ladder. With the default defaultEffortMode: "fallback", an explicit caller effort keeps precedence. defaultEffortMode: "force" overrides a valid caller effort with the configured default; it requires a valid, non-null defaultEffort and can increase cost and latency. Force mode is an explicit operator choice through combo configuration or management.
The target’s advertised ladder remains authoritative. An exact supported value is retained; otherwise the highest supported rung at or below it is selected, or the lowest supported rung when none is lower. Unknown or empty ladders never cause default injection. Force mode does not repair malformed caller effort into a valid expensive request. Other reasoning fields, including reasoning.summary, are preserved.
reasoningEffortMode remains independent of defaultEffortMode: explicit empty ladders remove unsupported effort/thinking controls, and adaptive unknown ladders do so as well, as described below. Strict unknown ladders preserve the caller’s request without forcing a default. Supported defaults are low, medium, high, xhigh, max, and ultra; omit defaultEffort or set it to null to disable default injection in fallback mode.
Mixed-capability groups (reasoningEffortMode)
Section titled “Mixed-capability groups (reasoningEffortMode)”The effort levels a combo advertises are the intersection of what its targets advertise. A target that explicitly advertises no effort control takes part in that intersection, so a single no-effort backup empties the effort picker for the whole combo — including for the targets that do support tuning.
Set reasoningEffortMode: "adaptive" to exclude those empty ladders from the published
intersection instead. The picker then shows the levels the remaining targets share, and the
no-effort target stays eligible for routing. Targets whose ladder is simply unknown are treated
as wildcards in both modes.
{ "combos": { "mixed": { "targets": [ { "provider": "openai-apikey", "model": "gpt-5.6-luna" }, { "provider": "local", "model": "no-effort-model" } ], "reasoningEffortMode": "adaptive" } }}The default is "strict", which keeps the original picker behavior. This setting does not change
target order or failover policy. At dispatch, an explicitly empty target ladder has its unsupported
effort/thinking controls removed in either mode while preserving supported non-effort reasoning fields
such as reasoning.summary; "adaptive" applies the same normalization to an unknown target
capability, while known non-empty targets keep their existing per-target effort resolution.
In the dashboard it is the Adaptive reasoning ladder switch in a
combo’s Capabilities section.
Image / multimodal capability
Section titled “Image / multimodal capability”By default a combo publishes the intersection of its targets’ input modalities (image is
enabled only when every target advertises it). Set imageInput: "disabled" to force text-only
even when every target supports images — the catalog drops image from inputModalities, and
image-bearing requests are rejected with HTTP 400 before any target is called. "auto" (or
omitting the field) keeps the automatic intersection.
Encrypted v2 sub-agent tasks
Section titled “Encrypted v2 sub-agent tasks”There is one important limitation for Codex v2 sub-agents (issue #92). A native parent can send a newly spawned worker’s task only as ciphertext minted for the native ChatGPT backend. An external provider cannot read that payload.
For such a request, a combo filters its eligible targets to canonical native ChatGPT routes, including after a retryable failure. If the combo has no decrypt-capable target, opencodex stops before dispatch and returns HTTP 400:
{ "error": { "type": "invalid_request_error", "code": "unreadable_encrypted_agent_task" }}This protects the task from being sent to a provider that would receive no readable instructions. Readable plaintext tasks use the normal combo strategy.
You have four recovery options:
- Select a native ChatGPT model for the child.
- Add a canonical native ChatGPT target to the combo.
- Use the v1 surface for delegation across different providers.
- If you control the caller, resend the task as plaintext v2
agent_messagecontent.
See Sub-agent Surface for the v1/base/v2 modes and the full encrypted task workflow.
Manage combos
Section titled “Manage combos”Dashboard
Section titled “Dashboard”Open the local dashboard and choose Models → Combos. The workspace creates, edits, renames, and removes combos, and its target picker excludes disabled models and nested combos.
Each target also shows a live quota badge: Available, Out of quota, or Quota unknown. The editor blocks Save and Create for quota only when every usable target has a current server-confirmed exhausted inference limit for its configured credential. Display-only account, model, search and MCP quota, or missing or expired routing evidence, does not cause this block. The block expires at the applicable reset or freshness boundary and is rechecked when the page becomes active or visible; Refresh reloads both Combo data and quota. The dashboard
editor does not yet expose cooldownMs or waitForCooldownMs; use the configuration file or management
API until the follow-up UI work lands.
The primary commands are:
ocx combo listocx combo show <id>ocx combo set <id> --targets provider/model[:weight],...ocx combo remove <id> --yesset also accepts --strategy, --sticky, --effort, --alias, --native-alias,
--display-name, and --rename-from. Use - as the value of --effort, --alias, or
--display-name to clear that field. --native-alias requires a currently supported bare native
model alias and a non-empty display name. create and update are aliases for set; delete is an alias for
remove; and the same subcommands are available under ocx route combo.
Management API
Section titled “Management API”Headless clients use GET, PUT, and DELETE on /api/combos. GET lists normalized combo
definitions, PUT creates or replaces one (and can rename one), and DELETE takes the id query
parameter. Authentication and request/response details are in the
Management API reference. When a PUT body omits cooldownMs
or waitForCooldownMs, the API preserves the value already stored for that combo; send an explicit
value to change it. An explicit cooldownMs (even 60000) is persisted as-is because it overrides
the request-rate fallback. A stored cooldownMs can only be removed by editing the configuration file;
waitForCooldownMs resets to its default when a PUT explicitly sends 0, because the sparse
serializer omits that default. Omission preserves both values and the dashboard does not expose them yet.
For the complete persisted configuration, see Configuration.
Configuration reference
Section titled “Configuration reference”Combos are stored in the top-level combos object, keyed by combo id:
{ "combos": { "balanced": { "targets": [ { "provider": "anthropic", "model": "claude-opus-4-8", "weight": 2 }, { "provider": "openai", "model": "gpt-5.6-sol", "weight": 1 } ], "strategy": "round-robin", "stickyLimit": 2, "defaultEffort": "high", "alias": "team/balanced" } }}| Field | Required | Default | Rules |
|---|---|---|---|
targets |
Yes | — | Non-empty ordered array of configured { provider, model, weight? } targets. Duplicate provider/model pairs are rejected. |
targets[].weight |
No | 1 |
Integer from 1 to 10,000. Used by round-robin and random; ignored by failover, least-used, and reset-window. |
strategy |
No | "failover" |
"failover", "round-robin", "random", "least-used", or "reset-window". |
stickyLimit |
No | 1 |
Integer from 1 to 100 successful requests per round-robin selection. Applies only to round-robin. |
cooldownMs |
No | unset → upstream fallback (5 s for request-rate 429 codes 1302/1305, otherwise 60 s) |
Integer from 1 to 600000. When set, applies as the per-target cooldown whenever no usable upstream Retry-After or Codex reset signal exists, including request-rate 429s; when unset, uses the upstream fallback. |
waitForCooldownMs |
No | 0 |
Integer from 0 to 600000. Maximum time to wait for the earliest eligible cooling target before returning combo_unavailable; abort cancels the wait. |
defaultEffort |
No | null |
low, medium, high, xhigh, max, or ultra; resolved against each target’s advertised ladder. |
defaultEffortMode |
No | "fallback" |
"fallback" preserves explicit caller effort. "force" overrides valid caller effort, requires a valid non-null default, and can increase cost and latency. |
reasoningEffortMode |
No | "strict" |
"strict" intersects every known target ladder, so one target advertising no effort control empties the combo’s picker. "adaptive" excludes those empty ladders from the published intersection. At dispatch, explicit empty or adaptive unknown ladders remove unsupported effort/thinking controls while preserving supported non-effort reasoning fields such as reasoning.summary; known non-empty targets keep existing effort resolution. |
imageInput |
No | "auto" |
"auto" or "disabled". "auto" publishes image support only when every target supports images; "disabled" forces text-only (drops image from published modalities and rejects image-bearing requests before dispatch). |
alias |
No | none | Optional trimmed public model id; use the alias rules above. An empty value is stored as no alias. |
nativeAlias |
No | false |
Explicitly permit a currently supported bare native alias to take routing and catalog precedence. Never inferred from the alias. |
displayName |
No | none | Bounded display-only catalog label. Required and non-empty when nativeAlias is true. |
Troubleshooting
Section titled “Troubleshooting”Why does combo/<id> return 404?
Section titled “Why does combo/<id> return 404?”The combo id is unknown. The response is HTTP 404 with type invalid_request_error. Run
ocx combo list, check spelling and case, and confirm your management command wrote to the same
running opencodex instance that receives model requests.
Why do I get combo_unavailable?
Section titled “Why do I get combo_unavailable?”Every target is currently ineligible: for example, its provider is disabled, it is cooling down,
it has already been attempted for this request, or an encrypted v2 task excludes it. Check target
provider state and recent upstream errors. For cooldowns, follow an observed Retry-After value first;
Codex reset headers also take precedence over cooldownMs.
If neither upstream signal is usable, the configured cooldownMs applies, or the upstream fallback applies
when it is unset (5 seconds for request-rate codes 1302/1305, otherwise 60 seconds); every cooldown is
capped at 10 minutes.
Why was my alias rejected?
Section titled “Why was my alias rejected?”Check the alias grammar and reserved names first. A duplicate alias or invalid shape is rejected as HTTP 400. A slashed alias whose first segment is a configured Codex account namespace is rejected as HTTP 409; choose a different alias namespace. The CLI and dashboard display the server’s exact validation message.
Why did failover stop after the first error?
Section titled “Why did failover stop after the first error?”The error was terminal rather than target-specific. Fix invalid input, reduce an oversized context, handle a policy refusal, or correct the rejected request origin. Combos do not hop for those cases.

