Gemini Web2API — a self-hosted, OpenAI-compatible API server that talks to Google's Gemini web interface instead of the paid API. Zero API keys, zero billing, running entirely on your own machine. It sounds like a weekend hack. It wasn't. This is the story of the 405 war, the stale-cookie problem, and why the whole thing now heals itself.
What's in the box: 8 Gemini models behind one OpenAI-compatible surface, 15 HTTP endpoints, SSE streaming, multimodal input, tool calling, an MV3 browser extension, a self-healing watchdog, and 515 tests across 16 suites.
The premise, and the problem nobody warns you about
The official Gemini API is pay-per-token. The Gemini web app is free — and it's just an HTTP endpoint. Bridge the two: accept OpenAI-style requests locally, translate them into Gemini's internal StreamGenerate protocol, stream back token-by-token. Free Gemini, anywhere your code can make an HTTP call.
The naive version works for about an hour. Then three things start breaking, in order of increasing obscurity:
- HTTP 405 Method Not Allowed on every request.
- HTTP 400 from a token the server scraped itself.
- Silent degradation: cookies expire, streams die mid-sentence, and nothing tells you why.
Each one turned out to be a protocol war. Here's how each was won.
War 1: the 405s and the rotating build label
The first sign of trouble is the one in the title — 405 Method Not Allowed from Google, on a POST that worked five minutes ago. Retry? Same 405. Restart the server? Same 405. It's not your code, your method, or your cookies.
The culprit is Google's build label (BL): a string that changes whenever Google ships a new build of the Gemini web client — think boq_assistant-bard-web-server_20260803.06_p0. Every StreamGenerate request must embed the current BL, and when yours goes stale, Google answers 405. New builds deploy constantly, so a hardcoded BL is a time bomb with a fuse measured in hours.
The first fix was to scrape the latest BL from the page's own JS bundle. That works — until the scraper breaks because Google renamed the chunk, which is a second, slower time bomb.
The real fix is probe-before-apply. When a request 405s, the server doesn't guess: it takes the candidate BLs it knows about and sends a minimal probe request against each one. The first candidate that does not return 405 is committed and used for the retry. A bad BL is never applied — the probe is cheap, the retry is atomic, and the server converges to the correct build without any hardcoded knowledge of Google's deployment schedule.
request -> 405
└─> probe BL₁ ── 405 ─┐
└─> probe BL₂ ── 200 ─┴─> adopt BL₂, retry request -> 200
It also counts consecutive 405s (bl_405_count in /health). One 405 is an event; three in a row is a storm, and that's a different signal — more on that in War 3.
War 2: the at token Google won't accept from you
Deeper in the protocol is the XSRF at token — a short-lived, per-session value signed into the page. A fresh server scrapes it from the page HTML and... Google rejects it with 400. The page-scraped token isn't valid for API use; only the token the session actually negotiated works. This one is nasty because it's intermittent: the server works fine while the extension is running (the extension holds a live session), then a brand-new instance with a fresh scrape 400s on its very first request.
The fix is a session probe: when the server boots with no usable token, it sends an at-less request. Google's own error response names the expected token — so the server recovers it from the rejection itself. No guessing, no scraping the wrong layer, and a brand-new instance streams out of the box.
War 3: stale cookies, and the watchdog that heals it
Cookie-based auth has a shelf life. Google rotates session cookies, and when they go stale you get a wall of failures that looks like a network problem but is actually an auth problem. The first version of this project failed exactly like that — confusing connection errors, no signal, users stranded.
Today the system knows when cookies are dying and fixes them without a human:
- The server exposes
/healthwith the cookie's file mtime, so "cookie age" is a first-class metric (cookie.age_sec). - A watchdog process polls
/health. If cookie age exceeds a threshold (default 24h), it logs a warning and triggers a refresh via the extension — debounced (4h between warnings, 30min between refreshes) and persisted towatchdog-state.json, so a reboot doesn't immediately re-trigger a refresh you already did. - The MV3 browser extension polls the server's refresh endpoint, opens a minimized window to the real sign-in page, completes the flow, and uploads the fresh
cookie.txtback. The whole cycle is observable: the server flag flips, the extension completes it, the file's mtime updates, and/healthshows age ≈ 0. - The 405 storm counter feeds the same loop: three consecutive 405s means "cookies are stale," so the watchdog treats it as a refresh trigger, not just a log line.
The result is a server that runs for weeks unattended. That's not a brag — it's the difference between "a hack" and "a service."
The SSE protocol: correctness under failure
Streaming is where this protocol earns its keep. An OpenAI-compatible /v1/chat/completions with stream: true must emit text/event-stream with data: frames and a terminating data: [DONE] — and a mid-stream upstream failure is the moment most bridges quietly break. The classic failure mode: the upstream dies mid-sentence, the bridge writes a raw JSON error object into the response after already sending 200 + SSE headers, and every OpenAI client on earth chokes on malformed SSE.
The rules, enforced and tested:
-
Never raw JSON after 200. If the upstream fails mid-stream, the server emits a valid SSE error frame followed by
data: [DONE]. Clients that respect the protocol see a clean, parseable stream that ends; nothing hangs, nothing crashes. -
stream_options.include_usageordering. When requested, the usage chunk must appear before[DONE]and after the final content chunk. OpenAI clients validate this ordering; getting it wrong silently drops token-usage stats. -
Client disconnect is a first-class event.
BrokenPipeError/ConnectionResetErrorfrom a client that hit stop isn't an error — it's a signal. The stream handler catches it, stops upstream work, and releases resources instead of logging a stack trace and leaking a thread.
Each of these has a dedicated test file (test_sse.py) that drives a fake upstream through every failure mode — mid-stream death, abort, disconnect — and asserts the exact byte-level output.
The hardest part: images through a web protocol
Text streaming through an undocumented protocol is one thing; multimodal is another. Sending an image means uploading bytes to Google's upload endpoint, attaching the returned reference to the prompt, and navigating the fact that some accounts simply reject uploaded images (Google answers with an error info code, not a network failure).
The design that survives reality is the image bridge: the server parks the image request, and the browser extension — which holds a real, signed-in session — claims it, re-attaches the image in an actual browser context, and POSTs the result back. The server's /health reports the bridge slot's age, so a stuck claim (extension crashed, window died) is auto-expired by the watchdog instead of blocking the next request for the full timeout. Every result carries the extension's version, so a stale extension can be detected without a live test.
The testing discipline
This project has 515 tests across 16 suites — and the suites that matter are the ones that simulate the enemy:
-
test_sse.py— byte-level SSE assertions against a fake upstream. -
test_watchdog.py— 84 tests on the decision logic (debounce, cooldowns, persistence) as a pure function. -
test_proxy_fallback.py/test_multimodal_proxy.py— proxy-down → direct → fallback ordering with a mocked transport. -
test_image_bridge*.py— the park/claim/expire cycle, including stale-claim expiry. - A bundle drift check: the single-file
gemini_web2api.pyand thegemini_web2api/package must never diverge — a bundler script regenerates one from the other, and CI fails if they differ.
The rule I landed on after getting burned: if you can't test the failure, you can't ship the fix. Every war above ended with a test file named after the battle.
The architecture, at a glance
Your AI app (OpenAI SDK) ──> /v1/chat/completions
│
Gemini Web2API server ──> Gemini web (StreamGenerate)
│ ├─ BL probe-before-apply (405 war)
│ ├─ session-probe `at` token (400 war)
│ ├─ proxy plan: proxy → direct → fallbacks
│ └─ cookie age + 405 streak in /health
▼
Watchdog ──> stale cookies? ──> extension refresh (minimized window)
└──> 405 storm? ──────> same refresh loop
The honest caveats
This is an unofficial bridge. It talks to Google's web interface, which is not a public API: it can break when Google changes anything, it's governed by Google's Terms of Service, and it's a personal-server tool — not a commercial product. Use it at your own risk, and don't build a business on someone else's free tier. What it is: a serious study in protocol reverse-engineering, failure-mode engineering, and self-healing systems — all of it tested, observable, and open source.
Gemini Web2API · github.com/flawsom/Gemini-api · MIT-style open source · self-hosted · Docker + Cloudflare Worker deploy targets
The 405s are gone, the cookies refresh themselves, and the streams never lie. Building it took a war; running it takes nothing.
Top comments (0)