RuriMeiko/whalegate
RuriMeiko/whalegate has 31 GitHub stars and 23 forks. Star changes are not measured yet. Last checked 2026-11-09 20:09 UTC. Last push 2026-11-04.
🐳 whalegate — OpenAI-compatible gateway into DeepSeek web sessions (Rust): account pool, failover, SSE, native-style tool calling (tool_choice, schema validation, repair retries), vision, PoW solver. Prefer the official paid API.
What you can do
- 🔌 OpenAI-compatible - POST /v1/chat/completions (stream and non-stream) and GET /v1/models. Responses use the OpenAI shape, including usage and cached_tokens
- 🛠️ Tool calling - Supports tools, tool_choice (auto / none / required / named) and parallel_tool_calls. Arguments are validated against your JSON Schema, and the gateway retries with a targeted correction before a broken call ever reaches the client
- 🖼️ Vision - Accepts image_url (data URL or http), Anthropic-style base64 image, and images inside tool results. Images are uploaded to DeepSeek and cached per account
- 🧠 DeepThink - reasoning_effort maps to none / auto / max. Reasoning streams as reasoning_content
Measured star growth
- in 24h
- Update delayed
- in 7 days
- Update delayed
- in 15 days
- Update delayed
- in 1 month
- Update delayed
Net star changes over overlapping periods, not daily totals. Missing measurements are left blank. Stars measure attention, not software quality.
README.md
Original
English · Tiếng Việt · 简体中文
whalegate turns your own chat.deepseek.com web sessions into a standard OpenAI-style /v1/chat/completions endpoint. It pools several accounts, fails over between them, streams over SSE, reads images, and translates OpenAI tools to and from DeepSeek's DSML format. Agents like Cline, Continue, LiteLLM, the OpenAI SDKs and dsh can use it as if it were the real API.
[!WARNING] Use the official API for real work. platform.deepseek.com is cheap, has an SLA, and doesn't risk your account. whalegate exists for learning, protocol research and personal automation on your own account. Automating the web app may violate DeepSeek's Terms of Service and get the account locked. Don't resell or share access. You use it at your own risk (MIT, no warranty).
Contents
- Features
- How it works
- Quick start
- Adding accounts
- Using the API
- Tool calling
- Images (vision)
- Reasoning effort
- Stateful conversations
- Account pool & failover
- Egress IP per account (proxies)
- Dashboard
- Moving accounts between machines
- Updating
- Configuration
- HTTP endpoints
- Using with dsh (PTC mode)
- Cloudflare Worker fan-out
- Project layout
- Limitations
Features
| 🔌 OpenAI-compatible | POST /v1/chat/completions (stream and non-stream) and GET /v1/models. Responses use the OpenAI shape, including usage and cached_tokens |
| 🛠️ Tool calling | Supports tools, tool_choice (auto / none / required / named) and parallel_tool_calls. Arguments are validated against your JSON Schema, and the gateway retries with a targeted correction before a broken call ever reaches the client |
| 🖼️ Vision | Accepts image_url (data URL or http), Anthropic-style base64 image, and images inside tool results. Images are uploaded to DeepSeek and cached per account |
| 🧠 DeepThink | reasoning_effort maps to none / auto / max. Reasoning streams as reasoning_content |
| 🐳 Account pool | Least-busy scheduling with a per-account concurrency cap, burst limiter and escalating cooldowns |
| 🔁 Failover | An error, empty or truncated stream moves the request to another account (up to 3 attempts) |
| 🧵 Stateful | Continues the same web conversation and sends only the new messages, so long agent loops stay fast and cheap |
| 🔐 Proof-of-work | Built-in solver for DeepSeekHashV1, with optional prefetch for the next request |
| 🌐 Session capture | One click on a bookmarklet adds the account with your own browser's fingerprint, or log in through a dedicated Chrome with its own fingerprint and auto-refreshed device cookies |
| 📦 One-line install | Prebuilt static binaries for Linux (x86_64/arm64), macOS and Windows. The installer starts the gateway on login and configures dsh |
| 🔄 Self-update | The dashboard checks GitHub Releases and, on one click, downloads, checksum-verifies, installs and restarts into the new build |
| 📦 Portable accounts | Export the pool as a file or clipboard JSON and import it on another machine; merging is idempotent |
| 📊 Dashboard | Accounts, live logs, token usage with "what this would cost on the API", latency p50/p90, and settings with graceful restart |
How it works
flowchart LR
subgraph Clients
A1[OpenAI SDK]
A2[Cline / Continue]
A3[dsh agent]
A4[LiteLLM]
end
subgraph WG["whalegate (Rust · axum)"]
direction TB
R["/v1/chat/completions"]
P["Prompt builder<br/>messages + tools → DSML"]
S["Scheduler<br/>least-busy · cooldown · burst"]
X["Output parser<br/>DSML → tool_calls · schema check"]
POW["PoW solver<br/>DeepSeekHashV1"]
R --> P --> S
S --> POW
X --> R
end
subgraph Pool["Account pool"]
B1[(acc 1)]
B2[(acc 2)]
B3[(acc n)]
end
DS["chat.deepseek.com<br/>/api/v0"]
A1 & A2 & A3 & A4 -->|OpenAI JSON| R
S --> B1 & B2 & B3
B1 & B2 & B3 -->|token + cookies| DS
DS -->|SSE| X
Lifecycle of one request
sequenceDiagram
autonumber
participant C as Client
participant G as whalegate
participant A as Account pool
participant D as DeepSeek web
C->>G: POST /v1/chat/completions (messages, tools, stream)
G->>G: fit context, render tools spec + history as one prompt
G->>G: fingerprint prefix → known web conversation?
G->>A: lease an account (least busy, not cooling down)
A-->>G: acc + session
G->>D: create PoW challenge
D-->>G: challenge
G->>G: solve DeepSeekHashV1
G->>D: completion (prompt or delta, parent_message_id, ref_file_ids)
loop SSE
D-->>G: thinking / response chunks
G-->>C: reasoning_content / content deltas<br/>(DSML markup held back, never leaked)
end
D-->>G: done (+ accumulated token usage)
G->>G: parse tool calls → validate against schema
alt valid
G-->>C: tool_calls + finish_reason + usage + [DONE]
else invalid / unparsable
G->>D: retry with a correction note (≤ 3 attempts, failover on errors)
end
Quick start
Install (recommended)
Linux / macOS
curl -fsSL https://raw.githubusercontent.com/RuriMeiko/whalegate/main/install.sh | sh
Windows (PowerShell)
irm https://raw.githubusercontent.com/RuriMeiko/whalegate/main/install.ps1 | iex
flowchart LR
I["install script"] --> D["download binary<br/>for your OS/CPU<br/>+ verify SHA-256"]
D --> S["start in background<br/>+ on every login"]
S --> C{"dsh installed?"}
C -- yes --> W["write dsh config<br/>(provider, model, PTC)"]
C -- no --> O
W --> O["open http://127.0.0.1:8790/<br/>→ add an account"]
| Linux | macOS | Windows | |
|---|---|---|---|
| Binary | ~/.local/bin/whalegate |
~/.local/bin/whalegate |
%LOCALAPPDATA%\whalegate\whalegate.exe |
| Runs via | systemd --user unit whalegate |
launchd agent io.github.rurimeiko.whalegate |
hidden Startup entry |
| Logs | journalctl --user -u whalegate -f |
~/Library/Logs/whalegate.log |
dashboard → log |
Installer options are environment variables: WHALEGATE_VERSION=v0.4.0, WHALEGATE_PORT=8790, WHALEGATE_NO_SERVICE=1 (binary only), WHALEGATE_NO_DSH=1. Re-running the installer upgrades in place. Prefer to click? Grab an archive from Releases and run whalegate (or double-click whalegate.exe).
Build from source
Requires Rust ≥ 1.85, plus cmake and clang (wreq builds BoringSSL; on Windows also NASM).
git clone https://github.com/RuriMeiko/whalegate.git ~/whalegate
cd ~/whalegate
cargo build --release # → target/release/whalegate
./target/release/whalegate # dashboard: http://127.0.0.1:8790/
# background service for a source checkout at ~/whalegate
mkdir -p ~/.config/systemd/user && cp deploy/whalegate.service ~/.config/systemd/user/
systemctl --user daemon-reload && systemctl --user enable --now whalegate
CLI
| Command | What it does |
|---|---|
whalegate / whalegate serve |
Run the gateway (default) |
whalegate setup-dsh [--all] [--preset ptc|standard] [--profile <dir>]... [--url <base>] [--context <tokens>] |
Point dsh at this gateway (--all = every profile on the machine) |
whalegate version |
Print the version |
whalegate whoami |
Print the current user for the first stored account |
whalegate chat "<prompt>" |
Send one raw prompt and dump the SSE stream |
whalegate chatraw "<prompt>" <thinking> <search> |
Same, with DeepThink and search toggles, for debugging |
Adding accounts
Accounts are stored in ~/.whalegate/accounts.json, which you can override with DS_ACCOUNTS_PATH. Upgrading from ds-harness moves ~/.ds-harness there automatically. That file holds live session tokens: never commit or share it.
Every account is one "machine" to DeepSeek: a TLS/HTTP2 fingerprint (a real Chrome/Edge build emulated by wreq), a user agent, a device id, cookies, a timezone and languages. All of these stay fixed for the life of the account, because a live session suddenly showing up on a different machine is exactly what gets flagged. There are four ways to add an account (the tabs in + Thêm, in this order), and they differ in which machine the account lives on.
| Machine the account uses | Cookie refresh | |
|---|---|---|
| Dedicated Chrome | A new dedicated Chrome with its own generated fingerprint and profile | Automatic, every DS_DEVICE_REFRESH_HOURS (default 6h) |
| Bookmarklet | Your own browser; ds_session_id is whalegate's own, not the browser's |
Click the bookmarklet again on a DeepSeek tab |
| Extension | Your own browser, with every cookie including HttpOnly ones | Click the extension again on a DeepSeek tab |
| Paste a token (legacy) | Derived from the token (fixed, but not a real browser) | None |
Option 1: dedicated Chrome (recommended)
flowchart LR
A["Dashboard → Chrome riêng:<br/>Mở cửa sổ riêng để đăng nhập / đăng ký"] --> B["New seed → new fingerprint<br/>+ profile ~/.whalegate/profiles/<seed>"]
B --> C["Dedicated Chrome on a random<br/>CDP port, fingerprint applied to every tab"]
C --> D["You sign in<br/>(Google / email / passkey)"]
D --> E["Gateway watches the tab (up to 10 min)<br/>→ token + device id + cookies"]
E --> F["Save, close Chrome<br/>(profile is kept)"]
F --> G["Every DS_DEVICE_REFRESH_HOURS:<br/>reopen that Chrome headless → fresh cookies"]
- Log in or sign up right in that window. A brand-new DeepSeek account works too: the gateway only waits for the tab to get a token. This is the recommended way to log in with a password, because nothing is typed into the dashboard. The dashboard also has a collapsed Tự điền email + mật khẩu option that fills the form for you; it is less reliable because DeepSeek's login page changes.
- Closing the window before you sign in cancels the login. Only one login window can be open at a time.
- Under systemd the gateway picks up
DISPLAY/WAYLAND_DISPLAYfrom the user session so the window can open. - If the same DeepSeek user logs in again, the existing account's token is replaced rather than duplicated. The account moves to the new Chrome device, and any bookmarklet profile it had is dropped.
- The thiết bị button on an account refreshes its device cookies right away.
- Quét Chrome chính (in the Chrome riêng tab) captures every logged-in
chat.deepseek.comtab from a Chrome started with--remote-debugging-port=9333. Since Chrome 136 that flag is ignored for the default profile, so it only works with a separate--user-data-dir; for your everyday Chrome, use the extension instead.
Option 2: bookmarklet
sequenceDiagram
participant DS as chat.deepseek.com tab<br/>(logged in)
participant D as whalegate dashboard tab
participant G as gateway
Note over DS: you click the bookmark
DS->>D: window.open(dashboard#bm)
DS->>DS: read userToken, device id, cookies,<br/>UA, screen, GPU, timezone, languages
D->>DS: postMessage "ready"
DS->>D: postMessage(bundle)<br/>(only to the dashboard origin)
Note over D: accepts only from its opener<br/>with origin chat.deepseek.com
D->>G: POST /api/accounts {token, cookies, browser}
G->>G: bind the browser's fingerprint
G->>DS: verify (users/current) with that fingerprint
G->>G: same token or same DeepSeek user already in pool?<br/>update that account : add a new one
G-->>D: saved (or error, fingerprint restored)
- On the dashboard, open + Add and drag Thêm vào whalegate onto the bookmarks bar (once). The bookmarklet points at the dashboard address you opened it from, so if whalegate runs on another machine, open the dashboard by that machine's address before dragging.
- On a logged-in
chat.deepseek.comtab, click the bookmark. A dashboard tab opens and the account is added.
The bundle never touches the URL, browser history or the network: it goes tab-to-tab with postMessage, and the dashboard only accepts it from the chat.deepseek.com tab that opened it, so another site cannot push an account into your pool. The account then uses your browser's own machine: the closest wreq Chrome/Edge build to your browser version (a browser newer than the table snaps the whole fingerprint to the nearest build), your OS, your device id, and your timezone and languages in x-client-timezone-offset / Accept-Language. The gateway never opens a separate Chrome for these accounts. Cookies DeepSeek sets on responses are kept automatically, but the WAF cookie (aws-waf-token) can only be renewed by a real browser. To refresh it, click the bookmarklet again on a DeepSeek tab: an account that is already in the pool is updated in place (cookies merged, device and browser profile refreshed) instead of being added twice. If you signed in again and got a new token, the same click swaps the new token into the existing account.
If the bookmarklet can't run, the same collector is available under Dán token: copy the console command, run it in the DevTools console of the DeepSeek tab, and paste the result.
Option 3: browser extension
The extension does what the bookmarklet does, and also reads HttpOnly cookies such as ds_session_id. A page's own JavaScript can never read those, but an extension with the cookies permission can, just as CDP does for Chrome login.
About
ds_session_id. It isHttpOnly; Secure; SameSite=Strictwith no expiry. DeepSeek's web server sets it on the first response outside/api(the favicon or a page) when the request does not carry one;/api/v0/*never sets it. Signing in with Google issues a new one, because the strict cookie is not sent when Google redirects back. Accounts added by bookmarklet or pasted token cannot read the browser's value, so whalegate gets its own the same way a browser does: oneGET /favicon.icothrough the account's client when the account is added, and once at startup for older accounts that lack it.
- In + Thêm → Tiện ích, click Tải tiện ích and unzip it. The download already points at the dashboard you opened it from.
- Open
chrome://extensions, turn on Developer mode, click Load unpacked and pick the unzippedwhalegate-extensionfolder. - On a logged-in
chat.deepseek.comtab, click the whalegate icon. The account is added (or refreshed, if it is already in the pool), and the result shows on the DeepSeek tab.
The extension can read cookies only on chat.deepseek.com and talk only to the dashboard's host. It sends the token, device id, cookies and browser profile straight to /api/accounts, the same request the bookmarklet makes.
Option 4: paste a token (legacy)
On a logged-in chat.deepseek.com tab, open DevTools → Console and run:
copy(localStorage.userToken)
Paste the result into + Thêm → Dán token. Any of these shapes is accepted: {"value":"…","__version":"0"}, a quoted string, Bearer …, or the bare token. The token is verified before it is saved, and cookies are optional. You can also add one through the API, optionally with a browser profile (the bookmarklet's fields: user_agent, cores, memory_gb, screen, avail, dpr, tz, tz_offset, langs, gpu_vendor, gpu_renderer, device_id):
curl -X POST http://127.0.0.1:8790/api/accounts \
-H 'content-type: application/json' -d '{"token":"<token>","label":"main"}'
Using the API
The single model is deepseek-web. The legacy aliases deepseek-web-r1 and deepseek-web:max are still accepted.
curl
curl http://127.0.0.1:8790/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek-web",
"messages": [{"role": "user", "content": "Explain the borrow checker in 3 sentences."}],
"stream": true
}'
OpenAI Python SDK
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:8790/v1", api_key="anything-unless-DS_API_KEY-is-set")
resp = client.chat.completions.create(
model="deepseek-web",
reasoning_effort="max", # none | auto | max
messages=[{"role": "user", "content": "Prove that √2 is irrational."}],
)
print(resp.choices[0].message.reasoning_content) # DeepThink trace
print(resp.choices[0].message.content)
Extra request fields
Pass these as extra_body in the SDKs.
| Field | Type | Default | Meaning |
|---|---|---|---|
reasoning_effort |
string | DS_DEFAULT_EFFORT |
none = fast, auto = think on user turns only, max = always think. reasoning.effort also works |
thinking |
bool | false |
Legacy switch for DeepThink |
search |
bool | false |
DeepSeek web search |
new_thread |
bool | false |
Start a fresh web conversation instead of continuing a matching one |
Compatibility notes
usagecomes from the web app's realaccumulated_token_usage. When it is missing, a per-script estimate is used, which lands within about 25% of measured values.prompt_tokens_details.cached_tokenscounts the part of the context that was already in the web conversation.- A
429includesRetry-After. SSE sends a keep-alive every 10s. Upstream failures return a clearerrorobject. - The web app has no equivalent for
temperature,top_p,max_tokens,stop,response_formatorn > 1, so they are ignored.
Tool calling
DeepSeek web has no tool API. whalegate emulates one by rendering your OpenAI tools into the same DSML block format the model already uses, then parsing it back:
flowchart TB
T["OpenAI tools[] + tool_choice"] --> R["Tools spec in the prompt<br/>(types, enums, REQUIRED, nested schemas, defaults)"]
H["History: assistant tool_calls<br/>+ role:tool results"] --> R2["Re-rendered as canonical DSML<br/>+ tool_result (name) [id]: …"]
R & R2 --> M(("DeepSeek<br/>web model"))
M --> O["Streamed reply"]
O --> G{"Tool marker?"}
G -- no --> TXT["content (streamed live)"]
G -- yes --> PARSE["Normalize & parse DSML<br/>(tolerant of the model's typos)"]
PARSE --> V{"Valid vs JSON Schema?"}
V -- yes --> OUT["tool_calls[]<br/>finish_reason: tool_calls"]
V -- no --> FIX["Retry with the bad output<br/>+ exact error as a correction note"]
FIX --> M
What the model writes, and what your client receives:
| Model output (DSML) | Client receives |
|---|---|
|
|
What makes it behave like a real API
- No false positives. Calls are only extracted when the request actually has
tools. Fallback formats (JSON, XML-ish) count only when they end the reply and name a tool you declared. HTML in an answer, or an example<bash command="…"/>in prose, stays text. - Schema validation. Types,
enum,required, nestedproperties/items,anyOf/oneOf. Values are also coerced to the declared type:"50"becomes50, and123becomes"123"for string params. - Repair retries. A broken call is never retried blind. The next attempt includes the model's own output plus the concrete problem, for example
todo: "items[0].status" must be one of "todo"|"done", so the model fixes exactly that. tool_choice.nonereturns plain text.requiredand named choices force a call and retry if the model answers in prose. A named tool that isn't declared returns400 invalid_request_error.parallel_tool_calls: falsekeeps a single call.- Markup never leaks. DSML is held back from
content, and stripped fromreasoning_contentwhen the model drafts calls while thinking. - History round-trips. Previous calls are re-rendered in canonical DSML. Empty tool results show as
(no output), so the model doesn't re-run a silentmkdir.
Example: an agent loop
tools = [{
"type": "function",
"function": {
"name": "bash",
"description": "Run a shell command",
"parameters": {
"type": "object",
"properties": {"command": {"type": "string"}, "description": {"type": "string"}},
"required": ["command", "description"],
},
},
}]
messages = [{"role": "user", "content": "How much disk is free on /?"}]
while True:
r = client.chat.completions.create(model="deepseek-web", messages=messages,
tools=tools, reasoning_effort="auto")
msg = r.choices[0].message
messages.append(msg)
if not msg.tool_calls:
print(msg.content)
break
for call in msg.tool_calls:
output = run(call) # your executor
messages.append({"role": "tool", "tool_call_id": call.id, "content": output})
Images (vision)
client.chat.completions.create(model="deepseek-web", messages=[{
"role": "user",
"content": [
{"type": "text", "text": "What is wrong in this screenshot?"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBORw0..."}},
],
}])
- Accepts
image_url(data URL or publichttp(s)URL; private and internal addresses are refused), Anthropic-style{"type":"image","source":{…}}, and images returned inside tool results, such as aread_imagetool. - Each image is uploaded through DeepSeek's file endpoint (with its own PoW) and attached with
ref_file_ids. Uploads are cached per account by content hash. - Up to the 4 most recent distinct images are sent per request. The per-image size cap is
DS_MAX_IMAGE_MB(default 20).
Reasoning effort
reasoning_effort |
DeepThink | Best for |
|---|---|---|
none (also off, minimal) |
never | Fast answers and simple tool loops |
auto |
on user turns, off on steps that only continue after a tool result |
Agents. Thinking steps take ~10–44 s and non-thinking steps ~2 s, so tool loops get several times faster |
max (any other value) |
every step | Hard problems |
When the client sends nothing, DS_DEFAULT_EFFORT applies (default none). Title-generation requests from clients never think.
Stateful conversations
Agents resend the whole history every step. whalegate fingerprints each conversation prefix plus the tool-call ids it issued. When the next request extends a known prefix, only the new messages go to the same web conversation, using parent_message_id.
sequenceDiagram
participant C as Client
participant G as whalegate
participant D as DeepSeek web
C->>G: step 1 — [system, user]
G->>D: full prompt (new web conversation)
D-->>G: tool_calls (ids issued: call_a)
G->>G: remember fingerprint(prefix + call_a) → (acc, session, msg_id)
C->>G: step 2 — [system, user, assistant(call_a), tool(call_a)]
G->>G: prefix matches ✔
G->>D: delta only: tool_result(call_a) → parent = msg_id
Note over G,D: same account, same session, cached_tokens reported
The gateway falls back to sending the full context, on any account, when the prefix doesn't match, the holding account stays busy for more than 4s, the tools change, or the conversation grows past DS_CONV_MAX_CHARS. Idle web conversations are deleted after DS_CONV_TTL. Set DS_STATEFUL=0 to always send everything.
Account pool & failover
stateDiagram-v2
[*] --> Ready
Ready --> Busy: lease (≤ DS_MAX_CONC_PER_ACC in flight)
Busy --> Ready: success
Busy --> Cooldown: error / empty / truncated stream
Ready --> Burst: > DS_BURST_LIMIT calls in DS_BURST_WINDOW
Burst --> Ready: after DS_COOLDOWN
Cooldown --> Ready: 15s → 30s → 60s … (cap DS_FAIL_COOLDOWN_MAX)
Busy --> NeedsLogin: code 40003 (token expired)
NeedsLogin --> Ready: re-login / new token
Ready --> Disabled: pool switch OFF
Disabled --> Ready: pool switch ON
- Each request gets up to 3 attempts. Upstream failures move to another account. Model mistakes (a bad tool call) retry with a correction note and don't penalize the account.
- When no account is free, the request waits up to
DS_QUEUE_WAIT_SECS, then returns429withRetry-After. - A stream that stalls for 90s or breaks mid-way (some text received, no
closeevent) is first resumed on the same account, exactly as the web does:POST /chat/resume_streamfor up to 5s (3s wait per try, 1s pause between tries), then the Continue button'sPOST /chat/continuewithfallback_to_resumeafter a short random pause. Only the part the client has not seen yet is forwarded. If that fails too, the answer counts as truncated and the request fails over to another account. - When DeepSeek asks for a captcha (
CHALLENGE_REQUIRED), the account rests and the gateway waits 2–5s at random before trying another account, instead of replaying the same payload at once. - The in-flight lease lives for the whole stream, even if the client disconnects, so the concurrency cap really holds.
Egress IP per account (proxies)
DeepSeek sees an account as an (IP, fingerprint) pair. When several accounts leave from one IP they look like one person, so whalegate can route each account through a different proxy.
The binding is per fingerprint seed, not per token: two accounts that are the same "machine" (same fingerprint) always share one IP, and two different machines never share one. Bindings live in ~/.whalegate/proxy-bindings.json, so an account keeps its IP across restarts, and keeps it even if you delete and re-add it.
DS_PROXY_MODE=strict
DS_PROXY_LIST='socks5h://user:[email protected]:1080
http://198.51.100.4:8080'
Or put the same lines in ~/.whalegate/proxies.txt, or paste them into Settings → Proxy on the dashboard (the list is validated line by line; one bad line rejects the whole save, so you never end up with half your accounts silently going direct).
How a proxy is assigned:
- First time a fingerprint is seen, it gets the proxy currently carrying the fewest fingerprints — so the pool spreads evenly instead of piling everyone onto the first entry.
- That binding is written to disk. Later requests from the same fingerprint always use it.
- If the bound proxy disappears from the list, the binding is dropped and a new one is chosen instead of leaving the account stuck.
Modes: off (direct, default) · auto (use a proxy when one is configured; log and go direct if the pool is broken) · strict (refuse to start the request when no proxy is usable — for when a leaked real IP is worse than a failed call).
Use socks5h rather than socks5 when you can: it lets the proxy resolve DNS, so the name lookup for chat.deepseek.com doesn't happen on your machine either.
GET /api/proxy shows the current pool and every binding; POST /api/proxy replaces the list; POST /api/proxy/unbind with {"fingerprint":"…"} releases one binding so that account gets a different IP next time.
Dashboard
Open http://127.0.0.1:8790/. It is loopback-only.
- Accounts: add, verify, reset, edit label or token, enable/disable in the pool, bulk verify/reset, and duplicate-user detection. Verify is not just an auth check: it sends one minimal real completion on a throwaway session, because a throttled account still passes the auth endpoint. The result is alive, throttled, auth_dead or unknown, and only alive clears a throttle label.
- Usage: tokens per hour (cache miss / cache hit / output), and what the same traffic would cost on the official API, shown in $ (English), ¥ (Chinese) or ₫ (Vietnamese) using the rates in Settings → Display. Prices are fetched live every 6h from DeepSeek's pricing page, peak hours are included, and there is a monthly projection. Click the Tokens, Saved or chart label to switch between today / 7 days / 30 days / all time (the choice is remembered). Data lives in
usage.jsonnext toaccounts.json. - Latency: p50/p90 over the last hour, plus the error count.
- Live logs: SSE stream with filters and search. Useful tags include
[toolcall-invalid],[toolcall-unparsed],[conv],[ds-stall]and[ds-fail]. Every line is also saved to~/.whalegate/logs/whalegate-YYYYMMDD.log(kept 7 days, 64 MB per file), and detailed events go todebug-YYYYMMDD.jsonl: each resume / continue attempt, the last SSE lines of a stream that broke, account errors such asCHALLENGE_REQUIRED. The export button in the log window downloads one debug bundle (version, settings, a per-account summary, both logs for the last 24 hours to 7 days). Nothing in it contains tokens, cookies or account labels. - Settings: verified knobs only. Saving waits until no request is in flight, then the process re-execs itself (same PID, picks up a new build) and the page reloads. Hard reset restarts immediately.
- Export / import: move the whole account pool to another machine as a JSON file or straight through the clipboard. Import merges by DeepSeek user id, so running it twice is safe; it never deletes accounts you already have.
- Language: Tiếng Việt / English / 简体中文 from the globe switch (VI / EN / 中) at the right end of the header. It follows your browser language the first time and remembers the choice. See docs/i18n.md.
- Updates: the dashboard checks GitHub Releases on load and every 30 minutes. When a newer tag exists a banner appears; one click downloads the prebuilt binary, verifies its SHA-256, swaps it in place and re-execs, and the page reloads by itself once the new process is up.
Moving accounts between machines
The pool lives in ~/.whalegate/accounts.json, which holds session tokens — the file is the credential. Two ways to carry it over, both from the dashboard:
- Export → Tải file downloads
whalegate-accounts-<ts>.json; copy it over and use Import → chọn file on the other machine. - Export → Copy clipboard puts the same JSON on the clipboard (falling back to a hidden textarea when the browser blocks the async clipboard API); paste it into the Import dialog on the target machine.
Import merges instead of replacing. Accounts are matched by DeepSeek user id first, then by token, so importing the same bundle twice adds nothing the second time. Without overwrite, accounts that already exist are skipped; with it, their token and cookies are replaced (and their stale web sessions cleared). Nothing is ever deleted.
Full backup (the whole machine). The Sao lưu đầy đủ button of the Export dialog downloads one whalegate-backup-<time>.tar.gz with everything above plus the dashboard settings, the proxy list and the fingerprint→proxy bindings, the usage history, and the identity part of every dedicated Chrome profile (localStorage with the login and the device ids, IndexedDB, cookies, preferences: a few hundred KB each, no caches). Pick the same file in Import and you get a preview (accounts, profiles, settings, proxies, usage) before anything is written. Import merges: accounts by DeepSeek user id, and whatever already exists on the machine (settings, proxy list, bindings, usage hours, a profile folder) is kept unless you tick overwrite. Profiles are never touched while a login window is open. Chrome encrypts its cookie database with a key tied to the operating system, so on a machine with another OS (or user) the cookies inside a profile may not decrypt; the login and device ids (localStorage) and the account's own cookies saved in accounts.json still arrive, and the next device refresh renews the rest. The archive is checked while it is read: paths outside the data directory, links and oversized contents are rejected. Over HTTP: GET /api/backup/export and POST /api/backup/import?dry_run=1&overwrite=1 with the file as the body.
[!WARNING] An exported bundle contains every account's token and cookies. Anyone holding it can use those sessions. Move it over a channel you trust, delete the file afterwards, and never commit it —
accounts.jsonand*.sessionare already in.gitignore.
Updating
GET /api/update reads https://api.github.com/repos/RuriMeiko/whalegate/releases/latest, compares it with the running CARGO_PKG_VERSION and returns {current, latest, has_update, notes, url}.
POST /api/update then:
- downloads
whalegate-<target>.tar.gz(.zipon Windows) andSHA256SUMS.txtfor the newest tag, - verifies the SHA-256 (implemented in-tree, no extra dependency),
- extracts the binary, copies it next to the running one and
renames it into place — atomic, and safe while the old inode is still executing, - answers the HTTP request, then re-execs so the new binary takes over the same PID.
The dashboard checks on load and every 30 minutes, shows a banner when a newer tag exists, and reloads the page once /api/restart reports a new boot id. Requests in flight are cut; agents such as dsh retry them.
Windows locks a running .exe, so the rename in step 3 cannot work there. Instead the updater copies the new binary to whalegate.exe.new and writes a whalegate.update.bat next to it; the restart path launches that script, which waits for the old process to exit, replaces the file with move /y, relaunches the app and deletes itself. The script only runs while both files still exist, so an abandoned update never replays.
If you installed with install.sh / install.ps1 there is nothing else to do — the binary is replaced in place and the service config is untouched. To update from a shell instead, re-run the installer:
curl -fsSL https://raw.githubusercontent.com/RuriMeiko/whalegate/main/install.sh | sh
Configuration
Precedence: value saved on the dashboard → DS_* environment variable → default. Rows marked ⚙️ are editable on the dashboard.
| Variable | Default | Meaning |
|---|---|---|
DS_PORT |
8790 |
Listen port (always binds 127.0.0.1) |
DS_API_KEY |
– | If set, /v1/* requires Authorization: Bearer <key>. Required before /v1/* answers anything from outside the machine |
DS_ACCOUNTS_PATH |
~/.whalegate/accounts.json |
Account store. Settings, usage and samples live next to it |
⚙️ DS_STATEFUL |
1 |
Continue web conversations and send deltas only |
⚙️ DS_POW_PREFETCH |
1 |
Fetch and solve the next PoW ahead of time |
⚙️ DS_DEFAULT_EFFORT |
none |
Effort when the client doesn't specify one |
⚙️ DS_CONTEXT_TOKENS |
512000 |
context_length advertised in /v1/models. The web app accepts ≥512K, while ~1.1M returns an empty stream |
⚙️ DS_PROMPT_MAX_CHARS |
1822000 |
Over budget: trim old tool results first, then drop the oldest turns |
⚙️ DS_MAX_CONC_PER_ACC |
2 |
In-flight requests per account. More than 2 tends to get rate-limited |
⚙️ DS_TZ_OFFSET |
25200 |
Timezone offset in seconds sent to the web app and used for daily stats |
⚙️ DS_FX_CNY |
720 |
Exchange rate for the dashboard in Chinese, in units of 0.01 ¥ per USD (720 = 7.20 ¥). Costs are computed in USD and converted for display |
⚙️ DS_FX_VND |
26000 |
Exchange rate for the dashboard in Vietnamese: dong per USD |
⚙️ DS_PROXY_MODE |
off |
Egress IP per account: off direct · auto use proxies when available, fall back to direct · strict fail instead of leaking the real IP |
⚙️ DS_PROXY_LIST |
– | Proxy list, one per line: scheme://[user:pass@]host:port with http, https, socks5 or socks5h |
DS_PROXY_FILE |
~/.whalegate/proxies.txt |
Proxy list file (same format, # comments allowed) |
DS_CONV_TTL |
1800 |
Delete idle web conversations after this many seconds |
DS_CONV_MAX_CHARS |
1200000 |
Maximum size of one stateful conversation |
DS_SESSION_ROTATE |
30 |
Turns on a shared (tool-less) session before rotating |
DS_QUEUE_WAIT_SECS |
120 |
Wait for a free account before returning 429 |
DS_FAIL_COOLDOWN_MAX |
600 |
Cap for the escalating failure cooldown |
DS_BURST_LIMIT / DS_BURST_WINDOW / DS_COOLDOWN |
6 / 30 / 10 |
Per-account burst limiter |
DS_MAX_IMAGE_MB |
20 |
Maximum size per image |
DS_READ_TIMEOUT_SECS |
120 |
Socket read timeout (restart required) |
DS_CHROME_BIN |
auto | Chrome/Chromium path for login |
DS_LOGIN_CDP_PORT |
random 40000–59999 | Force a fixed CDP port for the dedicated login Chrome |
DS_DEVICE_REFRESH_HOURS |
6 |
Reopen each Chrome-login account's Chrome in the background for fresh device cookies (0 = off) |
DS_CN_HOLIDAYS |
– | Extra Chinese holidays (YYYY-MM-DD,…) for peak-price math |
DS_DEBUG_SSE |
0 |
Dump raw SSE to /tmp/ds_debug_sse.log |
HTTP endpoints
| Method | Path | Description |
|---|---|---|
POST |
/v1/chat/completions |
OpenAI-compatible chat |
GET |
/v1/models |
Model list (context_length, reasoning_efforts, modalities) |
GET |
/ |
Dashboard |
GET / POST |
/api/accounts |
List / add accounts ({token, label?, cookies?, browser?}) |
PATCH / DELETE |
/api/accounts/{id} |
Edit label or token / delete |
POST |
/api/accounts/{id}/verify · /reset · /pool |
Probe with a real chat (returns probe: alive|throttled|auth_dead|unknown) / clear errors and sessions, then probe / {"pool_enabled": bool} |
POST |
/api/accounts/{id}/device |
Refresh device cookies now (Chrome-login accounts) |
POST |
/api/capture · /api/login_start |
Capture logged-in tabs from your main Chrome / open the dedicated login Chrome |
GET |
/api/stats · /api/usage |
Pool state (in-flight, cooldowns, stateful hit rate) / usage, cost and latency |
GET |
/api/logs · /api/logs/stream |
Logs as JSON / SSE |
GET / POST |
/api/settings |
Read or update settings (null resets a key) |
GET / POST |
/api/proxy |
Egress proxy pool and fingerprint→proxy bindings / replace the list ({"list": "…"}) |
POST |
/api/proxy/unbind |
Release one binding ({"fingerprint": "…"}) so that account gets a new IP |
GET / POST |
/api/dsh |
dsh status (lists every profile found) / write the dsh config ({"preset": "ptc"|"standard", "profiles": ["desktop","web"]}; omit profiles or set "all": true for every profile) |
GET / POST |
/api/restart |
Status / {"mode": "idle" | "now" | "cancel"} |
GET |
/api/accounts/export |
Download the whole pool as a JSON bundle (contains tokens — treat as a secret) |
POST |
/api/accounts/import |
Merge a bundle or {accounts: [...]}. dry_run previews, overwrite replaces tokens of known users |
GET / POST |
/api/backup/export · /api/backup/import |
Full backup as one .tar.gz (accounts, settings, proxies, bindings, usage, Chrome profiles) / merge it back (?dry_run=1 previews, ?overwrite=1 replaces what exists; body = the file) |
GET / POST |
/api/update |
Check the latest GitHub release / download, verify and install it, then restart |
[!IMPORTANT]
/and/api/*only answer loopback, even when the port is exposed. A request that arrives from loopback but carries reverse-proxy or tunnel headers (cf-connecting-ip,x-forwarded-for,forwarded, …) counts as external, so the dashboard stays private behind a tunnel too./v1/*answers external requests only whenDS_API_KEYis set.
Using with dsh (PTC mode)
dsh (@deepseek-ai/dsh, on Linux, macOS and Windows) reads its model list from the profile's patch file, not from /v1/models. whalegate writes that file for you. Use either:
- Dashboard → dsh — it lists every profile under
$DSH_HOME/profiles, shows which already point here, and writes the ones you tick (choose PTC or standard), or whalegate setup-dsh --all(--preset standardfor the classic preset,--profile <dir>repeatable to be explicit). The installers run this automatically when dsh is present.
Which profile? dsh on a terminal uses web. DeepSeek Harness Desktop (Windows/macOS) uses desktop, a profile the Electron app owns — that is why writing only web left Desktop users without the model. Quit the Desktop app while configuring, then reopen it. Other profiles (headless, acp, custom names) work the same way.
Either way the config is idempotent, backs up cordis.patch.yml to .bak.<timestamp>, and touches nothing else in the file:
- id: llm-pi-ai
config:
providers:
whalegate:
api: openai-completions
baseURL: http://127.0.0.1:8790/v1
headers: { Authorization: Bearer whalegate-local } # DS_API_KEY if you set one
compat: { supportsDeveloperRole: false, supportsReasoningEffort: true, supportsUsageInStreaming: true }
models:
- id: deepseek-web
contextWindow: 512000 # derived, see below
maxTokens: 32768
input: [text, image]
reasoningEfforts: { off: none, low: auto, max: max }
- id: agent-default-model
config: { provider: whalegate, model: deepseek-web }
- id: agent-preset-registry
config: { selectedDefault: ptc }
- The key goes straight into
headers. dsh's OpenAI transport refuses to send a request without a credential, even to a keyless local server. Writing it inline means there's noapiKeyEnvor.envto set, which also fixesno credential for provider route. contextWindowis chosen so history never overflows. dsh compacts at 80% of the window, while whalegate has to trim history itself once a prompt passesDS_PROMPT_MAX_CHARS(1.822M characters). Assuming a worst case of ~4 characters per token plus 10% headroom for the tool spec, the window isDS_PROMPT_MAX_CHARS × 0.9 ÷ 4 ÷ 0.8, capped byDS_CONTEXT_TOKENS. That gives ≈512K by default, so dsh always summarizes before the gateway would cut anything. Change Prompt cap on the dashboard and run the setup again to follow it.
flowchart LR
A["history grows"] --> B{"≥ 80% of contextWindow?<br/>(≈410K tokens)"}
B -- "yes" --> C["dsh summarizes old turns<br/>(clean, lossless log)"]
B -- "no" --> D["send as is"]
C --> D
D --> E{"prompt > DS_PROMPT_MAX_CHARS?"}
E -- "never reached<br/>with the derived window" --> F["DeepSeek web"]
E -. "only if misconfigured" .-> G["gateway trims old tool results / turns"]
Why PTC (Programmatic Tool Calling)? Instead of one schema per tool, the model sees a single run_code tool plus a generated TypeScript SDK, and writes one program that chains many tools:
flowchart LR
subgraph Standard["standard preset — 4 round trips"]
direction TB
s1[bash: ls] --> s2[read a.ts] --> s3[read b.ts] --> s4[bash: grep]
end
subgraph PTC["PTC preset — 1 round trip"]
direction TB
p1["run_code:<br/>const files = await tools.bash({command:'ls'})<br/>for (const f of …) await tools.read({path:f})<br/>return summary"]
end
Only what the program prints or returns comes back into the context. Through whalegate this means far fewer requests to DeepSeek web, which brings fewer throttles and less fixed per-turn latency. The trade-off is that the whole step rides on one code argument, so an SDK mistake fails the whole program.
Cloudflare Worker fan-out (optional)
worker/ds-proxy.js gives several whalegate instances (different machines or VPSes) one public address. It checks the client's token, spreads requests round-robin, and moves on to the next instance when one fails or has no usable account (429/5xx, including 503 accounts_logged_out). If every instance fails, the client gets the last instance's real error rather than a generic 502.
flowchart LR
C[Clients] -->|Bearer WORKER_TOKEN| W["Cloudflare Worker"]
W -->|Bearer UPSTREAM_TOKEN| H1["whalegate #1"]
W -->|Bearer UPSTREAM_TOKEN| H2["whalegate #2"]
W --> H3["whalegate #n"]
The client's WORKER_TOKEN never reaches an instance. The Worker sends its own UPSTREAM_TOKEN (the instances' DS_API_KEY) instead. Only /v1/chat/completions, /v1/search/messages and /v1/models are forwarded. The admin UI and /api/* are never reachable through the Worker.
1. Make each instance reachable and lock it
Cloudflare needs a public https:// address for every instance, for example a Cloudflare Tunnel to http://127.0.0.1:8790. Set DS_API_KEY on every instance first.
A tunnel or reverse proxy on the same machine makes requests arrive from
127.0.0.1. whalegate recognises the headers these proxies add (cf-connecting-ip,x-forwarded-for,forwarded, …) and treats such requests as external: the dashboard and/api/*answer403, and/v1/*answers403untilDS_API_KEYis set. Your account tokens are never exposed through the tunnel.
2. Create a Cloudflare API token
Cloudflare dashboard → My Profile → API Tokens → Create Token → "Edit Cloudflare Workers" template. Limit it to your account. The script needs no other permission.
3. Deploy
CLOUDFLARE_API_TOKEN=<token from step 2> \
WG_UPSTREAMS=https://wg1.example.com,https://wg2.example.com \
WG_UPSTREAM_TOKEN=<the DS_API_KEY set on the instances> \
./worker/deploy.sh
The script checks the token, deploys the Worker, sets the secrets, then tests the deployed address: /health answers, /v1/models is refused without a token, /api/accounts is blocked, and /v1/models with the token reaches an instance. It prints the base_url for clients. The client token is generated on the first run and kept in ~/.whalegate/worker-token (readable only by you); pass WG_WORKER_TOKEN to choose your own. Set CLOUDFLARE_ACCOUNT_ID if the token can see more than one account. Run it again any time to change the instance list.
The Worker's tests run without a Cloudflare account: node --test worker/ds-proxy.test.mjs.
Project layout
src/
├── main.rs CLI: serve | whoami | chat | chatraw
├── chat.rs /v1/chat/completions — one core (run_chat) for stream & non-stream,
│ failover, repair retries, tool_choice, stateful continuation
├── server.rs router, account pool, dashboard API, DSML parser & schema validator,
│ prompt rendering
├── client.rs HTTP + SSE client for chat.deepseek.com, stall detection
├── pow.rs Keccak-f[1600] + DeepSeekHashV1 proof-of-work
├── chrome_capture.rs CDP login, tab capture, cookie refresh
├── settings.rs dashboard settings (saved → env → default)
├── setup_dsh.rs `whalegate setup-dsh` + dashboard dsh button (cross-platform config writer)
├── usage.rs token accounting, live API pricing, latency
├── logbuf.rs in-memory log ring for the live log view
├── ui.html dashboard (embedded in the binary)
└── i18n.js dashboard translations vi/en/zh (inlined into ui.html at startup)
deploy/ systemd unit for source checkouts, dsh setup wrapper
install.sh, install.ps1 one-line installers (Linux/macOS, Windows)
.github/workflows/ release pipeline: static musl, macOS, Windows builds → GitHub release
worker/ Cloudflare Worker fan-out
docs/ README assets, release notes
Malformed tool-call outputs are saved to ~/.whalegate/samples.jsonl. Replay them against the current parser with:
cargo test --release replay_samples -- --ignored --nocapture
The Chinese dashboard translation grew out of a userscript contributed by wang1048920402 in issue #3.
The DeepSeekHashV1 PoW is SHA3-256 without round 0 of keccak-f, ported 1:1 from CJackHwang/ds2api.
Limitations
- DeepSeek web is not an API. Latency is higher, and endpoints or behavior can change or be blocked at any time.
- Tool calling is prompt-emulated. It is hardened (validation, repair retries, a tolerant parser), but still less exact than a native function-calling API.
- Tool calls arrive in one chunk when the model finishes the block, not as streamed argument deltas.
- Sampling controls (
temperature,top_p,max_tokens,stop,response_format) have no web equivalent. - No embeddings endpoint.
- Chrome login is tested on Linux, macOS and Windows in CI (
.github/workflows/ci.yml): find Chrome, open it with the account's fingerprint, close it cleanly, reopen the same profile with its cookies intact. whalegate finds Chrome in the standard locations of each OS (/Applicationsand~/Applicationson macOS;%ProgramFiles%,%LOCALAPPDATA%and the registry's App Paths on Windows), then falls back to Chromium, Edge or Brave. SetDS_CHROME_BINto pick another browser. - Release binaries aren't code-signed, so macOS Gatekeeper and Windows SmartScreen may ask once.
License
MIT © 2026 RuriMeiko. Use responsibly, and buy the official API if you need reliability.
Explore related projects
affaan-m/ECC
The agent harness performance optimization system. Skills, instincts, memory, security, and research-first development for Claude Code, Codex, Opencode, Cursor and beyond.
300,896 starsNousResearch/hermes-agent
The agent that grows with you
262,512 starsmultica-ai/andrej-karpathy-skills
A single CLAUDE.md file to improve Claude Code behavior, derived from Andrej Karpathy's observations on LLM coding pitfalls.
231,944 starsfirecrawl/firecrawl
Supercharge your AI agents with data from the web and beyond. Building the library for superintelligence. 🔥
200,444 stars