Server and Runtime Configuration
Server settings control how the local proxy listens, protects remote traffic, manages resources, and runs helper features around provider requests.
Server fields
Section titled “Server fields”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.
Native reasoning retention
Section titled “Native reasoning retention”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.
Workflow limits
Section titled “Workflow limits”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.
Upstream transport recovery
Section titled “Upstream transport recovery”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 timeouts and completion
Section titled “Native Chat timeouts and completion”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.
Codex quota network diagnostics
Section titled “Codex quota network diagnostics”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:
$quotaReport = ocx account list openai --quota --refresh --json | ConvertFrom-Json$quotaReport.accounts | ForEach-Object { if ($_.quotaRefresh) { $_.quotaRefresh } } | ConvertTo-Json -Depth 3If 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.
Which proxy path is used?
Section titled “Which proxy path is used?”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:
OCX_FRESH_CONNECTION_HOSTS="api.example.com, relay.example.net" ocx startThe 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.
Remote access
Section titled “Remote access”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:
export OPENCODEX_API_AUTH_TOKEN="your-secret-token"ocx startClients 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.
SSH port forwarding
Section titled “SSH port forwarding”Remote use does not require a remote bind. Keep loopback and forward it:
ssh -L 20100:localhost:10100 you@remoteAny 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:
ssh -L 20100:localhost:10100 -L 1455:localhost:1455 you@remoteIf 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.
Account email masking (privacy)
Section titled “Account email masking (privacy)”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.
Storage cleanup
Section titled “Storage cleanup”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.
Usage history size
Section titled “Usage history size”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>.
Catalog auto-refresh (catalogAutoRefresh)
Section titled “Catalog auto-refresh (catalogAutoRefresh)”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.
The delivered event
Section titled “The delivered event”{ "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.
Security notes
Section titled “Security notes”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.
API surfaces (apiSurfaces)
Section titled “API surfaces (apiSurfaces)”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.
Protocol paths (protocols)
Section titled “Protocol paths (protocols)”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.
Claude Code (claudeCode)
Section titled “Claude Code (claudeCode)”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.
Retained requests after compaction
Section titled “Retained requests after compaction”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.
Compaction routing
Section titled “Compaction routing”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.
Reasoning retention during compaction
Section titled “Reasoning retention during compaction”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.
Memory routing
Section titled “Memory routing”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.
Shadow calls
Section titled “Shadow calls”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"] }}When the target is unavailable
Section titled “When the target is unavailable”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.
Sidecars
Section titled “Sidecars”images (OcxImagesConfig)
Section titled “images (OcxImagesConfig)”| 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.
visionSidecar (OcxVisionSidecarConfig)
Section titled “visionSidecar (OcxVisionSidecarConfig)”| 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.
Remote Hub keys and defaults
Section titled “Remote Hub keys and defaults”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>.
Experimental native response controls
Section titled “Experimental native response controls”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.
Forced Claude Code subagent model
Section titled “Forced Claude Code subagent model”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.
Historical pool continuity
Section titled “Historical pool continuity”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.
Token reservations and reporting
Section titled “Token reservations and reporting”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.
Rollback to 2.80.0: contract C
Section titled “Rollback to 2.80.0: contract C”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.
Local upstream timeout classification
Section titled “Local upstream timeout classification”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.

