Repository navigation
Releases: thezillo/codex-proxy
Release list
v0.3.7 — rendezvous account affinity
- A conversation's home pool account is picked by rendezvous hashing instead of
hash % pool size(#11). Adding or removing an account now moves only ~1/N of live conversations, and a failing account's conversations spread over the rest of the pool instead of all landing on its neighbour. - Upgrading from v0.3.6 moves most conversations to a new home account once (one uncached turn each).
v0.3.6 — per-model token attribution
/v1/responses(the Codex CLI path) now labelscodexproxy_tokens_total/ request metrics with the request's (post-alias) model instead of-, so per-model usage and cost can be computed (#10).- The access-log
modelfield is truncated like other client-supplied fields.
v0.3.5
Per-account subscription quota metrics (#9).
What the Codex CLI's /status shows, per pool account
codexproxy_account_quota_used_percent{account, window}— share of the quota window used.codexproxy_account_quota_reset_timestamp_seconds{account, window}— when the window resets (unix seconds;… - time()is the countdown).codexproxy_account_state{account, state}—1for the state the account is in (ready,cooling,quota_held),0for the others.codexproxy_account_credits_balance{account},codexproxy_account_info{account, plan_type}.codexproxy_account_quota_observed_timestamp_seconds{account}— how fresh the numbers above are.
window is the window's length (5h, 7d), not its slot: a pro account reports only a weekly window (as primary), a plus account a 5h and a weekly one. One "weekly > N%" alert covers both.
Where the numbers come from
The x-codex-* headers on every /responses answer, so an account serving traffic costs no extra upstream calls. The usage poller now also asks accounts with no report for one quota_check_interval_secs (every account at boot). An idle account found exhausted is held right away instead of costing the next request a 429 round-trip. Request-path account selection is unchanged.
v0.3.4 — Codex compact/search, Chat structured output
Two Codex auxiliary endpoints and structured output for Chat Completions (#8).
Codex compact and search
POST /v1/responses/compact— OpenAI's stateless history compaction (JSON in, JSON out).POST /v1/alpha/search— Codex's standalone web search. A custom Codex provider needssupports_standalone_web_search = trueto call it.
Both go to one pool account (upstream.compact_path / search_path, defaults /codex/responses/compact and /codex/alpha/search) with the same model aliases and unknown-model gate as /v1/responses. Pool only: a pool error is relayed as is, never sent to a [[fallback]] provider. They never cool down or quota-hold an account, so a failing search can't push /v1/responses onto the paid fallback.
response_format on /v1/chat/completions
json_object and json_schema now become the Responses text.format instead of being dropped. JSON mode follows OpenAI Chat:
- "json" in any message, the system prompt included, is enough. When only the system prompt says it, that prompt is also sent as a
developerinput item, since the backend checksinputonly. That costs its tokens twice. - A request that never says "json", or a malformed
response_format(unknowntype,json_schemawithout its object orname), gets OpenAI's own 400.
Not included
The proxy serves /v1/responses over HTTP only. Leave supports_websockets off in Codex provider configs.
v0.3.3 — sessions survive an upstream switch
A live Codex session now survives failover — account → account, pool → fallback provider, and back once a quota resets — instead of dying on Encrypted content could not be decrypted or parsed. (#6, #7).
Replay recovery
Codex resends reasoning (and compaction) items whose encrypted_content only the upstream that minted them can decrypt. When an upstream rejects such a replay with 400 invalid_encrypted_content (or thinking_signature_invalid), the proxy retries the request once, on the same account or provider, without the input items carrying encrypted_content. Messages, tool calls and tool outputs are kept. Requests are never modified up front, and a rejected replay is not an account failure: no cooldown, no failover.
What it costs
- Until the session is restarted, every turn after a switch pays one rejected round-trip (no tokens billed) and logs a
could not decrypt replayed statewarning — Codex keeps the foreign items in its own history. Expected, not a new failure. - Hidden reasoning from earlier turns is gone for the new upstream (it couldn't read it anyway); a dropped
compactionitem takes the history it summarized with it. - Pool → fallback was reproduced with OpenAI's exact error body. Account → account assumes the ChatGPT backend returns the same body; if a session still dies on rotation, please open an issue with the 400 body the proxy relayed.
v0.3.2 — GPT-6 Sol and Luna
The accepted model set now matches the listed entries of the live Codex catalog (/backend-api/codex/models), and the proxy impersonates the latest Codex CLI.
Models (#5)
- gpt-6-sol and gpt-6-luna are accepted and advertised, after gpt-6-astra in catalog order. Both require
minimal_client_version0.155.0. - gpt-5.5 is accepted again. The catalog still lists it for the pro plan, and it answers over a ChatGPT account again (it returned 400 on 2026-09-07). It is deliberately absent from the sample fallback maps: if the pool refuses it, clients see that error rather than a paid answer.
- Hidden catalog entries (
gpt-reserve,codex-auto-review) are neither advertised nor accepted. Older generations the catalog no longer lists (gpt-5.4, the*-codexmodels) are still refused withmodel_not_found.
Verified live over a ChatGPT pro account: gpt-6-sol, gpt-6-luna, gpt-6-astra and gpt-5.5 all complete.
Codex CLI 0.155.1
The impersonated CLI version moves 0.153.4 → 0.155.1 (upstream.cli_version, CODEXPROXY_CLI_VERSION). Besides the User-Agent fingerprint, the catalog endpoint filters by client_version, so an older identity would not see the new models at all.
Fallback maps
If you map gpt-6 models onto OpenRouter, note the price spread there: gpt-6-astra $10/M input and $50/M output, gpt-6-sol $2/M and $10/M, gpt-6-luna $0.10/M and $0.50/M. Mapping gpt-6-astra to openai/gpt-6-sol keeps a pool outage from being billed at astra prices.
v0.3.1 — conversation affinity for prompt caching
Every turn of a conversation now reaches the place that already holds its prompt cache — the same pool account, and, on the fallback, the same OpenRouter provider. Non-breaking: the new setting defaults off and older configs load unchanged.
Conversation keys (#4)
Every request gets a conversation key, in order of preference:
- the
session-id(elsethread-id) header; prompt_cache_keyin the request body;- a stable FNV-1a hash of the request prefix —
instructions,toolsand the firstinputitem — which every turn of a conversation repeats verbatim. No similarity matching: prompt caching only hits on an exact prefix anyway.
- Pool: the key picks the account, so clients that send no session header (plain OpenAI-style clients on
/v1/chat/completions) now stay on one account too, not only Codex sessions. /v1/chat/completions: the client'sprompt_cache_keyis forwarded; otherwise the key is sent upstream asprompt_cache_key./v1/responsesbodies are still forwarded byte for byte.- Fallback:
sticky_session = trueon a[[fallback]]provider adds the key assession_idandprompt_cache_key(never overwriting the client's own) — the fields OpenRouter keeps provider stickiness on. OpenRouter bills cache writes at 1.25x input for GPT-5.6+, so without stickiness a turn that misses the cache is paid twice. Leave it off for Azure, which may rejectsession_id.
[[fallback]]
name = "openrouter"
sticky_session = trueCache observability
codexproxy_tokens_totalgainskind="cached"(a subset ofprompt) andkind="cache_write". A highcache_writewith a lowcachedmeans paying for a cache that is never read.- The access line gains
cached_tokens,cache_write_tokensandaffinity(header|body|derived). - Buffered and streamed chat completions now report
usage.prompt_tokens_details.cached_tokens.
v0.3.0 — quota-aware pool, fallback guardrails
The paid fallback chain now runs only when the subscription pool really can't serve a request: quota exhaustion is tracked per account, unknown models and pool-side request errors no longer leak onto paid providers, and a Codex session stays on one account so its prompt cache survives.
Breaking changes
- Unknown models are rejected by default. A model outside
/v1/modelsand[models] extragets a 400model_not_foundlisting what is available, after alias resolution, before the pool or any paid provider sees it. Setreject_unknown = falseto restore the old pass-through. gpt-5.5is no longer accepted. The ChatGPT-account upstream answers it with 400 "not supported", so its only effect was paid-fallback traffic. The sampleconfig.tomlalso drops thegpt-4o/gpt-4.1aliases: they would now be rejected. If your config maps retired names, add them to[models] extraor remove them.- Pool 4xx is relayed, not retried on the paid chain. A pool 4xx other than 401/403/429 (e.g. "model is not supported when using Codex with a ChatGPT account") goes back to the client.
fallback_on_bad_request = truerestores the old behavior. - All-accounts-down goes straight to the chain. With a
[[fallback]]chain configured, a request arriving while every account is cooling down or quota-exhausted skips the pool, including after a transport failure. Previously the next request retried upstream.
Quota-aware pool (#1, #2)
A 429 whose body says usage_limit_reached is the 5-hour or weekly Codex quota, not a transient throttle. The account is marked quota-exhausted until the reset the 429 reports (resets_at, resets_in_seconds, per-window rate_limits or the x-codex-*-reset-after-seconds headers; the latest wins, clamped to 60 s – 8 days), separately from account_cooldown_secs. The client still receives the real upstream 429 byte for byte.
While any account is quota-exhausted, the proxy re-reads usage_path for it every quota_check_interval_secs and clears the state as soon as either window shows headroom, so an early or manual reset is picked up without waiting. Healthy accounts are never polled; a failed poll keeps the state (fail-open).
[upstream]
usage_path = "/wham/usage"
quota_check_interval_secs = 600 # 0 = polling offFallback guardrails (#3)
[models]
reject_unknown = true
extra = []
fallback_on_bad_request = false
# On a plain 429, retry the POOL with a lower model before paying the fallback.
# Defining the section replaces the built-in map; empty disables downgrades.
[models.downgrades]
"gpt-6-astra" = "gpt-5.6-sol"A usage_limit_reached 429 is account-wide and skips downgrades. If every downgrade step fails, the paid chain runs with the original model.
Session-pinned pool accounts (#3)
Round-robin sent consecutive turns of one Codex session to different accounts, so each turn re-read the whole conversation on a cold prompt cache. A request carrying session-id (or thread-id) now starts its pool scan at a home account picked by a stable FNV-1a hash. Availability ordering is unchanged: a cooling or quota-held home hands the session to the next account until it recovers. Requests without these headers still round-robin.
Observability
New counters, so alerts can tell a quota outage from a dead model id:
codexproxy_failovers_total{client,model,reason,fallback}withreasonsuch asquota_exhaustedorcooling_downcodexproxy_model_downgrades_total{client,from,to,outcome}codexproxy_rejected_requests_total{client,reason}
Image: ghcr.io/thezillo/codex-proxy:v0.3.0.
Full changelog: v0.2.7...v0.3.0
v0.2.7 — embeddings, direct to provider
POST /v1/embeddings — served directly by a named fallback provider, because the subscription backend has none.
The gap
Everything this proxy fronts goes to chatgpt.com/backend-api, which speaks the Responses API and nothing else: there is no embeddings endpoint behind a ChatGPT account, and there never will be. Any client that wants both chat and embeddings from one OpenAI-compatible base URL (an agent runtime's memory search is the concrete case) had to be pointed at a second, paid endpoint with a second key — and its spend fell outside the per-client attribution this proxy exists for.
Design: direct, not a failover
A new [embeddings] section names one already-declared [[fallback]] provider and reuses its base_url, auth_style and api_key — including the CODEXPROXY_FALLBACK_{NAME}_API_KEY override. No new secret, no new env var:
[embeddings]
provider = "openrouter" # must be a [[fallback]] name
# path = "/embeddings" # appended to the provider's base_url
[embeddings.model_map]
"text-embedding-3-small" = "openai/text-embedding-3-small"
"text-embedding-3-large" = "openai/text-embedding-3-large"The route never touches the account pool and never cascades: one provider, one JSON request, one JSON response. Only model_map'd ids are accepted — anything else is a 400 naming what is, before a byte leaves the proxy. The provider's own id (openai/...) is an implementation detail: the response's model is rewritten back to what the client asked for, the same way /v1/chat/completions echoes echo_model. A provider non-2xx is relayed unchanged. A 2xx body is buffered under max_body_bytes and refused past it rather than truncated — a truncated success body would reach the client as JSON that isn't.
Deliberately not FallbackProvider::send and not FallbackChain::run: the former asks for text/event-stream, the latter treats a non-2xx as "try the next provider", and both are wrong for a single fixed JSON endpoint. Like them, it sends no Codex/ChatGPT headers to a third party.
Startup refuses a section whose provider isn't a declared fallback name or whose model_map is empty, and logs embeddings configured: direct to provider, no pool, no failover once it has loaded. Without the section the route answers a JSON 404 that points at the config, so a client aimed at an unconfigured deploy sees a message, not a bare Axum 404.
Telling it apart from an outage
Every request here carries account=<provider> — the same string a real pool→fallback switch would carry, because it is the same provider. What distinguishes them is the endpoint label, and nothing else was added: no new metric label, no new series on the three existing metrics.
codexproxy_requests_total{endpoint="/v1/embeddings", account="openrouter", ...}is the normal state, not a symptom.- No failover log line is ever emitted for it.
- Usage is booked as prompt tokens; the completion side is
0. modelis clamped to themodel_mapkeys (elseother), the same cardinality guard the chat path applies via its supported-model list — the two sets don't overlap.
Alerts on "served by fallback" must therefore exclude by endpoint, not by provider name: codexproxy_requests_total{account!="data", account!="-", endpoint!="/v1/embeddings"}. EMBEDDINGS_ENDPOINT in src/server.rs documents that coupling.
Full changelog: v0.2.6...v0.2.7
v0.2.6 — aliases on the Responses path
The model alias map now runs on /v1/responses — the endpoint Codex actually uses.
The gap
[defaults.model_aliases] existed to stop a bare gpt-6 / gpt-5.6 from reaching the upstream, where a ChatGPT account answers:
400 {"detail":"The 'gpt-6' model is not supported when using Codex with a ChatGPT account."}
But it only ever ran on the /v1/chat/completions translation. Codex speaks wire_api = "responses", so the one client the map exists for never reached it: the bare name went upstream verbatim, 400'd, and the request fell through to a paid fallback provider. Nothing failed loudly — the fallback answered, so a session looked healthy while being billed off-subscription. Worse, /v1/models advertises those bare names, so a client picking one out of the proxy's own list was the one being diverted.
--model gpt-5.6 against this proxy now lands on the subscription pool.
How the rewrite stays a passthrough
Three steps, and the ordering is the design:
- Read the model with a one-field struct. serde walks the JSON but builds no tree — every other field is skipped as
IgnoredAny. A body with no alias to apply returns here and is forwarded byte-for-byte, never parsed into aValue. - Splice the new name into the raw bytes. Not premature optimisation: bodies reach
server.max_body_bytes(16 MiB), and an aliased Codex client pays this on every request of a session whose history keeps growing. Measured on a 16 MiB body — the obviousValueround-trip costs +64 MB and 75 ms, the splice +17 MB and 17 ms. Deployments size this proxy as "baseline + the bodies currently in flight"; the splice keeps that arithmetic true. - Verify by re-reading the model, and fall back to the
Valueround-trip when it doesn't match. Correctness rests on serde, never on the byte scanner.
That third step is what makes step 2 safe rather than clever. A body can defeat the scan — a nested "model" member appearing first, a JSON-escaped model name, even a member whose name ends in an escaped quote ({"a\"model": …}, whose trailing bytes are literally "model"). Each is now a test; each fails closed into the tree rewrite and forwards a correct body.
An unrecognized id is deliberately not replaced by defaults.model the way resolve_model does on the other path. Here the id is the client's own, and rejecting it is the upstream's call, not the proxy's.
Breaking, if you configured a fallback model_map
model_map keys are matched against the model actually sent upstream. On /v1/responses that used to be whatever the client sent; it is now the aliased name, matching what /v1/chat/completions already did. A provider keyed by a bare name stops matching there — and an unmapped model makes the provider skipped, not degraded:
[fallback.model_map]
"gpt-5.6-sol" = "..." # not "gpt-5.6"The old wording in config.toml promised the previous behaviour; it and the README are corrected in this release.
Full changelog: v0.2.5...v0.2.6