Uncle Sam — a harness model provider that drives chat.deepseek.com
Adds deepseek-web as a model provider. A real browser answers each call, so the model runs on the
web account already open in a tab — no API key, no per-token billing.
Replies from the web route are signed 🔴 Uncle Sam. That name is a joke, not a claim: it marks which route answered, and API replies carry no signature at all.
provider deepseek-web
models deepseek-chat · deepseek-reasoner
measured first token ~1.9 s · overhead ~0.6 s · a full turn in ~2.5 s
Installing
Uncle Sam is a DeepSeek Harness plugin. It does nothing outside the harness, because it registers a model provider, a host route and two client slots. Installing it into anything else will simply do nothing visible.
Requirements
| DeepSeek Harness | any recent build with the plugin manager |
| Browser | Brave recommended, Edge used automatically when Brave is absent |
| Node | 22 or newer |
From a package registry, once published
pnpm add @operatingtag/dsh-unclesam
Then add the bundle row to your profile so the harness loads it:
# cordis.patch.yml
- insert:
- id: dsh-unclesam
name: '@operatingtag/dsh-unclesam'
config:
port: 9222
defaultModel: deepseek-chat
autoMinimise: true
From a folder
Settings → Plugins → install from a local path
pointed at the folder containing package.json.
What you should see afterwards
- Settings → Uncle Sam — a page with four status rows and a sign-in button.
- Under the chat box —
● DeepSeek Harnessor● Uncle Sam, click to switch. - The model list —
deepseek-chat (web)anddeepseek-reasoner (web)beside the API models.
First run
Click Sign in / open chat. The browser opens and stays visible until you have signed in at chat.deepseek.com. After that it minimises itself and you will not see it again. The session lives in your browser profile, so no password is stored by the plugin.
Removing it
Settings → Plugins → Uncle Sam → Uninstall
Nothing else is written outside the browser profile you signed in to.
Why it is built this way
The obvious route — call chat.deepseek.com's own HTTP API — was attempted and abandoned, because the
site makes every message clear two gates:
| Gate | What it means |
|---|---|
/api/v0/chat/create_pow_challenge |
a proof-of-work challenge per message |
challenges.cloudflare.com/turnstile |
a bot gate on the origin |
Re-implementing either is a losing game: they are obfuscated, they change, and when they change the failure looks exactly like a network fault. The page already solves both for its own requests.
So the adapter never speaks HTTP to the site. It drives the page, and the page does the rest. Auth, PoW and Turnstile stop being our problem and cannot break independently of the site itself.
Verified: a plain HEAD to the site returns 403; the browser path returns answers.
Architecture
index.js the adapter: harness StreamChunk ←→ engine
lib/engine.mjs browser + page: type, send, stream, settle
lib/cdp.mjs Chrome DevTools Protocol client, zero dependencies
lib/prompt.mjs flatten messages + tools into one message; recover tool calls from the reply
Nothing is installed as a dependency. Node 24 ships a global WebSocket and fetch, which is all CDP
needs, so the token stream never leaves the host process and there is no IPC hop per delta.
The four failures that shaped the code
Each of these was observed, not anticipated, and each is now guarded by a comment at the site.
The answer arrived with the previous answer glued to the front. The reader took the last "message-ish" block, and after a failed New chat click the previous transcript was still there. Fixed twice over: the reset is now verified (count of assistant blocks must reach zero), and the reader takes a baseline index so only blocks created after the prompt can ever be returned.
The answer was emitted twice. The DOM held it once; the adapter's fallback branch re-emitted the whole text for a non-prefix change and the consumer appended it. Deltas now emit only characters not yet sent, computed by longest common prefix. A streaming adapter may never resend text.
A
zoompan-style trap in miniature:waitForcalled its probe without a catch, so waiting for the browser's debug port died on the firstfetch failed— the normal state for several seconds. A throw inside the poll now means "not ready yet".The tool call JSON leaked into the transcript. Tool calls are JSON, so a reply beginning with
{or a fence is now buffered; prose streams immediately, and only a buffered JSON reply is turned into a tool call.committed text == concatenated deltasis asserted in the test.
Tool use is emulated, and that is a real limitation
A web chat has no structured tool channel. Tools are described in the prompt, and a call is recovered by parsing a JSON object out of the reply:
{"tool": "get_weather", "arguments": {"city": "Madrid"}}
This works — verified returning get_weather({"city":"Madrid"}) with finish: tool-calls.
But it is weaker than native tool calling. A model that ignores the instruction answers in prose, and the harness sees no tool use that turn. For heavy agentic work the API provider is more reliable; this provider is best for chat, subagents, compaction, and any turn where the model must be the web model.
Configuration
cordis.patch.yml sets:
| key | default | meaning |
|---|---|---|
port |
9222 |
the browser's debugging port |
defaultModel |
deepseek-chat |
used when a call names no model |
showBrowser |
true |
whether the window is visible while it works |
The browser must hold a signed-in chat.deepseek.com session. The first call launches Brave on the
Default profile if the port is not already open. If Brave is already running without a debugging
port, the adapter cannot attach and reports that clearly.
The API-vs-web switch
The model picker is the switch. There is no second control, because a second control that also chose the model would be two sources of truth for one decision.
| Pick this model | Provider | DeepThink | Search |
|---|---|---|---|
DeepSeek Chat (web) |
browser | off | off |
DeepSeek Chat + Search (web) |
browser | off | on |
DeepSeek Reasoner (web) |
browser | on | off |
DeepSeek Reasoner + Search (web) |
browser | on | on |
any model without (web) |
the API | — | — |
A WEB or API badge renders beside the model label so the active route is visible at a glance. When the label is not recognised the badge renders nothing rather than guessing — a wrong badge would be worse than none.
Search is visible in the transcript
With a search model, a turn produces two blocks:
Web search — "What is one technology news headline from today?"
Sources (4): yna.co.kr · fnnews.com · rthk.hk · sina.com.cn
Links: https://k.sina.com.cn/article_... http://www.21jingji.com/article_...
then the answer that used them. The page does not expose the query it typed internally, so the quoted
query is the prompt that triggered the search; the sources and links are read from the page after the
answer settles, from the cdn.deepseek.com/site-icons/<domain> favicons and the external citation
anchors. Because the sources only exist once the answer is finished, search turns buffer instead of
streaming; non-search turns still stream token by token.
The two switches
DeepThink is the reasoning pass; Search lets it read the live web. Both are per-conversation and are
cleared by New chat, which is why the engine sets them after the reset and before typing. They
are also re-read after clicking, because a click is a request rather than a result.
Testing
node test-adapter.mjs # prose path: contract checks
node test-adapter.mjs --tools # tool path: JSON must not reach the text channel
node test-search-adapter.mjs # search path: two blocks, query + sources + answer
node test-engine.mjs "prompt" # raw engine timings
DEBUG_WEB_CHAT=1 node test-engine.mjs "prompt" # per-poll trace
test-adapter.mjs enforces the three rules that matter: a block is opened before any delta, exactly one
terminal finish, and committed text equals the concatenated deltas.
Verification status
| Claim | Evidence |
|---|---|
| Engine drives the page end to end | measured across several prompts |
| Adapter emits a valid chunk sequence | test-adapter.mjs — passes |
| Search emits query + sources + answer | test-search-adapter.mjs — passes, 4 domains + links, 5.0 s |
| Tool emulation recovers a call | passes, get_weather({"city":"Madrid"}) |
| Bundle installs and activates | application: applied, v1.1.0 |
| Row composed into the profile | present in dsh.profile.bundles |
Client half present with dsh.client |
client.js + platform: web, inject conversation UI |
| Model selectable in the GUI | not visually verified — the Web UI answers a bare origin with dsh web authentication required; reopen the URL printed by dsh web, and that URL is not recoverable from this session |
| The WEB/API badge rendered | not visually verified — same reason |
| A live harness turn | not run — needs a model selected in the picker |
The last three need the harness restarted by the user and one model chosen; they cannot be closed from inside the running session.
Changelog
Every released version, newest first, in CHANGELOG.md.
Support
Uncle Sam exists to stop you paying per token. If it has saved you money, and you would like to see the next one built on hardware that does not run out of memory halfway through, you are welcome to buy me a coffee:
A star on the repo or a useful bug report is worth just as much.
Licence
MIT — Copyright (c) 2026 Operating Tag. All rights reserved.
Uncle Sam is an independent plugin. It is not affiliated with, endorsed by, or supported by DeepSeek.
@operatingtag/dsh-unclesam v3.1.0
Author Operating Tag
Licence MIT
No comments yet. Be the first to write one.