Skip to content

Releases: thezillo/codex-proxy

v0.3.7 — rendezvous account affinity

Choose a tag to compare

@thezillo thezillo released this 26 Sep 22:45
  • 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

Choose a tag to compare

@thezillo thezillo released this 24 Sep 04:41
  • /v1/responses (the Codex CLI path) now labels codexproxy_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 model field is truncated like other client-supplied fields.

v0.3.5

Choose a tag to compare

@thezillo thezillo released this 23 Sep 16:39

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} — 1 for the state the account is in (ready, cooling, quota_held), 0 for 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

Choose a tag to compare

@thezillo thezillo released this 23 Sep 15:44

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 needs supports_standalone_web_search = true to 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 developer input item, since the backend checks input only. That costs its tokens twice.
  • A request that never says "json", or a malformed response_format (unknown type, json_schema without its object or name), 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

Choose a tag to compare

@thezillo thezillo released this 22 Sep 20:42

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 state warning — 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 compaction item 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

Choose a tag to compare

@thezillo thezillo released this 22 Sep 18:45

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_version 0.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 *-codex models) are still refused with model_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

Choose a tag to compare

@thezillo thezillo released this 22 Sep 18:14

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:

  1. the session-id (else thread-id) header;
  2. prompt_cache_key in the request body;
  3. a stable FNV-1a hash of the request prefix — instructions, tools and the first input item — 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's prompt_cache_key is forwarded; otherwise the key is sent upstream as prompt_cache_key. /v1/responses bodies are still forwarded byte for byte.
  • Fallback: sticky_session = true on a [[fallback]] provider adds the key as session_id and prompt_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 reject session_id.
[[fallback]]
name = "openrouter"
sticky_session = true

Cache observability

  • codexproxy_tokens_total gains kind="cached" (a subset of prompt) and kind="cache_write". A high cache_write with a low cached means paying for a cache that is never read.
  • The access line gains cached_tokens, cache_write_tokens and affinity (header | body | derived).
  • Buffered and streamed chat completions now report usage.prompt_tokens_details.cached_tokens.

v0.3.0 — quota-aware pool, fallback guardrails

Choose a tag to compare

@thezillo thezillo released this 22 Sep 17:57

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/models and [models] extra gets a 400 model_not_found listing what is available, after alias resolution, before the pool or any paid provider sees it. Set reject_unknown = false to restore the old pass-through.
  • gpt-5.5 is no longer accepted. The ChatGPT-account upstream answers it with 400 "not supported", so its only effect was paid-fallback traffic. The sample config.toml also drops the gpt-4o/gpt-4.1 aliases: they would now be rejected. If your config maps retired names, add them to [models] extra or 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 = true restores 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 off

Fallback 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} with reason such as quota_exhausted or cooling_down
  • codexproxy_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

Choose a tag to compare

@thezillo thezillo released this 21 Sep 21:11

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.
  • model is clamped to the model_map keys (else other), 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

Choose a tag to compare

@thezillo thezillo released this 07 Sep 17:25

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:

  1. 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 a Value.
  2. 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 obvious Value round-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.
  3. Verify by re-reading the model, and fall back to the Value round-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