Skip to content
Projects

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.

How these counts are measured

🐳 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

whalegate — an OpenAI-compatible gateway into DeepSeek web

Rust OpenAI compatible Tool calling Vision License

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

🔌 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/&lt;seed&gt;"]
    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_DISPLAY from 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.com tab 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)
  1. 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.
  2. On a logged-in chat.deepseek.com tab, 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 is HttpOnly; Secure; SameSite=Strict with 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: one GET /favicon.ico through the account's client when the account is added, and once at startup for older accounts that lack it.

  1. 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.
  2. Open chrome://extensions, turn on Developer mode, click Load unpacked and pick the unzipped whalegate-extension folder.
  3. On a logged-in chat.deepseek.com tab, 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

  • usage comes from the web app's real accumulated_token_usage. When it is missing, a per-script estimate is used, which lands within about 25% of measured values. prompt_tokens_details.cached_tokens counts the part of the context that was already in the web conversation.
  • A 429 includes Retry-After. SSE sends a keep-alive every 10s. Upstream failures return a clear error object.
  • The web app has no equivalent for temperature, top_p, max_tokens, stop, response_format or n > 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
Let me check the repo.
<||DSML|| calls>
<||DSML|| invoke name="bash">
<||DSML|| parameter name="command" string="true">ls -la</||DSML|| parameter>
<||DSML|| parameter name="timeout" string="false">30</||DSML|| parameter>
</||DSML|| invoke>
</||DSML|| calls>
{
  "content": "Let me check the repo.",
  "tool_calls": [{
    "id": "call_9f2c41ab07de",
    "type": "function",
    "function": {
      "name": "bash",
      "arguments": "{\"command\":\"ls -la\",\"timeout\":30}"
    }
  }]
}

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, nested properties / items, anyOf / oneOf. Values are also coerced to the declared type: "50" becomes 50, and 123 becomes "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. none returns plain text. required and named choices force a call and retry if the model answers in prose. A named tool that isn't declared returns 400 invalid_request_error.
  • parallel_tool_calls: false keeps a single call.
  • Markup never leaks. DSML is held back from content, and stripped from reasoning_content when 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 silent mkdir.

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 public http(s) URL; private and internal addresses are refused), Anthropic-style {"type":"image","source":{…}}, and images returned inside tool results, such as a read_image tool.
  • 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 returns 429 with Retry-After.
  • A stream that stalls for 90s or breaks mid-way (some text received, no close event) is first resumed on the same account, exactly as the web does: POST /chat/resume_stream for up to 5s (3s wait per try, 1s pause between tries), then the Continue button's POST /chat/continue with fallback_to_resume after 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:

  1. 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.
  2. That binding is written to disk. Later requests from the same fingerprint always use it.
  3. 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.json next to accounts.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 to debug-YYYYMMDD.jsonl: each resume / continue attempt, the last SSE lines of a stream that broke, account errors such as CHALLENGE_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.json and *.session are 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:

  1. downloads whalegate-<target>.tar.gz (.zip on Windows) and SHA256SUMS.txt for the newest tag,
  2. verifies the SHA-256 (implemented in-tree, no extra dependency),
  3. 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,
  4. 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 when DS_API_KEY is 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 standard for 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 no apiKeyEnv or .env to set, which also fixes no credential for provider route.
  • contextWindow is chosen so history never overflows. dsh compacts at 80% of the window, while whalegate has to trim history itself once a prompt passes DS_PROMPT_MAX_CHARS (1.822M characters). Assuming a worst case of ~4 characters per token plus 10% headroom for the tool spec, the window is DS_PROMPT_MAX_CHARS × 0.9 ÷ 4 ÷ 0.8, capped by DS_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/* answer 403, and /v1/* answers 403 until DS_API_KEY is 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 (/Applications and ~/Applications on macOS; %ProgramFiles%, %LOCALAPPDATA% and the registry's App Paths on Windows), then falls back to Chromium, Edge or Brave. Set DS_CHROME_BIN to 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