codex-oauth-proxy is one Go binary with two execution modes:
servestarts the HTTP proxy and project APIs.admincalls the running server's loopback-only administration route.
The project intentionally keeps one Codex-specific implementation. It does not include provider translation, a management UI, plugin hosting, or alternate storage backends.
| Path | Responsibility |
|---|---|
cmd/server/ |
Process entrypoint, server lifecycle, admin CLI, HTTP client, and CLI formatting. |
internal/codexonly/config.go |
YAML loading and path/default resolution. |
internal/codexonly/auth.go |
OAuth file discovery, parsing, filtering, and persistence. |
internal/codexonly/auth_management.go |
Safe auth status, bounded management actions, binding clearing, and active-connection counts. |
internal/codexonly/auth_health.go |
Global credential health, model exclusions, cooldown reconciliation, and authoritative-state persistence. |
internal/codexonly/refresh.go |
OAuth refresh and outbound HTTP transport construction. |
internal/codexonly/failover.go |
Replay eligibility, upstream response classification, retry layers, and deterministic aggregate errors. |
internal/codexonly/models.go |
Per-auth runtime model synchronization, version LRU, aggregation, and embedded fallback. |
internal/codexonly/server.go |
Routing, authentication, headers, HTTP reverse proxy, and management/user handlers. |
internal/codexonly/chat_completions.go |
Chat Completions to Responses conversion and response translation. |
internal/codexonly/zed_edit_predictions.go |
Zed Qwen FIM validation, Responses conversion, and text-completion aggregation. |
internal/codexonly/user_store.go |
SQLite schema, users, API keys, and authentication. |
internal/codexonly/session_affinity.go |
Bounded signal extraction, digesting, persistent binding, renewal, and CAS rebind. |
internal/codexonly/usage.go |
Usage storage, aggregation, windows, dimensions, and timeseries. |
internal/codexonly/usage_proxy.go |
HTTP, SSE, and WebSocket usage capture. |
observability/ |
Optional Grafana Compose and provisioning assets. |
Startup performs these steps:
- Parse CLI flags and select server mode.
- Read YAML configuration and apply code defaults.
- Resolve
auth-dirand validate the two upstream base URLs. - Build an upstream client and a timeout-limited OAuth refresh client.
- Scan the auth directory to verify it is readable.
- Resolve the SQLite path, run idempotent schema migrations, validate the initial persisted user state, ensure tenant-scope digest metadata for affinity management, remove targets for auth identities that are no longer present, and restore unexpired authoritative auth health.
- Start one
net/httpserver.
The server sets ReadHeaderTimeout to 10 seconds. It does not set read or write
timeouts that would terminate established streaming or WebSocket traffic.
Graceful shutdown has a 10-second timeout and force-closes remaining HTTP
connections when that deadline expires.
SQLite is a process-level hard dependency. A database path, open, migration, or initial state-load failure prevents startup.
At runtime, the first unexpected SQLite read or write failure becomes the process-level fatal error. It cancels active proxy request contexts, closes both sides of established WebSocket bridges, starts bounded HTTP shutdown, and causes the server process to exit with an error. Concurrent later failures reuse the first fatal error and do not start additional shutdowns.
Expected application errors do not trigger this lifecycle. These include invalid input, missing records, disabled or invalid credentials, handled constraint conflicts, and canceled request contexts.
The process does not provide degraded, in-memory-only, or automatic database recovery. Production deployments must use systemd, Docker, Kubernetes, or another external supervisor to restart the process after the underlying SQLite problem has been corrected.
FileAuthStore recursively scans the configured directory on selection and
reconciles the result with the previous successful scan.
For every upstream request:
- Parse Codex auth files, including disabled records for reconciliation.
- Resolve stable identity from
account_id, token claims, or a normalized path fallback. - Group duplicate files for one account into one logical credential.
- Sort selectable logical credentials by stable identity.
- Follow a valid tenant-scoped session binding, or select the next logical credential in round-robin order when no binding exists.
- Treat credentials expiring within five minutes as expired.
- Coordinate refresh in process by stable credential ID so one effective refresh serves concurrent callers for that credential.
- Reuse a newer access token when another caller already replaced the token
that expired or received
401, but only while the same stable credential ID still exists. - Refresh an expired credential and reparse account and email claims.
- Atomically replace the selected source file after syncing a same-directory
0600temporary file. - Forward the request with the selected access token and account ID.
Reconciliation reports additions, removals, credential changes, metadata changes, eligibility changes, and source-file changes without including token contents. Stable account IDs survive file renames and same-account token replacement. Replacing one path with another account produces an old-identity removal and a new-identity addition. Disabled logical credentials remain known but are excluded from new selection. A five-second parse-error grace retains the last good representation during partial editor writes; persistently malformed files are then excluded.
Each effective token refresh has one 30-second deadline and at most three total
attempts. Retries are limited to transient network failures and HTTP 408,
429, 500, 502, 503, and 504; a valid Retry-After is honored only
within the refresh deadline. Missing refresh credentials, invalid_grant,
definitive 400/401/403 responses, malformed responses, and successful
responses without an access token are terminal. Refresh errors expose only safe
status and OAuth error-code context, never raw token-endpoint response bodies.
The remote /v0/management/* and loopback /v0/local-admin/* route groups use
one internal auth-management implementation. Every response is marked
Cache-Control: no-store.
Status reconciles the auth directory, health registry, cached per-auth model support, session-binding counts, and in-memory active connection counts. It returns real account IDs, masked email, source counts, configuration and runtime state, token and refresh timestamps, cooldown/error categories, model support and exclusions, and logical binding/connection counts. Unidentified path-based credentials are listed without exposing the fallback path and remain read-only.
Force refresh enters the existing per-auth singleflight with a 30-second
deadline even when the auth is disabled. Successful refresh clears only
credential-related failures. Enable and disable validate every source first and
then reuse Auth.Save for synchronized temporary-file writes and atomic rename;
rollback is attempted for already written sources if a later source fails.
SQLite never becomes an alternate enablement source.
Cooldown clear removes only time-based quota and transient states. Credential
failures, disabled state, continued unauthorized state, and model exclusions
remain intact. Binding clear accepts only exact user/session, user, or account
scope. Exact clearing derives the tenant-scoped digest server-side, and all
scopes return a logical deleted_count; no global clear or target-auth
migration exists.
Active HTTP and SSE responses are counted until their upstream body closes. Successful WebSocket bridges are counted until the bridge ends. Disabling an auth changes later selection but does not cancel these established operations.
Every mutation performs a SQLite readiness check before external changes. Subsequent storage errors still pass through the response commit guard, so a request cannot return success while the process is entering fail-fast shutdown.
Auth health is global to one stable logical credential in this single process. All sessions skip credentials that are disabled, cooling down, credential-invalid, continued-unauthorized, or excluded for the requested model. Session affinity remains sticky while healthy and uses its existing SQLite compare-and-swap rebind when failover is required. One request snapshots the auth IDs known at its start, so a newly added replacement identity cannot take over that in-flight execution.
The proxy classifies upstream outcomes before committing a downstream response:
- Request-scoped
4xxresponses stop without changing auth health. 401performs one coordinated same-auth refresh and retry when replay is available; another401blocks that credential.429usesRetry-Afteror an explicit Codex quota reset deadline.- Network failures,
408, and retryable5xxresponses create a short in-memory cooldown. - Model-not-supported responses create a credential/model exclusion rather than an auth-global cooldown. Cacheable model IDs use a safe 128-byte identifier format, and each auth retains at most 64 exclusions with deterministic oldest-entry eviction.
Retry uses three independent budgets. Same-auth 401 repair is outside the
credential budget. One execution round tries distinct eligible auths up to
max-retry-credentials, where zero means all. After a round, the proxy waits
for the nearest cooldown only when it is within max-retry-interval, then
starts up to request-retry additional rounds. Context cancellation interrupts
selection, refresh, and cooldown waiting immediately.
Cross-auth retry requires an explicit replayable route. Eligible JSON requests are buffered in memory up to and including 32 MiB; nothing is spooled to disk for retry. Unknown-length, oversized, multipart, file, realtime, side-effecting wham, hosted MCP, and unknown write requests remain one-shot. Responses WebSocket handshakes can fail over before a successful upgrade. HTTP streams and WebSockets never re-enter retry after downstream commitment.
Only explicit quota deadlines, invalid_grant, and continued unauthorized
state are stored in auth_health_states. Transient cooldowns and model
exclusions remain in memory. Credential-state fingerprints invalidate persisted
credential failures after real token material changes, while same-account token
updates preserve unexpired quota deadlines.
GET /v1/models synchronizes the authenticated Codex /models endpoint for
every active stable auth ID and the normalized Codex CLI version. A cold or
expired request starts those per-auth fetches concurrently and waits for every
auth to resolve. Concurrent misses for the same auth and version share one
foreground fetch.
Successful per-auth snapshots remain fresh for three hours. The in-memory cache keeps at most 16 normalized client versions and evicts the least recently used version. Model payloads are never written to SQLite. Removed auth identities are pruned from every cached version.
Aggregation exposes the union of model slugs. Duplicate slugs select one complete canonical object by stable auth ID and deterministic model encoding; fields from different accounts are never combined. A separate sorted support set records which auth IDs advertised each slug. When a synchronized support set exists for the request's Codex client version, routing preserves a healthy bound auth only if it supports the requested model. Otherwise the existing affinity CAS rebind moves the whole session to a healthy supporting auth. Model name remains outside the affinity key.
A failed refresh reuses that auth's last successful snapshot when available.
Otherwise its unique models are absent from the current union. One deduplicated
background task performs up to three additional retries with exponential
backoff, jitter, and Retry-After. The catalog retry budget is independent from
proxy request retries. An exhausted cycle reopens only after the auth is proven
healthy, the next three-hour refresh period begins, or another client version
is requested.
Model fetch 401 responses use coordinated same-auth OAuth refresh and one
retry. Continued 401 and 429 responses update the shared auth health and
cooldown state but never mutate session bindings by themselves. If no auth has
a usable snapshot, the embedded release catalog is returned as the final
fallback; it does not claim per-auth support for routing.
The incoming managed API key and the outgoing OAuth access token are separate credentials.
For public proxy and user routes:
- Read
AuthorizationandX-API-Key. - Hash each candidate with SHA-256.
- Look up the stored key hash.
- Verify the API key and user are enabled.
- Attach user and key identity to the request.
The incoming managed key is never forwarded upstream.
/v0/management/* compares candidate tokens with the configured
admin-api-key using constant-time comparison. The entire route group is hidden
with 404 when the key is not configured.
/v0/local-admin/* checks the TCP remote address and accepts only loopback IPs.
It then reuses the management handlers without an admin key.
Selected ChatGPT backend compatibility routes can accept a currently loaded Codex OAuth access token. This exists for Codex CLI behavior that already holds that token. Such requests have no managed user identity and are excluded from per-user usage accounting.
Session affinity is enabled by default and stored in the same SQLite database as managed users and usage.
The proxy accepts only these explicit signals:
Session-IdorSession_idrequest headers.- Top-level JSON
session_idorsessionId. - Top-level JSON
prompt_cache_key. - Top-level JSON
conversation_idorconversation.id.
Signal values are trimmed, limited to 512 bytes, and rejected for affinity when
empty, invalid UTF-8, containing unpaired UTF-16 surrogate escapes, or containing
control characters. JSON request bodies are tokenized as a stream without an
affinity-specific body-size cutoff. Replay keeps up to 64 KiB in memory and uses
a 0600 temporary file beyond that threshold so the exact body can still be
forwarded. If temporary replay storage cannot be created or written, inspection
stops before additional unbounded buffering, body-derived signals are discarded,
and the stored prefix is joined with the untouched source stream for exact
one-shot forwarding. An invalid, missing, or oversized signal does not reject or
truncate the proxied request; it uses normal round-robin selection instead.
Managed requests are scoped by stable user ID, so API-key rotation preserves bindings and two users cannot collide. OAuth compatibility requests are scoped by the stable identity of the access token that authenticated the request. Model name is not part of the affinity key.
The database stores only SHA-256 digests derived from tenant scope, signal type, and normalized value, plus a separate SHA-256 tenant-scope digest used for user-scoped management deletion. Prompt-cache, conversation, and session aliases observed together share one digest-only binding group. The first binding is inserted atomically; concurrent first requests follow the database winner. Rebinding uses compare-and-swap so concurrent failovers converge without overwriting another request's winner.
Bindings expire after one hour of inactivity. Active bindings renew only after 30 minutes since their previous persistence write, and expired rows are removed in bounded batches. Bindings survive process restarts. Disabling an auth leaves idle bindings intact; reuse while disabled rebinds to an active auth. Removing or replacing an auth identity invalidates bindings that target the old identity.
The HTTP handler checks project-owned routes before the reverse proxy whitelist.
| Route group | Owner |
|---|---|
/, /healthz |
Service handler |
/v0/local-admin/* |
Loopback administration |
/v0/management/* |
Admin-key user, usage, auth-status, recovery, and binding management API |
/v0/user/* |
Managed user self-service API |
/v1/models |
Runtime per-auth model aggregation with embedded fallback |
/v1/chat/completions |
Local protocol conversion |
/v1/zed/edit-predictions |
Local Zed Edit Prediction conversion |
Whitelisted /v1/* |
Codex upstream reverse proxy |
Selected /backend-api/* |
Codex CLI compatibility reverse proxy |
All unmatched routes return 404; there is no general catch-all upstream
forwarder.
For a whitelisted proxy request:
- Authenticate the incoming managed key or permitted OAuth compatibility token.
- Reject Fast service tiers when Fast mode is disabled.
- Reconcile auth files and global health, apply synchronized model support when available, then resolve healthy session affinity or choose the next eligible credential.
- Rewrite the target URL to the configured Codex or ChatGPT base.
- Replace
Authorizationwith the selected OAuth access token. - Add the ChatGPT account ID and compatibility headers when available.
- Apply same-auth repair, distinct-credential failover, and bounded cooldown rounds only while the request is replayable and no client response is committed.
- Update auth health and use affinity CAS when selection moves to another credential.
- Forward one-shot requests once, forward HTTP streaming responses, or bridge WebSocket frames after a successful handshake.
- Return deterministic safe aggregate errors when all candidates are exhausted.
- Capture usage metadata for managed user requests.
The proxy preserves established HTTP streams during normal operation. WebSocket forwarding uses Gorilla WebSocket and forces HTTP/1.1 ALPN for the upstream upgrade path. Server shutdown or a fatal storage failure cancels established streams and closes both WebSocket peers. No retry occurs after response commitment or successful upgrade. Non-replayable request bodies keep the first upstream response unchanged.
/v1/chat/completions is implemented locally rather than passed through:
- Decode the Chat Completions JSON object.
- Convert messages, tools, response format, reasoning, and service tier to a Responses request.
- Force
stream: trueandstore: falseupstream. - Preflight the upstream SSE before downstream commitment. For non-stream clients, validate the complete upstream stream inside the retry attempt; for stream clients, validate the first event before committing headers or the assistant role chunk.
- Feed pre-commit terminal errors into the same health-aware executor used by replayable Responses requests.
- Read bounded Responses SSE events and require exactly one
response.completedorresponse.incompleteterminal. - Reconcile indexed
response.output_item.donesnapshots with terminal output. Non-stream output uses the terminal snapshot as authoritative. Streaming emits only missing text or tool-argument suffixes and rejects conflicts. - Aggregate into a normal Chat Completions response or translate into Chat Completions SSE chunks. Post-commit failures emit one sanitized SSE error and never re-enter retry.
- Apply Unicode-safe local stop filtering, continue draining terminal state and usage after a stop match, and record success, upstream failure, or client cancellation outcomes.
This is a focused compatibility layer, not a generic schema-preserving translation engine.
/v1/zed/edit-predictions is another focused local conversion:
- Authenticate only with a managed user API key and require JSON
POST. - Normalize and validate the requested model with the shared safe model
identifier rules, validate a positive
max_tokensfrom 1 through 4096, and require exactly one ordered Qwen<|fim_prefix|>...<|fim_suffix|>...<|fim_middle|>sequence. - Build a tool-free Responses request with low reasoning effort,
stream: true,store: false, and separate prefix and suffix input text. - Use the same health-aware executor, model capability filtering, retry, session affinity, active-connection tracking, and managed usage accounting as other replayable Codex requests. The endpoint has no model allowlist; runtime catalog support and upstream failures decide availability.
- Require one valid
response.completedorresponse.incompleteterminal, reject tool output and malformed or missing terminal state, and aggregate text and terminal usage before committing the client response. - Apply local stop matching and the output budget without forwarding sampling or token-limit fields upstream. Matching and truncation are Unicode-safe, and the upstream stream is fully drained before either is applied.
- Return one OpenAI text-completion-shaped JSON response. Incomplete max-token
reasons and local budget truncation use
finish_reason: "length"; content filtering usescontent_filter.
The getting-started configuration uses gpt-5.6-luna as an initial example.
The local output cap counts Unicode code points. The route does not create a
generic /v1/completions contract and does not alter /v1/chat/completions.
SQLite is opened through database/sql with the pure-Go modernc.org/sqlite
driver. The connection pool is limited to one open connection.
A user has:
- Random
usr_...ID. - Case-insensitively unique name.
- Enabled state.
- Creation and update timestamps.
An API key has:
- Random
key_...ID. - Owning user ID.
- SHA-256 key hash.
- Display prefix and masked value.
- Enabled state.
- Creation, rotation, and last-used timestamps.
A partial unique index permits only one enabled key per user. Resetting a key disables the old active key and inserts the replacement in one transaction.
A binding has:
- One or more tenant-scoped SHA-256 session digests.
- A digest-only alias group identifier.
- The selected stable OAuth credential ID.
- Creation, renewal, and expiry timestamps.
No raw session ID, prompt-cache key, conversation ID, managed API key, or model name is stored in the binding table.
An authoritative persisted auth health row has:
- Stable OAuth credential ID.
- State kind and safe reason.
- Optional recovery deadline.
- Credential fingerprint for credential-related states.
- Safe upstream status and error code.
- Update timestamp.
The table never stores access tokens, refresh tokens, raw upstream bodies, or short transport cooldowns.
usage_buckets aggregates managed-user usage into 10-minute UTC buckets. The
logical bucket key contains:
- Bucket start.
- User ID.
- API key ID.
- Model.
- Reasoning effort.
- Service tier.
- Stable OAuth credential ID.
Counters are added with an SQLite upsert. New writes prune buckets older than the 30-day retention window.
Snapshots and timeseries are read from the same table. Grafana consumes the timeseries management API rather than reading SQLite directly.
The service handles three distinct secret types:
- Codex OAuth access and refresh tokens on disk.
- The optional remote
admin-api-key. - Generated managed user API keys.
OAuth files are read and refreshed in place. Managed plaintext keys are returned
only at creation or reset; only hashes and masked metadata are persisted.
Raw session affinity signals are not written to SQLite, returned by management
APIs, or written to debug logs. A large JSON request body may be staged in a
process-owned 0600 temporary replay file for the lifetime of that request; the
file is removed when replay closes, including when another request-processing
step replaces the replay body. That temporary storage exists only for affinity
signal extraction. Cross-auth retry bodies are memory-only and capped at
32 MiB.
The server itself provides HTTP. Listen address selection and transport termination belong to the deployment environment.