Skip to content
18.1k

Server and Runtime Configuration

Server settings control how the local proxy listens, protects remote traffic, manages resources, and runs helper features around provider requests.

completeOnMaxOutputTokens is an optional boolean, default false. Set it to true to report a token-limited translated Responses text answer as completed rather than incomplete. The answer remains truncated: OpenCodex preserves its exact text, including unfinished Markdown. Only nonempty plain-text output qualifies. Tool calls and hosted operations (even if completed), reasoning, text.format with json_schema or json_object, compaction, explicitly unfinished turns, errors and missing terminal events remain excluded. Context-window exhaustion and pause_turn remain incomplete. Chat Completions, Anthropic Messages and native Responses passthrough, including native WebSocket output, retain their existing stop behavior.

Field Type Default Meaning
completeOnMaxOutputTokens? boolean false Opt in to completing eligible token-limited translated Responses text output. Text stays truncated; see the eligibility and exclusions above.
port number 10100 Proxy listen port.
hostname? string "127.0.0.1" Bind address. A non-loopback bind requires a data-admission token, resolved from OPENCODEX_API_AUTH_TOKEN, then OCX_API_TOKEN_FILE, then the installed owner-only service-api-token — nothing has to be exported by hand. See Remote access.
proxy? string — Outbound HTTP(S) or SOCKS5 proxy URL (socks5://host:port), ${ENV_VAR}, or "auto". HTTP URLs apply to HTTP_PROXY / HTTPS_PROXY when those are unset. SOCKS5 URLs use OpenCodex’s real SOCKS5 transport and are also exposed through ALL_PROXY (ocx start --socks5); inherited HTTP(S)_PROXY is cleared in this process. Loopback stays in NO_PROXY. "auto" reads Windows WinINET or macOS static HTTP/HTTPS settings once at startup. Inherited HTTP(S) proxy variables skip discovery; on macOS, inherited ALL_PROXY/all_proxy also skips it. Windows keeps separate http= and https= entries; a bare ProxyServer applies to both. macOS translates IP literals, *, and a valid *.<domain> glob to Bun’s .<domain> bypass. That glob also bypasses the bare apex <domain>. The exact link-local ranges 169.254/16, 169.254.0.0/16, and fe80::/10 are omitted with a diagnostic: link-local IP literals use the proxy. Other CIDRs, globs, and simple-host exceptions refuse discovery without changing the proxy environment. Disabled, malformed, PAC/WPAD, SOCKS-only, and live changes are not followed. Restart after changing system settings.
noProxy? string | string[] — Hosts that bypass proxy, merged with inherited NO_PROXY and loopback entries. A string may use comma-separated NO_PROXY syntax or ${ENV_VAR}.
emptyCompletionRetry? boolean false Opt in to one identical Responses retry when a turn has no text or tool call, including a stream that ends before a terminal event and a Vertex AI MALFORMED_FUNCTION_CALL failure that arrives before any output. The retry may be billable. OCX_EMPTY_COMPLETION_RETRY=0 disables it without changing config; combo and routed-compaction turns remain excluded.
dropCodexSafetyBuffering? boolean false Remove optional client-facing hints from canonical Codex Responses passthrough: the two x-codex-safety-buffering-enabled / x-codex-safety-buffering-faster-model response headers, response.metadata events whose metadata type is safety_buffering, and top-level safety_buffering fields. Other headers, response data, policy refusals and failures are preserved. This does not disable provider safety enforcement or upstream buffering. Native codex.response.metadata.headers WebSocket metadata and /responses/compact are outside this filter.
stallTimeoutSec? number 300 (public) / disabled (local) Seconds without meaningful upstream progress (Responses and native Chat) before the stream is cut. Unset, a local upstream (loopback, private, or a .local/.lan name) defaults to disabled and a public upstream to 300 s; a positive value applies to both (minimum 1 s); 0 disables the silence watchdog everywhere. Disabled leaves a silent-but-healthy local model connected (keep-alives still flow). Canonical ChatGPT Responses folded from SSE into non-streaming JSON retain a separate 15-minute whole-turn ceiling even when the silence watchdog is disabled. Pending /v1/responses/compact body reads share this budget but default to 300 s even for a local upstream — the route buffers the complete body while holding an active-turn lease — and an explicit value, including 0, still wins.
oauthOpenBrowser? boolean true Whether a login may open a browser on the machine running the proxy. Absent and true both open, so an existing install is unchanged; only an explicit false declines. Decline when you need the authorization link in a different browser profile, or when the dashboard is not on the proxy’s machine — the login still starts and the URL is still returned and displayed. POST /api/oauth/login and POST /api/codex-auth/login accept a per-request openBrowser boolean that overrides this, and the dashboard exposes the same choice beside the login button. Both routes report the outcome as browserLaunch: started, failed (nothing could be opened; the dashboard says so), or skipped. Device-code flows never open a browser either way.
connectTimeoutMs? number 200000 (public) / disabled (local) Per-attempt deadline from dispatch until the upstream returns its first response header (DNS/TCP/TLS and the header wait); it ends before body generation and is unrelated to stallTimeoutSec, which governs the body stream. Unset, a local upstream (loopback, private, or a .local/.lan name) defaults to disabled and a public upstream to 200 s; a positive value applies to both; 0 (or any non-positive value) disables the header deadline everywhere. Disabled is what lets a local model spend minutes in prompt prefill on a large prompt without the first-header clock cutting a healthy turn.
shutdownTimeoutMs? number 5000 Graceful drain deadline before active turns are aborted.
websockets? boolean false Advertise and admit the client-facing Responses WebSocket path. False keeps clients on HTTP/SSE; it does not disable an eligible canonical ChatGPT upstream WS optimization. Complete-input requests may reuse an upstream connection within the same selected credential, account, thread and turn; changed handshake policy or missing identity keeps requests on separate connections. This does not trim HTTP input or create previous-response IDs.
codexNativeSteering? boolean false Experimental, native-only mid-turn steering on the Responses WebSocket endpoint. Requires websockets: true, a compatible upstream/client, and a pinned account/model/tool surface. Validated generation settings can change in explicit saved-result continuations. Does not enable translated models or HTTP fallback. See native steering.
codexNativeInjection? boolean false Experimental saved function-result injection on compatible native multi-agent WebSocket turns. Requires websockets: true, explicit multi_agent.enabled, and an eligible provider. Separate from steering; no automatic tool rerun or recovery create. See native injection.
nativeReasoningRetention? { modelSwitch?: boolean; accountSwitch?: boolean } both false Permit reasoning ciphertext forwarding across a known model or account change on the same canonical ChatGPT backend. Account retention is experimental. See Native reasoning retention.
workflowBudget? { maxConcurrentChildren?: number; maxPhysicalSends?: number; maxDistinctChildren?: number } 8 / 256 / 64 Per-task fan-out ceilings: turns running at once, upstream sends per 10-minute window, and distinct subagent threads per window. Omitted fields keep the built-in value. See Workflow limits.
corsAllowOrigins? string[] [] Additional exact origins allowed by CORS. Loopback origins are always allowed. Authority-based browser extension origins such as chrome-extension://<extension-id> are supported; * is not a wildcard. Firefox and Safari regenerate the extension UUID (per install / per browser launch), so update the entry when the origin changes.
apiKeys? OcxApiKey[] [] Generated ocx_… data-plane admission credentials on non-loopback binds. They do not authorize management APIs; management access uses the separate credential documented in the management reference. Dashboard-managed.
storageCleanupPolicy? StorageCleanupPolicy disabled Opt-in archived-session cleanup policy. Never enabled implicitly.
usageLedgerMaxBytes? number unset Opt-in ceiling in bytes for usage.jsonl. Absent means the request history grows without limit, which stays the default. See usage history size.
appOwnedMemoryBudgetMb? number 256 Cap in MiB for evictable app-owned logs, caches, blobs, and continuation payloads. Range 64–4096; not an RSS cap.
metricsExport.enabled? boolean false Enable process-local aggregate request metrics at authenticated GET /api/metrics. Restart required; disabled mode returns 404 and starts no exporter activity.
serviceHealthRecovery.enabled? boolean false Opt-in recovery for the background service: a small sentinel process probes /healthz and, when the proxy stays alive but answers nothing for about a minute of back-to-back probes, terminates it so the service manager (Task Scheduler, launchd, systemd, WinSW) restarts it. Only the managed service child starts one; requests the stuck process held are not replayed. At most three recoveries per hour. Restart required. Automatic recovery requires a numeric bind address (or the default localhost/wildcard mapping); DNS bind names are not monitored. A changed recorded address or port stops monitoring.
spend? { root?: { maxTokens?: number }; identity?: { maxTokens?: number }; pool?: { maxTokens?: number }; retentionDays?: number } unset Durable token ceilings, off unless you write one. Each scope bounds settled spend plus in-flight reservations plus unresolved spend: root is one task including its whole fan-out, identity is one account across every task it serves, and pool is one provider pool. They intersect, so a request is admitted only when all three have room — which is what holds a ceiling against a client that mints a new task id per request. A reservation is the request’s whole input plus its enforceable output ceiling, counted as if every cached prefix misses. Observe-only mode still journals, so every server owns the state directory’s single-writer lease; an explicit sibling must use a separate OPENCODEX_HOME. Spend survives an ordinary process restart when its writes reached the filesystem, but the journal does not promise survival across host power loss because each append is not fsynced. Raising or removing a ceiling changes the allowance for requests started afterward; requests already running keep their starting policy. maxTokens must be a positive integer (0 would refuse everything), retentionDays is 1–365 and defaults to 7, and an unknown key in this section is rejected rather than ignored. A refusal is a local HTTP 429 carrying x-opencodex-local-refusal: workflow_spend_exhausted, and its message names the scope and the ceiling; no provider is contacted. With an applicable ceiling, dispatch is also refused if its token reservation cannot be booked, including full tracking capacity. Requests without an applicable ceiling remain observe-only.

| codexAutoStart? | boolean | true | Let the Codex shim run ocx ensure before launching Codex. False makes ensure a no-op. | | codexShimAutoRestore? | boolean | true | Restore an installed shim after a completed external Codex update replaces it. Environment opt-out: OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0. | | codexDesktopAuthless? | boolean | false | Opt-in authless Codex Desktop routing on a loopback bind: inject the dedicated opencodex provider with requires_openai_auth = false so Desktop opens without a ChatGPT login. Ignored on non-loopback binds. ocx system settings --desktop-authless on. See Codex integration. | | codexAccountPickerEnabled? | boolean | unset | Whether generated account-qualified catalog rows (<selector>/<model>) are advertised. Enabling it once fills an absent or empty codexAccountNamespaces with default selectors; false hides the generated rows without deleting exact routing bindings. An omitted flag keeps a non-empty hand-written selector map enabled. The Luna Reserve row is account-qualified, so it is written only when a selector targets the main Codex account; when it is suppressed, catalog sync logs the reason and the action that restores it. | | codexAccountNamespaces? | Record<string, string> | unset | Public selector to Codex account mapping. The main login maps to the config-only sentinel @main; added accounts map to their account id. Normally generated by enabling codexAccountPickerEnabled rather than hand-authored. An empty map means no account-qualified rows exist, including the Luna Reserve row. | | codexClientCompaction? | boolean | false | Opt into Codex client-side compaction on an authenticated loopback bind. Uses the dedicated opencodex provider identity with requires_openai_auth = true, preventing new routed compactions from storing OpenCodeX-owned ocx1: state. codexDesktopAuthless takes precedence when both are enabled and keeps requires_openai_auth = false. V2 sub-agent routing is unchanged. ocx system settings --client-compaction on. See Codex integration. | | codexProviderDisplayName? | string | "OpenCodex Proxy" | Label Codex shows for the injected opencodex provider, written as its name field in config.toml and the reference profile. Presentation only: routing resolves through the provider id opencodex, so a rename never moves model_provider = "opencodex" or the [model_providers.opencodex] header and cannot orphan threads already tagged with that id. Codex refuses to load a provider with no name, so there is no way to omit the field — choose a neutral label instead. A blank, over-128-character, or control-character value is ignored and the default label is written. | | codexMainAccountHardLock? | boolean | true | Block new identity-matched main-account requests at their per-window threshold. Only explicit false opts out. See main-account protection. | | codexMainAccountHardLockThresholds? | { short?: number; long?: number } | { short: 90, long: 98 } | Independent 5h and weekly/monthly thresholds. Integers 80–100, with short <= long. Invalid persisted fields fall back to defaults; the settings API rejects invalid writes before mutation. | | codexMainAccountHardLockFallback? | { enabled?: boolean; model?: string; reasoningEffort?: string } | off | Extended hard-lock settings: while the lock blocks and no Codex account can serve the request, qualifying native-Codex-model requests reroute to model with reasoningEffort instead of failing. enabled: false pauses the reroute and keeps the model. An absent section or model keeps the refusal behavior; a usable Pool account still serves first. reasoningEffort must be a declared effort; invalid persisted sections degrade to off and the settings API rejects invalid writes before mutation. See main-account protection. | | nativeMainRefresh? | "codex" \| "opencodex" | auto | Who refreshes the native main ChatGPT login in CODEX_HOME. "codex" asks the Codex binary (app-server getAuthStatus, Codex 0.130.0 or newer, found through the same runtime resolution as ocx sync, including the ChatGPT app bundle) and never writes auth.json; this also works when Codex stores its login in the OS keyring. When the file is absent, native Pool and stored Direct requests consult the current Codex login, including after restart; logout and account changes take effect at the next credential lookup. Each keyring answer is authoritative for that lookup: a missing or withheld token makes that materialization unavailable, and a later answer may use even the same bearer. No permanent keyring refusal is retained; requests already holding a bearer may finish without a send-time recheck. File-backed credential behavior is unchanged. A verified account change clears the previous account’s quota restrictions before selection. On the first lookup after restart, a valid saved quota record must identify a different account before old restrictions are removed. The same account keeps its restrictions, and manual pause settings remain unchanged. Account-card quota reads, startup Pool priming and idle main-quota recovery also use the owned keyring login; passive recovery preserves the traffic reauthentication quarantine. "opencodex" keeps OpenCodex’s guarded auth.json writer. Unset picks "codex" when a supported binary is found; without one, both unset and "codex" fall back to the guarded writer. ocx doctor shows the active mode under Native main refresh. | | resetCreditAutoRedeem? | { enabled?: boolean; leadTimeMinutes?: number } | off | Opt-in: redeem the main Codex account’s soonest-expiring reset credit leadTimeMinutes (1–60, default 10) before it expires. Every attempt re-reads the upstream credit list first and skips when the credit is gone (for example, redeemed by hand); the redeem_request_id is journaled in $OPENCODEX_HOME/reset-credit-auto-redeem.json before the call so a crash replays the same idempotent request instead of spending a second credit. Servers sharing this configuration directory coordinate reservations and settlements so one process does not replace another’s request record. Logs carry a hashed account key only. | | syncResumeHistory? | boolean | true | Reversible Codex App history compatibility. Original metadata is backed up and restored by ocx stop / ocx restore. | | spendPoolAliases? | Record<string, string> | unset | Explicit historical salted pool-alias to canonical provider mappings. Keep this key at the top level, outside spend. See Historical pool continuity. | | shadowCallIntercept? | { enabled?: boolean; model?: string; sourceModels?: string[] } | off | Redirect recognized Codex helper/shadow calls to a chosen model while preserving the request’s configured reasoning effort. The default source prefixes are gpt-6-luna and gpt-5.6-luna. The dashboard exposes one toggle per source and keeps both Terra generations (gpt-6.1-terra and gpt-5.6-terra) and the retired gpt-5.4-mini helper off until selected. At least one source must stay enabled; disable the main switch to stop all shadow calls. | | memoryModels? | { extract?: { model: string; reasoningEffort?: string }; consolidation?: { model: string; reasoningEffort?: string } } | off | Route Codex’s two memory phases to a chosen model, with an optional reasoning effort per phase. See Memory routing. | | webSearchSidecar? | OcxWebSearchSidecarConfig | on when usable | Web-search sidecar options. | | visionSidecar? | OcxVisionSidecarConfig | on when usable | Image-description sidecar options. | | images? | OcxImagesConfig | automatic OpenAI selection | Standalone Images relay options for Codex image_gen. |

On Linux x64/arm64 with pidfd support, service health recovery binds termination to the proxy’s kernel process identity, so PID reuse cannot redirect it. The sentinel’s watching log ends in (pidfd). Unsupported kernels or architectures retain the existing live-parent PID check and log that fallback; other binding failures disable recovery for that proxy. macOS retains the live-parent check, and Windows retains its process handle binding.

The dashboard overview exposes two independent opt-ins: retain reasoning when switching models, and retain it when switching accounts. Both default off. The same policy can be saved in config.json:

{
"nativeReasoningRetention": {
"modelSwitch": true,
"accountSwitch": false
}
}

This example retains reasoning encrypted_content on a known model switch within the same native ChatGPT destination and credential. Set accountSwitch to true only to opt into experimental cross-account forwarding. When both model and account change, both switches must be enabled. The policy applies to each actual native Combo target and pool retry; it does not grant retention to third-party or API-key destinations.

Retention means that OpenCodex may forward the carried ciphertext. It cannot confirm that the upstream accepts or reuses that reasoning. A detected decrypt rejection still enters the existing bounded cleanup recovery, and the rejection memo takes precedence over either switch. Response and conversation references, foreign reasoning item ids, uploaded-file account restrictions and cache isolation continue using their own ownership rules. Native compaction blocks keep their existing cleanup behavior; direct native /v1/responses/compact sanitization is unchanged.

Use GET /api/native-reasoning-retention to read the resolved booleans and PUT with a partial boolean object to update them. PUT with JSON null restores both defaults. Invalid fields or unknown keys are rejected; a malformed file block disables retention with a warning. Dashboard/API changes apply to subsequent requests. Local ocx config set/unset edits require a restart to converge the running proxy.

OpenCodex bounds how much one task may fan out. A task is the root conversation, identified by the client’s x-codex-parent-thread-id header, together with every subagent it starts. Three count ceilings apply to each task, and requests over any of them are refused locally with HTTP 429 and an x-opencodex-local-refusal header naming the ceiling (workflow_concurrency_exhausted, workflow_sends_exhausted or workflow_children_exhausted); no provider is contacted.

Field Default Allowed Meaning
maxConcurrentChildren 8 2–64 Turns one task may run at the same time. One slot is always reserved for the task’s own conversation, so subagents may use one fewer.
maxPhysicalSends 256 1–10000 Upstream sends, retries included, after which the task’s new requests are refused for the rest of the 10-minute window. The count is checked when a request is admitted, so retries inside a request that was already admitted, and requests admitted concurrently, can take the total past the ceiling.
maxDistinctChildren 64 1–1024 Distinct subagent threads one task may start inside the same window.

The defaults suit most installs. Raise them when a workflow that legitimately runs many subagents in parallel keeps hitting a ceiling; lower them to stop a runaway fan-out sooner:

{
"workflowBudget": {
"maxConcurrentChildren": 16,
"maxPhysicalSends": 1024
}
}

The Subagents page of the dashboard shows the effective ceilings and edits them. GET /api/workflow-budget/settings returns the effective values with their defaults and allowed ranges; PUT with a partial object updates them, and JSON null restores the defaults. Dashboard/API changes apply to the next request without a restart and never reset what a task has already used; POST /api/workflow-budget/clear remains the way to forgive one task. A malformed file block is ignored with a warning and the built-in ceilings stay in force. Local ocx config set/unset edits require a restart to converge the running proxy. The 10-minute window, the reserved interactive slot and the 512 tracked tasks are not configurable.

While the canonical ChatGPT upstream WebSocket waits for the first Responses event after sending the create frame, it watches for liveness rather than a fixed deadline. When the socket supports protocol pings, the proxy pings it every 15 seconds. Any inbound frame — quota, response metadata, or a pong — resets a 90-second silence clock, so a socket without ping support still stays alive on its own frames, and only 90 seconds with nothing at all settles the request as an HTTP 504 with an upstream_no_response error. A slow but alive origin therefore waits for the client’s own deadline or for connectTimeoutMs (default 200s for a public upstream, disabled for a local one unless set), whichever comes first; a connect timeout that fires after the create frame was sent settles as the same 504. A socket that closes or errors before the first Responses event settles as an HTTP 502 with upstream_closed_before_response. The proxy does not retry either status on its own: the frame may already be executing upstream, so the client applies its own retry policy exactly as it would when connected to the backend directly. The one exception is that 502 on a provider that opted into retryOnReset, described below. Once the response has started, a later drop surfaces inside the stream as before. stallTimeoutSec is unrelated to this window.

An ordinary HTTP send has a third case. When the connection dies before any response header arrives, the proxy cannot tell whether the model already processed the request, so it refuses to send it again and answers HTTP 429 with upstream_reset_replay_refused. The status is deliberate: a 5xx here is an instruction to most clients, including Codex, to send the whole turn again, which is the duplicate the refusal exists to prevent. No Retry-After is attached, and the response carries x-should-retry: false, which the official OpenAI and Anthropic SDKs read before their own status rules — without it those clients retry a 429 on their own schedule and resend the turn anyway. The same replay-refusal behavior applies on /v1/responses and /v1/chat/completions, whether the request is forwarded natively or translated. Routed /v1/messages requests translate through Responses and project the same status, code, and retry headers into an Anthropic-shaped error envelope. The proxy performs no key rotation, account failover or same-target replay on it, nor does it record the refusal as rate-limit or quota evidence against the credential it was holding. Tool-call side requests such as vision and web search are replayed normally, because repeating them cannot duplicate a turn.

Native ChatGPT Responses and compact HTTP requests whose serialized JSON strings are at least 1 MiB in UTF-8 use byte-buffer uploads to avoid Bun resetting a large string upload before response headers arrive. This preserves the request contents and does not enable automatic retries. A genuine connection reset still follows the replay-refusal policy above.

A native Responses provider can opt into replacing that send with retryOnReset. The same grant covers the case where the connection survives the header and the SSE body then dies carrying only control events, because the caller has observed nothing in either one. It also covers the canonical ChatGPT WebSocket above: a socket that closes or errors before the first Responses event is replaced by one HTTP send, never by a second socket. A silent socket keeps its 504, and a native steering or injection turn is never replaced. A replacement happens only when the request is self-contained (store: false, complete input, client-executed tools only, no server-side continuation state), and one logical request gets the configured number of replacements in total — across every recovery leg and every combo child, not one each. The refusal returns as soon as that grant is spent, the leg has no send left, or a replacement fails for any other reason. A request that already emitted output or a tool call keeps the refusal regardless. A caller that cancels mid-replacement gets the cancellation, not the refusal.

For WebSocket recovery, “before the first Responses event” is stricter than “before the first text”: even response.created, a tool event or a usage-bearing response closes the replacement window. Ping/pong and quota metadata alone do not. A later socket close cannot turn an already settled cancellation or connect/silence timeout into an HTTP retry.

noProxy accepts either a comma-separated string or an array. Both forms add entries without replacing an inherited NO_PROXY:

{ "proxy": "http://proxy.corp:8080", "noProxy": "internal.example,10.0.0.0/8" }
{ "proxy": "http://proxy.corp:8080", "noProxy": ["internal.example", "10.0.0.0/8"] }

SOCKS5 (Clash mixed-port listeners included) belongs on ALL_PROXY, not HTTP_PROXY:

{ "proxy": "socks5://127.0.0.1:10808" }

ocx start --socks5 writes that value; ocx start --socks5-off clears it.

HTTPS requests through SOCKS5 have a separate 30-second TLS handshake deadline. A stalled handshake fails without disabling certificate verification; caller cancellation can stop it sooner.

If an older development build changed resume-history metadata before backup support existed, run ocx recover-history --legacy-openai --yes to force native-provider recovery. It force-relabels every user-message opencodex row, including legitimate dedicated-provider history; review the full-scope warning in the lifecycle reference before running it.

Native Chat also uses stallTimeoutSec while waiting for upstream output, with the same local-vs-public default: an unset budget is disabled for a local upstream and 300 s for a public one, and 0 disables it everywhere. Nonempty text, reasoning, refusal, tool updates, and finish frames renew the allowance; keepalive comments, role-only frames, and usage alone do not. Waiting for a slow client to read pauses the allowance. A stall produces upstream_stall_timeout: an error frame for streaming clients, or HTTP 502 for non-streaming clients. Cancellation before a terminal result returns a cancellation error instead of a successful partial answer. Buffered Chat results accept both LF and CRLF SSE framing, including multiline data.

The main Codex account row may include quotaRefresh when a quota fetch was attempted. This describes that fetch, not remaining quota, model access or permission to retry. Cached reads and rows without a fetch may omit it; absence does not mean success. A null quota value means unavailable, not zero quota.

To request fresh data and display only the diagnostic in PowerShell:

Terminal window
$quotaReport = ocx account list openai --quota --refresh --json | ConvertFrom-Json
$quotaReport.accounts |
ForEach-Object { if ($_.quotaRefresh) { $_.quotaRefresh } } |
ConvertTo-Json -Depth 3

If no diagnostic is present, this projection produces no diagnostic object. Share only these fields when comparing network modes, rather than the full account list.

quotaRefresh.status Meaning
ok The fetch completed and a quota object was parsed.
not_reported The response contained no usable quota object.
http_error The upstream returned an HTTP failure; httpStatus contains its status code.
timeout The quota fetch timed out.
network_error The request failed before a classified HTTP response.
invalid_response The response was not a usable quota document.
internal_error An internal refresh step failed.

Only http_error includes httpStatus. Other statuses do not imply HTTP 0 or an account entitlement problem. An http_error may also include code when the provider named a reason that only a new sign-in fixes (token_invalidated, invalid_refresh_token, invalid_workspace_selected). A token_invalidated response indicates a revoked session; the row then shows needsReauth: true with the last-known plan.

If the token endpoint rejects the stored main refresh grant, automatic requests stop retrying that same grant even if a usage refresh succeeds. Sign in again with codex login to replace the credential. The process keeps up to 64 profile-and-grant refusal records; restarting the proxy or evicting an old record permits a fresh check. Rate-limit and server failures remain retryable and do not retire the grant. Credentials supplied by the caller remain caller-owned.

The running proxy service fetches quota. It uses its own environment, not the interactive shell that later runs ocx account list. Configure the service’s proxy setting or environment, then restart it; changing variables in another terminal does not update an already running service.

An unset proxy leaves inherited proxy variables unchanged. An explicit HTTP(S) proxy URL fills HTTP_PROXY and HTTPS_PROXY only where they are unset. "proxy": "auto" reads Windows static WinINET or macOS static HTTP/HTTPS settings once at startup. Existing proxy environment variables take precedence; macOS discovery also skips inherited ALL_PROXY/all_proxy. A macOS *.<domain> exception becomes .<domain>: foo.local bypasses for *.local, xlocal does not, and the bare local apex also bypasses. Exact link-local CIDRs are dropped with a warning, so link-local IP literals use the proxy. Other unrepresentable exceptions refuse discovery without changing proxy variables. Auto discovery does not resolve PAC/WPAD, SOCKS-only settings or live proxy changes. Use a supported static HTTP proxy setting or an explicit HTTP(S) proxy URL when needed.

Compare the diagnostic on the same machine and account under the two network modes. A successful TUN test alone does not identify why the service’s HTTP proxy path failed, and does not establish a general fix.

Connection reuse for specific upstream hosts

Section titled “Connection reuse for specific upstream hosts”

Some upstreams keep a connection open after they have stopped serving it. The next request reuses that pooled socket and fails without reaching the provider. OCX_FRESH_CONNECTION_HOSTS names the hosts that should never reuse a pooled connection, as an environment variable rather than a config field, so it can be applied to one machine without editing shared configuration:

Terminal window
OCX_FRESH_CONNECTION_HOSTS="api.example.com, relay.example.net" ocx start

The value is a comma-separated list of hostnames. Matching is case-insensitive, covers each named host and its subdomains, and ignores leading dots, so .example.com and example.com both match api.example.com. Do not include a scheme, port or path. An unset or empty variable leaves the default connection behavior unchanged.

A matching send carries Connection: close and is dispatched with keep-alive disabled. The decision is made against the address actually used on the wire, so it still applies when a provider transport rewrites the destination after credential selection. Per-request latency rises slightly for those hosts, since each request pays a fresh TCP and TLS handshake; name only the hosts that need it.

The default 127.0.0.1 bind is loopback-only. A non-loopback address such as 0.0.0.0 or a tailnet IP requires token authentication on both /api/* and the data plane.

You do not have to produce that token. ocx service install provisions one on a non-loopback bind, in this order: OPENCODEX_API_AUTH_TOKEN from the installing shell, then an existing owner-only service-api-token file, then 32 fresh random bytes. The result is written 0600 and the launch wrapper (launchd plist, systemd unit, Windows wrapper) reads the file at start, so the value never enters a service definition or argv. A foreground ocx start applies the same precedence — environment, then OCX_API_TOKEN_FILE, then the installed service-api-token — so it binds a non-loopback hostname without an exported token too.

A management admin token is refused in either place it can appear — the environment variable or a reused service-api-token file — and the message names the remedy for that place: unset the variable, or delete the file and run ocx service repair. Both checks run before the loopback short-circuit, because the launch wrapper reads the file into OPENCODEX_API_AUTH_TOKEN whatever the hostname, so an admin-token file fences the management API closed even on a loopback bind. ocx status reports that state as admin-collision (file) on a hub.

Setting the variable yourself is still supported for an operator who wants to own the value:

Terminal window
export OPENCODEX_API_AUTH_TOKEN="your-secret-token"
ocx start

Clients should send:

x-opencodex-api-key: your-secret-token
Endpoint Authorization: Bearer x-opencodex-api-key x-api-key
/v1/responses not accepted required not accepted
/v1/chat/completions not accepted required not accepted
/v1/messages accepted accepted accepted
/v1/messages/count_tokens accepted accepted accepted
/v1/models accepted accepted accepted

Responses and Chat Completions reserve Authorization for possible Codex Direct passthrough, so only the dedicated admission header is accepted there. Dashboard-generated apiKeys may replace the environment token after startup; candidates are compared in constant time.

Messages and count_tokens keep accepting all three admission forms for routed-client compatibility. Native Anthropic passthrough is stricter on a non-loopback bind: proxy admission must use x-opencodex-api-key, while Authorization and x-api-key are reserved for Anthropic credentials. Any proxy admission secret placed in those provider headers is removed before forwarding.

Local clients that cannot receive the token

Section titled “Local clients that cannot receive the token”

A remote bind requires a credential from every caller, including local ones. That breaks a specific case: a codex app-server launched by a host process that resolves the Codex entrypoint directly (require.resolve('@openai/codex/bin/codex.js')) never passes through the generated codex shim, so it never inherits OPENCODEX_API_AUTH_TOKEN and every model call fails with 401 before a stream opens.

unauthenticatedLoopbackListener opens a second listener bound to 127.0.0.1 that admits without a credential. The main listener is untouched — remote callers still need the token.

{
"hostname": "0.0.0.0",
"port": 10100,
"unauthenticatedLoopbackListener": { "enabled": true, "port": 10200 }
}

ocx sync then writes base_url = "http://127.0.0.1:10200/v1" into the managed Codex provider block and omits the auth header, so a directly spawned app-server works without any credential plumbing.

When you set port, it must differ from the proxy port. It is never OS-assigned: an ephemeral port would change across restarts while already-running app-servers kept the previous base_url.

Omitting port selects the companion form — the listener binds the proxy port on 127.0.0.1:

{
"hostname": "100.76.170.81",
"port": 10100,
"unauthenticatedLoopbackListener": { "enabled": true }
}

Remote clients dial 100.76.170.81:10100 with a credential; local processes dial 127.0.0.1:10100 without one. That is the address every local integration already writes, so ocx claude, Claude Desktop, Cursor and the system-env injection keep working on a host whose public bind they cannot reach. The companion form is accepted only when hostname is a specific non-loopback, non-wildcard address: on 127.0.0.1, localhost or 0.0.0.0 the public listener already holds that loopback address, so OpenCodex refuses the pair at write time and at startup rather than failing the second bind. On those binds you do not need the listener at all — a loopback bind already admits local callers.

With a port set, the local integrations follow the listener: ocx claude, the system-env injection, the Claude Desktop profile, the Cursor gateway value and the routed vision helper all write http://127.0.0.1:<listener port>, the same port ocx sync writes into Codex. In the companion form those same integrations keep writing the proxy port, which is where the companion socket is.

Restart the proxy after changing this field, in either form. The sockets are bound once at startup and the exported client values are written from the resolved port, so a running proxy keeps its previous answer — on a ported listener that is the difference between a served request and a 404 from the listener.

On a runtimeRole: "hub", this field is also the gate on whether the hub rewrites its own local client configuration. With the listener off, ocx sync, ocx ensure and ocx restore back skip the hub’s own Codex/Grok/Claude writes and say so, naming unauthenticatedLoopbackListener rather than the clientIntegrations toggle.

The listener serves only POST /v1/responses, its WebSocket upgrade, POST /v1/responses/compact, POST /v1/messages (the Anthropic wire Claude Code and Claude Desktop speak), POST /v1/chat/completions (the OpenAI chat wire Cursor and the vision helper speak), POST /v1/alpha/search (the native Codex web-search relay), GET /v1/models, and the realtime voice surface: the standalone WebSocket upgrades, WebRTC call creation (POST /v1/live, POST /v1/realtime/calls), and the keyed sideband join upgrades (/v1/live/{callId}, /v1/realtime/calls/{callId}, /v1/realtime?call_id=). Everything else, including /api/*, /healthz, /readyz and the dashboard, returns 404 — local management reads such as ocx claude’s discovery call go to the authenticated management surface with a management credential, never here.

Remote use does not require a remote bind. Keep loopback and forward it:

Terminal window
ssh -L 20100:localhost:10100 you@remote

Any local port works. Requests whose Host resolves to localhost, 127.0.0.1, or ::1 remain loopback regardless of port, so http://localhost:20100/v1 works. Set that base URL in the client; ocx writes only the default local 127.0.0.1 address into managed client config.

Provider OAuth callbacks listen on a fixed remote port. Log in on the remote machine or forward that port too:

Terminal window
ssh -L 20100:localhost:10100 -L 1455:localhost:1455 you@remote

If a registered callback port is already in use and the login surface offers manual input, OpenCodex keeps the registered redirect URI and still returns the provider authorization URL. Complete the provider login, then paste the final redirect URL from the browser address bar or the authorization code into OpenCodex. The pending flow preserves state and PKCE validation. Callers without manual input still fail closed.

Stored account emails are masked everywhere they leave the proxy — the dashboard account lists, GET /api/codex-auth/accounts, GET /api/oauth/status, and the ocx status logins section all show p***n@example.com rather than the address on file.

Field Type Default Meaning
privacy.maskEmails? boolean true Set to false to show stored account emails in full. Absent, true, and any malformed value all keep masking, so only a deliberate false reveals an address.

Turn it off when you run many accounts on a machine you control and cannot tell them apart from masked forms. Read the flag as a disclosure decision rather than a display preference: management is not always loopback, so on a hub configured with remoteGui an unmasked address reaches every management principal that can reach the hub, not only someone sitting at the machine. The flag moves only the email field — tokens, refresh tokens, and account identifiers stay redacted either way.

{ "privacy": { "maskEmails": false } }

ocx config set privacy.maskEmails false fails with config parent path not found until the block exists, because config set walks into existing objects and never creates them. Write the whole object instead — ocx config set privacy '{"maskEmails":false}' — or add the block to config.json by hand.

storageCleanupPolicy is disabled by default. When enabled, it runs on startup, daily, weekly, or manual after archived bytes exceed trigger.archivedBytesOver. It selects oldest archives toward either target.reduceToBytes or target.removeOldestPercent. mode defaults to quarantine; use permanent only as an explicit destructive choice. The policy persists lastRun and nextRun. Configure it on the Storage page or with GET/PUT /api/storage/cleanup-policy; trigger a manual run with POST /api/storage/cleanup-policy/run.

usageLedgerMaxBytes is unset by default, and unset means the request history in usage.jsonl grows without limit. Nothing deletes history you did not ask to have deleted.

Set it to a byte ceiling and the proxy trims the file after an append crosses it, keeping the newest whole rows and dropping the oldest. It trims a little below the ceiling rather than exactly to it, so the next append does not immediately re-cross the line. The minimum accepted value is 1 MiB; a smaller number, or one that is not a safe integer, leaves the limit off rather than failing the configuration.

Rows are copied byte for byte and never rewritten, so every field survives a trim — including fields a newer build wrote that an older one does not understand. The replacement is refused outright if anything appended to the ledger while it ran, so a request logged during a trim is never lost; the next append tries again. Trimming also refreshes what the dashboard shows, so /api/logs stops serving rows the ledger no longer has.

There is no dashboard control for this yet; set it in config.json or with ocx config set usageLedgerMaxBytes <bytes>.

Enabled by default when OpenCodex manages your local Codex client: the section or enabled may be absent. The proxy refreshes its model catalog every 60 minutes, with one initial refresh about three minutes after startup. When the Codex integration is turned off, or the instance runs as a hub or beside another live proxy, background refresh runs only with an explicit "enabled": true, and it never reads Codex sources.

{
"catalogAutoRefresh": { "enabled": true, "intervalMinutes": 60 }
}
Field Default Meaning
enabled true Set to false to disable automatic refresh.
intervalMinutes 60 Refresh cadence; positive values below 15 are clamped to 15. 0 disables refresh.

Each refresh observes the selected Codex runtime’s bundled catalog, then warms authenticated Codex model rosters before converging the served list. Source failures use existing evidence and retry on a later tick. Cadence and enablement edits take effect on a subsequent tick.

A native OpenAI model that your ChatGPT account’s Codex roster lists, but that this OpenCodex release does not know yet, is added with the metadata upstream publishes for it (name, reasoning levels, context window). OpenCodex remembers such models in discovered-native-models.json in its home directory and forgets one that has not been seen for 14 days. A later release that ships the model takes over its row.

Running Codex sessions retain an in-memory model list. When the served set changes while Codex app-servers are running, the proxy logs a restart hint and records reloadRequired in its auto-refresh status. Run ocx sync --restart-codex when ready to restart those sessions. Automatic refresh never restarts them.

When a background refresh changes the served set, OpenCodex also updates client integrations it has already written: an existing Grok Build block, the Claude Desktop gateway profile it applied (only while that profile is still selected and unedited, keeping its static, hybrid or discovery mode), and the owned blocks of file integrations such as OpenCode, Kilo, Pi, omo and Aside. It never connects a client for the first time, never refreshes Cline (stop Cline and run ocx sync instead), and skips local clients on a hub without the loopback listener. A client it could not refresh is counted in the proxy log; ocx sync retries it.

Quota-reset notifications (quotaResetNotify)

Section titled “Quota-reset notifications (quotaResetNotify)”

Off by default. When the section is absent, no detection runs, no timer starts, and no state file is written.

Enable it to be told when a usage window resets — both the scheduled rollover you can predict and an out-of-band reset you cannot:

{
"quotaResetNotify": {
"enabled": true,
"webhookUrl": "https://hooks.slack.com/services/...",
"kinds": ["scheduled", "surprise"],
"pollSeconds": 900
}
}
Field Default Meaning
enabled false Master switch. Also requires at least one sink, below.
kinds both scheduled (the deadline passed) or surprise (quota returned early).
pollSeconds 900 Idle poll interval; floor 600. 0 observes live traffic only.
webhookUrl — https POST target for the event JSON. Treated as a secret.
allowPrivateNetwork false Permit a loopback or private-network webhook target.
timeoutMs 5000 Webhook timeout.
command — Argv array run with the event JSON on stdin.

enabled: true with neither webhookUrl nor command resolves to off: an enabled subsystem with nowhere to deliver is a misconfiguration, not a half-on state.

The floor is 600 seconds because a faster poll cannot see anything new: observation is bounded by the 10-minute per-account cache, so a shorter interval only adds load to a quota endpoint that rate-limits. A configured value is adopted on the next tick without a restart.

Set pollSeconds to 0 only if you accept that a reset happening while the proxy is idle is noticed on the next request rather than when it happens. The poll exists because the overnight case is the one worth knowing about.

{
"type": "quota_reset",
"kind": "surprise",
"scope": "codex",
"accountTag": "k3f9x2ab",
"window": "weekly",
"percentBefore": 96,
"percentAfter": 4,
"previousResetAt": 1772000000000,
"resetAt": 1772400000000,
"detectedAt": 1771900000000
}

accountTag is a per-install salted hash, not an account identifier: it distinguishes your accounts from each other without telling the receiver who they are. No email, token, path, or URL is ever included.

detectedAt is when the proxy NOTICED, not when the reset happened. Observation is bounded by the 5-minute provider cache and the 10-minute per-account cache, so the reset instant can only be bracketed between two observations.

webhookUrl is a credential — for Slack and Discord, holding the URL is sufficient to post — so it is redacted by ocx config show and excluded from ocx config export.

A webhook target that resolves to a private or loopback address is refused unless you set allowPrivateNetwork: true. The proxy can reach hosts your browser cannot, including cloud metadata endpoints, so the default assumes an external receiver.

webhookUrl must use https. The payload and the URL itself are both sensitive, and an http target would put them in cleartext; an http value is rejected when the config is written rather than downgraded silently.

A redirect is refused rather than followed. The destination check above validates the URL you configured, so following a 3xx would deliver the payload somewhere unvalidated — a public endpoint could bounce the POST to loopback or a metadata address. Configure the final URL directly; a redirected delivery reports blocked-destination.

command is an argv array and is never passed through a shell, so its values cannot become a shell-injection surface. Delivery is attempted once; there is no retry.

Read recent detections with ocx provider resets or GET /api/quota-resets.

Responses (/v1/responses) and Chat Completions (/v1/chat/completions) are always served. The Messages API (/v1/messages and /v1/messages/count_tokens) can be closed on its own.

Key Type Default Description
apiSurfaces.messages.enabled? boolean inherit true serves the Messages API, false refuses both routes with 403. Unset inherits claudeCode.enabled, so a Claude integration that is off also closes Messages.

A present but malformed value (a non-object apiSurfaces or messages, or a non-boolean enabled) closes the Messages API rather than falling back to the inherited value. Both routes always agree.

The dashboard’s API page shows one card per API with the setting’s source (explicit, inherited from Claude settings, or invalid) and a toggle for Messages. Turning Messages off there writes apiSurfaces.messages.enabled: false and claudeCode.enabled: false in the same save, so a proxy version older than this setting, which only reads claudeCode.enabled, keeps the endpoint closed after a downgrade. Turning it on writes only apiSurfaces.messages.enabled: true; an older version then still follows claudeCode.enabled and may keep Messages closed, which is the safe direction.

How a Chat Completions or Messages request may reach a provider. Every value defaults to the behavior before these keys existed; see Protocol paths for the delivery modes, the preview, and the per-request trace.

Key Type Default Description
protocols.unrepresentable? "legacy" | "reject" "legacy" legacy sends a request whose path drops a feature and records the loss in the trace. reject refuses it with HTTP 400 before any send, naming only the feature keys.
protocols.rollout.nativeChatCombos? boolean false Send an eligible Chat candidate inside a combo natively from its own copy of the client body.
protocols.rollout.managedMessagesNative? boolean false Send Messages natively to a direct, key-authenticated Anthropic provider instead of through the internal Responses bridge.
protocols.rollout.managedMessagesNativeOAuth? boolean false Native Messages for the anthropic OAuth provider on api.anthropic.com, including stored account pools. Requires managedMessagesNative. For the settled anthropic provider, an enabled pool supplies true for absent native flags when anthropicAccountPool.nativeMessages is absent or true. Explicit false/malformed flags and a false/malformed pool preference remain off. Other providers and pool-off keep explicit rollout behavior. Eligible pooled requests retain shared selection, affinity and bounded pre-output recovery. Recognized JSON-string metadata.user_id account UUIDs are aligned with the serving OAuth credential; device and session fields are preserved.
protocols.rollout.directEncoders? boolean false Encode Chat and Messages answers from a non-Responses upstream directly from adapter events.
protocols.rollout.antigravityDirect? boolean true Send a Chat or Messages request to a single Google Antigravity route without the internal Responses body in either direction. Request settings still pass through the usual policies, and a request the lane cannot carry uses the bridge. Set false to turn it off.
protocols.rollout.shadowPlan? boolean false Compare each Chat or Messages request’s path with the plan a preview predicts and mark a disagreement as planMismatch on its log row. Sends nothing extra.

Malformed protocol blocks retain a conservative disabled native policy when loaded from disk. Valid absent native flags may inherit eligible Anthropic pool defaults; explicit false or malformed flags stay off. Validated writes reject malformed input.

{
"protocols": {
"unrepresentable": "legacy",
"rollout": { "shadowPlan": true }
}
}

ocx api policy shows the resolved values and changes them through the running proxy (--unrepresentable <legacy|reject>, --rollout <switch>=<on|off>, --messages <on|off> for apiSurfaces). It writes only when a setting flag is given. The dashboard’s API page and PATCH /api/protocols/settings use the same validation.

These settings govern /v1/messages, /v1/messages/count_tokens, the ocx claude launcher, and the Claude dashboard page. Whether the Messages API is served at all is decided by apiSurfaces, which inherits claudeCode.enabled while unset.

Key Type Default Description
claudeCode.bodyStallSec? number 90 Native-passthrough body inactivity budget in seconds while a read is pending, not total duration. Minimum 1; exactly 0 disables.
claudeCode.bodyMaxBytes? number 67108864 Cumulative native-passthrough body cap for streamed and buffered responses. Exactly 0 disables.
claudeCode.compatibility? "shadow" | "enforce" unset Optional compatibility admission for translated /v1/messages requests. shadow records unsupported features and continues; enforce returns an Anthropic-shaped 400 before inference. Native Anthropic passthrough remains unchanged.
claudeCode.authMode? "proxy" | "subscription" auto How launch handles ANTHROPIC_AUTH_TOKEN. Auto detects auth each launch; an explicit value is never overridden.
claudeCode.authModeMigratedAt? string unset Internal one-time upgrade marker. Do not set manually.
claudeCode.classifierModel? string unset Explicit target for Claude Code Auto Mode classifier turns, as a qualified provider/model (for example RelayA/claude-opus-5). Auto Mode sends bare safety checks such as claude-opus-5 with no provider, so without this they fall through to defaultProvider — which may not speak Anthropic at all. Nothing is inferred automatically: only a target you declare here is used.
claudeCode.classifierFallbacks? string[] unset Ordered classifier targets used when classifierModel is not set. Same qualified provider/model form; the first usable entry wins. An explicit modelMap entry for the classifier model still outranks both.
claudeCode.desktopModelVisibility? { mode: "include" | "exclude"; routes: string[] } unset Which provider/model routes the Claude Desktop gateway list carries. include lists only routes (new models stay hidden); exclude lists all but routes (new models appear). Unset lists every available model. If the selection leaves no available models, the writer falls back to every available model, including normally hidden routes; update the selection to narrow it again. Affects only Claude Desktop’s static and hybrid lists; set it from Connect → Claude Desktop.
claudeCode.subagentEffort? "low" | "medium" | "high" | "xhigh" | "max" inherit Effort written to generated ~/.claude/agents/ocx-*.md; separate from Codex guidance and proxy caps. Restart through ocx claude to regenerate.

The compatibility policy applies to Claude Code, Desktop and other clients using translated Messages, including ?beta=true and non-streaming requests. Every translated target uses the same conservative policy, including Anthropic and native Responses adapters. It rejects document content, thinking/redacted-thinking replay, hosted search and execution tools, tool-search references, active deferred loading, strict tools, non-default caller modes, structured-output formats, explicit service-tier intent, MCP connector features, context management, containers, inference placement and unsupported protocol fields or blocks.

Unset preserves legacy translation. Cache hints, tool input examples and ordinary thinking/effort settings are deliberately admitted with possible degradation: this setting does not guarantee cache breakpoints or TTL, retained examples, exact thinking budgets or lossless translation. Beta headers alone are not validated for feature support. Shadow evidence contains only fixed protocol codes and derived reasons, retained in request logs and usage.jsonl and restored on restart. An invalid non-unset mode returns a fixed 503 configuration error on translated Messages. Configure the value in config.json and restart the proxy to load it; there is no dedicated GUI setter. Count-tokens and direct Responses/Chat APIs are outside this policy; successful token counting does not imply Messages admission. This setting does not add a global authorization boundary.

Auto auth selects subscription when stored Claude auth is found, proxy when none is found, and subscription with a warning when detection is inconclusive. See Claude Code auth mode.

For translated providers and routed Responses gateways, OpenCodex automatically frames plain-text user requests before the latest readable Codex checkpoint as historical context. Requests the checkpoint reports as completed should not be repeated; retained text remains available for unfinished work within its existing authorization. Later user input takes priority, including pauses, cancellations and changes of scope. A checkpoint grants no new permission.

This is enabled by default and needs no setting. Original request text and canonical thread/replay history are preserved. Native OpenAI Responses passthrough, summarizer turns, context wrappers, images/files and assistant/tool items are unchanged. The framing applies to plaintext checkpoints beginning with Codex’s summary prefix; decoded OpenCodex checkpoints retain their existing continuation guidance. Client-side summarizer turns using Codex’s standard compaction prompt also bypass framing. Framing is a prompt instruction, so model behavior can vary.

In Dashboard → Overview → Compaction routing, choose a model, which triggers it applies to, an optional reasoning effort, and whether the override covers all source models or only selected sources. For selected sources, check individual models or provider-wide provider/* entries, then click Save. Select Use conversation model and save to remove the override. Changes apply to the next compaction request without restarting the proxy.

Set compactionRouting in OpenCodex config.json to override the model Codex’s compaction requests use. The setting is disabled when omitted.

{
"compactionRouting": {
"model": "provider/model-id",
"reasoningEffort": "low",
"triggers": ["manual"]
}
}

model accepts native model IDs, provider-qualified model IDs, and configured combos. Optional sourceModels limits the override to exact incoming model IDs or provider/* patterns, for example ["kimi/*", "google-antigravity/*"]. Matching is case-sensitive. Omit it for the existing all-model behavior; empty, duplicate, or malformed lists are invalid. The Dashboard source-scope picker can edit the allowlist and preserves saved selectors absent from the current catalog. The model checklist shows at most 300 matching entries; use its search field to narrow large catalogs without removing saved selections. You can also edit it through config.json or PUT /api/settings. This checks the incoming compaction model, not an inferred conversation model. If Codex sends a bare native compaction model for a routed thread, it does not match those provider patterns and keeps its existing route. This conservative behavior protects GPT but can leave some automatic compactions on the native path. Ordinary non-compaction turns are never changed.

reasoningEffort is optional; omit it to preserve the incoming effort. Supported declarations are none, minimal, low, medium, high, xhigh, max, and ultra. Existing provider effort rules still apply. The native /responses/compact endpoint keeps its existing behavior and does not forward reasoning settings.

triggers names which compaction requests the override covers, using Codex’s own compaction.trigger values: "manual" for a /compact you typed, "auto" for the automatic compaction Codex runs when a thread approaches its context limit. Omit triggers and the override applies to manual /compact only, leaving automatic compaction exactly where it routes today. Use ["auto"] or ["manual", "auto"] to route automatic compaction as well.

Routing automatic compaction is what lets a long thread on a routed provider keep going when the canonical OpenAI quota is exhausted. Codex picks a bare native model for the compaction turn, and OpenCodex reserves that for an enabled canonical openai provider whenever one exists, so the compaction fails with a quota error before the routed turn begins even though the thread itself runs elsewhere. Naming "auto" here points the compaction at a provider-qualified model with its own credentials and quota only when sourceModels is omitted or matches the incoming model. A list containing only provider/* entries does not cover a bare native compaction model.

OpenCodex changes only requests with explicit request_kind: "compaction" metadata whose compaction.trigger is one of the values you listed, sent to /v1/responses/compact or to /v1/responses with a compaction_trigger input item. Later conversation turns keep their original routing and settings. Missing, malformed, or conflicting metadata does not activate the override, including on older clients without trigger metadata; when several copies of the metadata are supplied they must name the same trigger. WebSocket requests use each frame’s metadata rather than the connection’s earlier handshake metadata.

The selected model’s provider receives the entire conversation for summarization, including conversations that normally run on another provider. A combo selects a target by its routing strategy and may retry the same conversation on another target after a retryable failure, so one or more targets can receive it. The dashboard panel states this next to the model picker and names the destination provider, or the combo’s target providers, once a model is chosen. When the override covers automatic compaction, that transfer happens without you asking for it, at whatever point Codex decides to compact; the dashboard panel says so as well. The override reuses the existing compaction handlers and summary formats. When the selected model shares the conversation model’s provider and account-routing identity (provider name, Codex account mode, and account namespace), the request keeps the caller’s credential and may use that backend’s native compact endpoint. Otherwise, including when either side is a combo or the conversation model is remembered as a combo target, OpenCodex runs the portable summarizer instead, so the summary stays readable when the conversation resumes on its own model, and the caller’s credential does not cross to the other provider. The selected model must support the input size and content. For portable Responses summaries, hosted web_search_call history items become labeled reference notes from the assistant, because the summary request has no tools. Known status, search queries, visited URLs, find patterns and source metadata are preserved, with long values and lists cut short and at most 64 KiB of notes per summary request; opaque state and unknown or malformed fields are omitted. These notes describe past search actions, not fetched page content or new instructions. Existing answer text, citations and ordinary tool results remain available. Lite requests retain parallel_tool_calls=false, which the upstream requires even without tool declarations. This changes only the summary request, not stored history or native compaction; a failed summary does not replace the history. Restart the proxy after editing config.json by hand. Dashboard saves apply immediately.

Readable reasoning retention is an advanced, explicit opt-in and is off by default. Only reasoningRetention.enabled: true enables it; an omitted block, enabled: false, or legacy limits without enabled: true leave ordinary compaction behavior unchanged. When enabled for routed v1 POST /v1/responses/compact and routed v2 compaction_trigger requests, readable reasoning stays local while the selected model summarizes other context. After a readable summary succeeds, exact retained text returns alongside it in framed historical context messages, not as new assistant reasoning or instructions. Repeated compaction withholds those messages again. Ordinary subsequent turns replay them as history.

{
"reasoningRetention": {
"enabled": true,
"maxContextPercent": 20,
"maxTokens": 100000
}
}

Both limit fields are optional and, when enabled, default at runtime to 20 percent and 100000 estimated tokens. The effective limit is the smaller of the percentage of the compacting model’s context window and the absolute token cap; when the context window is unknown, only the absolute cap applies. The estimate includes each retained message’s historical-context frame, not just its reasoning text. Above the limit, readable text is written verbatim under the resolved OpenCodex home’s reasoning-archive/ (normally ~/.opencodex/reasoning-archive/). The summarizer receives a path note instead of the text, and the finished readable summary includes a notice naming that file. New archives are private to the user on POSIX. An archive is provisional until successful replacement history returns its path, either in a readable summary or in a separate notice alongside a native result after routed v1 Combo failover. Failures, cancellation, and invalid summaries remove the current attempt’s file. Internal retries reuse it; previously successful archives are preserved. Failed compaction returns no replacement history.

The percentage/token limits bound reasoning returned in context, not cumulative archive storage. Each archive contains only readable reasoning admitted in that request; request admission is bounded by maxInboundBodyBytes (256 MiB by default, configurable up to 512 MiB), plus small block separators. The archive directory has a separate compiled-in ceiling of 512 MiB and 256 files, counting committed archives, provisional remnants, and other regular files in that directory. A bounded streaming scan stops once refusal is certain; nonregular or uninspectable entries refuse closed. A sibling SQLite transaction serializes the scan, exclusive creation, write, and close across cooperating writers. The capacity check runs before a new archive payload is written; acquiring the lock and recording local ownership can still write lock or metadata files. This ceiling is not a physical free-space guarantee and does not enforce writes by older builds or external tools. There is no automatic expiry or background archive cleanup.

If the archive is full, routed compaction refuses the new payload with HTTP 503 reasoning_archive_quota_exceeded: review and manually remove older archives before retrying the unchanged request. HTTP 503 reasoning_archive_busy means another writer holds the lock; wait for it to finish and retry. HTTP 503 reasoning_archive_unavailable requires checking directory permissions and storage health. These responses do not expose filesystem paths or raw causes and do not invent a Retry-After delay. A refused archive does not truncate reasoning or delete earlier files, and failed compaction returns no replacement history.

Successful archives remain until you manually delete their files, including after disabling retention or resetting the setting. They are plaintext, contain potentially sensitive conversation reasoning, and share the resolved OpenCodex home of that proxy instance. Deleting an archive makes any existing path notice unable to recover its contents; save anything you need first. A process crash or failed filesystem write/cleanup can also leave a provisional file requiring manual cleanup.

Dashboard → Overview → Advanced settings → Reasoning retention provides the explicit switch and both limits; Reset and turn off removes the override and switches retention off. GET/PUT /api/settings exposes reasoningRetention: null clears the block, and omission preserves it. enabled must be a boolean when present. Percentages must be greater than 0 and at most 100; token caps must be positive integers, and unknown fields are rejected. Invalid hand edits disable only this block with a warning, keeping unrelated providers and settings.

Private provider ciphertext and signatures are not decrypted or copied into portable retained messages. An upstream native opaque compaction response retains its exact output item and ciphertext. If routed v1 Combo failover reaches a native result after withholding readable reasoning, replacement history also includes the retained historical messages or a notice naming their archive. Native compact endpoints keep their existing behavior.

Routed v2 still returns exactly one compaction item. Its proxy-owned ocx2: envelope stores the summary and retained historical reasoning separately as JSON/Base64; this is encoding, not provider encryption. Subsequent requests restore separate historical messages, and repeated compaction withholds the reasoning again. Old ocx1: summaries remain supported. Opt-in emergency compaction recovery also preserves this separation for JSON and SSE. Explicit native-history recovery restores separate summary and historical reasoning messages too.

In Dashboard → Overview → Memory routing, choose a model and an optional reasoning effort for each of Codex’s two memory phases, then click Save. Select Off and save to remove the override. Changes apply to the next memory request without restarting the proxy.

Set memoryModels in OpenCodex config.json to route those requests. With the block omitted, both phases keep their existing route. The phases are independent: configuring one leaves the other alone.

{
"memoryModels": {
"extract": { "model": "provider/model-id", "reasoningEffort": "low" },
"consolidation": { "model": "provider/model-id", "reasoningEffort": "medium" }
}
}

extract is the pass that summarizes one finished session into a raw memory; consolidation is the single agent run that merges those raw memories into the files under $CODEX_HOME/memories. model accepts native model IDs, provider-qualified model IDs, and configured combos. reasoningEffort is optional; omit it to keep the effort Codex asked for. Supported declarations are none, minimal, low, medium, high, xhigh, max, and ultra. Codex hard-codes low for extract and medium for consolidation, so a configured effort replaces that value.

OpenCodex recognizes these requests from Codex’s own turn metadata: request_kind: "memory" in the x-codex-turn-metadata header marks an extract pass, and thread_source: "memory_consolidation" marks the consolidation thread. On HTTP, a request whose x-openai-subagent header names memory_consolidation counts as a consolidation pass only when turn metadata is absent. Explicit non-memory metadata wins over that fallback. The model id is deliberately not a signal: the extract pass runs on the same helper model Codex uses for titles and commit messages, so a model-based rule would also capture ordinary helper calls. Missing, malformed, or conflicting metadata does not activate the override; when several copies of the metadata are supplied they must name the same phase. WebSocket requests use each frame’s metadata rather than the connection’s earlier handshake metadata — the bridge re-attaches the handshake’s x-openai-subagent header to every frame, so that header names the connection, not the current pass, and is not a websocket signal.

A configured phase wins when shadowCallIntercept would match the same request. A phase left off keeps its current routing, including any existing shadow-call rule that matches its model. The selected model’s provider receives the session text Codex summarizes for memory, including sessions that normally run on another provider; the dashboard panel states this next to the model pickers. A phase whose target stopped resolving — the provider is disabled or deleted, or its combo no longer exists — fails that memory call with 409 and error code memory_model_target_unavailable instead of falling back to the default provider. The request log names the phase (memory-extract or memory-consolidation) as the routing reason. Restart the proxy after editing config.json by hand. Dashboard saves apply immediately.

Codex uses small helper models for tasks such as titles and commit messages. Enable shadowCallIntercept to redirect recognized source-model prefixes to another configured model. The replacement keeps the request’s configured reasoning effort. The dashboard lists the sources the runtime supports and persists one switch per source through PUT /api/shadow-call-settings; GET returns both the active sourceModels and that sourceModelOptions inventory. A non-empty sourceModels replaces the default prefixes instead of extending them, so include gpt-6-luna (and gpt-5.6-luna for 0.145.0-0.153.x clients) when current clients should still be intercepted. The API rejects an empty, duplicate, whitespace-bearing, or provider-qualified source prefix, caps the list at 32 prefixes of 128 characters each, and it refuses a target that would intersect a selected source. A rejected source list returns code: "shadow_call_source_invalid" and a conflicting target returns code: "shadow_call_target_invalid", so the dashboard can show the matching message instead of treating every 400 or HTTP error as a network failure. At least one source stays active; use the main switch to stop every shadow call. Memory consolidation is separate: Codex asks for Terra there, and memoryModels.consolidation controls that phase. Terra is also a normal foreground and subagent model, so its source toggles stay off by default. Selecting the active Terra generation routes consolidation through the shadow target only while no consolidation model is configured; a configured Memory-Routing phase still wins. The dashboard keeps both gpt-6.1-terra and gpt-5.6-terra in the source inventory and labels them as Memory rather than silently omitting them. Matching bare model ids are eligible for interception, but explicit user turns keep their selected model: x-codex-turn-metadata must contain both request_kind: "turn" and thread_source: "user". Spawned children also keep their model, identified by the x-openai-subagent: collab_spawn connection header (HTTP request or WebSocket upgrade) or metadata subagent_kind: "thread_spawn". On WebSocket, turn metadata comes only from each frame’s client_metadata["x-codex-turn-metadata"]; the upgrade’s turn metadata never classifies later frames. Missing or malformed metadata keeps the existing interception behavior. Helpers can also carry request_kind: "turn", so that field alone does not exempt them. Maintenance provenance such as x-openai-subagent: compact/review or thread_source: "memory_consolidation" remains eligible, subject to configured memory routing. These rules apply to concrete and combo targets.

{
"shadowCallIntercept": {
"enabled": true,
"model": "gpt-5.5",
"sourceModels": ["gpt-6-luna", "gpt-5.6-luna"]
}
}

The replacement is the one destination the operator chose, so a target that stops resolving fails the helper call instead of sending it elsewhere. When the target’s provider is disabled or deleted, or its combo no longer exists, an intercepted request returns 409 with error code intercept_target_unavailable before anything is sent upstream. The request log records the same code. The request is not passed through to the native helper model and does not fall back to the default provider, because either would change the destination, credentials and cost without your choice. A combo or routing-profile target still fails over among its own members. A qualified target such as provider/model whose provider segment names nothing configured is treated the same way, and the settings API refuses to save one. A bare model id that resolves through the default provider stays valid.

Disabling (PATCH /api/providers?name=<provider> with disabled: true) or deleting a provider that the target resolves to still succeeds; the response adds dependentShadowIntercept: { model, enabled } and the dashboard shows a warning. Re-enabling the provider, or choosing another target, restores interception.

Field Type Default Meaning
provider? string automatic OpenAI selection Explicit custom API-key openai-responses provider for /v1/images/generations and /v1/images/edits. Registry-managed ids are rejected.
timeoutMs? number 300000 Whole-request timeout for one standalone Images request.

Explicit selection fails closed when the provider is missing, disabled, incompatible, or lacks a usable key; it never falls back to another paid upstream. The endpoint must implement the OpenAI Images API paths and response shape expected by Codex.

Both search loops keep web_search declared after the search budget is exhausted. Further calls receive a paired limit-reached result without another physical search; at most maxSearchesPerTurn + 3 model iterations run before a terminal error. Ordinary caller tools and cancellation still end the loop. Empty-answer recovery alone removes all tools.

webSearchSidecar (OcxWebSearchSidecarConfig)

Section titled “webSearchSidecar (OcxWebSearchSidecarConfig)”

RunTurn adapters also use the configured search sidecar. Search turns preserve progress heartbeats. A first-event OAuth 429 rotates the account on the initial request and on each post-search answer request. The replay keeps the search tool and the gathered results. With emptyCompletionRetry: true, an empty answer before the search limit receives one retry using the current conversation and gathered results. The existing tool-free recovery after the search limit remains available independently of that setting. Upstream failures stop the turn instead of triggering another search.

Field Type Default Meaning
enabled? boolean on when usable Master switch. When false, OpenCodex stops intercepting web_search AND the Codex integration writes web_search = "disabled" into ~/.codex/config.toml.
backend? "openai" | "anthropic" | "xai" | "gemini" | "exa" openai Explicit wins; unset always resolves to openai. anthropic and xai run only when explicitly configured; gemini and exa remain reserved until their executors ship.
model? string backend-dependent gpt-5.6-luna for OpenAI, claude-sonnet-5 for Anthropic, or grok-4.6 for xAI. Legacy explicit gpt-5.4-mini migrates on start.
exaApiKey? string none Operator key for the exa backend. Write-only: management reads never return the stored value.
xSearch? object omitted xAI-only opt-in for hosted x_search: enabled, mutually exclusive allowedXHandles / excludedXHandles arrays (maximum 20), and ISO fromDate / toDate (YYYY-MM-DD).
reasoning? string low Sidecar effort. minimal is rejected with web search.
maxSearchesPerTurn? number 3 Real searches allowed per main-model turn.
routedModelStallTimeoutMs? number 200000 Config-file-only routed-model raw-body inactivity deadline. Integer 1–2147483647; every non-empty chunk resets it.
timeoutMs? number 60000 Deadline for one hosted search.

The OpenAI backend requires a ChatGPT login and enabled ChatGPT forward provider. Claude-inbound routed replays inject main ChatGPT auth into the internal request. The Anthropic backend uses the active stored credential from an enabled Anthropic OAuth provider. An explicitly selected Anthropic backend with no usable account fails closed instead of falling back. The Anthropic executor uses its native web_search_20250305 tool. The xAI backend requires a usable stored Grok OAuth account, uses hosted web_search, and adds hosted x_search when xSearch.enabled is true. Malformed xSearch management input returns 400; a malformed persisted block fails closed during planning. The gemini and exa lanes never activate from credential discovery or fallback; the operator must select them explicitly. exaApiKey is accepted on writes but omitted from management responses.

Four clocks govern search: base stallTimeoutSec, connectTimeoutMs, routed-model inactivity, and hosted-search timeout. The effective bridge watchdog is the maximum plus 30 seconds. Routed stall is an inactivity guard, not a total generation deadline.

Field Type Default Meaning
enabled? boolean on when usable Master image-description switch.
backend? "openai" | "anthropic" auto Explicit wins; unset prefers a usable stored Anthropic OAuth credential, else openai.
model? string backend-dependent gpt-5.6-luna for OpenAI or claude-sonnet-5 for Anthropic.
maxDescriptionsPerTurn? number 8 New description cache misses admitted per main turn. 0 disables calls; invalid values use default.
timeoutMs? number 45000 Sidecar fetch timeout. Integer 1–2147483647.

Vision activates only for images sent to a model in its provider’s noVisionModels. OpenAI has the same login/forward requirements as search; explicitly selected Anthropic fails closed without a usable credential. Successful data: descriptions use a bounded cache keyed by backend, model, detail, image bytes, and normalized message context. Hits and same-turn duplicates do not consume the limit. Remote https: images and failed or empty descriptions are not cached.

Anthropic OAuth sidecars reuse opencodex’s existing Claude Code OAuth fingerprint. Soak-test the intended account and workload.

runtimeRole defaults to standalone. A hub uses hub.managementPublicOrigin, loopback-only hub.managementIngress (enabled:false when absent), and exact remoteGui.allowedTailscaleUsers (empty when absent). A client data key lives in service-api-token, never config.json; rotation may temporarily create service-api-token.prev. Usage stores are not mirrored.

Key Type Default when absent What it does
hub.managementPublicOrigin string unset The canonical browser-reachable management origin a hub advertises, for example the HTTPS origin Tailscale Serve prints. It is what /readyz reports as managementUrl while runtimeRole is hub; with it unset the hub falls back to whatever origin each request arrived on, so a client behind a different frontend can be handed an address it cannot reach.
hub.dataPublicOrigin string unset The canonical origin a remote client should dial for the data plane, for example the HTTPS origin a TLS frontend publishes in front of the tailnet bind. Advisory only: it is never a bind address and changing it moves no socket. ocx hub invite prints it as the positional URL of the ocx connect line, falling back to http://<hostname>:<port> — which is a LAN/tailnet address a remote machine may not be able to reach over TLS, so set this on any hub with a frontend. Unlike most optional keys it is not silently dropped when malformed: a typo is rejected at write time, because falling back to the bind address is exactly what the field exists to avoid.
hub.managementIngress {enabled:false} or {enabled:true, port} {enabled:false} An extra management-only listener for a local HTTPS frontend. The hostname is not configurable: when enabled the socket always binds 127.0.0.1, and only GUI, session-bootstrap, and management API routes are admitted. Data-plane routes are rejected before dispatch.
remoteGui.allowedTailscaleUsers string[] [] (empty — nobody) Exact Tailscale login identities allowed to be issued an automatic remote GUI session. The Tailscale-User-Login header is trusted only on the separate management ingress; an empty list means no remote identity can mint a session, which is the safe default rather than an oversight. Identities are compared exactly, so a typo silently denies access.
remoteGui.allowInsecureHttp boolean unset Retired — has no effect. It once permitted a one-time pairing exchange over non-loopback plaintext HTTP. A pairing grant now crosses loopback or authenticated HTTPS only. The key is still parsed so an existing config.json keeps loading (the schema is strict, and dropping the key outright would make an older config fail to load entirely); a persisted true is reported once and then ignored. Remove it from your config.

A hub that is reachable from a browser needs hub.managementPublicOrigin and at least one entry in remoteGui.allowedTailscaleUsers. Setting the origin without the user list produces a hub that advertises itself correctly and then refuses every session; setting the user list without the origin produces sessions pointed at whichever origin the request happened to use.

dataPublicOrigin and managementPublicOrigin are two independent advertisements, and on a real deployment they are two different sockets: management is the loopback-only ingress published on 443, data is the tailnet bind published on its own HTTPS port. They are the two halves of what ocx hub invite prints, and managementPublicOrigin is the stricter of the two — a pairing grant records it as the grant’s own server origin and the exchange compares against it, which is why ocx hub invite --management-url can only confirm the configured value and refuses one that differs. --data-url really is an override, because nothing is bound to it. With neither dataPublicOrigin nor --data-url set, invite falls back to the bind address — and on a loopback or wildcard bind, where that would resolve to this machine’s own loopback, it refuses rather than advertising an address the other machine cannot use.

A hub that serves its own local clients also sets unauthenticatedLoopbackListener. Its port-less companion form is what makes a hub a single-port deployment, and it is refused on a loopback or wildcard hostname, where the public listener already holds 127.0.0.1:<port>.

codexNativeSteering and codexNativeInjection enable separate, default-off native WebSocket control paths. See the canonical guide for supported steering routes and settings, typed result and approval continuations, and confirmation deadlines and retained context.

The Subagents page offers Force all subagents onto one model, off by default. Select an exposed roster-style id, such as combo/tev-auto, then enable the switch. The roster is offered first; unavailable saved roster entries cannot be force targets.

ocx agent subagents force combo/tev-auto sets claudeCode.subagentModelForce; ocx agent subagents force - clears it. ocx agent status reports the setting. GET /api/subagent-models returns force, forceAvailable, and forceStatus; PUT accepts { "force": "combo/tev-auto" } or { "force": null } without changing the roster. Omitting force leaves it unchanged. Invalid or unexposed targets are rejected on write; stale targets are reported and skipped at launch.

This takes effect on the next routed ocx claude launch, injecting CLAUDE_CODE_SUBAGENT_MODEL as an explicit proxy alias (with [1m] only for an authoritative million-token window; native Claude targets use a reversible native alias) and CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1. Each nonempty shell-exported variable independently wins. Native launches inject neither variable; plain claude is not affected. No plugin files or settings.json are modified by this setting.

Claude Code 2.1.257 or newer is required for FORCE. Plugin and built-in agents (including Explore/Plan) and per-call model arguments are overridden. Forks and subagent skills with model: inherit keep the main conversation model. The main loop and Haiku/small-fast sidecars are unaffected. Existing roster files remain available.

The dashboard warns about old or unknown CLI versions, unavailable targets, and either variable already present in settings.json → env (which overrides launch env). Detection is read-only and server-local: it cannot inspect another launch shell, another machine, or project-local settings. An unknown result is not proof of force support.

Provider-pool ceilings use the canonical routed provider. For every currently configured provider P, OpenCodex automatically binds its salted pool alias, h(pool, P), to P when reading balances. No manual self-mapping or salt calculation is needed for independent provider ceilings. This rule also applies to retained historical balances with that exact alias.

Request logs still use account-specific display labels. A different historical label, including an account ordinal, does not establish provider ownership. With spend.pool.maxTokens, each original balance contributes once to its bound provider group or, when still unbound and positive, to every candidate pool. Settled, reserved and unresolved usage all count; unknown usage is not a refund. Unbound history may therefore restrict an otherwise unused pool. Current account order and display names do not resolve that history. Root and identity ceilings remain independent.

For distinct historical labels whose ownership you have verified, add mappings to the top-level spendPoolAliases object in config.json. Each key is an exact 32-character lowercase hexadecimal pool alias from this installation’s journal; each value is the exact ID of a verified, currently configured canonical provider, with no surrounding whitespace. Keep evidence for the mapping and keep the journal and salt private.

Automatic self-bindings and explicit mappings apply only when balances are read. They do not move original balances or persist identity links in the journal. Removing an explicit historical mapping makes that history unbound again unless its alias is a currently configured provider’s own alias. Keep spendPoolAliases outside spend, because older versions reject unknown fields inside that strict section. Invalid mappings are rejected on writes; invalid hand edits retain ceilings and refuse pool admission. A configured provider’s own salted pool alias cannot map to another provider. This is checked again when the provider set changes. Validation reads an existing salt without creating one.

Dormant unbound history expires only after its last activity is strictly older than spend.retentionDays. Capacity pressure cannot shorten that interval for positive unknown history. Live reservations and seed targets remain protected, even at zero tokens. Cleanup persists a normal journal deletion record before admission uses the reduced total. Known groups keep the ordinary active/exhausted protections. No automatic tool reconstructs unverifiable mappings.

When a root, identity or pool ceiling applies, the first physical send for each selected target/key requires a normal-capacity reservation. If that cannot be recorded, the send is refused before it reaches upstream. Stable retries reuse that reservation’s scopes; a different target/key needs a new one. L is the request-wide physical-send limit, frozen when the request starts (default four; the existing bounded OAuth request profile allows up to eighteen). Every additional physical send shares this finite limit.

The enforcement state, applicable root/identity/pool token ceilings, and L are captured when each request starts. Configuration changes apply only to requests started after the change. Requests already running keep their starting policy for all retries and continuations: enabling or lowering a ceiling does not tighten them, and raising or removing a ceiling does not relax them. A request that started in observe-only mode remains observe-only until it finishes.

Delayed reports retain already-started usage. Terminal settlement waits for reporters, and send IDs are forgotten only after their accounting is durable; forgetting an ID does not remove its balance. The limit bounds retries, not the final bill: actual usage or already-started retries can exceed a token estimate, and their full liability remains counted. With no applicable ceiling, behavior stays observe-only, including omitted bookings when tracking capacity is full. No identity checkpoints are added for unconfigured traffic.

Claude CLI, CodeBuddy and Qoder count each CLI invocation as one send. Their first invocation still needs a normal reservation and is refused if that reservation cannot be obtained. Internal CLI retries and tool turns do not separately consume this request-wide send limit; their cost settles from actual reported usage, including amounts above the initial estimate. This exception preserves CLI use with configured spend ceilings while limiting the number of child invocations.

New records use the existing v1 format and pool alias domain. Unmodified 2.80.0 reads them using its own per-label rules, including ordinary compaction and retention. There is no required backport, launcher fence or reconciliation command. Keep the current journal and salt: restoring an older copy loses the spend recorded since that copy.

This does not preserve canonical aggregation after downgrade, nor promise the same remaining allowance that 2.80.0 would have calculated for identical traffic without the upgrade. It may book new traffic under account labels. On re-upgrade, current configured-provider self-bindings and explicit alias mappings apply to the original balances still retained by 2.80.0. New checkpoints contain no identity-link metadata that an older reader must preserve.

Journals written by unpublished experimental builds with pool-current aliases or poolContinuity metadata are excluded from this compatibility contract, including later checkpoints carrying those aliases. Their original balances and send targets remain conservative unbound history until normal retention expires them; they are not converted or immediately deleted.

A complete invalid journal record (including a final null) causes configured admission to refuse. Compaction can still preserve valid accounting in a clean checkpoint; the live refusal remains until a clean restart replays it. A torn, unparseable final line keeps the existing recovery behavior. Storage/corruption denials use workflow_spend_undurable; unsafe files and ownership failures remain storage errors. Alias validation failures use workflow_pool_history_unresolved.

Locality is a timeout policy, not network-access authorization. Classification uses the configured HTTP(S) destination without DNS lookups: exact localhost (case-insensitive, optional trailing dot), 0.0.0.0, IPv4 loopback (127/8), RFC 1918 private and link-local (169.254/16) addresses, IPv6 loopback, link-local (fe80::/10), unique-local (fc00::/7), IPv4-mapped equivalents, and names ending in .local or .lan. Other DNS names keep public deadlines even if they resolve to loopback; malformed destinations and unsupported schemes also keep public deadlines. Names such as localhost.example are public.

Disabling the header clock preserves client cancellation and any enclosing overall deadline. An explicitly positive stallTimeoutSec still bounds local body silence; unset local stall/header clocks provide no automatic bound before headers. Native Chat/Messages local JSON reads use the local stall budget; public JSON reads retain their previous max(connectTimeoutMs ?? 200000, 5000) budget. Compact body reads retain their 300-second default even locally. Image/search sidecar budgets are unchanged.

Optional opencodeClientExport controls the generated OpenCode provider’s headerTimeoutMs, chunkTimeoutMs, and timeoutMs (positive milliseconds or false to disable). Unset values preserve OpenCode defaults; malformed fields are omitted. These provider-wide settings are emitted into V1 options and V2 settings. See slow local models.

Optional, config-file-only omoClientExport.api selects the dialect of the managed Pi-based omo provider block: openai-completions (default) or openai-responses. Other values are ignored. Only omo reads it. After adding, changing or removing this key, restart the proxy (ocx service restart for a service install, or ocx stop, then ocx start), then refresh Connect → omo in the dashboard. Refresh uses the running proxy’s config; it does not reload the file. See integrations.