gpt-web-gateway

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.

Get started Read the docsView on GitHub

What it does

Quickstart

Never expose this gateway without authentication. With API_KEY unset the gateway is open, and /login is 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. Set API_KEY, put auth on your reverse proxy, or bind the port to localhost — and note that /, /images/*, /health and /metrics stay outside API_KEY by 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

MethodPathDescriptionAuth (API_KEY set)
POST/v1/chat/completionsChat 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/responsesMinimal Responses-style endpoint (input → output_text)yes
POST/v1/images/generationsImage generation: n 1..10, aspect_ratio, reference_images, quality, output_format, response_format (url / b64_json)yes
POST/v1/images/editsImage editing: multipart (image + optional reference_images[]) or legacy JSON+base64yes
GET/v1/images/capabilitiesProbe: which toggles the current ChatGPT UI actually exposes (cached 60 s)yes
GET/v1/images/statusQueue size, rate-limit state, batch metricsyes
GET/healthReadiness: process is upno
GET/health/liveLiveness: 503 when the browser layer is stuck or N consecutive infra failures (pair with a restart policy)no
GET/metricsPrometheus metrics (counters, error types, duration histogram)no
GET/loginRemote-login web UI (screenshot stream)yes (Basic prompt in browsers)
GET/Simple web UI for generation/editingno*

* 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:

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:

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).

FlagWhat 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" }
}
HTTPerror_kindshould_retry
400invalid_requestfalse
401unauthorizedfalse
404conversation_not_foundfalse — the conversation_id does not exist or belongs to another account
503tier_limittrue — the requested tier's quota is exhausted (reported only after submit); the retry is served on the fallback tier
422refused / policy_violationfalse — change the prompt
429rate_limitfalse — wait Retry-After
429queue_fulltrue
500server_errortrue
503login_failed / upload_failed / page_load_failedtrue
504timeouttrue

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.

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