Open source · MIT · Docker
OpenAI-compatible API gateway backed by the ChatGPT.com web UI
Self-hosted gateway that drives a real ChatGPT session in Chrome: chat completions with thinking modes, image generation and editing. Docker, MIT.
What it does
- OpenAI-style endpoints: /v1/chat/completions, /v1/responses, /v1/images/generations and /v1/images/edits; works with the OpenAI SDK.
- Thinking modes instant, standard, extended, extra_high and pro, with an applied echo block that shows what the UI really activated.
- Image generation and editing: batches up to 10, aspect ratios, reference images and multipart uploads.
- Multi-turn conversations via conversation_id and OpenAI-style streaming with stream: true.
- Operational hardening: request queue with Retry-After, typed errors, Prometheus metrics and a liveness endpoint with stuck detection.
- One-time remote login at /login (SSO/2FA supported) or automatic login with e-mail, password and TOTP secret.
Quickstart
Never expose this gateway without authentication. With
API_KEYunset the gateway is open, and/loginis not a login form — it is a remote-control surface for the browser: screenshots out, clicks, keystrokes and "save session" in. Anyone who can reach that path can drive a browser that is already signed in to your ChatGPT account, read your conversations and take the session cookies with them. SetAPI_KEY, put auth on your reverse proxy, or bind the port to localhost — and note that/,/images/*,/healthand/metricsstay outsideAPI_KEYby design, so a public deployment needs the proxy.
1. Docker run
docker build -t gpt-web-gateway .
docker run -d --name gpt-web-gateway \
-p 3000:3000 \
-v "$PWD/auth:/app/auth" \
-v "$PWD/images:/app/public/images" \
-e HEADLESS=false \
-e API_KEY=change-me \
gpt-web-gateway
2. docker-compose
cp .env.example .env # set API_KEY and (optionally) auto-login credentials
docker compose up -d
3. Log in once
Open http://localhost:3000/login — a remote-login UI streams browser
screenshots so you can complete the ChatGPT login (including SSO/2FA) on a
headless server. The session is persisted to the auth/ volume.
Alternatively, set CHATGPT_EMAIL, CHATGPT_PASSWORD and (for 2FA)
CHATGPT_TOTP_SECRET — the gateway logs in automatically and re-logs-in when
the session expires.
4. Use it
# Chat completion
curl -u "api:$API_KEY" -X POST http://localhost:3000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"Answer with one word: OK"}],"thinking_mode":"instant"}'
# Image generation
curl -u "api:$API_KEY" -X POST http://localhost:3000/v1/images/generations \
-H "Content-Type: application/json" \
-d '{"prompt":"a red sports car on a mountain road, photorealistic","aspect_ratio":"16:9"}'
Works with the OpenAI SDK too:
from openai import OpenAI
client = OpenAI(base_url="http://localhost:3000/v1", api_key="change-me")
result = client.images.generate(prompt="a cat in space", n=1)
print(result.data[0].url)Endpoints
| Method | Path | Description | Auth (API_KEY set) |
|---|---|---|---|
| POST | /v1/chat/completions | Chat Completions-compatible text chat; thinking_mode: instant / standard / extended / extra_high / pro; optional conversation_id (multi-turn) and stream: true (SSE; needs backend-api reading, which is enabled by default) | yes |
| POST | /v1/responses | Minimal Responses-style endpoint (input → output_text) | yes |
| POST | /v1/images/generations | Image generation: n 1..10, aspect_ratio, reference_images, quality, output_format, response_format (url / b64_json) | yes |
| POST | /v1/images/edits | Image editing: multipart (image + optional reference_images[]) or legacy JSON+base64 | yes |
| GET | /v1/images/capabilities | Probe: which toggles the current ChatGPT UI actually exposes (cached 60 s) | yes |
| GET | /v1/images/status | Queue size, rate-limit state, batch metrics | yes |
| GET | /health | Readiness: process is up | no |
| GET | /health/live | Liveness: 503 when the browser layer is stuck or N consecutive infra failures (pair with a restart policy) | no |
| GET | /metrics | Prometheus metrics (counters, error types, duration histogram) | no |
| GET | /login | Remote-login web UI (screenshot stream) | yes (Basic prompt in browsers) |
| GET | / | Simple web UI for generation/editing | no* |
* the web UI at / and generated images under /images/ are not covered by
API_KEY — protect them at your reverse proxy if the gateway is exposed
publicly. Generated images are served to anyone who knows (or guesses) the id,
and they are never expired: give the volume a retention policy of your own if
the prompts that produced them are sensitive.
All responses include an applied echo block (what was really activated in the
UI, e.g. applied.thinking_verified) next to requested, so clients can
detect silent degradation when the ChatGPT UI changes.
Automatic tier downgrade (Pro → Extra High)
The Pro tier has its own quota and it runs out. When a thinking_mode=pro
request cannot actually get Pro, the gateway serves it on the next tier down
rather than running at whatever level the composer happened to show:
"applied": { "thinking_mode": "extra_high", // what really ran
"thinking_verified": true, // ...and the UI confirmed it
"requested_verified": false, // but it is NOT what was asked for
"thinking_fallback": { "from": "pro", "to": "extra_high",
"reason": "quota", "verified": true } },
"requested": { "thinking_mode": "pro" } // unchanged: what the caller asked
thinking_verified refers to applied.thinking_mode. After a downgrade it is
true because Extra High really is active — read requested_verified (or the
presence of thinking_fallback) to know whether the requested tier was applied.
reason is one of quota / disabled / not-offered / click-not-applied /
cooldown:<earlier reason>, or empty_turn when the answer came from the instant resend of
an empty turn (to: "instant"). Only strong evidence memoizes the tier as gone
(TIER_UNAVAILABLE_COOLDOWN_SEC): an explicitly disabled item or the post-submit
limit notice immediately, a missing menu item after two consecutive sightings, a
single unverified click never. A memo never overrides reality — if the pill
already shows Pro, Pro is used and the memo is dropped.
Two limits worth knowing:
- The downgrade is decided before the prompt is sent. If ChatGPT only reveals
the exhausted quota after submission (a limit notice instead of an answer),
that request fails with
error_kind: tier_limit(HTTP 503, `should_retry: true`) — the prompt is already submitted, and re-sending it silently is how you get duplicate generations. The tier is memoized at that moment, so a retry and every request after it are downgraded automatically. On an image turn the same notice is told apart from the image-creation limit, so it raisestier_limitrather than the global-cooldownrate_limit. - A batch (
n > 1) can straddle the boundary.thinking_verifiedandrequested_verifiedare true only if every image confirmed it, andapplied.thinking_mode_mixedmarks a batch whose images did not all run on the same tier (applied.thinking_modethen describes the first one). tier_limitis deliberately notrate_limit: that kind arms a global cooldown that blocks every endpoint for 30 minutes, while this affects one tier.
Set TIER_FALLBACK_PRO=off to disable the downgrade entirely.
Multi-turn conversations (conversation_id)
Text responses from /v1/chat/completions and /v1/responses always include a
top-level conversation_id (the ChatGPT conversation UUID, or null when it
could not be determined). Pass it back in the next request to continue the same
conversation instead of starting a new chat:
curl -s localhost:3000/v1/chat/completions -H 'Content-Type: application/json' \
-d '{"messages":[{"role":"user","content":"My name is Ada. Remember it."}]}'
# → { ..., "conversation_id": "1f0e4b2a-...", ... }
curl -s localhost:3000/v1/chat/completions -H 'Content-Type: application/json' \
-d '{"conversation_id":"1f0e4b2a-...","messages":[{"role":"user","content":"What is my name?"}]}'
Notes:
- Backward compatible: omit
conversation_idand every request is a fresh chat (previous behavior, unchanged). - The id must be a UUID exactly as returned earlier — anything else is rejected
with 400 (
invalid_request). - Conversations live inside the logged-in ChatGPT account: ids from another
account (or deleted chats) fail with
page_load_failed. Treat the id as valid only for the lifetime of that account session. - When continuing a conversation, send only the NEW user message — the history already lives in the ChatGPT chat itself.
Streaming (stream: true)
POST /v1/chat/completions accepts OpenAI-style stream: true and answers with
text/event-stream of chat.completion.chunk events (delta.content pieces,
then a finish_reason: "stop" chunk carrying conversation_id, then
data: [DONE]). Errors after the stream started are sent as a terminal
data: {"error": ...} event.
Streaming requires backend-api reading (READ_VIA_BACKEND_API, on by default) —
the intercepted backend-api SSE deltas are the streaming source. If the server
was started with READ_VIA_BACKEND_API=0, stream: true is rejected with 400
instead of faking a stream. /v1/responses does not support streaming.
Advanced features
These automate unofficial ChatGPT internals (/backend-api/*); endpoints may
drift with ChatGPT updates, so each one degrades gracefully (logs + fallback to
the default path) when it fails. All of them were verified against the live
ChatGPT UI on 2026-07-18 (answer source, multi-turn context carry-over, SSE
chunks, trim route rewrite, memory PATCH, image generation with interception
active, and the DOM-only fallback path).
| Flag | What it does |
|---|---|
READ_VIA_BACKEND_API (on by default; 0 to disable) | Read assistant answers passively from ChatGPT's own backend-api traffic (page.on('response') + an injected fetch tee for live SSE deltas) instead of scraping the DOM. More robust against UI redesigns; the DOM extractor remains the fallback. Prerequisite for stream: true. |
CONVERSATION_TRIM_LIMIT=N (off by default) | Rewrite GET /backend-api/conversation/{id} responses via page.route() so ChatGPT renders only the last N messages of the active branch — long conversations stop freezing the tab (matters for multi-turn conversation_id reuse). Client-side load optimization only: the server-side conversation context is untouched. 0 = off. |
DISABLE_CHATGPT_MEMORY=1 (off by default — account-wide side effect, deliberate opt-in) | Once per process, PATCH the account settings that control ChatGPT "memory" to false, so chat history stops leaking into answers (more deterministic, closer to a stateless API). Verified: "reference chat history" toggles fine (200); "saved memories" (sunshine) can be server-gated (403 on some accounts) — disable that one manually in ChatGPT settings if needed. Best-effort with logging, never a hard failure; the setting is not switched back automatically. |
Error format
Every endpoint returns a uniform error shape:
{
"ok": false,
"error_kind": "refused",
"should_retry": false,
"model_message": "verbatim refusal/banner text from the ChatGPT UI",
"error": { "message": "...", "type": "server_error" }
}
| HTTP | error_kind | should_retry |
|---|---|---|
| 400 | invalid_request | false |
| 401 | unauthorized | false |
| 404 | conversation_not_found | false — the conversation_id does not exist or belongs to another account |
| 503 | tier_limit | true — the requested tier's quota is exhausted (reported only after submit); the retry is served on the fallback tier |
| 422 | refused / policy_violation | false — change the prompt |
| 429 | rate_limit | false — wait Retry-After |
| 429 | queue_full | true |
| 500 | server_error | true |
| 503 | login_failed / upload_failed / page_load_failed | true |
| 504 | timeout | true |
Disclaimer
This project automates the ChatGPT web interface, which violates OpenAI's Terms of Use (automated access outside the official API). Using it may get the ChatGPT account suspended or banned without warning.
- Use a dedicated account, never your main one.
- You need your own paid subscription (Plus/Pro); this is not a way to avoid paying OpenAI.
- The session cookies stored in
auth/grant full access to the account — keep that volume private and never commit it. - Provided as-is, for educational purposes, with no warranty of any kind. You are solely responsible for how you use it.
See SECURITY.md for security notes and reporting.
FAQ
How do I run gpt-web-gateway?
Build the Docker image and run it with the auth/ and images volumes mounted and API_KEY set, or copy .env.example to .env and run docker compose up -d. Then open /login once to sign in to ChatGPT; the session is saved to the auth/ volume.
Can I use it with the OpenAI SDK?
Yes. Point the client at the gateway, for example base_url http://localhost:3000/v1 with your API_KEY as api_key, and call chat completions or images.generate as usual. The model field is accepted and ignored.
Is using it allowed by OpenAI?
No. Automating the ChatGPT web interface violates OpenAI's Terms of Use and the account can be suspended or banned without warning. Use a dedicated account with its own Plus or Pro subscription, never your main one.
Does it bypass ChatGPT rate limits or the subscription?
No. You need your own Plus or Pro subscription, the account's limits still apply, and the gateway applies cooldowns when ChatGPT rate-limits the account.
How do I secure the gateway?
Never expose it without authentication: set API_KEY, put auth on your reverse proxy or bind the port to localhost. The web UI at /, generated images under /images/, /health and /metrics stay outside API_KEY by design, and the auth/ volume holds session cookies, so keep it private.
How many requests can it handle at once?
Up to MAX_CONCURRENT_JOBS jobs run in parallel (1 to 5, default 3), each on its own browser tab; further jobs wait in a queue capped by MAX_QUEUE_SIZE. It is a personal or team gateway, not a high-throughput proxy.
Documentation pages
- gpt-web-gatewayOpenAI-compatible API gateway backed by the ChatGPT.com web UI. A headless Real Google Chrome (patchright — a patched Playwright fork) drives a real…
- Security PolicyPlease report security issues privately via GitHub Security Advisories ("Report a vulnerability" on the repository's Security tab) rather than public…