dsh-deepseek-web-login 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md ADDED
@@ -0,0 +1,510 @@
1
+ <div align="center">
2
+
3
+ <img src="docs/assets/banner-dsh-deepseek-web-login.jpg" alt="dsh-deepseek-web-login — drive DSH agents with a chat.deepseek.com web login" width="900">
4
+
5
+ [中文](README.md) · [**English**](README.en.md)
6
+
7
+ [![License](https://img.shields.io/badge/license-Apache--2.0-263146?style=flat-square&labelColor=0b1220)](LICENSE)
8
+ [![DSH Plugin](https://img.shields.io/badge/DSH-plugin-4f46e5?style=flat-square&labelColor=0b1220)](https://github.com/deepseek-ai/deepseek-harness)
9
+ [![Provider](https://img.shields.io/badge/provider-deepseek--web-06b6d4?style=flat-square&labelColor=0b1220)](#models)
10
+ [![Tests](https://img.shields.io/badge/tests-111%20assertions-10b981?style=flat-square&labelColor=0b1220)](#testing)
11
+ [![CI](https://github.com/cv-superding/dsh-deepseek-web-login/actions/workflows/ci.yml/badge.svg)](https://github.com/cv-superding/dsh-deepseek-web-login/actions/workflows/ci.yml)
12
+ [![Release](https://img.shields.io/github/v/release/cv-superding/dsh-deepseek-web-login?style=flat-square&labelColor=0b1220&color=f59e0b)](https://github.com/cv-superding/dsh-deepseek-web-login/releases)
13
+ [![Status](https://img.shields.io/badge/status-unofficial%20%C2%B7%20use%20at%20your%20own%20risk-ef4444?style=flat-square&labelColor=0b1220)](#disclaimer)
14
+ [![PRs](https://img.shields.io/badge/PRs-welcome-brightgreen?style=flat-square&labelColor=0b1220)](#contributing)
15
+
16
+ **Unofficial DSH plugin: use the chat.deepseek.com web models as a DSH LLM provider — browser-login capture, PoW solving, SSE streaming, prompting-based tool calls, and image input. No API key.**
17
+
18
+ </div>
19
+
20
+ ---
21
+
22
+ ## What it is
23
+
24
+ DSH (DeepSeek Harness) reaches models through **provider adapters** on `ctx.llm`. This project implements the
25
+ `deepseek-web` provider — it does not call the official API. It reuses your **already logged-in web session**:
26
+ proof-of-work challenges, chat sessions, SSE streaming and file upload all go through the web client's
27
+ private endpoints.
28
+
29
+ So you can pick `DeepSeek 网页 · 快速模式` (web fast mode) in the model picker and run DSH agents on the
30
+ free web quota.
31
+
32
+ <img src="docs/assets/architecture.svg" alt="Architecture and data flow" width="1000">
33
+
34
+ ### Diagnostics: transport layer (`net.fetch` / TLS fingerprint)
35
+
36
+ Web requests currently go out through Node's `fetch` (undici), whose TLS/HTTP2 fingerprint differs
37
+ **structurally** from a real browser (measured: JA4 `h1` vs `h2`, no GREASE at all, 3x more ciphers).
38
+ To check whether switching to Chromium's network stack is viable, run the probe — **zero quota by
39
+ default** (no generation, no messages):
40
+
41
+ ```bash
42
+ # The host process HTTP endpoint is only reachable from DSH's own same-origin page,
43
+ # so this path goes through a file instead:
44
+ echo '{"mode":"probe"}' > "$HOME/.dsh/web-login/probe-request.json"
45
+ # Restart DSH; the log will contain deepseek-web: [net-fetch 探测] {...}
46
+ ```
47
+
48
+ | Step | Fields | Pass condition |
49
+ |---|---|---|
50
+ | ① fingerprint | `ja4` / `http_version` / `http2_hash` | becomes `t13d…h2…` with GREASE = matches Chrome |
51
+ | ② streaming | `hasBody` / `chunks` / `abortedEarly` | all true, otherwise SSE is impossible |
52
+ | ③ auth | `status` / `body` | 200 with the account readable = headers/cookies pass through |
53
+
54
+ Use `"mode":"stream"` instead to also verify DeepSeek's SSE end-to-end (**consumes a little quota**).
55
+ The file is renamed to `probe-request.json.done-<timestamp>` once consumed, so it runs only once.
56
+
57
+ ## How it works (and why it is not a "reverse proxy")
58
+
59
+ The two questions we get most: **does it drive the DOM, or intercept the page's requests? Is it a reverse
60
+ proxy?** Neither. It builds the requests itself and sends them straight to the web app's private endpoints.
61
+ The accurate label is an **unofficial client for the web app's private API**.
62
+
63
+ | Stage | What happens | How |
64
+ | --- | --- | --- |
65
+ | **① Login capture** (once) | Opens a browser window on an **isolated profile** for you to sign in, then reads one copy of the headers the page itself sends: `Authorization: Bearer`, domain cookies, the anti-bot headers `x-hif-dliq` / `x-hif-leim`, and a set of `x-client-*` | Electron `webRequest.onBeforeSendHeaders` |
66
+ | **② Request building** (every call) | Assembles `POST /api/v0/chat/completion` itself; solves the PoW itself: asks `create_pow_challenge`, computes the answer with the SHA3 WASM, sends it as `x-ds-pow-response` | the plugin's own HTTP client |
67
+ | **③ Sending** | Leaves through the **Chromium network stack** by default (Electron `net.fetch`), so TLS / HTTP2 fingerprints match a real browser; can be switched back to Node | see "Transport" below |
68
+ | **④ Parsing and bridging** | Decodes the `response/fragments` SSE frames itself and splits thinking from answer text; tool calls use a **prompt-based protocol** (the web app has no native function calling) | in-house parser |
69
+
70
+ **Only step ① touches anything like "interception"** — and it is a read plus cleaning up our own fingerprints:
71
+ the same callback strips the Electron branding (UA and UA-CH) from the headers, because otherwise the page
72
+ flags the environment as suspicious. It never rewrites the page's own requests and never forwards anything,
73
+ and it only applies during that one login. **Once you are signed in, the window can be closed and the plugin
74
+ keeps working.**
75
+
76
+ ### Why it is not a "reverse proxy"
77
+
78
+ A reverse proxy is an **intermediary**: the client believes it is talking to the origin server while the
79
+ request is forwarded through another hop. There is no intermediary here — **the plugin is the client**,
80
+ talking to DeepSeek as the web app does. No forwarding layer, and no extra local service to run.
81
+
82
+ ### How it differs from the two common alternatives
83
+
84
+ | | DOM automation (e.g. cuckoo-code) | Request hooking (browser extension, e.g. deepseek-pp) | This plugin |
85
+ | --- | --- | --- | --- |
86
+ | **Who sends the request** | **The page** | **The page** | **The plugin itself** |
87
+ | Where the session comes from | You sign in inside its window | Your everyday browser's session | Captured once, stored locally |
88
+ | When the site is redesigned | Selectors break | Only depends on endpoint paths | Only depends on endpoints, never touches the DOM |
89
+ | Browser must stay open | Yes | Yes | **No** |
90
+
91
+ Both alternatives share one property: **the browser sends the requests**. This plugin is the sender instead.
92
+ The trade-off is that endpoint changes require updates; what you get is independence from the DOM and
93
+ unattended background operation.
94
+
95
+ ## Screenshots
96
+
97
+ The settings panel is split into **6 tabs** (one page at a time): **Account** (login status /
98
+ current account / **account library** / manual token) · **Models** (available models /
99
+ connectivity test) · **Anti-throttle** (request pacing / session cleanup with three ranges / **call ledger**) ·
100
+ **Transport** (fingerprint + one-click test) · **Context** (full resend / chained incremental) ·
101
+ **About** (version & updates / data locations / risks).
102
+ The action-feedback strip sits above the tab bar, so it stays visible from any tab.
103
+
104
+ Settings panel (real screenshot, taken before the tab split): current account / login status (adapter registration, credential source, PoW WASM, server-side verification) / three login paths (Microsoft Edge · default browser · recover from a logged-in window) / manual token.
105
+
106
+ <img src="docs/assets/screenshot-settings.png" alt="DSH settings panel · DeepSeek web login (real screenshot)" width="820">
107
+
108
+ DSH's usage-stats page on the free web channel — 10.4M tokens / 164 calls in a single day:
109
+
110
+ <img src="docs/assets/screenshot-usage-stats.png" alt="DSH usage stats · deepseek-web free channel" width="820">
111
+
112
+ ## Features
113
+
114
+ | | |
115
+ |---|---|
116
+ | 🔐 **Web login, no API key** | Log in normally inside an Electron window on its own persistent partition; the plugin captures the real `Authorization`, cookies, `x-hif-*` fingerprint headers and client version headers on the side (it never touches your password) |
117
+ | 🧩 **PoW solving** | `create_pow_challenge` + DeepSeek's own `sha3_wasm_bg.*.wasm` `wasm_solve`, with automatic WASM URL discovery (the hash changes between deployments) |
118
+ | 🌊 **Streaming** | Handles both the `response/fragments` format (THINK/RESPONSE) and the direct `thinking_content`/`content` paths, including `{o:"APPEND"}` and bare `{v}` continuations; logical-stream dedup so snapshots never double-emit |
119
+ | 🛠 **Tool calling** | The web endpoint has no native function calling → a prompting JSON protocol plus a streaming filter (cross-chunk markers, fenced blocks, multiple calls, false-positive fallback) that synthesizes `tool-call` blocks and `finish: tool-calls`; tool definitions are emitted under a 56k-character budget, and when that is exceeded the **names of the omitted tools are listed** with an instruction not to guess their parameters |
120
+ | 🛡 **Drift covered twice** | The instructions explicitly forbid XML/DSML markup (the model then refuses that format by itself), and the parser accepts both the JSON and XML/DSML families (`\|DSML\|` prefix, hyphenated `dsml-` tags, bare `<invoke>`, CDATA) |
121
+ | 🩹 **Lenient JSON repair** | Models write Windows paths with single backslashes: `\A` is an illegal escape while `\r` is legal and would silently turn `\resources` into a carriage return. A chain of repair candidates restores paths literally; if nothing parses, the text is passed through — **content is never silently dropped** |
122
+ | 🖼 **Image input** | Not native multimodal input: images are uploaded via `/api/v0/file/upload_file` and referenced with `ref_file_ids`. The same image appearing more than once in history is deduplicated (the server rejects duplicate ids). Verified against a generated left-red/right-blue PNG — the model answered "left=red, right=blue" |
123
+ | 🧹 **Session hygiene** | One temporary chat session per call, deleted afterwards. Verified: the web chat list is byte-identical before and after |
124
+ | 🎛 **Settings panel** | Status, browser login, recover-from-window, manual token, connectivity test (host API at `/deepseek-web-login/api/*`) |
125
+ | 🔓 **Logout / switch account** | A dedicated "current account" card: **log out** — also clears the chat.deepseek.com storage inside the Electron partition, so the session is really gone and you can log in as somebody else — plus "log out and sign in as another account". Two-step confirmation, so no accidental logout |
126
+
127
+ <img src="docs/assets/tool-bridge.svg" alt="Tool-calling protocol bridge" width="1000">
128
+
129
+ ## Quick start
130
+
131
+ ```bash
132
+ # A: from the release tarball (recommended, no build step)
133
+ dsh plugin --profile desktop add ./dsh-deepseek-web-login-0.1.3.tgz
134
+
135
+ # B: git install (requires github.com reachability)
136
+ dsh plugin --profile desktop add github:cv-superding/dsh-deepseek-web-login
137
+
138
+ # C: install from npm (no git, no build step)
139
+ # confirm it is published first: npm view dsh-deepseek-web-login version (else use A/B)
140
+ dsh plugin --profile desktop add dsh-deepseek-web-login
141
+ ```
142
+
143
+ > ⚠️ **If "Update" in the DSH plugin market fails** (reporting `update failed and restoration of the
144
+ > previous build could not be verified`, with a parenthetical about "the exact GitHub commit before
145
+ > the update"), that parenthetical only explains **why automatic rollback was unavailable** — it is not
146
+ > the cause. This plugin shipped only a **git install source**, so updating clones GitHub, and a failed
147
+ > update cannot be rolled back. Workarounds (any of these, no need to wait for the market):
148
+
149
+ ```bash
150
+ # 1) Install an exact tag (steadier than HEAD — never tripped by an intermediate commit)
151
+ dsh plugin --profile desktop add github:cv-superding/dsh-deepseek-web-login#v0.2.0
152
+
153
+ # 2) Use the release tarball (no git at all; download the matching tgz from the Releases page)
154
+ dsh plugin --profile desktop add ./dsh-deepseek-web-login-0.2.0.tgz
155
+
156
+ # 3) Install from npm (no git, no build step; check `npm view dsh-deepseek-web-login version` first)
157
+ dsh plugin --profile desktop add dsh-deepseek-web-login
158
+ ```
159
+
160
+ > The catalog entry is currently `npm: null` (**not published to npm**), which is why installs go
161
+ > through git. The package itself is ready (`private` removed, `repository` added — the latter is a
162
+ > hard requirement for the catalog to recognise an npm package). Once published, the catalog
163
+ > re-probes daily (an "unpublished" verdict expires after a day) and switches to the npm path
164
+ > (version compare + tarball, no local git). If it still shows the git command, give it a day —
165
+ > or use option 3 above.
166
+
167
+ ### 2. Log in once
168
+ `--profile desktop` is the profile used by DSH Desktop (the Electron app); use `--profile web` for a web profile.
169
+ Restart DSH — the plugin is assembled as a bundle and loads automatically.
170
+
171
+ Then: **Settings → DeepSeek 网页登录 → 浏览器窗口登录**, log in normally in the window that opens
172
+ (phone / email / verification code all work). The window closes itself once the credentials are captured.
173
+ Finally pick provider **`DeepSeek 网页版(免费)`** → `DeepSeek 网页 · 快速模式` in the model picker.
174
+
175
+ > ⚠️ **One chat window per account**: running several windows against the same account triggers a temporary web-side ban (1 day). Use one account per window, or move the extra windows to another provider — see [Known limitations](#known-limitations).
176
+
177
+ Credentials live only on your machine (`~/.dsh/web-login/deepseek-auth.json`), never in this repository. The panel's **current account → log out** removes them (and the partition storage) in one click.
178
+
179
+ In manual-token mode the panel shows "cookie / fingerprint headers not captured" — that is expected for this
180
+ path (it only has the Bearer token), and it is verified working end to end: validation, PoW solving and a real
181
+ completion all succeed. Switch to browser login if you ever hit frequent `AUTH` / `40003` errors.
182
+ > 💡 **The browser-login button clears the previous login state first** — it drives a dedicated
183
+ > profile (`~/.dsh/web-login/browser-profile`, which never reads or writes your everyday Edge cookies
184
+ > or history), so the window always opens on a clean sign-in page. If you would rather reuse a session
185
+ > that is still valid, use **Re-login** on the account row instead — that path deliberately keeps it.
186
+
187
+ If they are lost, **Recover from the logged-in window** reuses the persistent partition — no re-login needed.
188
+
189
+ ## Models
190
+
191
+ Taken from the account's own server config (`GET /api/v0/client/settings?scope=model` → `model_configs`,
192
+ configVersion 81 at the time of writing): only `default` (fast mode) is enabled and switchable; `expert` and
193
+ `vision` are **disabled server-side and merged into fast mode**.
194
+
195
+ The plugin therefore exposes two entries — which are **not two models** but two presets of the same
196
+ `thinking_enabled` switch:
197
+
198
+ | model id | thinking | Best for |
199
+ |---|---|---|
200
+ | `deepseek-chat` | off | tool calls, rewriting, retrieval — fastest, cheapest |
201
+ | `deepseek-reasoner` | on | math, multi-step debugging, planning — reasons first (streamed as thinking blocks) |
202
+
203
+ Context (verified field by field on 2026-09-11 via `GET /api/v0/client/settings?scope=model`, configVersion 81):
204
+
205
+ - Hard per-request input cap: `input_character_limit = 2621440` characters (≈2.5 MiB)
206
+ - Attachment (`file_feature`) token budget: `token_limit = 890880` — **this is not the context window**.
207
+ It was once mistaken for one (890880 = 870×1024, so a ÷1024 display reads "870K"). The server exposes no
208
+ total-context field, so `contextWindow` is set to the advertised 1M (`1048576`).
209
+
210
+ ## Configuration
211
+
212
+ | Field | Default | Meaning |
213
+ |---|---|---|
214
+ | `maxPromptChars` | `400000` | Prompt character budget (excess is middle-truncated, keeping the system prompt, the tool protocol and the most recent turns). ⚠️ **Raising it clearly increases the risk of being rate-limited**; range `[120000, 1500000]`, and the default is deliberately **not** at the ceiling |
215
+ | `maxRefImages` | `24` | How many images one request may carry (`ref_file_ids` length). The web endpoint caps that batch (measured: 40 pass, 52 rejected); going over rejects the **whole turn**, and then **every later turn in that conversation fails** because the images stay in the history. So only the most recent N are sent; skipped ones are marked `[earlier image omitted]` in the prompt. `0` = unlimited (**not recommended**). ⚠️ That "N images" is really **N image-content entries**: every `read_image` by the model, and every re-render / crop that changes the bytes, adds one — so it is usually far more than the number of images you pasted yourself. Since 0.1.79 the notice says "N image-content entries". Since 0.1.81 it is shown on a **ladder** instead: always the first time, then only once the number of skipped entries has doubled (and grown by at least 10) — 0.1.79's "only once per identical trim size" was not enough, because that signature contained the growing total. So it is O(log n) notices per conversation rather than one per turn |
216
+ | `idleTimeoutMs` | `120000` | SSE idle timeout |
217
+ | `deleteWebSessions` | `true` | Delete the temporary web chat session after each call |
218
+ | `autoContinue` | `true` | Auto-continue when an answer is cut mid-sentence (seamlessly appended to the same answer). The tail character decides: `,` `、` `;` `:` (and their ASCII forms) mean "clearly unfinished" and trigger a continuation; sentence-ending punctuation (`。` `!` `?` `)` …) counts as complete — including `…`, since an ellipsis may be a deliberate ending. It also gates the corrective round used when the model writes a tool program into the visible text instead of emitting a tool call |
219
+ | `maxContinuations` | `2` | Max auto-continuation rounds (each round is a new web request, so it spends more of the free quota) |
220
+ | `minRequestIntervalMs` | **`2000`** | Lower bound of the gap between two web calls, measured from when the previous one **finished** |
221
+ | `maxRequestIntervalMs` | **`4000`** | Upper bound; the actual wait is picked **randomly** inside the range (equal bounds = fixed interval) |
222
+ | `allowConcurrent` | **`false`** | Allow concurrent requests on one account. Off by default: calls queue (FIFO) |
223
+ | `sessionCleanup` | **`deferred`** | Temp-session cleanup: `immediate` (delete 1.5s after each call) / `deferred` (batched, default) / `keep` (never delete) |
224
+ | `sessionCleanupDelayMs` | `90000` | deferred: max wait before flushing the queue (scalar fallback when no range is set) |
225
+ | `sessionCleanupBatchSize` | `8` | deferred: flush as soon as this many sessions are queued (scalar fallback) |
226
+ | `cleanupBatch` | `6~10` (random) | deferred: **range** for the queue threshold. How many this cycle = re-rolled at each flush |
227
+ | `cleanupDelayMs` | `60000~120000` (random) | deferred: **range** for the max wait (ms). Re-rolled at each flush |
228
+ | `cleanupGapMs` | `800~2500` (random) | deferred: **range** for the gap between two adjacent delete requests (ms). Re-rolled per delete |
229
+ | `transport` | **`chromium`** | Transport: `chromium` = Electron `net.fetch` (browser-identical fingerprint) / `node` = Node fetch |
230
+ | `contextMode` | **`full`** | Context feeding: `full` = resend the whole prompt every turn / `chained` = send only the delta and hang it off the previous answer (see below) |
231
+ | `probeIntervalMs` | `1800000` | Read-only login-state probe interval (ms); `0` disables. Uses `users/current`, zero quota |
232
+
233
+ > ⚠️ **`maxPromptChars` is a throttling valve, not a "bigger is better" knob.**
234
+ > The web API is **stateless**: every turn resends the **entire transcript**, so this ceiling directly sets the
235
+ > size of each request. Measured within a single conversation, one request grew from 9.7k to **293k tokens**;
236
+ > leaving the default at 1.5 M characters (≈1 M tokens of Chinese) means the default itself permits
237
+ > "fill the whole 1 M context in one shot". Four of our own accounts were rate-limited within two days,
238
+ > with request size the prime suspect. **So from 0.1.76 the default is 400 000** (≈270k tokens — plenty for
239
+ > long tasks). You can still raise it (the ceiling stays at 1.5 M), but read that as **trading account
240
+ > stability for longer memory**: if you genuinely need long context, switch "Context feeding" to `chained`
241
+ > (send only the delta and let the server keep the history) instead of raising this ceiling — the ceiling
242
+ > costs you on *every single turn*.
243
+
244
+ ### Context feeding: full resend vs chained incremental
245
+
246
+ Every completion request carries the **entire transcript** (system prompt + tool catalogue + full history) as
247
+ `prompt`. Why resend it all? Because the plugin has always sent `parent_message_id: null` — meaning every message
248
+ is a **root** of the web conversation with no parent chain, so the server walks the message tree up to nothing.
249
+ That behaviour was measured on 2026-09-12 (send "remember the code ZC-7391-KX" in one session, then ask for the
250
+ code → "don't know").
251
+
252
+ A browser does it differently: `nextParentMessageId = history?.parentMessageId ?? finalAssistantMessageId` and
253
+ `isFirstMessage = parent_message_id === null` — **only the first message of a session has a null parent**;
254
+ after that each turn sends the previous message id as its parent and the server keeps the history.
255
+
256
+ The **Context** tab in the settings panel can switch to **chained feeding**: later turns send only the delta and
257
+ set `parent_message_id` to the previous answer's `message_id` (read from the first SSE frame,
258
+ `event: ready` → `response_message_id`). Requests get much smaller and look like a real continuous chat. The cost:
259
+ the tool protocol only exists in the first message of the chain, so if the server ever drops that early context the
260
+ model may stop emitting tool calls in the agreed format.
261
+
262
+ So `full` stays the default (identical to 0.1.61 and earlier), while `chained` follows a "save when safe, fall back
263
+ on any doubt" policy — any of these restarts the chain (full prompt + `parent=null`; it only costs a few tokens):
264
+
265
+ | Falls back to full when | Why |
266
+ | --- | --- |
267
+ | New session / session rotated / account switched | a chain belongs to one specific session |
268
+ | The fixed head (system prompt + tool catalogue) changed | the chain head is stale |
269
+ | History is not a **strict append** (compacted, rewritten, rolled back) | the delta cannot be computed |
270
+ | Nothing new this turn / the delta itself exceeds budget | nothing worth saving, or the risk outweighs it |
271
+ | Previous stream failed, was cancelled, or no `message_id` arrived | the parent may not exist any more |
272
+
273
+ The decision logic is a pure function (`src/context-feed.ts`), covered by `tests/check-context-feed.mjs`
274
+ (the rules) and `tests/check-context-chain.mjs` (wiring and lifecycle, fake transport + fake SSE).
275
+
276
+ ### Why throttling is on by default, and which values to use
277
+
278
+ The web client allows only one generation per account at a time; concurrent generations are rejected, and the
279
+ real cost is worse — two windows generating at once triggered a **1-day account-level restriction** in under
280
+ 6 minutes (the login stays valid, but every request from that account is refused).
281
+
282
+ DSH itself does call the same account concurrently. Reconstructing the start/end of 272 calls from the plugin
283
+ log showed **16 real overlaps**: one side is the main answer, the other is only 8–17 characters taking 1–3
284
+ seconds — that is DSH's **session-title generation** (`options.purpose === 'session-title'`). In other words,
285
+ while you are still waiting for the answer, another request has already gone out to the same account.
286
+
287
+ So the plugin now serialises calls (including title/compaction) and enforces a minimum gap between them.
288
+
289
+ | Situation | `minRequestIntervalMs` | `allowConcurrent` |
290
+ |---|---|---|
291
+ | **Recommended (default)** | `3000` | `false` |
292
+ | Speed over safety, short tasks only | `1500` | `false` |
293
+ | Already throttled once / dense multi-step automation | `8000` | `false` |
294
+ | No throttling at all (**not recommended**) | `0` | `false` |
295
+ | Experimental: restore native concurrency | any | `true` ⚠️ |
296
+
297
+ > The gap is measured from when the previous call **finished**, so a long answer is never followed by an
298
+ > extra pointless wait — it only affects genuinely dense back-to-back calls.
299
+ >
300
+ > **Two ways to change it**: ① the "Request throttling" card at the bottom of the settings page
301
+ > (switch + slider + three presets) — takes effect immediately and is persisted;
302
+ > ② the plugin entry config — needs a DSH restart.
303
+ > Precedence: **settings page > entry config > built-in default** (the settings page is an explicit
304
+ > user action, so a stale config value never overrides it). Stored at
305
+ > `${DSH_HOME:-~/.dsh}/web-login/gate.json`.
306
+
307
+ ### Transport layer: Chromium network stack by default
308
+
309
+ Measured on the same machine, same day:
310
+
311
+ | | JA4 | cipher list hash | ALPN |
312
+ |---|---|---|---|
313
+ | Node fetch (undici) | `t13d5212h1_…` | — | **h1** |
314
+ | Chrome (local, 152) | `t13d1517h2_8daaf6152771_cb7bf5808d99` | `8daaf6152771` | h2 |
315
+ | **default: net.fetch (Electron 43)** | `t13d1516h2_8daaf6152771_806a8c22fdea` | **`8daaf6152771`** | h2 |
316
+
317
+ Node's fingerprint gives you away at the TLS layer (no HTTP/2, 3x more ciphers, no GREASE) and
318
+ none of that is fixable by tuning. Going through Electron's `net.fetch` uses Chromium's built-in
319
+ network stack — the cipher list hash matches Chrome byte for byte — with **zero new dependencies**
320
+ (no uTLS, no curl-impersonate). The only residual gap is 16 vs 17 extensions (bundled Chromium 150
321
+ vs local Chrome 152, a normal version difference).
322
+
323
+ ### Session cleanup: three ranges instead of hard-coded values
324
+
325
+ One model call issues 4 requests (create session → PoW → completion → delete session), and
326
+ "create one temp session, delete it right away, every single turn" is one of the strongest script
327
+ signals. So the delete side is batched: flush once the queue reaches **6–10** sessions, or after at
328
+ most **60–120 s**, whichever comes first — with **one batched delete request** whenever the server
329
+ accepts it.
330
+
331
+ The three numbers used to be dead constants, and a constant has a variance of ~0 — itself the
332
+ clearest statistical tell (no human is that precise). Each is now a **range** and the actual value is
333
+ drawn randomly: the threshold and the max wait are re-rolled **every flush**, and the gap between two
334
+ adjacent delete requests is re-rolled **per delete**. That last one exists so that when the server
335
+ rejects batched delete (the code then falls back to deleting one by one, permanently) you do not fire
336
+ dozens of delete requests back to back. Set the upper bound to 0 to disable the gap. Both batched and
337
+ one-by-one deletion are **serialised** — a flush that is still running makes the next one queue up
338
+ instead of interleaving.
339
+
340
+ Switchable in Settings, with a **zero-quota one-click test** (echoes fingerprint / streaming / auth).
341
+
342
+ ⚠️ The Chromium stack **follows the system proxy** (Node ignores it entirely). If your proxy still
343
+ points at `127.0.0.1:7897` while the VPN is off, requests will fail — switch back to `node`.
344
+
345
+ ### Account library, call ledger, login probe
346
+
347
+ **When credentials die, the panel tells you what to do.** An account that fails the probe is
348
+ flagged **"needs re-login"** and gets a **"re-login this account"** button on its own row. Unlike
349
+ "add new account", re-login by default **does not clear the browser session**: adding must
350
+ clear it (otherwise the window opens already logged in as the old account and you capture that one
351
+ again), whereas repairing the *same* account is the opposite — keeping it means the window may reuse
352
+ it immediately with no password at all.
353
+ **An account already known to be dead will not have requests sent for it.** A failing probe writes
354
+ the reason onto the account record, and since 0.1.80 the adapter **reads that record before sending
355
+ anything**: if the failure is an **authorization** one (`Authorization Failed` / `invalid token` /
356
+ `HTTP 401·403`), it returns "this account's login state is invalid (…), the request was not sent"
357
+ instead of burning a whole round. Before this, the probe's verdict was used **for display only**
358
+ (red flag + log) — a token declared dead at 22:42 was still used at 22:50 to retry the upload of
359
+ 14 images one by one.
360
+ ⚠️ **Network failures (offline, timeouts, 5xx, 429) never block** — the credentials are fine there,
361
+ and blocking on them would lock a healthy account out. So if an account is blocked but you are sure
362
+ it still works, run **"verify all"** once (read-only probe, zero quota) to clear the flag.
363
+
364
+ ⚠️ The exception is an account that is **already flagged as failed**: there the browser session is
365
+ cleared first, because that session is exactly what went bad — reusing it would capture the same
366
+ dead credential over and over (you would click re-login any number of times and it would never work).
367
+ The record is updated **in place**, so whichever account you
368
+ are currently using does not change.
369
+
370
+ **Cookie expiry composition is recorded at capture time** — per cookie, whether it is session-scoped
371
+ or persistent, and when the latest one expires; shown as
372
+ `5 items · 1 session · 4 persistent · smidV2 399 days left`.
373
+ ⚠️ This is **not** the lifetime of your login. Measured: the real credential is the `token`
374
+ (token alone works; token-less requests are rejected with `40002 Missing Token`), so cookie expiry is
375
+ only an **upper bound on the browser side**. Older records and manually pasted tokens have no such
376
+ info and the panel says "not recorded (will be filled in on your next login)".
377
+
378
+ - **Account library** (`~/.dsh/web-login/accounts/`): keep several DeepSeek web accounts, switch with
379
+ one click, add a **note** (the per-account label), remove, export/import backups (both open a **native OS dialog** so you pick the location and file yourself). Switching takes effect on the **next** request.
380
+ - **Groups**: create / rename / delete groups, assign an account with the per-row dropdown, sections
381
+ collapse (state is local only), and the group holding the **current account is pinned to the top** so the
382
+ account you are using never sinks. Inside a group the order is still newest-captured-first.
383
+ Group definitions live in `~/.dsh/web-login/groups.json` and an account only stores a pointer, so
384
+ **deleting a group never deletes accounts** — orphans fall back into "Ungrouped".
385
+ Groups affect **display only**: switching, session reuse and cleanup ignore them.
386
+ - **Verify all**: one read-only `users/current` probe per account (**zero quota**, run serially) to refresh
387
+ login state, fill in account names and clear recovered failure marks. Not the same as the per-account
388
+ "refresh" button, which re-reads `/status`.
389
+ **Switching accounts does not lose your conversation** — the transcript lives locally in DSH and
390
+ every request re-sends the whole history; the account is just a pass and a quota owner.
391
+ - **Call ledger**: per-day JSONL (metadata only, no conversation content or credentials) showing the
392
+ **gap distribution between chat calls** (p50/p90/min — the minimum is what reveals bursts) and the
393
+ **failure breakdown** (throttled / account muted / auth / network).
394
+ - **Login probe**: a read-only `users/current` check 20s after startup and every 30 minutes
395
+ (`probeIntervalMs`, zero quota, can be disabled) so an expired login is discovered *before* a long
396
+ task fails midway.
397
+ - **Mute countdown**: when the account is temporarily limited, the panel shows the remaining time.
398
+ This state can only be learned from a **rejected generation** — read-only endpoints still return
399
+ 200 while muted, so the probe cannot detect it.
400
+
401
+ > ⚠️ **There is deliberately no auto-rotation between accounts.** Switching is manual only.
402
+ > A real person does not swap accounts and keep sending within minutes — that is a very strong
403
+ > machine-behaviour signal, and it directly conflicts with the transport-fingerprint / randomized
404
+ > pacing / session-cleanup work this plugin does to look less like a script. Providers also link
405
+ > accounts (same device, same IP, same fingerprint, similar behaviour), and a "same person, many
406
+ > accounts" verdict is usually treated more harshly than single-account overuse.
407
+ > Exported backups contain fully usable credentials — never share them or commit them.
408
+
409
+ **"Sign in a new account" vs "Sign out" — the difference matters:**
410
+
411
+ - **Sign in a new account (add)** clears the browser-side login state only, then **adds the new
412
+ account to the library without switching to it**. The account you are using is untouched; click
413
+ *Switch* in the list to start using the new one. This is how you keep several accounts side by side.
414
+ - **Sign out** **removes that account from the library** — both the local credentials and the browser
415
+ login state are cleared. It is not "just log out". Export a backup first if you want to keep it.
416
+
417
+ The **Account** tab is also split into two sub-pages (**Login status** / **Account library**) so you
418
+ never have to scroll through both halves at once.
419
+
420
+ **How export/import pick files.** *Export backup…* opens the native **Save As** dialog, so the
421
+ location and file name are yours to choose; *Import backup…* opens the native **Open** dialog, so
422
+ there is no path to type (and none to look up first). Both fall back gracefully when the environment
423
+ cannot show a native dialog: export then writes into the plugin directory and echoes the **full
424
+ path**, and import reads the file in the UI instead — the feature never silently stops working.
425
+ Import prefers passing only the **file path** to the host (which reads the file itself), so
426
+ credential plaintext normally does not travel over HTTP.
427
+
428
+ ## Known limitations
429
+
430
+ - **One chat window per account**: the web client limits generation per account. Running two or more windows against the same account triggers a server-side **temporary ban (1 day)** — the login stays valid, but every request from that account is rejected until it lifts. Use one account per window, or move the extra windows to another provider
431
+ - **Account display names are the server's masked values**: the web API only returns forms like `192******27` or `lidi*********+mn1@gmail.com`, so the raw email / phone number never reaches the plugin — showing the full identifier is **not possible**. Since 0.1.69 that value is no longer masked a **second** time: before, two Gmail accounts both rendered as `lid***@gmail.com` and looked like the same account
432
+ - **The account library refreshes itself**: the settings panel re-reads it every **3 seconds** while a login flow is in progress (so a captured account shows up on its own — before 0.1.67 you had to close and reopen the panel) and every **30 seconds** when idle (so the account name / limit / failure mark that the probe fills in appear by themselves). The list is only rebuilt when its content actually changed, so it will not steal a click from you.
433
+ - **No native tools**: tool calling is prompting-based. Drift is covered by both the instructions and the parser, but it remains model behaviour
434
+ - **The tool catalog has a budget**: the plugin tries to emit every tool definition DSH sends (before 0.1.33 the budget was 24k characters, which silently dropped 26 of 61 real tools). If the catalog still does not fit, the **names of the undescribed tools are listed** so the model asks the user for their parameters instead of guessing
435
+ - **60s per-request cap** (`completion_request_timeout_ms`): the web client resumes streams via `sse_auto_resume`; this plugin does not implement resumption and reports `max-tokens` when a stream ends without a `FINISHED` marker instead of pretending it completed
436
+ - **Images**: uploaded through the web file channel (`/api/v0/file/upload_file` → `ref_file_ids`). If an upload fails the plugin degrades to the `[image attached]` text marker **and says so at the top of the answer** ("N image(s) could not be sent to the model, ...") — before 0.1.66 the image was dropped silently and the log was the only trace. The same image appearing several times in history (user message plus an embedded `read_image` tool result) is deduplicated, because the server rejects duplicate ids (`biz_code 9 / invalid ref file id`) and a rejected session keeps failing on every later turn. **The upload filename must carry a supported image suffix** (png / jpg / jpeg / webp / gif): the server decides the type from the filename suffix, not from the multipart `content-type` — and the host gives `read_image`-style tool results a `name` that is **a bare sha256 with no suffix**. Since 0.1.68 `imageUploadName()` normalises it to `image.<ext>` (0.1.67 and earlier: every image coming back from a tool was rejected). **A rejected image reference (`code 9 / invalid ref file id`) is now self-healing**: the plugin drops those cache entries, re-uploads, and retries the turn — and if that is rejected too, it resends **without any images** so the session can never get stuck in a fail-on-every-turn loop (0.1.78; the retry only ever happens when nothing has been shown to the user yet, so no duplicated output). **An authorization failure while uploading aborts the remaining images** (the same token would be rejected for those too, so retrying them is pure waste) and the notice says how many were never attempted — 0.1.80; only `AUTH` short-circuits, a stray 5xx still lets the rest try. **Since 0.1.83 the later turns of a chained feed no longer re-reference earlier images**: when the server walks back the `parentMessageId` chain, the attachments of those messages are already in its context (measured: two differently laid-out images, neither carried on turn 2, both answered 4/4) — so the old "re-send the most recent 24 on every turn" was pure redundancy, and it made the web UI show the same batch under every new message. References are now sent only on a **full resend** (new session / account switch / system-prompt change) or when the turn carries a **newly pasted** image
437
+ - **The DSH renderer treats a single `$` as inline math (not this plugin's doing)**: DSH's frontend markdown enables `singleDollarTextMath`, so any text containing `$` is rendered as math — **the `$` disappears, `-` becomes `−` (U+2212), `|` becomes `∣` (U+2223), letters get split one per line while digit runs such as `256` stay together**. PowerShell / bash commands are hit hardest and it looks a lot like "the model produced garbage". Rule of thumb: **if the original text can be reconstructed verbatim, it is not model degradation** (degradation loses information; an encoding/rendering fault only re-encodes it). Workaround: wrap commands in fenced code blocks or backticks — code constructs do not run the math extension.
438
+ - **An answer that "stops halfway"** has two causes and since 0.1.70 both are visible instead of silent: (a) the model produced **thinking only** — no text, no tool call — which the adapter used to report as a normal `stop`, so the turn simply ended (it now reports a retryable `EMPTY_RESPONSE` and DSH resends automatically); or (b) the transcript-echo guard fired and **dropped everything from the matching line to the end of the answer**. The guard used to trigger on a *single* inline `[Tool Result for …]` — which is exactly how a model cites a tool result as evidence — so it could swallow the rest of a perfectly good answer. Inline markers are now split into **strong** (prompt truncation placeholders such as `truncated]` / `[N chars omitted]`; still dropped immediately) and **weak** (`[Tool Result` / `[status:` / `[System]`; held, and released as normal prose unless echo markers follow within the next two lines). When content really is dropped you get an explicit line at the end of the answer. **Limitation**: a citation sitting alone at the *start* of a line is byte-identical to a real replay and is still dropped — but you will see the notice. To avoid it entirely, tell the model not to paste raw tool output.
439
+ - Reasoning blocks are not replayed into history (token saving)
440
+ - `temperature` / `stop` / `max_tokens` have no web equivalent and are ignored; usage is estimated
441
+ - Free-tier rate limits apply; `429` carries `providerRetryAfterMs` for DSH's retry policy
442
+
443
+ ## Testing
444
+
445
+ ```bash
446
+ node tests/logic-test.mjs # 111 assertions across 8 files
447
+ node tests/probe-live.mjs # raw SSE event stream + timings (--big=N for long prompts)
448
+ node tests/probe-xml-live.mjs # XML-marker scenario against the live model
449
+ node tests/probe-vision.mjs # image channel (generates a red/blue PNG, uploads it, asks)
450
+ node tests/probe-batch-live.mjs # live repro of incident #4 (deep thinking + a batch of 3 commands with $env:/Windows paths)
451
+ node tools/changelog-section.mjs 0.1.3 # print one CHANGELOG section (reused by the release workflow)
452
+ node tests/check-bundle.mjs # verify every fix made it into lib/
453
+ node tests/check-image-refs.mjs # image reference assembly (dedup + "the image was dropped" notice)
454
+ node tests/check-image-chain-send.mjs # chained feed sends only images the server has not seen (a newly pasted one must still go)
455
+ node tests/probe-upload-name.mjs # live A/B: how the filename suffix affects upload (needs a logged-in account)
456
+ node tests/check-account-sync.mjs # account library auto-sync (cadence + change signature)
457
+ node tests/check-account-groups.mjs # account groups (storage tolerance / CRUD / section ordering)
458
+ node tests/check-accounts-view.mjs # /accounts sections must carry renderable views (title must not fall back to the id)
459
+ node tests/check-login-fresh.mjs # optional login-state wipe before sign-in (profile + partition, idempotent)
460
+ ```
461
+
462
+ Two assertions are frozen from a real incident: a tool call containing an unescaped Windows path once failed
463
+ to parse and leaked into the answer as text. It must now parse, with the path restored verbatim.
464
+
465
+ CI (`.github/workflows/ci.yml`) runs both commands on every push to `main` and every PR; pushing a `v*` tag
466
+ makes `.github/workflows/release.yml` create the GitHub Release and attach the tarball, using the repository token
467
+ (so a maintainer never needs a personal token).
468
+
469
+ Another real incident: the model omitted the closing brace of each call object in a batch of three, so the
470
+ whole `{"tool_calls":[…]}` block leaked into the answer. The filter now closes such braces structurally —
471
+ but only when the array itself is closed (a truncated stream must never be repaired into a half command) —
472
+ and an unparsable protocol block is never emitted as answer text again: with no other text in the step it
473
+ reports a retryable `EMPTY_RESPONSE`, otherwise it appends a one-line notice and logs the raw block.
474
+
475
+ ## Disclaimer
476
+
477
+ > ⚠️ **Unofficial.** Not affiliated with, endorsed by, or sponsored by DeepSeek. "DeepSeek" is a trademark of its owner.
478
+ >
479
+ > ⚠️ **Use at your own risk.** This plugin talks to a **private web endpoint** (not the official API). That may
480
+ > violate the service terms and may get your account rate-limited or banned. Evaluate it yourself; intended for
481
+ > learning, research and personal use only.
482
+ >
483
+ > ⚠️ **No credentials in this repo.** The web session is captured locally and stored in `~/.dsh/web-login/`.
484
+ >
485
+ > ⚠️ **Provided as is**, without warranty of any kind (Apache-2.0 §7). The endpoints can change at any time.
486
+
487
+ ## Acknowledgements
488
+
489
+ The implementation is original work, but the behaviour of the web endpoints (PoW/WASM convention, SSE patch
490
+ stream, file upload with `ref_file_ids`, DSML/XML tool-marker variants) was informed by and verified against
491
+ these public projects. **None of their source code is included here.**
492
+
493
+ - [LLM-Red-Team/deepseek-free-api](https://github.com/LLM-Red-Team/deepseek-free-api)
494
+ - [Fly143/deepseek-free-api](https://github.com/Fly143/deepseek-free-api)
495
+ - [ForgetMeAI/FreeDeepseekAPI](https://github.com/ForgetMeAI/FreeDeepseekAPI)
496
+
497
+ ## License
498
+
499
+ [Apache License 2.0](LICENSE) — includes a patent grant and patent retaliation; does **not** grant trademark
500
+ rights (§6). See [NOTICE](NOTICE).
501
+
502
+ ## Community
503
+
504
+ Questions, updates and general chat — join the QQ group (Chinese):
505
+
506
+ <p align="center">
507
+ <img src="docs/assets/qq-group.jpg" alt="QQ group QR code" width="300">
508
+ <br>
509
+ <sub>QQ group: <strong>1124773537</strong></sub>
510
+ </p>