Device-side OpenAI-compatible gateway that lets apps use subscription-based LLM accounts (xAI Grok, OpenAI Codex) - fixed monthly cost, no per-token billing surprises - through standard OpenAI client SDKs.
The proxy handles the OAuth subscription dance under the hood: it
stores credentials produced by a device-code OAuth flow, refreshes
access_tokens lazily when upstream returns 401, persists rotated
refresh_tokens to disk atomically, and exposes everything as a
vanilla OpenAI-compatible HTTP API. Apps don't see OAuth, refresh,
or rotation - they just point at OPENAI_BASE_URL=http://...:9080/v1
and use the OpenAI SDK they'd use anywhere else.
USE AT YOUR OWN RISK. This software is provided "AS IS", without warranty of any kind, express or implied. The authors and contributors accept no liability for any consequence of running it — including, but not limited to, suspension or termination of accounts with upstream LLM providers, loss of subscription credit, rate-limit lockouts, or data loss. You are solely responsible for ensuring your use complies with the Terms of Service of every upstream provider whose credentials you attach. Provider policies change without notice; review them periodically.
- A single-user proxy running on a device you own, forwarding requests from apps you also own/run, all on the same machine or same private bridge network.
- Authentication via OAuth tokens minted by the providers' own
device-code flows (
auth.openai.com/oauth/token,auth.x.ai/oauth2/token), explicitly built for headless / terminal / developer-tool consumption. - Backend endpoints exposed for exactly this use case:
chatgpt.com/backend-api/codexandapi.x.ai/v1. Not the consumer web UIs (chatgpt.com,grok.com), no cookie scraping, no headless-browser automation. - Cloudflare passthrough headers (
originator,User-Agent) shaped to match the upstream CLIs — the same set hermes-agent and openclaw inject when calling codex directly. The traffic looks exactly like a first-party CLI doing what a first-party CLI does.
Both upstream clients we mirror treat this use case as supported.
The cleanest public statement is openclaw's codex provider page,
quoted verbatim: "Policy note: OpenAI Codex OAuth is explicitly
supported for external tools/workflows like OpenClaw." See:
github.com/openclaw/openclaw
docs/concepts/model-providers.md. xAI's OAuth tier is similarly
documented as a first-class developer-facing surface.
- Multi-tenant proxying. Routing requests from people who are not the account owner through one OAuth pool. ToS violation across every provider, and triggers their abuse detection within hours.
- Account sharing. Letting multiple individuals consume one subscription via this proxy.
- Commercial reselling. Charging third parties for access to your account's capacity.
- Tying this to consumer web-session credentials (e.g. SSO cookies extracted from chatgpt.com or grok.com). The proxy doesn't support that path and adding it would put you in scraper territory regardless of what wrapping you put on top.
- Unbounded automation loops that burn through subscription fast-mode quotas at machine speed. Both OpenAI and xAI flag velocity anomalies; an agent in a runaway loop can cost you the account, not just the daily quota.
Subscription-via-OAuth has been allowed for years, but the field
is in motion: in early 2026 Anthropic narrowed its position around
third-party-CLI reuse of Claude subscriptions, forcing some users
back to paid API keys. OpenAI and xAI have not made that change;
they could. If a provider publishes a new policy, this proxy stops
being the right fit for that provider and the operator should fall
back to an official billable API key (console.x.ai /
platform.openai.com).
POST /v1/chat/completions(and any other/v1/*path) - forward to the upstream provider matched by the request'smodelfield.GET /v1/models- union of/v1/modelsfrom each attached provider, prefixed with the provider slug (e.g.xai/grok-4,codex/gpt-4o). Cached with a 24h TTL; falls back to the last successful fetch if upstream is down.GET /healthz- liveness probe.GET /internal/stats- request + token counters (per provider/model, prompt vs completion split), for polling collectors. Consumed by Reefy's per-device metrics page when the proxy ships as part of a fleet device; see Reefy fleet integration.GET /internal/debug- inspect the request-capture flag.POST /internal/debug- flip request capture on/off without restarting. See Debug capture below.
| Providers configured | Request | Result |
|---|---|---|
| 0 | (any) | 503 - no provider configured |
| 1 | (any) | route to the one provider |
| >1 | xai/grok-4 |
route to xAI by prefix |
| >1 | gpt-4o (bare) |
resolved via dynamic registry |
| >1 | unknown (bare, unmatched) |
503 - prefix required |
All via env vars.
| Var | Default | Purpose |
|---|---|---|
LISTEN_HOST |
0.0.0.0 |
Bind address |
LISTEN_PORT |
9080 |
Bind port |
DATA_DIR |
/data |
credentials.json + models-cache.json location |
MODELS_CACHE_TTL_S |
86400 |
How long /v1/models cache is fresh |
LOG_LEVEL |
INFO |
Logging verbosity |
REEFY_LLM_PROXY_DEBUG |
unset | If 1/true/yes/on, start with debug capture on. Hot-toggle at runtime via /internal/debug. |
REEFY_LLM_PROXY_DEBUG_DIR |
/data/debug |
Where per-call JSON dumps go when debug is on. Created on first use. |
Ad-hoc tool for inspecting proxied traffic. Off by default; flip on without a restart:
# inspect
curl -s http://127.0.0.1:9080/internal/debug
# {"enabled":false,"dump_dir":"/data/debug"}
# turn on
curl -sX POST http://127.0.0.1:9080/internal/debug \
-H 'Content-Type: application/json' -d '{"enabled":true}'
# turn off
curl -sX POST http://127.0.0.1:9080/internal/debug \
-H 'Content-Type: application/json' -d '{"enabled":false}'While on, every proxied request produces:
- One short summary line in the container journal:
[debug] POST /v1/chat/completions -> codex 200 (in=92B out=8244B 1005ms) dump=/data/debug/<file>.json - One JSON file under
REEFY_LLM_PROXY_DEBUG_DIR(default/data/debug) containing the full inbound body, the translated upstream body (when the proxy rewrote it - e.g. codex ChatCompletions -> Responses), the redacted upstream headers, the response status + headers, and the complete response body (SSE streams included, tee'd chunk-by-chunk).
Headers Authorization, Cookie, X-API-Key, ChatGPT-Account-ID,
OpenAI-Organization, and Proxy-Authorization are redacted in
BOTH the journal line and the dump file. Request and response
bodies are written verbatim - assume the dump dir is sensitive.
Mount DATA_DIR (or REEFY_LLM_PROXY_DEBUG_DIR) on persistent
storage if you want dumps to survive container recreate.
Off-mode cost is one boolean check per request. On-mode adds one
list-append + b''.join per stream chunk and a single file write at
stream end. Restarting the container reverts to whatever the
REEFY_LLM_PROXY_DEBUG env said at boot.
Cleanup is the operator's responsibility - this is debugging, not telemetry. Delete files manually when you're done.
The proxy ships as part of Reefy devices. The
device's metrics publisher polls GET /internal/stats and the
Reefy dashboard renders per-model request and token series
(prompt vs completion broken out), time-ranged across
15m / 1h / 24h / 7d / 30d.
Example: AI agent periodic activity (heartbeat) visible as the steady stair-step in requests and tokens over 24h.
LISTEN_HOST=0.0.0.0 inside the container is intentional - the
proxy is designed to be reached via a private docker bridge network
(e.g. reefy-llm) where every member is a trusted app you wired in.
There is no auth on any route, including /v1/* and
/internal/stats. The bridge network IS the access boundary.
If you publish the container port on the host (e.g.
-p 127.0.0.1:9080:9080), every local process on that host can use
your subscription LLM credentials with no authentication.
The proxy uses two files with disjoint writers:
| File | Writer | Role |
|---|---|---|
$DATA_DIR/credentials.json |
The user (or any external system you point at it) | "What was attached" snapshot. Holds the OAuth pair the user obtained via the provider's device-code flow. Proxy NEVER writes here. |
$DATA_DIR/credentials.runtime.json |
The proxy | Rotated state. Written after every successful OAuth refresh. Includes a derived_from_attach map (see below). |
$DATA_DIR/stats.json |
The proxy | Lifetime request + token counters surfaced via /internal/stats. Loaded on startup so the counters survive container restarts. Written atomically after every increment. |
Both files have mode 0600 and contain the same providers shape:
{
"providers": {
"xai": {
"provider": "xai",
"label": "default",
"access_token": "...",
"refresh_token": "...",
"expires_at": 1747000000,
"scopes": ["openid", "..."],
"last_refreshed_at": null
},
"codex": { ... }
}
}credentials.runtime.json carries one additional top-level field:
{
"providers": { ... },
"derived_from_attach": {
"xai": "rt_originalAttachRefreshTokenForXai...",
"codex": "rt_originalAttachRefreshTokenForCodex..."
}
}derived_from_attach[slug] records the refresh_token that was in
credentials.json at the moment this rotation chain began. The
proxy stamps it on every persist; it stays stable across multiple
rotations within the same chain.
The access_token expires (hours to days), so the proxy has to refresh and persist new tokens. The attach file holds the user-controlled origin (months-stable) and shouldn't be mutated by the proxy. Splitting writers means any rewrite of the attach file (e.g. routine config sync, an idempotent re-deploy of an infrastructure script) does NOT clobber the proxy's rotated chain.
For each provider slug present in credentials.json:
| Runtime has slug? | runtime.derived_from_attach[slug] == attach.refresh_token? |
Action |
|---|---|---|
| no | n/a | use attach (first run, or rotation has never happened yet) |
| yes | yes | use runtime (rotation chain is alive, anchored to this very attach token) |
| yes | no | use attach, treat runtime entry as stale (user re-attached: attach now holds a different refresh_token than the chain was started from) |
Comparison is on refresh_token values, not file mtimes. A real
re-attach always mints a server-side-fresh refresh_token, so
value-equality is the exact "is this still the same chain" signal.
File mtimes lie - any rewrite of credentials.json bumps mtime even
when the bytes are unchanged.
The proxy watches credentials.json via inotify (through
watchfiles). When the file is modified, added, or atomically
replaced (tempfile + rename), the proxy re-runs the merge above and
swaps its in-memory vault. No restart required.
Practical effect:
- Update
credentials.jsonwith a freshly-obtained OAuth pair → proxy notices within ~milliseconds → next request uses the new pair → no container restart, no dropped connections. - Same file written with identical bytes → merge re-runs, picks the same winners → no observable change.
Each provider speaks an OpenAI-ish API but with subtle differences
the proxy normalises. Defaults in ProviderSpec track the OpenAI
shape; per-provider entries override what diverges.
Vanilla OpenAI shape. /models returns {data: [{id: ...}]}, chat
completions accept the standard ChatCompletions request body, no
extra headers required. The OAuth bearer is enough; xAI doesn't
enforce User-Agent / originator gating. (Confirmed by inspecting
hermes-agent's xAI aux client — it uses a stock OpenAI(api_key, base_url=api.x.ai/v1) with no default_headers.)
Not OpenAI-compatible despite the surface similarity. Three overrides:
-
?client_version=<semver>query param on every call. Upstream rejects without it. Pinned to a value matching a real codex-rs CLI release; bump if the response shape we depend on drifts. -
/modelsresponse uses{models: [{slug: ...}]}instead of{data: [{id: ...}]}. Registry maps viamodels_list_key+model_id_keyoverrides. -
Cloudflare originator + User-Agent + ChatGPT-Account-ID headers. chatgpt.com/backend-api is behind a Cloudflare layer that whitelists a small set of first-party originators (
codex_cli_rs,codex_vscode,codex_sdk_ts, anything starting withCodex). Requests from non-residential IPs (VPS, server-hosted runners) or without an allowed originator getHTTP 403withcf-mitigated: challenge, regardless of bearer validity. We send:User-Agent: codex_cli_rs/0.155.0-alpha.9.2 (reefy-llm-proxy) originator: codex_cli_rs ChatGPT-Account-ID: <extracted from JWT claim>ChatGPT-Account-IDis extracted per-request from the bearer JWT'shttps://api.openai.com/auth.chatgpt_account_idclaim (same path the codex-rs CLI uses). On a malformed token we drop the header rather than fail — the request still goes through and any auth issue surfaces as a clean 401.This mirrors what hermes-agent and openclaw inject when they call codex directly. Both of their docs explicitly state these headers are NOT added on their generic OpenAI-compatible proxy path, so the responsibility for the Cloudflare passthrough lands on the proxy in front of codex. Sources (all open):
- codex-rs CLI (upstream we shape against):
github.com/openai/codex —
authoritative for
User-Agent/originator/ account-ID handling. - hermes-agent's direct-codex header builder:
auxiliary_client.py::_codex_cloudflare_headersin github.com/NousResearch/hermes-agent — docstring there is the source for the Cloudflare originator whitelist explanation copied above. - openclaw's docs page on the codex provider:
github.com/openclaw/openclaw
docs/concepts/model-providers.md— explicitly states the hidden attribution headers are not added on OpenAI-compatible-proxy paths.
- codex-rs CLI (upstream we shape against):
github.com/openai/codex —
authoritative for
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
mkdir -p /tmp/reefy-llm-proxy-data
# place a credentials.json with valid tokens here for live testing
DATA_DIR=/tmp/reefy-llm-proxy-data \
LOG_LEVEL=DEBUG \
python -m reefy_llm_proxy.main
# in another terminal:
curl http://localhost:9080/healthz
curl http://localhost:9080/v1/models
curl -X POST http://localhost:9080/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model": "xai/grok-4-fast", "messages": [{"role":"user","content":"hi"}]}'CI builds on push to main via .github/workflows/build.yml,
publishing ghcr.io/reefyai/reefy-llm-proxy:sha-<short-sha> and
:latest.
Run on Linux with the normal dependencies installed:
PYTHONPATH=src python -m unittest discover -s tests -v
PYTHONPATH=src E2E_ARTIFACT_DIR=artifacts/e2e python -m unittest discover -s tests/e2e -vThe suite starts real HTTP proxy/upstream servers, exercises FastAPI startup, the credential-file watcher and persisted caches, and uses synthetic credentials. It makes no external provider calls. CI runs it before publishing an image and saves sanitized request/response evidence even on failure. See the reproduction and fix.

