A lightweight Rust proxy that translates the OpenAI Responses API (used by Codex CLI) into the Chat Completions API, letting Codex work with any OpenAI-compatible provider — DeepSeek, Kimi, Qwen, Mistral, Groq, xAI, OpenRouter, and more.
Codex CLI speaks the OpenAI Responses API, which is an OpenAI-proprietary stateful protocol. Every other provider exposes the standard Chat Completions API. codex-relay sits between Codex and your chosen provider, translating on the fly — no code changes to Codex required.
# From PyPI — prebuilt binary for your platform
pip install codex-relay
# From crates.io
cargo install codex-relay1. Start the relay
CODEX_RELAY_UPSTREAM=https://api.deepseek.com/v1 \
CODEX_RELAY_API_KEY=$DEEPSEEK_API_KEY \
CODEX_RELAY_PORT=4446 \
codex-relayOn startup, the relay logs the available upstream models and prints a hint:
ℹ upstream models: deepseek-chat, deepseek-reasoner
⚠ To configure Codex with model metadata, run: codex-relay --print-config --upstream ...
2. Generate your Codex config
codex-relay --print-config \
--upstream https://api.deepseek.com/v1 \
--api-key $DEEPSEEK_API_KEY \
--model-catalog ~/.codex/codex-relay-models.jsonThis prints a ready-to-use ~/.codex/config.toml snippet and writes a model
catalog containing both Codex's built-in models and every upstream model, so
you won't see the "Model metadata … not found" warning. The generated
entries inherit version-sensitive instructions and tool formats from the
installed Codex CLI. Use --model-template <MODEL> to select a different
bundled model as that template.
If you prefer to write the config by hand, here is the minimal form:
model = "deepseek-chat"
model_provider = "deepseek-relay"
model_catalog_json = "/home/user/.codex/codex-relay-models.json"
[model_providers.deepseek-relay]
name = "DeepSeek"
base_url = "http://127.0.0.1:4446/v1"
wire_api = "responses"
env_key = "DEEPSEEK_API_KEY"Recent Codex versions use
model_catalog_json; the former[model_properties]config syntax is no longer supported. A custom catalog replaces the built-in catalog, socodex-relaystarts with the catalog from your installed Codex version and appends upstream models instead of creating an incomplete replacement.
3. Use Codex normally — it routes through the relay transparently.
| Flag | Env var | Default | Description |
|---|---|---|---|
--port |
CODEX_RELAY_PORT |
4444 |
Listen port |
--bind |
CODEX_RELAY_BIND |
127.0.0.1 |
IP address to bind the listener to (e.g. 0.0.0.0 to accept remote connections) |
--upstream |
CODEX_RELAY_UPSTREAM |
https://openrouter.ai/api/v1 |
Upstream Chat Completions base URL |
--api-key |
CODEX_RELAY_API_KEY |
(empty) | API key forwarded to upstream |
--opencode-go-compat |
CODEX_RELAY_OPENCODE_GO_COMPAT |
false |
Enable OpenCode Go compatibility normalization and schema-reference stripping |
--max-request-bytes |
CODEX_RELAY_MAX_REQUEST_BYTES |
67108864 |
Maximum inbound Responses request size |
--upstream-extra-params |
CODEX_RELAY_UPSTREAM_EXTRA_PARAMS |
(empty) | JSON object merged into each upstream request |
--drop-upstream-params |
CODEX_RELAY_DROP_PARAMS |
(empty) | JSON array of top-level upstream request parameters to remove |
--model-map |
CODEX_RELAY_MODEL_MAP |
(empty) | Comma-separated source:target model name translations |
--print-config |
(none) | — | Print a Codex config snippet and exit |
--model-catalog |
(none) | — | With --print-config, write a version-matched full model catalog to this path |
--model-template |
(none) | first visible bundled model | Bundled Codex model whose instructions and tool formats generated entries inherit |
--record-corpus |
CODEX_RELAY_RECORD_CORPUS |
(off) | Append the conversation flow of every completed turn to daily JSONL files (OpenAI messages format) in this directory |
--session-ttl-hours |
CODEX_RELAY_SESSION_TTL_HOURS |
168 |
Retain idle previous_response_id history and reasoning state for this many hours |
--max-sessions |
CODEX_RELAY_MAX_SESSIONS |
256 |
Maximum completed response histories retained for continuation |
--max-session-memory-mb |
CODEX_RELAY_MAX_SESSION_MEMORY_MB |
512 |
Approximate memory budget for retained session/reasoning state |
| Provider | Base URL | Suggested port |
|---|---|---|
| DeepSeek | https://api.deepseek.com/v1 |
4446 |
| Kimi (Moonshot) | https://api.moonshot.cn/v1 |
4447 |
| GLM (Zhipu) | https://open.bigmodel.cn/api/coding/paas/v4 |
4453 |
| Qwen | https://dashscope.aliyuncs.com/compatible-mode/v1 |
4448 |
| Mistral | https://api.mistral.ai/v1 |
4449 |
| Groq | https://api.groq.com/openai/v1 |
4450 |
| xAI | https://api.x.ai/v1 |
4451 |
| OpenRouter | https://openrouter.ai/api/v1 |
4452 |
Any OpenAI-compatible endpoint works.
Some providers expose non-standard Chat Completions parameters. You can merge top-level JSON fields into every upstream request, and optionally drop generated top-level fields before the merge. For example, to disable DeepSeek V4 thinking/reasoning mode:
CODEX_RELAY_UPSTREAM_EXTRA_PARAMS='{"thinking":{"type":"disabled"}}' \
CODEX_RELAY_DROP_PARAMS='["reasoning_effort"]' \
codex-relay --upstream https://api.deepseek.com/v1 --api-key "$DEEPSEEK_API_KEY"- Streaming — full SSE streaming with correct event sequencing
- Tool calls — accumulates streaming deltas and emits structured function_call items
- Parallel tool calls — consecutive function_call input items merged into one assistant message
- Reasoning models — streams
reasoning_content(or thereasoningalias) as Responses reasoning summaries and preserves it across turns (Kimi k2.6, DeepSeek-R1, GLM). For GLM/Zhipu models the relay automatically sendsthinking: {"type": "enabled"}, since GLM otherwise suppresses reasoning under Codex's system prompt - Model catalog — proxies
/v1/modelsfrom the upstream provider - Auto-config —
--print-configgenerates a complete Codex config with model metadata
Ctrl-C, or SIGTERM on Unix, stops accepting new connections and waits for active requests to finish, including streaming responses. Normal shutdown exits with status 0. When disk history or corpus recording is enabled, completed Chat Completions turns are written before normal shutdown completes.
The first signal waits without a timeout. A second supported shutdown signal interrupts a stalled drain and exits with status 1; unfinished requests can be interrupted. Signal listeners are registered before the server announces readiness, and registration failures exit with a nonzero status.
| Variable | Default | Description |
|---|---|---|
CODEX_RELAY_PORT |
4444 |
Port to listen on |
CODEX_RELAY_BIND |
127.0.0.1 |
IP address to bind the listener to (e.g. 0.0.0.0 to accept remote connections) |
CODEX_RELAY_UPSTREAM |
https://openrouter.ai/api/v1 |
Upstream Chat Completions base URL |
CODEX_RELAY_API_KEY |
(empty) | API key forwarded to upstream |
CODEX_RELAY_OPENCODE_GO_COMPAT |
false |
Enable OpenCode Go compatibility normalization and schema-reference stripping |
CODEX_RELAY_MAX_REQUEST_BYTES |
67108864 |
Maximum inbound Responses request size |
CODEX_RELAY_UPSTREAM_EXTRA_PARAMS |
(empty) | JSON object merged into each upstream request body |
CODEX_RELAY_DROP_PARAMS |
(empty) | JSON array of top-level upstream request parameter names to remove before forwarding |
CODEX_RELAY_MODEL_MAP |
(empty) | Comma-separated source:target model name translations (e.g., gpt-5.4:deepseek-v4-pro) |
CODEX_RELAY_TOOL_DENYLIST |
(empty) | Comma-separated tool names to remove before forwarding tools to the upstream model |
CODEX_RELAY_DISABLE_QUIRKS |
(empty) | Comma-separated platform quirk names to disable (e.g. dsml_heal,glm_thinking) |
CODEX_RELAY_SESSION_TTL_HOURS |
168 |
Retain idle session/reasoning state for this many hours |
CODEX_RELAY_MAX_SESSIONS |
256 |
Maximum completed response histories retained for previous_response_id |
CODEX_RELAY_MAX_SESSION_MEMORY_MB |
512 |
Approximate memory budget for retained session/reasoning state |
CODEX_RELAY_HISTORY_STORE |
memory |
Retained history backend: memory or disk |
CODEX_RELAY_HISTORY_DIR |
.codex-relay-history |
Directory for disk-backed history records |
CODEX_RELAY_RECORD_CORPUS |
(off) | Directory to append per-turn conversation records (OpenAI messages JSONL); off unless set |
RUST_LOG |
codex_relay=info |
Log verbosity |
This opt-in mode sends all requests to the configured upstream's native
/responses endpoint instead of translating them to /chat/completions. Use
an upstream and model that support native Responses (for example, Muse on
OpenCode Go); there is no automatic fallback to Chat Completions.
CODEX_RELAY_OPENCODE_GO_COMPAT=true \
codex-relay --upstream https://opencode.ai/zen/go/v1 --api-key "$OPENCODE_GO_API_KEY"The bridge normalizes custom tools to structured functions, flattens namespace
tools, and bounds upstream tool names to 64 bytes. It restores the original
client identities on both JSON responses and SSE events, including Codex
apply_patch custom calls and namespace routing. History items, forced
tool_choice, and allowed_tools choices use the same mapping. Ambiguous
names or forced choices targeting unavailable tools return HTTP 422.
Text and reasoning SSE events are forwarded as they arrive, with keepalives
while the provider is idle. Custom tool arguments are buffered per tool
until complete JSON is available, then emitted as raw custom-input events.
Malformed/truncated streams produce response.failed; unknown or duplicate
tool calls, including undeclared native call types, are rejected. Hosted search
calls require a declared and forwarded web_search_preview tool. Response usage
and other provider metadata survive the translation. apply_patch descriptions
retain caller-supplied editing constraints when their transport prose is normalized.
Provider-specific workarounds are limited to this mode:
- Orphan function/custom tool outputs without a
call_idbecome explicitly labeled, untrusted user messages, never developer/system instructions. - JSON Schema
$refkeywords are removed from tool parameter schemas while preserving sibling constraints, literal property names, and example data. This can broaden the accepted schema: enable it only for an upstream that requires this workaround. - Unsupported hosted tools are dropped (
web_search_previewis preserved). reasoning.effort=noneis removed while preserving other reasoning fields.
Model mappings, tool denylists, and upstream extra/drop parameters also apply in this mode. A tool injected through extra parameters cannot authorize a call absent from the original client declarations. Overrides that change the client's streaming mode are rejected.
An incoming x-opencode-session header (1–256 ASCII bytes) is forwarded.
Otherwise, each new conversation gets a random identity; a known
previous_response_id reuses its identity. Bindings are held in memory and
bounded by --max-sessions and --session-ttl-hours. Clients replaying full
history without previous_response_id should provide the session header to
retain a provider session. Prompt text is never used as a session identifier.
Native conversation history remains upstream; the relay's disk history store
is used only by the Chat Completions path. Corpus recording with
--record-corpus is not supported in native mode and is rejected at startup.
Normalization logs contain change counts, not request contents or credentials.
The configurable inbound body limit defaults to 64 MiB in both modes;
raise --max-request-bytes / CODEX_RELAY_MAX_REQUEST_BYTES for larger image
requests. Requests exceeding the limit return HTTP 413.
Some providers need workarounds that are not part of the Responses ⇄ Chat Completions translation itself. These are registered as named quirks (see src/quirks.rs for the full registry, triggers, and removal criteria):
| Quirk | Kind | What it does |
|---|---|---|
glm_thinking |
request-shaping | Sends thinking: enabled for GLM/Zhipu models so they emit reasoning_content (issue #26) |
dsml_heal |
response-healing | Parses DeepSeek V4's intermittently leaked DSML tool-call markup in text content back into structured tool calls |
missing_done |
response-healing | Treats a cleanly closed SSE stream without [DONE] as complete when a full turn was received (issue #31) |
Response-healing quirks activate only when the anomaly is detected and log a quirk <name> fired warning each time, so you can tell from the logs whether a workaround is still needed. Once the platform fixes the underlying bug, disable a quirk immediately with:
CODEX_RELAY_DISABLE_QUIRKS=dsml_heal codex-relayfrom codex_relay import start
proc = start(port=4446, upstream="https://api.deepseek.com/v1", api_key="sk-...")
# ... use Codex ...
proc.terminate()Two layers — offline tests pin behavior against captured Codex wire-shape; live tests pin behavior against real provider APIs.
Unix shutdown tests launch the real binary and send process signals. On dynamic
Linux/glibc builds, startup-failure tests also compile a small C fault-injection
fixture with cc; the fixture is loaded only into the test child process.
For tool-routing issues, enable debug logs:
RUST_LOG=codex_relay=debug codex-relayThe relay logs tool names only, never tool arguments or message content:
response tools=...— tools received from Codex's Responses API requestupstream tools=...— tools forwarded to the Chat Completions upstreamupstream function_calls=...— function calls returned by a blocking upstream responseupstream stream function_calls=...— function calls returned by a streaming upstream response
These lines are useful for checking whether a tool such as spawn_agent
was preserved by the relay, and whether the failure happened before or after
the model selected that tool.
By default, codex-relay keeps retained previous_response_id histories and
reasoning lookups in memory. For longer-running processes or deeper debugging,
you can opt into an inspectable on-disk store:
CODEX_RELAY_HISTORY_STORE=disk \
CODEX_RELAY_HISTORY_DIR=.codex-relay-history \
codex-relayThe disk backend writes JSON records under:
.codex-relay-history/
sessions/
reasoning/
turns/
Session records contain the translated Chat Completions messages retained for
a response id. New records retain conversation input (including developer/system
messages) and assistant replies, while excluding the request's top-level
instructions/system. Send those top-level instructions again on each request
where they should apply. Existing records without this separation preserve
their system content to avoid discarding conversation rules.
Reasoning records keep call-id and turn-fingerprint lookups used to round-trip provider reasoning content. The relay keeps only an in-memory index for disk-backed entries and loads payloads on demand.
Treat this directory as sensitive: records may contain prompts, tool outputs, and other conversation data. The same TTL/count/byte retention knobs apply to disk-backed records, and evicted entries are removed from disk.
For building datasets, --record-corpus <dir> continuously appends the
conversation flow to daily-sharded JSONL files in OpenAI messages format:
codex-relay --record-corpus ./corpus \
--upstream https://api.deepseek.com/v1 --api-key "$DEEPSEEK_API_KEY"This is off by default and is a separate subsystem from the retention cache above: the corpus is an append-only archive that is never evicted, whereas the session store is an evictable continuation cache.
Each line is an incremental turn event — only the messages new to that turn
are written, so the same conversation is reconstructed by concatenating the
messages of every event that shares a conversation_id:
corpus/
corpus-2026-04-04.jsonl
{
"conversation_id": "resp_abc…",
"response_id": "resp_def…",
"parent_response_id": "resp_abc…",
"timestamp_unix_ms": 1783447750503,
"model": "deepseek-chat",
"messages": [ { "role": "user", "content": "…" }, { "role": "assistant", "content": "…" } ]
}The messages payload uses the standard OpenAI schema and preserves
tool_calls, role: "tool" outputs (tool_call_id), and assistant
reasoning_content (a widely-used non-standard field that training frameworks
ignore if unknown). The first event of a conversation includes the system
prompt; subsequent events omit it. Isolated spawn_agent child requests start
their own conversation_id.
To fold the turn events back into whole-conversation OpenAI records:
jq -s 'group_by(.conversation_id)[]
| {messages: (map(.messages) | add)}' corpus/*.jsonl
⚠️ Records contain prompts, tool call arguments, and tool outputs — more than the debug logs ever emit. Treat the directory as sensitive, especially when combined with--bindon a non-loopback address.
Codex subagent tools such as spawn_agent, wait_agent, and close_agent
are runtime tools. The relay can preserve them in the tool schema and round-trip
the model's selected function call, but it cannot reliably detect whether the
local Codex app-server daemon is new enough to execute those calls.
If Codex shows unsupported call: spawn_agent, first verify that the Codex CLI
and app-server daemon versions match. A stale daemon can expose a newer tool
schema to the model while lacking the handler that executes the returned call.
Also check your Codex config: [features] subagents = true is not recognized;
use [features] multi_agent = true only if you need to override the default.
As an escape hatch for affected runtimes, remove unsupported tools before they reach the upstream model:
CODEX_RELAY_TOOL_DENYLIST=spawn_agent,wait_agent,close_agent codex-relayThe denylist matches the tool name forwarded to Chat Completions. Namespaced
MCP tools use their flattened name, for example
mcp__codex_apps__github-_fetch_issue.
Offline (always green, default cargo test)
Replays Codex CLI fixtures through the translation layer and asserts
role/tool/reasoning behavior. Each fixture pins a Codex CLI version under
tests/fixtures/codex_<major>_<minor>_<patch>/.
cargo testLive (gated on provider API key, #[ignore] by default)
Spawns the relay binary on a random port, points it at the real provider, and
exercises /v1/models, blocking + streaming, tool calls, and (for thinking
models) the reasoning_content round-trip via an in-process recording proxy.
DEEPSEEK_API_KEY=sk-... cargo test --test compat_deepseek_live -- --ignored --test-threads=1Regenerating fixtures after a Codex upgrade
- Add a debug dump to the relay (write
bodybytes fromhandle_responsesto a file before parsing). - Run a real
codex execagainst it; copyinbound_*.jsonto a newtests/fixtures/codex_<major>_<minor>_<patch>/folder. - Trim each payload down to the smallest one that exercises the feature you want to lock in.
- Add a row to
tests/fixtures/VERSIONS.mdand a test pointing at the new directory.
The old fixture directory stays as a regression net so the relay keeps working with the previous Codex CLI release.
This project is not affiliated with, endorsed by, or sponsored by OpenAI. "Codex" refers to OpenAI Codex CLI, an open-source project licensed under Apache-2.0. codex-relay is an independent, community-built translation proxy.
- myk5010 — system/developer message ordering fix and model name mapping (#4)
- qcnhy — streaming usage, MCP namespace bug reports, namespace tool-routing analysis, reasoning and apply_patch feature reports, nested-namespace and encrypted-content regression reports, and independent verification (#5, #6, #17, #26, #37, #39, #40, #60, #61)
- JasonC93 — subagent tool-routing and spawned-agent context isolation reports (#10, #12, #24)
- ma-buting — namespace tool-name separator fix (#19)
- SaladDay — prompt-cache accounting debug logs (#22)
- Cherno76 — prompt-cache hit tokens in Responses API usage (#23)
- the1here2me — upstream parameter override request behind
--upstream-extra-paramsand--drop-upstream-params(#29) - andrewzah — dropped
[DONE]recovery, leaked<think>markup routing, parallel-test port races, and the outdated config-syntax report (#31, #32, #58, #46) - 0xAndoroid — plaintext collaboration argument negotiation for subagents (#43)
- 0x5446 — empty-message filtering,
reasoning.effortforwarding, and doubled-delimiter DSML healing (#47, #48, #49) - dennisimoo — zero-argument tool-call wedge report (#57)
- dhruv-anand-aintech — opt-in OpenCode Go compatibility mode (#64)
- 365daysdreamer — Codex instruction preservation and
apply_patchtool-description normalization (#65) - andrei-21 — graceful shutdown with request draining (#66)
- VikingOwl91 — Mistral structured SSE and GLM thinking compatibility, relay URL in the generated config, upstream context limits in the model catalog, and opt-in request lifecycle timing (#67, #68, #69, #70)
MIT