Convert chat.deepseek.com into a local, OpenAI-compatible HTTP API.
Run any OpenAI client — the official openai SDK, OpenCode,
the DeepSeek Harness, LangChain,
Cursor, Aider, Claude Code (via OPENAI_BASE_URL) — against your own signed-in
DeepSeek account, with no separate DeepSeek API key.
Inspired by Sophomoresty/gemini-web2api.
Single-file entry point, but adapted for DeepSeek's web protocol (PoW challenge,
SSE patches, localStorage.userToken).
┌──────────────┐ OpenAI /v1 ┌──────────────────────┐ chat.deepseek.com ┌────────────┐
│ any client │ ────────────► │ deepseek_web2api.py │ ──────────────────► │ DeepSeek │
│ OpenCode, │ ◄──────────── │ (FastAPI + uvicorn) │ ◄────────────────── │ web │
│ Claude Code, │ SSE │ │ SSE └────────────┘
│ harness... │ └──────────────────────┘
└──────────────┘ ▲
│ captured once via Chromium
▼
┌──────────────┐
│ your login │
└──────────────┘
- OpenAI Chat Completions API (
/v1/chat/completions) — streaming + non-streaming - OpenAI Responses API (
/v1/responses) — Codex CLI compatible - Auto session refresh — once you've logged in, future runs reuse the persistent browser profile
- PoW solving in-process — runs DeepSeek's own
sha3_wasm_bg.wasmviawasmtime, no round-trip to a real browser for every request - THINK / RESPONSE fragment filtering — only the user-facing reply is exposed as
content; reasoning is filtered out - Multi-turn via
conversation_id— DeepSeek thread lineage preserved across requests - Multiple model ids —
deepseek-chat,deepseek-reasoner,deepseek-expert,deepseek-expert-reasoner, plus OpenAI-style aliases - Optional API-key auth (
--api-key) — turn the proxy into a private relay - Cross-platform — Linux, macOS, Windows; Chromium via Playwright
- Zero auth bypass — this isn't a free DeepSeek. It uses your account, your quota, your normal web login.
| What | Status |
|---|---|
| OpenAI Chat Completions wire format | ✅ verified — 28/28 checks pass with the official openai Python SDK |
| OpenAI Responses API (Codex CLI) | ✅ basic mapping (input + instructions + previous_response_id + stream) |
| Unit tests | ✅ 9 groups / 51 assertions pass |
Server boots, /v1/models, /healthz |
✅ |
| Chromium login + cookie/userToken capture | ✅ implemented; login subcommand handles the AWS WAF human-check via a visible window |
| Temp-mail signup (mail.tm / tempmailc) | ✅ implemented; signup subcommand drives the DeepSeek signup form |
| End-to-end with a real captured session | |
| PoW round-trip against real DeepSeek |
See tests/VERIFICATION.md for the full honest list.
Requires Python 3.10+ and a Chromium-capable OS (Linux/macOS/Windows; WSL works).
git clone https://github.com/<you>/deepseek-web2api.git
cd deepseek-web2api
pip install -r requirements.txt
playwright install chromiumrequirements.txt
playwright>=1.44
httpx>=0.27
fastapi>=0.111
uvicorn[standard]>=0.30
pydantic>=2.7
curl_cffi>=0.7
wasmtime>=21.0
python deepseek_web2api.py login
# → a Chromium window opens on https://chat.deepseek.com/sign_in
# log in with your email + password (or Google), solve the AWS WAF human-check
# the script polls localStorage.userToken, saves it under data/session.json, exitsSubsequent runs reuse the persistent profile in data/profile/, so the
login step is only needed once (or when the session expires after ~6
hours — python deepseek_web2api.py refresh silently re-captures).
python deepseek_web2api.py serve --host 127.0.0.1 --port 8082
# → 🚀 DeepSeek Web2API listening on http://127.0.0.1:8082
# OpenAI base URL: http://127.0.0.1:8082/v1Flags:
| Flag | Default | What it does |
|---|---|---|
--host |
127.0.0.1 |
Bind address (use 0.0.0.0 to expose on the network) |
--port |
8082 |
HTTP port |
--api-key |
(none) | If set, require Authorization: Bearer <key> on /v1/* |
--log-level |
info |
debug / info / warning / error |
--no-interactive-login |
off | Never pop a browser; return 503 if no cached session |
Python (openai SDK)
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:8082/v1", api_key="not-needed")
for chunk in client.chat.completions.create(
model="deepseek-chat",
messages=[{"role": "user", "content": "hi"}],
stream=True,
):
print(chunk.choices[0].delta.content or "", end="")curl
curl http://127.0.0.1:8082/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek-reasoner",
"messages": [{"role": "user", "content": "Reply with the word ready."}]
}'OpenCode — drop in ~/.config/opencode/opencode.json:
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"deepseek-local": {
"npm": "@ai-sdk/openai-compatible",
"name": "DeepSeek (local web2api)",
"options": {
"baseURL": "http://127.0.0.1:8082/v1",
"apiKey": "anything"
},
"models": {
"deepseek-chat": {},
"deepseek-reasoner": {},
"deepseek-expert": {},
"deepseek-expert-reasoner": {}
}
}
}
}DeepSeek Harness — in $DSH_HOME/settings.yaml:
llm-pi-ai:
providers:
deepseek-local:
apiKeyEnv: ANYTHING
api: openai-completions
baseURL: http://127.0.0.1:8082/v1
models:
- id: deepseek-chat
- id: deepseek-reasoner
- id: deepseek-expertClaude Code — set two env vars:
export OPENAI_BASE_URL=http://127.0.0.1:8082/v1
export OPENAI_API_KEY=anything
export OPENAI_MODEL=deepseek-chat| Method | Path | Notes |
|---|---|---|
| GET | /healthz |
liveness probe |
| GET | /v1/models |
list model ids |
| POST | /v1/chat/completions |
OpenAI Chat Completions (stream + non-stream) |
| POST | /v1/responses |
OpenAI Responses API (Codex CLI compatible) |
Non-streaming reply:
{
"id": "chatcmpl-…",
"object": "chat.completion",
"created": 1700000000,
"model": "deepseek-chat",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "Hello! How can I help?"},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 4, "completion_tokens": 6, "total_tokens": 10},
"conversation_id": "abcd-1234-…:42"
}Streaming follows the standard OpenAI chat.completion.chunk shape: a role
chunk first, then a sequence of content deltas, then a finish chunk carrying
the conversation_id, and a final data: [DONE].
| id | DeepSeek model_type | thinking |
|---|---|---|
deepseek-chat |
default |
off |
deepseek-reasoner |
default |
on (DeepThink) |
deepseek-expert |
expert |
off |
deepseek-expert-reasoner |
expert |
on |
deepseek-v3 |
alias for deepseek-chat |
|
deepseek-r1 |
alias for deepseek-reasoner |
Unknown model ids are accepted (fall through to deepseek-chat) — better
than hard-failing a friendly typo. To add a new id, edit MODELS in
deepseek_web2api/format.py.
Each non-streaming reply (and the final streaming chunk) carries a
conversation_id of the form <chat_session_id>:<last_message_id>. Pass it
back in the next request's conversation_id field to keep the same DeepSeek
thread — the server resumes with the thread's existing parent_message_id
chain.
reply1 = client.chat.completions.create(
model="deepseek-chat",
messages=[{"role": "user", "content": "Tell me a joke."}],
)
print(reply1.choices[0].message.content)
reply2 = client.chat.completions.create(
model="deepseek-chat",
messages=[{"role": "user", "content": "Another one."}],
extra_body={"conversation_id": reply1.conversation_id},
)
print(reply2.choices[0].message.content)Note: a thread's model is fixed when it's created. Resuming keeps the
original model; passing a different model on resume is silently ignored.
-
Login — Playwright opens a persistent Chromium profile at
data/profile/, navigates tochat.deepseek.com/sign_in, waits for the user to sign in (solving the AWS WAF human-check), and reads the bearer token fromlocalStorage.userTokenplus the cookies. Saved todata/session.jsonwith0o600permissions. -
Refresh — subsequent calls (or the first server request) try a headless capture from the same profile. Only a full expiry (no token found, all cookies gone) triggers a visible re-login.
-
Chat — the server's pure-HTTP client calls DeepSeek's internal API:
POST /api/v0/chat_session/create → new thread id POST /api/v0/chat/create_pow_challenge → get a PoW challenge solve it via the bundled WASM POST /api/v0/chat/completion → SSE stream (with x-ds-pow-response header) -
SSE parsing — DeepSeek's stream is JSON-patch format:
{"v": {"response": {"fragments": [ {"type": "THINK", "content": "Let me think..."}, {"type": "RESPONSE", "content": "The answer is"} ], "message_id": 42}}} {"p": "response/fragments/-1/content", "o": "APPEND", "v": " 42"} {"v": "."}We track which paths point at RESPONSE fragments and skip THINK/SEARCH content so the user only sees the actual reply.
-
PoW — DeepSeek gates
/api/v0/chat/completionbehind a proof-of-work header (x-ds-pow-response). The algorithm ("DeepSeekHashV1") is shipped as a WebAssembly module on DeepSeek's own CDN (fe-static.deepseek.com/.../sha3_wasm_bg.wasm). We bundle the same module indeepseek_web2api/sha3_wasm_bg.wasmand run it viawasmtime— same code the browser runs, no round-trip, no reimplementation.
deepseek_web2api/
├── deepseek_web2api.py # single-file CLI entry point
├── deepseek_web2api/ # package — the actual implementation
│ ├── pow.py # PoW solver (runs DeepSeek's WASM via wasmtime)
│ ├── auth.py # Chromium login + session capture + CLI
│ ├── client.py # pure-HTTP DeepSeek client
│ ├── format.py # OpenAI <-> DeepSeek message conversion
│ ├── server.py # FastAPI app
│ ├── tempmail.py # mail.tm + tempmailc.com helpers (test only)
│ ├── signup.py # browser-driven signup automation (test only)
│ └── sha3_wasm_bg.wasm # DeepSeek's PoW module, bundled
├── tests/
│ ├── test_units.py # 51 unit assertions
│ ├── test_openai_compat.py # 28 OpenAI SDK compat assertions
│ ├── test_orchestration.py # 37 full-code-path assertions with mock client
│ ├── test_e2e.py # signup → capture → serve → query (needs real account)
│ ├── run_all.py # runs all 3 deterministic suites sequentially
│ └── VERIFICATION.md # what works, what doesn't, why
├── data/ # gitignored — your session + browser profile live here
├── README.md
├── requirements.txt
└── .gitignore
# 3 deterministic suites (no DeepSeek account needed)
python -m tests.run_all
# individually
python -m tests.test_units # 9 groups / 51 assertions
python -m tests.test_openai_compat # 28 assertions (boots server, drives openai SDK)
python -m tests.test_orchestration # 37 assertions (boots server, mock client)The test_e2e.py script drives a real DeepSeek signup (with a temp-mail
address) and tests the full flow. It needs a real Chromium environment
where you can solve the AWS WAF captcha — it won't run end-to-end in a
headless sandbox.
- Single-file CLI at the root (
deepseek_web2api.py) with subcommands. - Implementation lives in the
deepseek_web2api/package. - All file I/O writes
data/with0o600/0o700for credential material. - Bearer tokens are never logged.
- Public functions have docstrings; types are PEP 604 (
X | None). - Server is FastAPI on uvicorn, with explicit CORS-free defaults (it's a local proxy — don't expose it on the public internet without a reverse proxy and auth).
data/session.jsoncontains your bearer token. Keep it private. The file is created with0o600perms.- The bundled
sha3_wasm_bg.wasmbelongs to DeepSeek — included only because it's required to talk to their public web API. - Default bind is
127.0.0.1. Don't expose this server on the public internet without putting it behind a reverse proxy (nginx, caddy, etc.) and a real auth layer. --api-keyis intentionally minimal — aBearertoken check via a Starlette middleware. It is not a substitute for TLS, rate limiting, or audit logging.authorization: Bearer <your-token>is sent on every upstream request tochat.deepseek.com. If you setlog_requests: true(future feature) or share a core dump, that token is exposed. Don't.
| Symptom | Likely cause / fix |
|---|---|
playwright._impl._errors.Error: Executable doesn't exist |
Run playwright install chromium |
LoginRequired: No DeepSeek session found |
Run python deepseek_web2api.py login once to capture a session, or set --no-interactive-login + load data/session.json |
RuntimeError: PoW solver returned no answer |
The bundled WASM is stale — DeepSeek shipped a new one. Set DS_POW_WASM=/path/to/new.wasm after downloading from https://fe-static.deepseek.com/chat/static/sha3_wasm_bg.*.wasm |
openai.AuthenticationError: 401 from upstream |
Session expired (cookies, token, or both). Run python deepseek_web2api.py refresh to silently re-capture, or login again. |
403 Request blocked from chat.deepseek.com |
AWS WAF thinks you're a bot. Run login in a visible window and complete the captcha. |
No message arrived for <email> within 120s |
DeepSeek's signup email went to spam, or the email service is down. Try a different temp-mail provider. |
Server returns 502 upstream_error |
DeepSeek's protocol changed or you're being rate-limited. Check data/session.json is fresh. |
MIT — see LICENSE.
The bundled deepseek_web2api/sha3_wasm_bg.wasm belongs to DeepSeek and is
included only because it's required to talk to their public web API.
- Sophomoresty/gemini-web2api — the pattern this project is built on
- kittors/deepseek-web-api — TypeScript reference for the PoW + session flow
- sums001/Deepseek-API — pure-Python reference for the WASM solver
- mail.tm and tempmailc.com — temp-mail providers used by the test harness
This is unofficial, not affiliated with DeepSeek, and not endorsed by them. Use at your own risk. The DeepSeek web protocol is not a public API and can change without notice. This tool reuses your signed-in session to talk to your own account — it does not bypass authentication, does not create free DeepSeek usage, and does not scale to industrial workloads. Read DeepSeek's Terms of Service before using.