A single OpenAI-compatible local server over login-only web LLMs, driven through a stealth browser. For people who have a web login (ChatGPT, a Databricks workspace, Microsoft Copilot) but no API key/budget, and want to point OpenAI-SDK-compatible tools — coding agents, scripts — at it.
One server, one port, N providers. Every provider speaks the same OpenAI
surface (/v1/chat/completions, /v1/models) and exposes exactly two methods
internally (models(), completions()):
| Provider | Backend | Tools | Reasoning |
|---|---|---|---|
chatgpt |
chatgpt.com (GPT-5, ...) | emulated (tag contract) | reasoning_effort -> web thinking_effort |
databricks |
Databricks Genie / llmproxy (Claude Sonnet 4.5 on Bedrock; Azure GPT-4.1) |
native (Claude) / passthrough (GPT) | native extended thinking |
copilot |
Microsoft Copilot (M365 BizChat) | emulated (tag contract) | model variations (copilot__Reasoning) |
Models from every enabled provider are merged onto one /v1/models, ids
namespaced <provider>__<slug> (e.g. chatgpt__gpt-5-mini,
databricks__claude-4-5-sonnet, copilot__Reasoning). Requests route by that
prefix.
Each provider wraps a persistent, logged-in
CloakBrowser session (a stealth
Chromium that passes Cloudflare Turnstile; auto-downloads its own binary). The
gateways/cloakbrowser session runs the page on one worker thread and captures
the relevant network response over the Chrome DevTools Protocol; the provider
supplies a trigger, a capture_url predicate, and a parse accumulator per
turn.
- chatgpt — types the prompt into the composer (the frontend mints the
single-use Turnstile/PoW token), captures the
backend-api/f/conversationSSE, translates thev1delta encoding into OpenAI chunks. Model + reasoning effort are forced by rewriting the request body via CDPFetch. Function calling is emulated via a<tool>/<assistant>tag contract. - databricks — issues the
llmproxyfetch in-page (httpOnly cookie auto-attaches; CSRF from/auth/session/infonever leaves the browser). Claude ids convert the OpenAI request to Anthropic Messages and convert the native Anthropic SSE back; GPT ids pass through the Azure OpenAI channel. - copilot — types into the M365 BizChat composer and captures the ChatHub SignalR WebSocket frames. Function calling is emulated (same tag contract).
Requires Python >= 3.10 and uv. Pick whichever fits:
# From a clone of this repo (development):
uv sync # create .venv, install deps + this package
uv run webllm-proxy install # pre-download the stealth browser (~200MB; optional)
# As a standalone CLI tool, straight from GitHub (not on PyPI -- see below):
uv tool install --from git+https://github.com/SamuelHaidu/webllm-proxy webllm-proxy
# Pin to a specific release instead of tracking main's latest commit:
uv tool install --from git+https://github.com/SamuelHaidu/webllm-proxy@v0.2.0 webllm-proxy
# Fully offline (no browser download either): download the zip for your OS
# from this repo's GitHub Releases page and see "Corporate / air-gapped
# install" below.Not published to PyPI right now (GitHub is the only distribution channel for
the moment) -- uv tool install webllm-proxy won't work; use the git+
form above instead. uv tool install puts a webllm-proxy executable on
your PATH (run uv tool update-shell once if it isn't already);
webllm-proxy install still needs to be run afterward to fetch the browser
binary unless you used the offline zip.
webllm-proxy.yaml is gitignored (it can hold a personal workspace_url), so
copy the template first:
cp webllm-proxy.example.yaml webllm-proxy.yamlThen set enabled: true on whichever provider(s) you actually have a web
login for and fill in any required fields:
server:
host: 127.0.0.1
port: 5100
providers:
chatgpt:
enabled: true
headless: true
databricks:
enabled: false
workspace_url: "https://<your-workspace>.cloud.databricks.com/?o=<org-id>"
copilot:
enabled: false
edition: m365Models are not listed in the config — each provider discovers its
available models live from the upstream web app on every GET /v1/models
call. workspace_url is the only field databricks requires; everything
else (tokenizer, models.<slug>.tokenizer, system_prompt, user_suffix,
profile_dir, style_rules, ...) is optional tuning — see the fully
commented webllm-proxy.example.yaml and
Configuration reference below.
Log in once per enabled provider (headed, needs a display — this ignores the
config's headless setting), then serve them all on one port:
uv run webllm-proxy login --provider chatgpt # once, headed
uv run webllm-proxy serve --config-file ./webllm-proxy.yamlcurl -s http://127.0.0.1:5100/health # {"status": "running", "providers": {...}}
curl -s http://127.0.0.1:5100/v1/models
curl -N http://127.0.0.1:5100/v1/chat/completions -H 'Content-Type: application/json' \
-d '{"model":"chatgpt__gpt-5-mini","stream":true,
"messages":[{"role":"user","content":"Count to 5"}]}'Research is a model, not a separate API: send model: "chatgpt__research" for a
long, web-search-backed, structured-markdown answer.
Running webllm-proxy with no subcommand is shorthand for serve --config-file ./webllm-proxy.yaml. webllm-proxy --version and
webllm-proxy <cmd> --help also work.
Any OpenAI-compatible client points at http://127.0.0.1:5100/v1 with a
namespaced model id. pi consumes the OpenAI format natively — add one provider
in ~/.pi/agent/models.json pointing at the unified endpoint and list the
<provider>__<slug> ids you want. (There used to be a dedicated
@webllm-proxy/pi extension package with extra tooling on top of plain model
access; it was retired when the server moved from a multi-port/gateway layout
to this single-process one — see docs/pi/webllm-integration.md for what it
did and how to rebuild it against the current server.)
The YAML config is the source of truth (parsed with pyyaml, validated with
pydantic — see webllm_proxy/utils/config.py). Fields per provider, all
optional except where noted:
| Field | Default | Meaning |
|---|---|---|
enabled |
false |
must be true for the provider to boot at serve/login |
headless |
true |
used by serve; login always runs headed regardless |
profile_dir |
per-OS data dir | override where the persistent browser profile lives |
browser |
stealth |
stealth (bundled CloakBrowser) or edge/chrome = drive your installed browser on its real profile (see below) |
browser_profile |
Default |
which installed-browser profile to open (when browser is edge/chrome) |
browser_user_data_dir |
auto-detect | override the installed browser's "User Data" dir |
import_chrome_extensions |
false |
load your installed Chrome's extensions into the stealth profile (see below) |
chrome_profile |
Default |
which installed-Chrome profile to import extensions from |
chrome_user_data_dir |
auto-detect | override the Chrome "User Data" dir to import from |
tokenizer |
openai/gpt-5 |
BPE profile used to estimate usage (see below) |
models.<slug>.tokenizer |
— | per-model tokenizer override, e.g. for a mini/nano tier |
system_prompt |
none | name of a prompts/system_prompts/<name>.md file to send |
models.<slug>.system_prompt |
— | per-model override of system_prompt |
user_suffix |
none | literal text appended to every turn's user message |
models.<slug>.user_suffix |
— | per-model override of user_suffix |
databricks.workspace_url |
"" (required when enabled) |
workspace URL incl. ?o=<org-id> |
databricks.style_rules |
true |
inject the style-rules addendum into Genie/Claude turns |
copilot.edition |
m365 |
Copilot edition to drive |
copilot.url |
provider default | override the nav URL for a non-default tenant |
A few debug env vars remain: WEBLLM_PROXY_DUMP_SSE=<path> (dump raw captured
SSE to a file), WEBLLM_PROXY_DUMP_DIR=<dir> (where redacted
*_last_request.json dumps land, defaults to the OS temp dir).
If you want a provider to run in your actual browser — with every extension
and login already working — set browser: edge (or browser: chrome) instead of
using the extension import above:
providers:
databricks:
browser: edge
browser_profile: "Default" # or "Profile 1"; from edge://version -> Profile Path
# browser_user_data_dir: null # auto: %LOCALAPPDATA%\Microsoft\Edge\User DataThe proxy then launches your Edge/Chrome on your profile (via Playwright's browser channel), so nothing is copied and nothing starts logged-out. Three caveats:
- Fully close that browser first — a profile can only be open in one
instance. Point
browser_profileat a dedicated profile if you want to keep using your daily one alongside the proxy. - Not for
chatgpt— this turns off the anti-detect stealth engine, so ChatGPT's Turnstile/PoW would fail. Ideal fordatabricks/copilot; keep chatgpt onbrowser: stealth. - It uses your real logins (that's the point) and writes to that profile
(history, etc.) — this is normal
msedge.exe/chrome.exebehavior.
This supersedes the extension-import feature for that provider (no
import-extensions step needed).
Set import_chrome_extensions: true on a provider to run the stealth browser
with the extensions from your everyday Chrome (ad blockers, helpers, etc.). It is
opt-in and deliberately conservative: only the public Extensions/ folder of
the chosen chrome_profile is ever read — never cookies, saved passwords, or
Local State. The extensions are copied into the proxy's own profile, so your
real Chrome can stay open and is never modified.
The read+copy happens on an explicit, user-run step, not silently at serve time:
uv run webllm-proxy import-extensions --provider chatgpt # copy them in now
# ...or just `login`, which imports them too:
uv run webllm-proxy login --provider chatgptserve then loads whatever was copied (it never touches your real Chrome dir).
Chrome's "User Data" dir is auto-detected (Windows:
%LOCALAPPDATA%\Google\Chrome\User Data; macOS/Linux equivalents, incl. Flatpak);
override it with chrome_user_data_dir. Extensions load under headless too, though
UI-heavy ones may not fully function without a display.
If your antivirus/EDR flags the app: the flag will target CloakBrowser's
patched, unsigned chrome.exe (under ~/.cloakbrowser, or CLOAKBROWSER_CACHE_DIR)
or generic browser-automation behavior — not this import, which never reads
credential files. On a personal machine you can add an exclusion for that binary in
your AV/EDR. This project does not attempt to hide from or evade security tooling.
- Browser-backed: a pure HTTP reimplementation isn't feasible for chatgpt (per-request Turnstile/PoW). databricks could be mostly server-side, but reuses the same transport for now.
- Emulated function calling (chatgpt, copilot) via a tag prompt contract — reliability is model-dependent. databricks Claude is native; there are no native-channel interception tricks anymore (chatgpt's internal tool messages are ignored).
- Serialized: one turn at a time per provider (single browser each) — a second concurrent request to the same provider waits on the first.
usageis estimated, not measured, for chatgpt/copilot: none of the three web apps expose a real token-count API, soprompt_tokens/completion_tokensare computed locally with a vendored BPE tokenizer (tiktoken, plus a vendored Claude vocab) per thetokenizerconfig above. databricks usage is real, reported by the upstream channel itself.- Automates a web app you're logged into — likely against ToS beyond personal use.
Exactly five folders under webllm_proxy/, each a clear responsibility:
webllm_proxy/
cli.py, server.py argparse CLI (serve|login|install) + composition root
http/ one Flask app + controllers (models, chat/completions,
health), decoupled from providers
gateways/
cloakbrowser/ the browser session/transport + login (run_turn/evaluate)
providers/
base.py the 2-method Provider protocol
chatgpt/ models() + completions(); v1 SSE parser; research model
databricks/ Claude (convert) + Azure GPT channels; llmproxy envelope
copilot/ M365 BizChat; SignalR decode
prompts/system_prompts/*.md every injected prompt, as a .md file
utils/ config (yaml+pydantic), env/logging/redaction/process,
openai wire, openai<->anthropic convert, tag contract,
token counting
tests/ browser-free unit tests seeded from real captures +
an OpenAI-SDK smoke suite (skippable, needs a live server)
docs/discovery/ how each web backend was reverse-engineered
uv run poe check # fmt + lint (ruff, strict) + typecheck (ty) + test (pytest)
uv run poe release # check + build (uv build)
uv run poe publish # uv publish -- manual only for now, see belowThe openai / anthropic SDKs are dev-only, used purely as validation clients
in tests/smoke_openai_sdk.py to prove SDK compatibility across tools, thinking,
effort, roles, and streaming — never in the runtime path.
Three workflows under .github/workflows/:
ci.yml— every pull request intomainrunsuv run poe check(fmt/lint/typecheck/test). Required to pass before merging.release.yml— every push tomain(i.e. every merge) re-runs the quality gate, then checks whetherwebllm_proxy/_version.py's__version__is already tagged. If it's a new version: builds the sdist + wheel, tags the commitvX.Y.Z, and creates the GitHub Release for that tag with the sdist/wheel attached. A merge that doesn't bump__version__is a no-op here — nothing releases until you do.offline-bundle.yml— explicitly dispatched byrelease.ymlfor thevX.Y.Ztag it just created (a tag pushed with the defaultGITHUB_TOKENdoesn't auto-trigger other workflows'push: tags:, sorelease.ymlcallsgh workflow run offline-bundle.yml --ref vX.Y.Zitself instead — itspush: tags:trigger still fires normally for a tag pushed some other way). Builds the Linux + Windows offline bundles natively (one runner per OS) and attaches them as zips to that same GitHub Release.
To ship a release: bump __version__ in webllm_proxy/_version.py in a PR,
merge it, and the rest is automatic.
Not published to PyPI right now — GitHub (git installs + Release
zips/wheels) is the only distribution channel for the moment; uv run poe publish remains available for a manual one-off if/when PyPI comes back,
but nothing in CI runs it. That needs a PyPI account with a
Trusted Publisher registered
for this repo (project webllm-proxy, workflow release.yml, environment
pypi — that environment already exists in this repo's Settings →
Environments from when this was wired up) plus re-adding the uv publish
step (and its id-token: write permission) to release.yml.
CloakBrowser's binary download (~200MB) is the thing most likely blocked by a TLS-inspecting corporate proxy or an air-gapped policy.
Simplest: every GitHub Release ships a pre-built, fully offline zip for
Linux and Windows (webllm-proxy-offline-linux-x64.zip /
-windows-x64.zip, built by offline-bundle.yml) — download it from this
repo's Releases page, unzip on the target (no-internet) machine, and run
install_offline.sh / install_offline.ps1 inside it. That installs the
package (pip install --no-index --find-links wheels webllm-proxy) and
extracts the matching CloakBrowser binary; no PyPI, no browser download,
nothing else needed.
Otherwise, any one of:
- Pre-staged binary — set
CLOAKBROWSER_BINARY_PATH;webllm-proxy installthen skips the download. - Internal mirror — point
CLOAKBROWSER_DOWNLOAD_URLat a mirror; also setHTTPS_PROXY/HTTP_PROXYandREQUESTS_CA_BUNDLE/SSL_CERT_FILE(your root CA) if the gateway does TLS inspection. - Build your own offline bundle — on a connected machine
uv run poe bundle(orbundle-linux/bundle-windows) collects wheels + the CloakBrowser binary intodist/offline/with an install script (install_offline.sh/.ps1) for the target machine — useful if you need a build newer than the last tagged release, or a platform the Release zips don't cover. - Docker fallback — run the
cloakhq/cloakbrowserimage instead of a locally installed binary.
If external clients (pi, curl, the SDKs) run behind a proxy, keep local
traffic direct: export NO_PROXY=127.0.0.1,localhost.
docs/discovery/(start at itsREADME.mdindex) documents the reverse-engineering of each backend — the ChatGPT web API + anti-bot flow, the Databricks llmproxy channel + model enumeration, the Copilot ChatHub protocol — including the process, not just the result.docs/pi/covers integrating this proxy with thepicoding agent: the generalpiextension/SDK mechanics (pi-extension-sdk-index.md) and the retired dedicatedwebllmpipackage (webllm-integration.md).