Guidance for Claude Code when working in this repo.
Drives a chat web UI through an automated Chrome browser
(nodriver) and exposes it as an
OpenAI-compatible HTTP API — chat completions, image generation, and image input (vision +
image-to-image). There is no official API key for either backend; it piggybacks on a logged-in
web session stored in a local Chrome profile. Prompts are typed into the page, input images are
uploaded through the site's own file picker, and answers (text and generated images) are scraped
back out of the DOM.
A mini web UI (webui/index.html, single file, no build step) is served at
http://localhost:8081/ — streaming chat (with 📎 image attachments), image generation and
image-to-image, gallery of saved images, a live per-provider status bar, and a Status tab with live telemetry (requests / errors / avg+last
latency / last-error / recycle countdown per provider), backed by /api/status + /api/gallery.
Theming (added 2026-08-04). The UI is a "precision instrument" design — warm-paper light theme,
graphite dark theme, borders (not shadows) carrying the structure, mono for every label/metric, a
faint 24px drafting grid, dot-matrix provider readouts, and a scan line across the answer while it
streams. Both themes are real designs, not inversions. The header has a 3-state switch
(light / auto / dark) persisted in localStorage under blm.theme; auto follows
prefers-color-scheme live. An inline script in <head> resolves the theme onto
documentElement.dataset.theme before first paint, so there's no flash — keep it there.
Colours are CSS custom properties on :root (light) and :root[data-theme="dark"]; add new colours
as tokens, never as literal hex in a rule. Fonts are local-only on purpose (Inter if installed, else
system; mono falls back to Noto/DejaVu) — no Google Fonts link, so the UI still renders offline.
Two traps: the footer must NOT uppercase #ft-dir (filesystem paths are case-sensitive), and
md()'s code-block sentinel is the six-character escape \u0000 in the source, not a raw NUL byte — a literal NUL makes the
file binary to grep and is invalid in HTML source.
There is also an embeddable chat widget (webui/widget.js, served at /widget.js): a
self-contained, Shadow-DOM-isolated floating chat bubble that any other page on the LAN can add with
<script src="http://<host>:8081/widget.js"></script>. It auto-discovers this server as its API base
from its own script URL (CORS is already open) and streams from /v1/chat/completions. Config via
data-* attrs (provider, title, accent, position, greeting, system, open, attach,
theme); runtime
handle window.BrowserLLMWidget (open/close/reset/config). The Status tab shows a copy-paste
embed snippet + a "Preview widget on this page" button. The widget is themed with the same palette
via --w-* tokens inside its shadow root; data-theme defaults to auto, which follows the HOST
page's prefers-color-scheme (it used to be hardcoded dark, which looked broken on a light site).
Two providers, selected per-request by the OpenAI model field:
| model | site | profile | images out | images in |
|---|---|---|---|---|
gemini-browser |
gemini.google.com | gemini_profile/ |
yes | yes (chooser path) |
chatgpt-browser |
chatgpt.com | chatgpt_profile/ |
yes | yes (verified) |
Unknown/absent model → DEFAULT_PROVIDER (env, default gemini-browser).
This is inherently fragile: each provider depends on its site's live DOM/selectors, and ChatGPT additionally sits behind Cloudflare/anti-bot. A UI change can silently break extraction or submission.
/home/eben/Downloads/Ebenworks (EW)/Open Source Projects/browser-llm-api ← real directory
/home/eben/Downloads/browser llm api ← symlink to it
Filed with the other Ebenworks MIT repos. The symlink at the old path is permanent, not a
migration leftover: this repo is the image-asset tool every Claude Code session on the box uses, so
its old path is quoted in other repos' scripts, in skill files and in conversation memories that
nothing will ever rewrite. Deleting the symlink breaks those silently. Everything that could be
repointed was — the systemd unit, the desktop launcher, the design-with-chatgpt skill,
statosports/scripts/generate-assets.sh, fsms_algorithm2, 3/docs/analysis/scripts/gen_figs*.sh,
gpt_slides.py — plus the Claude Code project key
(-home-eben-Downloads-Ebenworks--EW--Open-Source-Projects-browser-llm-api, with the old key
symlinked to it so transcripts and memories stayed in one place).
Both paths contain a space, so quote every path in scripts and unit files, and note that a plain
#! shebang cannot hold one: the venv's console scripts (venv/bin/pip, uvicorn, …) use pip's
#!/bin/sh + '''exec' wrapper form instead. Don't "simplify" them back to a bare shebang.
server.py # FastAPI app, port 8081. Model→provider router, the generic
# completion loop, generic image persistence, attachment
# (image-input) materialization + local-path policy, CDP patch.
# main() is the `browser-llm` console entry point
# (BROWSER_LLM_HOST/PORT). Serves "/" + /widget.js + /demo +
# /version + /api/status (incl. per-provider telemetry:
# _metrics + _record_request) + /api/gallery, and
# /v1/images/edits (multipart or JSON).
_version.py # single source of truth for __version__ (read by pyproject + server).
pyproject.toml # packaging: metadata, deps, dynamic version, `browser-llm` entry point.
# Flat layout — install editable from a clone (`pip install -e .`).
LICENSE # MIT.
README.md # project overview / usage.
QUICKSTART.md # fast-path setup guide.
authz.py # stdlib-only access control + remote-upstream config: loopback + origin trust,
# API-key gating rules (which paths need the key), REMOTE_PROVIDERS parsing.
# Separate from server.py so tests import it without server's side effects.
client.py # tiny stdlib-only CLI/importable client (no deps): `./client.py "prompt"`
# or `from client import ask`. `--image FILE` (repeatable) sends
# images with the prompt. Env BROWSER_LLM_API/BROWSER_LLM_MODEL/
# BROWSER_LLM_API_KEY.
mode.sh # toggle the systemd service's Chrome visibility (headless/visible) via a
# drop-in override: `./mode.sh headless|visible`; `./mode.sh` shows current mode.
webui/index.html # mini web UI (single file, no build step): streaming chat with
# image attachments (click/paste/drop), image gen + image-to-image
# with elapsed timer, gallery, live provider status,
# Status tab (telemetry + embed snippet + version in footer).
webui/widget.js # embeddable floating chat bubble (Shadow-DOM isolated, no build step);
# served at /widget.js; auto-discovers API base from its own <script src>.
# Has 📎 image attach (click/paste/drop); data-attach="0" hides it.
webui/widget-demo.html # standalone demo page (served at /demo) embedding the widget.
desktop/ # NATIVE Linux desktop app + tray widget (GTK3), a thin client of the
# HTTP API — NOT browser automation. Runs on SYSTEM python3 (has
# PyGObject); stdlib only, no venv/pip. GTK3 (not 4) on purpose:
# AppIndicator (the tray) is GTK3-only and can't share a process
# with GTK4. The tray widget's job: generate image assets for the
# focused VS Code project.
browser_llm_desktop.py # whole app: Api (stdlib urllib); ProjectManager (reads open VS Code
# windows from ~/.config/Code/User/globalStorage/storage.json, resolves
# the focused window via xdotool, auto-follows focus, remembers a save
# folder per project in ~/.local/share/browser-llm-desktop/projects.json);
# ChatStore (single SHARED + persisted conversation store — popup and
# window are both views, so enlarging never loses the chat; chats.json);
# ProjectImagePanel (gen or 📎 image-to-image -> save into
# project, pick&remember folder);
# ChatPanel, GalleryPanel/ImageViewer, StatusPanel, MainWindow (Chat/
# Images/Gallery/Status + History menu), QuickChatWindow (Image|Chat
# tabs), TrayApp.
icon.svg run.sh install-desktop.sh browser-llm-desktop.desktop.in README.md
tests/ # unit tests (no browser needed):
# ./venv/bin/python -m unittest discover -s tests
# test_completion_tracker.py (done-decision incl. status
# placeholders), test_authz.py (key gating, origin trust,
# remote parsing), test_attachments.py (attachment specs/
# limits/local-path + origin policy/remote inlining)
providers/
__init__.py # PROVIDERS registry + get_provider(model) + DEFAULT_PROVIDER
base.py # Provider ABC, StreamMonitor, CompletionTracker (done-decision),
# generic open_and_send(), generic file-upload/attach machinery
# (file input + CDP file-chooser interception), patch_cdp()
gemini.py # GeminiProvider — shadow-DOM extraction, blob→b64 images,
# upload via the "Upload & tools" menu + chooser interception
chatgpt.py # ChatGPTProvider — plain-DOM extraction, oaiusercontent/blob images,
# upload via the hidden upload-photos-input; excludes INPUT images
# from generated-image scans; shimmer-aware text/generating reads
login.py # generic re-auth helper: python login.py gemini|chatgpt
gen_asset.py # CLI wrapper: POST /v1/images/generations (no model → DEFAULT_PROVIDER),
# or /v1/images/edits with `--ref FILE` (image-to-image restyle),
# then Pillow post-process (resize/crop/favicon/knockout) → asset file
AGENT_IMAGE_GUIDE.md # instructions to hand an AI agent for generating site image assets
gemini_bot.py # standalone single-prompt prototype (Gemini only). UNCHANGED, not part of the server.
serve.sh # run the server: venv python + display auto-detect (real $DISPLAY else Xvfb)
install-service.sh # venv + deps + generate the systemd --user unit from the template
browser-llm-api.service.template # unit template; install-service.sh substitutes the clone path
*_profile/ # per-provider Chrome user-data dirs. Gitignored. Never commit.
Deps are installed in a local venv at ./venv (system Python is PEP-668
externally-managed, so a venv is required): ./venv/bin/pip install -r requirements.txt.
Run the server with ./serve.sh (foreground) or ./install-service.sh (background
service); both use ./venv/bin/python. Also needs Google Chrome and the system xvfb package. Python 3.12.
Pillow (in requirements.txt) is only needed by gen_asset.py.
Adding/altering a backend means editing a provider, not server.py. A provider is mostly
declarative — class attributes name, chat_url, profile_dir, stream_url_fragments (CDP
completion signal), supports_images, image_text_is_caption, input_selector, send_selectors,
load_wait, and for image input supports_upload, attach_click_path, attachment_ready_js,
upload_timeout, upload_settle — plus site-specific reads:
open_and_send(browser, prompt, attachments=None) -> (page, monitor)— generic in base; navigates, attaches theStreamMonitor, uploads anyattachments(see the image-input section), types intoinput_selector, clicks the first workingsend_selectors(Enter fallback). Override only for a site that needs something special.attach_files(page, paths) -> int/wait_uploads_ready(page, n)— generic in base; driven by the declarativesupports_upload,attach_click_path,attachment_ready_js,upload_timeout,upload_settle.get_response_text(page) -> str— current text of the last assistant turn (UI chrome stripped).is_generating(page) -> bool— is the model still producing?image_status(page) -> {loaded, pending, creating}— default no-op (text-only).get_images(page) -> [{mime, b64|src, alt}]— default[].b64= read inline;src= remote URL fallback when the in-page fetch is CORS-blocked.logged_in(page) -> bool— used bylogin.py.
Everything downstream (_stream_completion, run_chat, drive_once, image persistence, the
StreamMonitor, and the CompletionTracker that decides when an answer is done) is generic
and provider-parameterized in server.py/base.py.
get_provider(req.model)picks the provider.- One persistent Chrome per provider (
_browsers[name], started lazily) with that provider's profile. - Per-provider
asyncio.Lock(_locks[name]) serializes requests within a provider; Gemini and ChatGPT can run concurrently. _build_prompt()flattens the OpenAImessagesarray (system →[Context/Instructions: …]preamble; multi-turn →User:/Assistant:labels) and returns(prompt, image specs)._attachment_files()materializes those specs into files, thenprovider.open_and_send()opens the chat, uploads them, types, submits (temp files are cleaned up when the drive ends)._stream_completion()polls, yielding text deltas fromprovider.get_response_text(). It suppresses transient status text — "Creating your image…" / "Analyzing image" (seeCompletionTracker._PLACEHOLDER_RE, short text only) and thinking text whileimage_statusreports an image pending — and keeps waiting until the<img>renders.- Completion: the
CompletionTracker(inbase.py, unit-testable without a browser) is fed one poll sample at a time and decides done via: image-stability (an<img>rendered and stable ≥4s), or text settled (text unchanged ≥2.5s while not generating), or a give-up guard (generation happened but no text — 10s, stretched to 45s while a status placeholder is on screen). TheStreamMonitor's HTTPstream_url_fragmentssignal (cdp_fired_at) is informational only. Deadline is progress-aware: base 420s, extended up to 900s while the answer is still actively streaming (text still growing or WebSocket frames still arriving), so long code/HTML answers aren't truncated. Thenprovider.get_images()runs and images are_persist()ed + appended. - The tab is left open on purpose — closing/navigating away destabilizes the browser.
Images can go in as well as out. The server materializes whatever the client sent into real files, and the provider uploads them through the site's own file picker before submitting the prompt.
- Wire formats (
server.py): OpenAI vision content parts ({"type":"image_url","image_url":{"url":…}}, plain-stringimage_url, and Anthropic's{"type":"image","source":{…base64…}}are all accepted —ContentPartis deliberately permissive), a non-OpenAIimages: [...]shorthand on chat requests,image/imageson/v1/images/generations, andPOST /v1/images/editswhich takes OpenAI's multipart upload (needspython-multipart) or the same JSON body. - Spec forms:
data:URL, bare base64,http(s)URL (downloaded server-side, size-capped),file://, or a local path._attachment_files()is an async context manager that writes temps (sniffing the real extension from magic bytes) and deletes them after the drive; caller-supplied paths are used in place and never deleted.MAX_ATTACHMENTS(6),MAX_ATTACHMENT_MB(20). - Local paths need a loopback and same-origin caller (
_client_may_send_paths): a keyed LAN client could otherwise make the server upload any file off this box to ChatGPT — and so could any website open in the operator's browser, since CORS is wide open and a drive-by POST arrives from 127.0.0.1 (see the origin gotcha below).ALLOW_REMOTE_FILE_PATHS=1opts out of both checks. Proxied requests inline local paths asdata:URLs (_inline_paths_for_remote) — a path is meaningless on the upstream's filesystem. _build_promptnow returns(prompt, specs)and annotates multi-turn text with[N attached images], since every image lands in one composer message and the model otherwise can't tell which turn an image belonged to.- Provider side (
providers/base.py):open_and_send(browser, prompt, attachments=None)→attach_files(), which tries (1) an<input type=file>already in the DOM (shadow-piercing, best-scoring candidate first, verified by watching for the attachment chip) then (2) CDP file-chooser interception:Page.setInterceptFileChooserDialog, click throughattach_click_path, thenDOM.setFileInputFileson the backend node fromPage.fileChooserOpened. Interception stays on for the tab's life on purpose — an un-intercepted native dialog would wedge the renderer with nobody to dismiss it.wait_uploads_ready()pollsattachment_ready_js({ready, busy}) for two clean samples —require_idle=Falsewhile probing an input (chips only), then the full idle wait inopen_and_sendbefore submitting, because sending mid-upload loses the attachment. - ChatGPT (verified live 2026-07-28): hidden
input[data-testid="upload-photos-input"](accept=image/*) exists at rest, so path (1) always wins; each accepted file renders a 144px tile withbutton[aria-label^="Remove file …"]— that's the chip count. Gemini keeps no file input at rest, so it needs path (2); itsbutton[aria-label="Upload & tools"]is verified but the menu-item labels are best-guess (the box's Gemini profile was signed out) — check those first if a Gemini upload stops landing. - Don't let an input image look like a generated one. ChatGPT's
image_status/get_imagesscan the whole page (a generated image renders outside the assistant turn), and an uploaded image has the sameblob:/content?URL shape._isInput()excludes anything inside[data-message-author-role="user"], aform, or afile-tile— without it every vision request completed instantly on "image stability" (truncating the text) and echoed the upload back into the gallery. - "Analyzing image" placeholder (fixed 2026-07-28): on a vision request ChatGPT puts transient
status text in the same
.markdownnode as the answer, markedloading-shimmer aria-busy, and the stop button can be absent during that phase — soget_response_textreturned "Analyzing image", it settled for 2.5s, and that was returned as the whole answer. Fixed structurally:get_response_textreturns""andis_generatingreturns True while that node is shimmering/aria-busy. Generic backstop inCompletionTracker:_PLACEHOLDER_REalso covers analyzing/analysed/reading/thinking/working but only for text ≤PLACEHOLDER_MAX_LEN(48), so a real answer opening with "Analyzing the image, …" isn't swallowed; while a placeholder shows, the give-up-empty window stretches toSILENT_PLACEHOLDER_DONE(45s) instead of 10s.
- Extraction is per-provider (
image_status+get_images). Gemini readsblob:URLs to base64 by shadow-piercing; ChatGPT readsoaiusercontent/blob:<img>s in the last assistant turn, falling back to the remotesrcURL if CORS blocks the in-page fetch. - Storage (
_persist): images with inlineb64are written to a per-provider subfolder of the base dir (<IMAGE_DIR>/<provider>/<provider>_<ts>_<hash>.ext, e.g.chatgpt/…vsgemini/…) and served at/images/<provider>/<file>(mountedStaticFilesserves nested dirs); the returned link usesGEMINI_PUBLIC_URL. The per-provider slug (_provider_slug) stops one provider's images from being mislabeled as another's. Remote-only images keep theirsrc. If the dir isn't writable, saving is skipped (_SAVE_ENABLED=False). - In chat:
_compose()returns image-only markdown whenimage_text_is_captionis False (Gemini — its image-prompt prose is internal thinking), or text + images when True (ChatGPT — real caption). - Endpoint
POST /v1/images/generations:{"created", "data":[{b64_json?, url?, path?}]}.n/sizeaccepted but ignored. 501 if the provider doesn't support images, 502 if it returned none.
| Var | Default | Meaning |
|---|---|---|
DEFAULT_PROVIDER |
gemini-browser |
Provider used when model is unknown/absent. |
GEMINI_IMAGE_DIR |
~/Pictures/browser-llm |
Base dir for saved images; each provider gets a subfolder (chatgpt/, gemini/). IMAGE_DIR also accepted. |
GEMINI_PUBLIC_URL |
http://localhost:8081 |
Base URL used to build returned image links. |
BROWSER_RECYCLE_AFTER_IMAGES |
3 |
Recycle a provider's browser after this many image gens (renderer bloats and times out otherwise). |
MAX_ATTACHMENTS |
6 |
Most input images one request may attach. |
MAX_ATTACHMENT_MB |
20 |
Per-attachment size ceiling (data URLs, downloads and local files alike). |
BROWSER_LLM_HOST |
127.0.0.1 |
Interface to bind. Localhost-only by default (2026-08-11) — the server drives logged-in accounts, so LAN exposure is opt-in. This box's systemd override sets 0.0.0.0 explicitly, so the service is unaffected. |
BROWSER_LLM_PORT |
8081 |
Port to bind. |
ALLOW_REMOTE_FILE_PATHS |
(unset) | Let non-loopback or cross-origin clients attach server-side file paths. Off by default — it's a file-read primitive. |
BROWSER_LLM_API_KEY |
(unset) | When set, non-loopback clients must send it (Authorization: Bearer … or X-Api-Key) on /v1/* and /api/*. Localhost stays open; pages/assets (/, /widget.js, /images/*, …) stay public. Makes binding to 0.0.0.0 sane. |
REMOTE_PROVIDERS |
(unset) | model=url[,model=url…] — proxy those models to another browser-llm-api instance instead of a local browser (overrides the local provider of the same name). E.g. a second install without a ChatGPT login sets chatgpt-browser=http://<host-with-login>:8081. |
REMOTE_API_KEY |
(unset) | Bearer key sent on proxied requests (the upstream's BROWSER_LLM_API_KEY). |
./serve.sh # foreground → http://localhost:8081/v1
# background (systemd --user): venv + generated unit + linger, one command:
./install-service.sh
journalctl --user -u browser-llm-api -f # logs live in the journal, NOT server.log
# serve.sh auto-detects the display: real $DISPLAY (ChatGPT images work) else headless Xvfb.
# On a headless box, force a real display for ChatGPT image gen:
DISPLAY=:1 ./serve.shEach provider needs its own login (separate profile). Empty answers / a sign-in or "verify you're human" wall ⇒ that provider's session expired. Re-auth on a real display:
systemctl --user stop browser-llm-api
DISPLAY=:1 ./venv/bin/python login.py gemini # or: chatgpt — visible Chrome opens; sign in; auto-closes
systemctl --user start browser-llm-apiWhy you must use login.py, not a normal Chrome: nodriver launches Chrome with
--password-store=basic, while a normal Chrome uses the system keyring. Cookies written by one
cannot be decrypted by the other. The service's Chrome is also invisible (Xvfb), so you can't
sign in there — the helper opens a real, visible window in the same cookie store.
- Auth model: localhost is always unauthenticated. With
BROWSER_LLM_API_KEYset, non-loopback clients need the key on/v1/*+/api/*; pages/assets (/,/ui,/widget.js,/demo,/version,/images/*) stay public — image links must work in a bare<img>/browser, and the filenames are unguessable (uuid hex). CORS preflights (OPTIONS) are exempt — they can't carry auth headers; the real request is still checked. Decision helpers live inauthz.py(unit-tested,tests/test_authz.py); the middleware inserver.pyis registered after CORSMiddleware so it runs before it. - Remote proxying:
REMOTE_PROVIDERS="chatgpt-browser=http://<host>:8081"+REMOTE_API_KEY=<upstream key>makes this install forward that model verbatim to the other instance (httpx, streaming relayed byte-for-byte; ~940s timeout ≥ the 900s max drive deadline). A remote mapping overrides the local provider of the same name and shows up in/v1/models,/api/status(remote_upstreamfield) and telemetry. Failures surface exactly like local ones: 502 with detail non-streaming, in-band[browser-llm error: remote: …]chunk streaming. The proxy takes no local lock (the upstream's per-provider lock serializes), and lifespan skips pre-warming a remote default. Requireshttpx(in requirements.txt; guarded import — local-only installs without it still run). - Proxying image input: attachments ride along in the forwarded JSON, but a local file path is
rewritten to a
data:URL first (_inline_paths_for_remote, applied toimages/imageand to every content part) — the upstream would otherwise resolve the path against its own filesystem./v1/images/editsproxies to the upstream's/v1/images/edits(multipart bytes are converted todata:URLs), so an upstream older than 0.2.0 answers 404 there. - This box (eben) is set up as the upstream: systemd override
(
~/.config/systemd/user/browser-llm-api.service.d/override.conf) binds0.0.0.0, sets the API key +GEMINI_PUBLIC_URL=http://192.168.1.34:8081; ufw allows 8081 from 192.168.0.0/16 only. - Web UI key: open
http://<host>:8081/#key=<key>once — stored in localStorage, attached to all API fetches via a fetch wrapper. Widget:data-key="<key>"attr.client.py:BROWSER_LLM_API_KEYenv. - The venv's
pipscript has a stale shebang (venv predates a folder rename) — use./venv/bin/python -m pip …, not./venv/bin/pip ….
- Loopback is NOT the same as trusted (fixed 2026-08-11). CORS is
allow_origins=["*"]on purpose — the widget is embedded on other pages — so any website open in the operator's browser can make it POST here, and that request arrives from127.0.0.1and passes every loopback check. Reproduced live before the fix:Origin: https://evil.exampleplus a local file path got the server to read that file, upload it to ChatGPT and return the model's description of it, i.e. a file-read primitive for any path a page can guess (no image check —_resolve_local_attachmentonly tests existence and size, andDOM.setFileInputFilesignores the input'saccept). Fix:authz.origin_is_trusted(origin, host)— trust noOrigin(curl/CLI/desktop app; a browser cannot omit it cross-origin) or one matching the addressed host, and gate_client_may_send_pathson it. All three attachment endpoints share that helper. Unit-tested intests/test_authz.py+tests/test_attachments.py; verified live in three states (drive-by 400, same-origin UI passes, keyed LAN client still refused). Don't "simplify" the path policy back to a loopback check. - The per-provider lock is taken INSIDE the SSE generator in
chat_completions(server.py). FastAPI runs the generator after the handler returns, so anasync witharoundreturn StreamingResponse(...)releases the lock before the first poll and lets concurrent requests fight over one browser tab (that bug existed and was fixed 2026-07-08). Don't move it back out. Streaming failures are surfaced in-band as a[browser-llm error: …]chunk — raising would just cut the SSE dead. - CompletionTracker, authz and attachments have unit tests —
./venv/bin/python -m unittest discover -s tests(83 tests, no browser). If you change the done-decision logic inproviders/base.pyor the attachment layer inserver.py, run/extend them. - CDP parser patch:
patch_cdp()(inbase.py) monkeypatchesnodriver.cdp.util.parse_json_eventto swallowKeyErrorfrom unknown CDP events (e.g.DOM.adoptedStyleSheetsModified). Called at import time byserver.pyandlogin.py; call it in any new entry point. _build_prompt()returns(prompt, image_specs), not a string — andrun_chat()/drive_once()take(provider, prompt, attachments)rather than a messages list. Handlers flatten the messages themselves so they can validate attachments (and return a real 4xx) before the SSE generator starts.- nodriver gotchas hit while building uploads:
page.evaluate()deep-serializes its result, so it can't hand you a DOM element — useeval_handle()(rawRuntime.evaluatewithreturn_by_value=False) and pass the RemoteObject'sobject_idtocdp.dom.set_file_input_files. Handler removal ispage.remove_handler(singular):remove_handlersdoesn't exist, and since ours ran insidetry/exceptit silently leaked oneFileChooserOpenedcallback per upload until fixed. - File-chooser interception is left ON for the tab's lifetime (
_attach_via_chooser). That's deliberate: an un-intercepted native file dialog blocks the renderer forever and this Chrome has no human to dismiss it. Don't "clean it up" by disabling it after an attach. - Attachment temp files must outlive the upload, not just the call — Chrome reads them when the
page uploads, so
_attachment_files()wraps the whole drive as an async context manager and deletes them at the end. Caller-supplied paths are used in place and never deleted. - Never submit while an upload is in flight — the prompt goes without the image. But don't
require "upload idle" when probing which file input works either: a slow upload would look like
a wrong input and the files get attached again to the next one (two chips → image sent twice).
The probe waits for chips only (
require_idle=False);open_and_sendwaits for idle before send. - Multipart
/v1/images/editsneedspython-multipart(in requirements/pyproject). Without it Starlette'srequest.form()raises and the endpoint answers 400 with an install hint; the JSON body shape keeps working regardless. - Non-headless is mandatory — the sites block true headless Chrome. Background = Xvfb virtual
display, never
--headless. - ChatGPT image generation REQUIRES a GPU / real display — it does NOT work under headless Xvfb.
GPT-image renders progressively on a
<canvas>, which stalls indefinitely under Xvfb's software rendering (even with SwiftShader GL flags, which are set inCHROME_ARGSand help other cases). So: ChatGPT text and Gemini run fine under the Xvfb systemd service, but ChatGPT image requests must run with the server on a real display (e.g.DISPLAY=:1 ./venv/bin/python server.py). Verified working on:1(produced a real 1536×1024 PNG in ~40s). Image gen on the free "Go" tier is also slow/variable (30s–4min+), hence the 420s completion deadline. - ChatGPT specifics (verified): composer
#prompt-textarea; sendbutton[data-testid="send-button"](only appears after typing — the composer shows a Voice button at rest); response text in the last[data-message-author-role="assistant"] .markdown; generation state =[data-testid="stop-button"]. ChatGPT streams over WebSocket (ws.chatgpt.com), so the HTTP CDP stream signal never fires — completion relies onis_generatinggoing false + image-stability, NOT oncdp_fired_at. WS frames are tracked (ws_url_fragments) only as a "still streaming" heartbeat that extends the deadline for long answers; they are not parsed for a done-signal. - ChatGPT big-text / "canvas" hang (fixed):
image_status's "creating" flag counted any<canvas>on the page as image generation. ChatGPT's code/Canvas editors (Monaco/CodeMirror) draw on<canvas>, so long code/HTML answers were mis-read as "image pending" → text suppressed + loop rode the full deadline → 7-min hang returning nothing. Fix: only a large, image-shaped canvas (min side ≥256px) counts (image-render canvas is 512–1024px; editor minimap/gutter canvases are narrow). Belt-and-suspenders inCompletionTracker: if "creating" stays set with no image after generation ends, it's treated as a false positive after 45s.get_response_textalso reads the Canvas side-panel editor (.cm-content/ Monaco.view-lines/ a non-composer.ProseMirror) and returns whichever is the LARGER payload — the message body or the canvas. (Was gated to "message body near-empty", which let a short intro like "Here's the file:" shadow a big canvas and return only the stub; now the canvas always competes.) Safe because each request opens a fresh chat (open_and_send), so any editor on the page is THIS answer's, never a stale prior turn; inline code blocks live inside.markdownand are already fenced into the message, somsgis a superset of them and only a genuine SIDE canvas can exceed it. Best-guess selectors — verify live if canvas answers look wrong; and CodeMirror virtualizes offscreen lines, so a very long canvas can still read partial. (Aside: ChatGPT sometimes refuses to emit very large content in one message and only offers a canvas — that's model behavior, not a capture bug; chunk the prompt or ask for inline fenced output.) A generated image is an<img src="…/backend-api/estuary/content?id=file_…" alt="Generated image: …">(same-origin → fetchable to base64), NOToaiusercontent/blob:. The finished image is not inside adata-message-author-roleelement, soimage_status/get_imagesscan the whole page. - ChatGPT code blocks are CodeMirror, and the stream is buffered (fixed 2026-07-08): an inline code
answer is NOT a plain
<pre><code class="language-x">— it's a CodeMirror editor (.cm-editor/.cm-content,#code-block-viewer) with the language shown only as a toolbar pill (nolanguage-*class) plus Copy/Run buttons, and two<pre>s (CM internals). A naiveinnerTextread flattened the toolbar in with the code and dropped the markdown fence, so code answers came back as"Python\nRun\ndef …".get_response_textnow keepsinnerTextas the prose base (untouched — clean prose + list markers) and surgically splices each code card into afencedblock: it finds the editor, extracts the real code from.cm-content(or.cm-lines), reads the language from the toolbar, and replaces the card's flattenedinnerTextchunk in-place (innerText-to-innerText match, so the substitution is reliable). Verified against the live DOM. Because the extracted text reshapes near the end (flattened while streaming → fenced once CM finalizes), append-only SSE deltas can't represent it — so ChatGPT setsProvider.buffered_stream = True(providers/base.py):_stream_completionsuppresses incremental deltas and emits the final authoritative text once at completion (CompletionTracker.textholds the last non-empty full text). Gemini keeps incremental streaming (buffered_stream = False). Trade-off: ChatGPT answers appear all-at-once (spinner until done) instead of typing in — the cost of correctness for a reshaping source. Known limit: very long code may be partial (CodeMirror virtualizes offscreen lines); the Canvas side-panel fallback still applies when.markdownis near-empty. - ChatGPT session cookie is chunked:
__Secure-next-auth.session-token.0/.1(no un-suffixed name).login.pyprefix-matches it and waits for it (not the DOM) before closing, so the session actually persists. Gemini's path is behaviorally unchanged from the original. - Service unit is generated, not committed —
install-service.shfillsbrowser-llm-api.service.template(__INSTALL_DIR__→ the clone path) into~/.config/systemd/user/browser-llm-api.service; itsExecStartrunsserve.sh(venv python + display auto-detect). No paths are hardcoded in the repo.IMAGE_DIRdefaults to~/Pictures/browser-llm(override withGEMINI_IMAGE_DIR); if it isn't writable, image saving silently disables. usagetoken counts are fake — plain.split()word counts, not a real tokenizer.- Stale lock after a crash: a hard crash leaves
<profile>/SingletonLock;serve.shclears both profiles' locks on every start, so the service and a foreground run are both covered. Runningserver.pydirectly, withoutserve.sh? delete*_profile/Singleton*yourself. - Logs:
server.pywritesserver.log(modew, wiped each start) + stderr; under systemd the journal is the real log.gemini_bot.pywritesgemini_session.log. - Dead browser/CDP connection used to wedge a provider until restart (fixed 2026-07-10):
get_browser()only recycled a cached browser afterBROWSER_RECYCLE_AFTER_IMAGESimage gens — it never noticed a browser that was still in the cache but actually dead (Chrome exited, or the CDP websocket dropped:websockets.exceptions.ConnectionClosedError, seen 2026-07-09 on a long-idle ChatGPT session). Every request reused the corpse and failed identically until a manual service restart (~20h that day), while/api/statuskept reportingbrowser_running: true. Fixed in four layers (verified live by killing ChatGPT's Chrome mid-session — next request self-healed, HTTP 200):_browser_alive()probe inget_browser():b.stopped+ a 5scdp.target.get_targets()ping (viaBrowser.send, which also re-attaches a dropped-but-recoverable socket) — a dead cached browser is replaced before the request runs, so it succeeds instead of failing once.run_chat/drive_oncecatch drive exceptions →_evict_dead_browser()pops the cache on transport-level errors (ConnectionClosed/ConnectionError/OSError, plus nodriver'sRuntimeError("WebSocket is not connected")), so even mid-request death can't wedge the next one.- Eviction resets
_img_gen_count— the bloat count belonged to the dead browser, not its successor. /api/status'sbrowser_runningnow checksnot b.stopped(free returncode check, no browser I/O), so the UI shows the truth when Chrome dies.
Default/main branch is master (there is no main branch), remote origin →
https://github.com/StaticB1/browser-llm-api. Never commit *_profile/, *.log,
gemini_research_data.txt, or __pycache__/ (all gitignored).