@blankc/codex-proxy
v0.4.0
Published
Local multi-provider Responses API gateway for Codex Desktop and Chat Completions models.
Downloads
84
Readme
codex-proxy
Local multi-provider gateway for Codex Desktop. It exposes one OpenAI Responses-compatible endpoint to Codex while each logical model can use a different upstream provider.
What it supports
- Models are configured inside their provider. Codex IDs are generated as
provider-id/model-name. - Native Responses API passthrough for compatible providers.
- Responses-to-Chat-Completions translation for intranet or self-hosted providers that do not implement Responses API.
- Per-provider authentication, endpoint paths, and SOCKS4/4a/5/5h proxy settings.
- Per-model context window, maximum output tokens, reasoning levels, tool support, and vision support.
- Model discovery through an upstream
/modelsendpoint, with a searchable opt-in selection dialog before any model is added. - Built-in FinWork portal template, bundled model list, and manual FinWork catalog refresh with an add/update/delete preview.
- Web configuration that is persisted and hot-applied to subsequent proxy requests.
- Optional synchronization of Codex
config.tomland a dedicated JSON model catalog.
Configuration schema version 3 automatically migrates schema version 2 model entries into their provider on the next save, and converts a pre-schemaVersion config.json (the v1 routes table, or the original providers/defaultProvider list) on load, keeping the previous file next to it as config.json.<layout>.bak.
Requirements
- Node.js 22 or later
- Codex Desktop or Codex CLI
Install and run
Install the published package globally:
npm install -g @blankc/codex-proxy
codex-proxy --webTo run from source instead:
npm install
npm run build
node dist/src/cli.js --webThe configuration page starts on a random localhost port and remains open after saving. To run the managed proxy:
codex-proxy startUseful commands:
codex-proxy stop
codex-proxy restart
codex-proxy config
codex-proxy logsThe default proxy address is http://127.0.0.1:8080/v1.
One-command source deployment on Linux or Git Bash
After pulling the latest source, run this from the repository root:
bash scripts/deploy-local.shThe script installs locked dependencies, validates and builds the source, creates a local package, installs it globally, then stops the old process and starts the new version. It reuses npm's normal cache and, by default, downloads dependencies from the registry returned by npm config get registry. To use a specific intranet npm registry and port:
bash scripts/deploy-local.sh --registry https://npm.example.internal/ --port 8080Use --skip-tests for a faster deployment or --dry-run to validate without installing or restarting the service.
Configuration model
Configuration is stored in ~/.codexproxy/config.json by default. Set CODEXPROXY_HOME to use a different directory.
{
"schemaVersion": 3,
"port": 8080,
"providers": [
{
"id": "intranet",
"name": "Intranet vLLM",
"protocol": "chat-completions",
"baseUrl": "http://10.0.0.20:8000/v1",
"apiKey": "",
"authType": "none",
"socksProxy": null,
"chatCompletionsPath": "chat/completions",
"modelsPath": "models",
"reasoningMapping": {
"mode": "field",
"field": "reasoning_effort"
},
"models": [
{
"name": "Qwen3-Coder",
"displayName": "Qwen Coder (Intranet)",
"contextWindow": 131072,
"maxOutputTokens": 32768,
"reasoningEfforts": ["none", "low", "high"],
"defaultReasoningEffort": "high",
"supportsTools": true,
"supportsVision": false,
"supportsParallelToolCalls": true,
"enabled": true
}
]
},
{
"id": "external",
"name": "External Responses Provider",
"protocol": "responses",
"baseUrl": "https://gateway.example.com/v1",
"apiKey": "replace-me",
"authType": "bearer",
"socksProxy": "socks5h://127.0.0.1:1080",
"responsesPath": "responses",
"modelsPath": "models",
"reasoningMapping": { "mode": "omit" },
"models": [
{
"name": "gpt-upstream-id",
"displayName": "External GPT",
"contextWindow": 200000,
"reasoningEfforts": ["low", "medium", "high", "xhigh"],
"defaultReasoningEffort": "medium",
"supportsTools": true,
"supportsVision": true,
"enabled": true
}
]
}
],
"codex": {
"enabled": true,
"codexHome": null,
"defaultModel": "intranet/Qwen3-Coder"
}
}Logical model IDs are matched exactly. There is no prefix match or fallback provider, so a model cannot silently escape from the intranet to an external provider.
Reading an older config.json
A file written before schemaVersion existed is converted the first time it is read — by codex-proxy start, codex-proxy --web, or any other command — and the original is kept beside it:
| Old file | Becomes | Backup |
|---|---|---|
| routes table, activeRoute / defaultRoute (0.1.x) | One provider per route, its defaultModel as the provider's only model | config.json.route-table.bak |
| providers + defaultProvider (original layout) | One Chat Completions provider per entry, its defaultModel as the model | config.json.provider-list.bak |
What the conversion does:
targetUrlis split back intobaseUrlplus the endpoint path, sohttps://api.deepseek.com/responsesbecomesbaseUrlhttps://api.deepseek.comwithresponsesPathresponses.authTypeisbearerwhen the route carried an API key,noneotherwise. Keys are copied as they are.- Route names become provider IDs. Characters outside
[a-zA-Z0-9._-]turn into-, and a collision gets a-2suffix. defaultRoute/activeRoute/defaultProviderbecomes the Codex default model,provider-id/model-name.- Anthropic Messages routes are dropped, because this version only speaks Responses and Chat Completions. The command prints what it skipped and why.
- Codex synchronization stays off, as it did not exist in those versions. Turn it on in the Web page once the imported model IDs are what you want in
config.toml. - Prefix matching is gone: only the imported
defaultModelexists per provider. Add the rest on the Providers page, or through Pull models. - If nothing can be imported, the file is left untouched and the error names the reason instead of writing an empty config.
Built-in FinWork portal
Use FinWork 门户 on the Web page to add the built-in FinWork provider. It uses Chat Completions, http://agile.dev.jh/api/ai/basic/v1, and the required app-name: DSH header. Your FinWork inference API key is still entered separately in the provider form.
If an existing provider uses the FinWork inference URL, clicking FinWork 门户 attaches catalog synchronization to that provider without changing its ID, display name, or API key.
The built-in template ships with the current six FinWork models. 刷新 FinWork 目录 calls FinWork's model catalog and opens a preview grouped by added, updated, and removed models. Confirming the preview only stages a full replacement of that provider's model list; you must still click 保存并立即应用 to persist it and refresh Codex. A failed or empty catalog response does not change the current model list.
The catalog endpoint and management token can be overridden for testing or deployment with CODEXPROXY_FINWORK_CATALOG_URL and CODEXPROXY_FINWORK_CATALOG_TOKEN. These values are never returned by the Web configuration API.
Provider protocols
responses: rewrites the logical model toupstreamModel, then streams or returns the upstream Responses payload without protocol translation.chat-completions: translates Codex Responses requests, tool calls, images, responses, and SSE events to and from Chat Completions.
Each Provider can also set headers as a JSON object of string values. These headers are added to every upstream request and take priority over default, authentication, and forwarded caller headers. For example: {"x-api-version":"2026-01-01"}.
socks5h:// is recommended when DNS resolution must also happen through the proxy. Proxy credentials may be included in the SOCKS URL and are redacted from logs.
Reasoning levels
Reasoning is configured at two levels, and confusing them is the usual cause of "the level is selectable but nothing changes":
| Level | Field | Decides |
|---|---|---|
| Model | reasoningEfforts | Which levels Codex may offer. A level that is not enabled is rejected by the proxy. |
| Model | defaultReasoningEffort | The starting level, which must be one of reasoningEfforts. |
| Provider | reasoningMapping | How the chosen level reaches the upstream request body. |
The supported values are none, minimal, low, medium, high, xhigh, max, and ultra.
A new model — added by hand or imported from discovery — starts with none, low, medium, high enabled and medium as its default level. Untick what the upstream does not really support: an enabled level the upstream does not understand is either silently ignored or rejected with a 400. When discovery reports levels for a model, those are used instead.
Only Chat Completions providers need reasoningMapping: a responses provider forwards the request body unchanged, where reasoning.effort is already standard.
{ "mode": "omit" }sends nothing and leaves the upstream default in charge.{ "mode": "field", "field": "reasoning_effort" }writes the level to that field verbatim. This is almost always the right answer.- Add
"values": { "high": "xhigh" }only when the upstream names its levels differently. An empty mapped value, such as{"none":""}, omits the field and lets the upstream choose its default.
reasoningMapping is a Provider-level setting shared by every model under that upstream. The field name may be a dotted path and the mapped values may be strings, numbers or booleans, so both {"field":"chat_template_kwargs.enable_thinking","values":{"none":false,"high":true}} and {"field":"thinking.type","values":{"none":"disabled","high":"enabled"}} are expressible without adapting anything on the gateway.
You rarely write it by hand: the Provider page has a reasoning dialect dropdown that fills in the field name and the value mapping at once.
| Option | What it writes |
|---|---|
| Do not forward (leave it to the upstream) | {"mode":"omit"} — no reasoning field is sent at all |
| OpenAI standard · reasoning_effort | reasoning_effort, values passed through |
| vLLM (older) · chat_template_kwargs.enable_thinking | chat_template_kwargs.enable_thinking = true / false |
| Zhipu · thinking.type | thinking.type = enabled / disabled |
| Alibaba / ModelScope · enable_thinking | enable_thinking = true / false |
| OpenAI nested · reasoning.effort | reasoning.{effort} |
| Custom… | only then do the manual fields appear |
OpenAI standard is the default, since it is what most OpenAI-compatible servers accept. A new Provider has Zhipu or Alibaba pre-selected when a keyword (glm/bigmodel/zhipu, or qwen/dashscope/modelscope) appears in the base URL or model names; a dialect you chose yourself is never overwritten, and an existing Provider is never touched.
When unsure, leave it on the default: an unrecognised field is silently ignored by most servers, so behaviour does not get worse. Switch dialects only once you know the default did nothing.
The switch dialects (vLLM older, Zhipu, Alibaba) only have on and off. For those models, untick low and medium so only none and high are enabled — the middle levels behave identically and offering them is just misleading. New models start with all four ticks, so this is a deliberate step.
The /models standard does not require context-window or reasoning metadata. Discovery shows these fields when present, but never imports a model automatically: select only the candidates you want, then review and fill any missing values in the Web page.
Why the thinking process is sometimes missing
The grey thinking text in Codex comes from a reasoning item in the Responses protocol, and Chat Completions has no equivalent field — upstreams each invented their own. The translation branch recognises all four of these, and any one of them is enough to make reasoning show up in Codex:
| Upstream field | Seen on |
|---|---|
| delta.reasoning_content | DeepSeek, Qwen, vLLM, GLM, Kimi |
| delta.reasoning | OpenRouter, Ollama |
| delta.thinking | some conversion gateways |
| delta.reasoning_details[].text | OpenRouter's structured form |
If it still does not show up and the transcript only has Running Command / Ran Command, check two things in order:
- Whether the upstream is thinking at all. With
reasoningMappingset to "do not forward" no switch is sent, so the upstream has no reason to emit reasoning; and when a model'sreasoningEffortsonly hasnoneticked, Codex itself never asks for it. - Whether the field name is one of the four above. If not, it needs a code change rather than a config change — the price of auto-detection.
Reasoning is display-only: it is never replayed upstream, because the reasoning items Codex sends back on the next turn carry no equivalent in Chat Completions and are dropped.
Codex synchronization
When enabled, every successful Web save also:
- Writes
~/.codex/model-catalogs/codex-proxy/models.json(or the selected Codex home). - Updates managed root keys and
[model_providers.codex_proxy]inconfig.tomlonly when their values change, backing up the existing file with a timestamp first. - Preserves unrelated Codex settings and MCP server tables.
The generated provider uses wire_api = "responses" and points Codex to the local proxy. Proxy routing changes take effect immediately. A port change requires restarting the proxy, and Codex must be reloaded after the model catalog or config.toml changes because the catalog is loaded at startup.
Use Restore from backup in the Web page to inspect timestamped config.toml backups, permanently delete a selected backup, or restore one exact version. The preview shows the complete TOML text and may include sensitive settings. Restoring overwrites the current config.toml without creating another backup, disables Codex synchronization to prevent a later save from overwriting the restored file, and leaves the generated model catalog in place. Reload Codex after restoring.
Development
npm run typecheck
npm test
npm run build
node scripts/smoke-test.mjsSecurity notes
- The server binds to
127.0.0.1only. - API keys are masked by the configuration API and are never returned to the browser after saving.
- Configuration and generated catalog files are written with owner-only permissions where supported.
- API traffic is recorded in
~/.codexproxy/logs/codexproxy.sqliteby default. The Web 日志 tab shows each request's API route, logical model, resolved Provider/upstream model, status, and live progress. - Full traffic capture includes request/response headers and bodies. It is enabled by default for local troubleshooting and can be changed to metadata-only in the Web page; treat the database as sensitive because it may contain prompts and credentials.
