From c343aaff60d7328150b8b61e23cf5086cd702d17 Mon Sep 17 00:00:00 2001 From: Bu Date: Tue, 25 Aug 2026 00:21:46 +0300 Subject: [PATCH 01/17] fix(cookie): support Netscape cookies.txt and auto-refresh XSRF token - Parse Netscape-format cookie files (tab-separated cookies.txt) instead of sending raw file contents as the Cookie header - Extract SNlM0e XSRF token from the app page at startup when cookies are configured; required by StreamGenerate for authenticated requests - On HTTP 400/405, refresh BL and XSRF token mid-retry and rebuild the request so long-running servers survive token rotation - Sync modular package (refresh_bl_and_xsrf) with single-file script --- gemini_web2api.py | 174 +++++++++++++++++++++---------------- gemini_web2api/__main__.py | 4 +- gemini_web2api/gemini.py | 77 ++++++++++++++-- 3 files changed, 171 insertions(+), 84 deletions(-) diff --git a/gemini_web2api.py b/gemini_web2api.py index 1f73f6e..766c49c 100644 --- a/gemini_web2api.py +++ b/gemini_web2api.py @@ -127,6 +127,20 @@ def load_cookie() -> tuple: data = json.loads(content) cookie_str = data.get("cookie", "") sapisid = data.get("sapisid", "") + elif "# Netscape HTTP Cookie File" in content or content.startswith("#HttpOnly_"): + # Netscape cookies.txt (tab-separated: domain, flag, path, secure, expiry, name, value) + pairs = {} + for line in content.splitlines(): + if line.startswith("#HttpOnly_"): + line = line[len("#HttpOnly_"):] + if not line or line.startswith("#"): + continue + parts = line.split("\t") + if len(parts) != 7: + continue + pairs[parts[5]] = parts[6] + cookie_str = "; ".join(f"{k}={v}" for k, v in pairs.items()) + sapisid = pairs.get("SAPISID", "") else: cookie_str = content pairs = dict(p.split("=", 1) for p in cookie_str.split("; ") if "=" in p) @@ -161,11 +175,13 @@ def apply_chat_persistence_flags(inner: list) -> None: def fetch_latest_bl() -> Optional[str]: - """Fetch the latest gemini_bl from gemini.google.com page.""" + """Fetch the latest gemini_bl (and xsrf token when cookies are set) from gemini.google.com page.""" try: - req = urllib.request.Request( - "https://gemini.google.com/app", - headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}) + headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"} + cookie_str, _ = load_cookie() + if cookie_str: + headers["Cookie"] = cookie_str + req = urllib.request.Request("https://gemini.google.com/app", headers=headers) ctx = ssl.create_default_context() proxy = CONFIG.get("proxy") if proxy: @@ -176,6 +192,9 @@ def fetch_latest_bl() -> Optional[str]: else: resp = urllib.request.urlopen(req, context=ctx, timeout=15) html = resp.read().decode("utf-8", errors="replace") + m = re.search(r'"SNlM0e":"([^"]+)"', html) + if m: + CONFIG["xsrf_token"] = m.group(1) m = re.search(r'(boq_assistant-bard-web-server_\d+\.\d+_p\d+)', html) if m: return m.group(1) @@ -185,11 +204,12 @@ def fetch_latest_bl() -> Optional[str]: def update_bl_if_needed() -> bool: - """Attempt to fetch and update gemini_bl. Returns True if updated.""" - new_bl = fetch_latest_bl() - if new_bl and new_bl != CONFIG["gemini_bl"]: - log(f"BL auto-updated: {CONFIG['gemini_bl']} -> {new_bl}") - CONFIG["gemini_bl"] = new_bl + """Attempt to fetch and refresh gemini_bl + xsrf_token. Returns True if anything changed.""" + old_bl = CONFIG["gemini_bl"] + old_xsrf = CONFIG.get("xsrf_token") + fetch_latest_bl() # also refreshes xsrf_token when cookies are set + if CONFIG["gemini_bl"] != old_bl or CONFIG.get("xsrf_token") != old_xsrf: + log(f"BL/XSRF refreshed: bl {old_bl} -> {CONFIG['gemini_bl']}, xsrf {'-> new' if CONFIG.get('xsrf_token') != old_xsrf else 'unchanged'}") return True return False @@ -246,37 +266,39 @@ def gemini_stream_generate(prompt: str, model_id: int, think_mode: int, file_ref inner[79] = model_id outer = [None, json.dumps(inner)] - params = {"f.req": json.dumps(outer)} - if CONFIG.get("xsrf_token"): - params["at"] = CONFIG["xsrf_token"] - body = urllib.parse.urlencode(params).encode() - reqid = int(time.time()) % 1000000 - prefix = account_prefix() - url = ( - f"https://gemini.google.com{prefix}/_/BardChatUi/data/" - "assistant.lamda.BardFrontendService/StreamGenerate" - f"?bl={CONFIG['gemini_bl']}&hl=en&_reqid={reqid}&rt=c" - ) - headers = { - "Content-Type": "application/x-www-form-urlencoded", - "Origin": "https://gemini.google.com", - "Referer": f"https://gemini.google.com{prefix}/app", - "X-Same-Domain": "1", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - } - if prefix: - headers["X-Goog-AuthUser"] = str(CONFIG["auth_user"]) - - cookie_str, sapisid = load_cookie() - if cookie_str: - headers["Cookie"] = cookie_str - if sapisid: - headers["Authorization"] = make_sapisidhash(sapisid) + + def build_request(): + params = {"f.req": json.dumps(outer)} + if CONFIG.get("xsrf_token"): + params["at"] = CONFIG["xsrf_token"] + body = urllib.parse.urlencode(params).encode() + reqid = int(time.time()) % 1000000 + prefix = account_prefix() + url = ( + f"https://gemini.google.com{prefix}/_/BardChatUi/data/" + "assistant.lamda.BardFrontendService/StreamGenerate" + f"?bl={CONFIG['gemini_bl']}&hl=en&_reqid={reqid}&rt=c" + ) + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Origin": "https://gemini.google.com", + "Referer": f"https://gemini.google.com{prefix}/app", + "X-Same-Domain": "1", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + } + if prefix: + headers["X-Goog-AuthUser"] = str(CONFIG["auth_user"]) + cookie_str, sapisid = load_cookie() + if cookie_str: + headers["Cookie"] = cookie_str + if sapisid: + headers["Authorization"] = make_sapisidhash(sapisid) + return urllib.request.Request(url, data=body, headers=headers, method="POST") last_err = None for attempt in range(CONFIG["retry_attempts"]): try: - req = urllib.request.Request(url, data=body, headers=headers, method="POST") + req = build_request() ctx = ssl.create_default_context() proxy = CONFIG.get("proxy") if proxy: @@ -289,14 +311,8 @@ def gemini_stream_generate(prompt: str, model_id: int, think_mode: int, file_ref resp = urllib.request.urlopen(req, context=ctx, timeout=CONFIG["request_timeout_sec"]) return resp.read().decode("utf-8", errors="replace") except urllib.error.HTTPError as e: - if e.code == 405 and update_bl_if_needed(): - reqid = int(time.time()) % 1000000 - url = ( - f"https://gemini.google.com{prefix}/_/BardChatUi/data/" - "assistant.lamda.BardFrontendService/StreamGenerate" - f"?bl={CONFIG['gemini_bl']}&hl=en&_reqid={reqid}&rt=c" - ) - log("Retrying with updated BL...") + if e.code in (400, 405) and update_bl_if_needed(): + log("Retrying with refreshed BL/XSRF...") last_err = e continue last_err = e @@ -337,31 +353,34 @@ def gemini_stream_generate_iter(prompt: str, model_id: int, think_mode: int, fil inner[79] = model_id outer = [None, json.dumps(inner)] - params = {"f.req": json.dumps(outer)} - if CONFIG.get("xsrf_token"): - params["at"] = CONFIG["xsrf_token"] - body = urllib.parse.urlencode(params) - reqid = int(time.time()) % 1000000 - prefix = account_prefix() - url = ( - f"https://gemini.google.com{prefix}/_/BardChatUi/data/" - "assistant.lamda.BardFrontendService/StreamGenerate" - f"?bl={CONFIG['gemini_bl']}&hl=en&_reqid={reqid}&rt=c" - ) - headers = { - "Content-Type": "application/x-www-form-urlencoded", - "Origin": "https://gemini.google.com", - "Referer": f"https://gemini.google.com{prefix}/app", - "X-Same-Domain": "1", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - } - if prefix: - headers["X-Goog-AuthUser"] = str(CONFIG["auth_user"]) - cookie_str, sapisid = load_cookie() - if cookie_str: - headers["Cookie"] = cookie_str - if sapisid: - headers["Authorization"] = make_sapisidhash(sapisid) + + def build_stream_request(): + params = {"f.req": json.dumps(outer)} + if CONFIG.get("xsrf_token"): + params["at"] = CONFIG["xsrf_token"] + body = urllib.parse.urlencode(params) + reqid = int(time.time()) % 1000000 + prefix = account_prefix() + url = ( + f"https://gemini.google.com{prefix}/_/BardChatUi/data/" + "assistant.lamda.BardFrontendService/StreamGenerate" + f"?bl={CONFIG['gemini_bl']}&hl=en&_reqid={reqid}&rt=c" + ) + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Origin": "https://gemini.google.com", + "Referer": f"https://gemini.google.com{prefix}/app", + "X-Same-Domain": "1", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + } + if prefix: + headers["X-Goog-AuthUser"] = str(CONFIG["auth_user"]) + cookie_str, sapisid = load_cookie() + if cookie_str: + headers["Cookie"] = cookie_str + if sapisid: + headers["Authorization"] = make_sapisidhash(sapisid) + return url, body, headers proxy = CONFIG.get("proxy") @@ -377,6 +396,7 @@ def gemini_stream_generate_iter(prompt: str, model_id: int, think_mode: int, fil transport = httpx.HTTPTransport(proxy=proxy) if proxy else None with httpx.Client(transport=transport, timeout=CONFIG["request_timeout_sec"], verify=True) as client: try: + url, body, headers = build_stream_request() with client.stream("POST", url, content=body, headers=headers) as resp: resp.raise_for_status() buf = "" @@ -410,14 +430,14 @@ def gemini_stream_generate_iter(prompt: str, model_id: int, think_mode: int, fil except (json.JSONDecodeError, IndexError, TypeError): pass except Exception as e: - if HAS_HTTPX and hasattr(e, 'response') and getattr(e.response, 'status_code', 0) == 405: - if update_bl_if_needed(): - log("BL updated, falling back to non-streaming for this request") - raw = gemini_stream_generate(prompt, model_id, think_mode, file_refs) - text = extract_response_text(raw) - if text: - yield text - return + status = getattr(getattr(e, "response", None), "status_code", 0) + if HAS_HTTPX and status in (400, 405) and not prev_text and update_bl_if_needed(): + log("BL/XSRF refreshed, falling back to non-streaming for this request") + raw = gemini_stream_generate(prompt, model_id, think_mode, file_refs) + text = extract_response_text(raw) + if text: + yield text + return raise diff --git a/gemini_web2api/__main__.py b/gemini_web2api/__main__.py index 5b58982..e7b7caa 100644 --- a/gemini_web2api/__main__.py +++ b/gemini_web2api/__main__.py @@ -4,7 +4,7 @@ from .config import CONFIG, load_config, find_config from .models import MODELS -from .gemini import HAS_HTTPX +from .gemini import HAS_HTTPX, refresh_bl_and_xsrf from .server import GeminiHandler, ThreadedServer from . import __version__ @@ -29,6 +29,8 @@ def main(): if args.proxy: CONFIG["proxy"] = args.proxy + refresh_bl_and_xsrf() + port = CONFIG["port"] server = ThreadedServer((CONFIG["host"], port), GeminiHandler) print(f"gemini-web2api v{__version__}") diff --git a/gemini_web2api/gemini.py b/gemini_web2api/gemini.py index d69b08d..0be4fe0 100644 --- a/gemini_web2api/gemini.py +++ b/gemini_web2api/gemini.py @@ -5,9 +5,11 @@ import re import urllib.request import urllib.parse +import urllib.error import ssl import os import hashlib +from typing import Optional try: import httpx @@ -60,6 +62,20 @@ def load_cookie() -> tuple: data = json.loads(content) cookie_str = data.get("cookie", "") sapisid = data.get("sapisid", "") + elif "# Netscape HTTP Cookie File" in content or content.startswith("#HttpOnly_"): + # Netscape cookies.txt (tab-separated: domain, flag, path, secure, expiry, name, value) + pairs = {} + for line in content.splitlines(): + if line.startswith("#HttpOnly_"): + line = line[len("#HttpOnly_"):] + if not line or line.startswith("#"): + continue + parts = line.split("\t") + if len(parts) != 7: + continue + pairs[parts[5]] = parts[6] + cookie_str = "; ".join(f"{k}={v}" for k, v in pairs.items()) + sapisid = pairs.get("SAPISID", "") else: cookie_str = content pairs = dict(p.split("=", 1) for p in cookie_str.split("; ") if "=" in p) @@ -71,6 +87,41 @@ def load_cookie() -> tuple: return _cookie_cache["str"], _cookie_cache["sapisid"] +def refresh_bl_and_xsrf() -> bool: + """Fetch the app page with cookies; refresh gemini_bl and xsrf_token (SNlM0e). + Returns True if either value changed.""" + old_bl = CONFIG["gemini_bl"] + old_xsrf = CONFIG.get("xsrf_token") + try: + headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"} + cookie_str, _ = load_cookie() + if cookie_str: + headers["Cookie"] = cookie_str + req = urllib.request.Request("https://gemini.google.com/app", headers=headers) + proxy = CONFIG.get("proxy") + ctx = _get_ssl_ctx() + if proxy: + opener = urllib.request.build_opener( + urllib.request.ProxyHandler({"http": proxy, "https": proxy}), + urllib.request.HTTPSHandler(context=ctx)) + resp = opener.open(req, timeout=15) + else: + resp = urllib.request.urlopen(req, context=ctx, timeout=15) + html = resp.read().decode("utf-8", errors="replace") + m = re.search(r'"SNlM0e":"([^"]+)"', html) + if m: + CONFIG["xsrf_token"] = m.group(1) + m = re.search(r'(boq_assistant-bard-web-server_\d+\.\d+_p\d+)', html) + if m: + CONFIG["gemini_bl"] = m.group(1) + except Exception as e: + log(f"BL/XSRF refresh failed: {e}") + changed = CONFIG["gemini_bl"] != old_bl or CONFIG.get("xsrf_token") != old_xsrf + if changed: + log(f"BL/XSRF refreshed: xsrf {'new' if CONFIG.get('xsrf_token') != old_xsrf else 'unchanged'}, bl {old_bl} -> {CONFIG['gemini_bl']}") + return changed + + def make_sapisidhash(sapisid: str) -> str: ts = int(time.time()) h = hashlib.sha1(f"{ts} {sapisid} https://gemini.google.com".encode()).hexdigest() @@ -204,15 +255,15 @@ def extract_response_text(raw: str) -> str: def generate(prompt: str, model_id: int, think_mode: int, file_refs: list = None, extra_fields: dict = None) -> str: """Non-streaming generation with retry.""" - body = _build_payload(prompt, model_id, think_mode, file_refs, extra_fields).encode() - url = _get_url() - headers = _build_headers() ctx = _get_ssl_ctx() proxy = CONFIG.get("proxy") last_err = None for attempt in range(CONFIG["retry_attempts"]): try: + body = _build_payload(prompt, model_id, think_mode, file_refs, extra_fields).encode() + url = _get_url() + headers = _build_headers() req = urllib.request.Request(url, data=body, headers=headers, method="POST") if proxy: opener = urllib.request.build_opener( @@ -224,6 +275,15 @@ def generate(prompt: str, model_id: int, think_mode: int, file_refs: list = None resp = urllib.request.urlopen(req, context=ctx, timeout=CONFIG["request_timeout_sec"]) raw = resp.read().decode("utf-8", errors="replace") return extract_response_text(raw) + except urllib.error.HTTPError as e: + if e.code in (400, 405) and refresh_bl_and_xsrf(): + log("Retrying with refreshed BL/XSRF...") + last_err = e + continue + last_err = e + if attempt < CONFIG["retry_attempts"] - 1: + log(f"Retry {attempt+1}/{CONFIG['retry_attempts']}: {e}") + time.sleep(CONFIG["retry_delay_sec"]) except Exception as e: last_err = e if attempt < CONFIG["retry_attempts"] - 1: @@ -240,15 +300,15 @@ def generate_stream(prompt: str, model_id: int, think_mode: int, file_refs: list yield text return - body = _build_payload(prompt, model_id, think_mode, file_refs, extra_fields) - url = _get_url() - headers = _build_headers() client = _get_httpx_client() last_err = None emitted_raw_text = "" for attempt in range(CONFIG["retry_attempts"]): try: + body = _build_payload(prompt, model_id, think_mode, file_refs, extra_fields) + url = _get_url() + headers = _build_headers() with client.stream("POST", url, content=body, headers=headers) as resp: resp.raise_for_status() buf = "" @@ -273,6 +333,11 @@ def generate_stream(prompt: str, model_id: int, think_mode: int, file_refs: list yield delta return except Exception as e: + status = getattr(getattr(e, "response", None), "status_code", 0) + if status in (400, 405) and not emitted_raw_text and refresh_bl_and_xsrf(): + log("Stream retrying with refreshed BL/XSRF...") + last_err = e + continue last_err = e if attempt < CONFIG["retry_attempts"] - 1: log(f"Stream retry {attempt+1}/{CONFIG['retry_attempts']}: {e}") From ad154036b8f61cc3b67b61db68ee5bc59e42e8cf Mon Sep 17 00:00:00 2001 From: Bu Date: Tue, 25 Aug 2026 11:15:13 +0300 Subject: [PATCH 02/17] fix(upstream): detect new BardErrorInfo format and surface clear errors - Google changed the upstream error envelope from 'BardErrorInfo [code]' to JSPB 'application.BardErrorInfo",[code]'; the old regex missed it, so rejected requests returned HTTP 200 with content:null instead of an error - Map known codes to human-readable hints (1060 = IP temporarily blocked / unsupported region, 1037 = usage limit, 1013 = transient) - Drop the 200-char line-length and 50-char payload heuristics that silently skipped valid short wrb.fr payloads - Streaming SSE responses now end with a finish chunk on error so clients do not hang on dropped streams - Add tests/test_upstream_errors.py covering both error formats and the short-line regression --- gemini_web2api.py | 32 +++++++++++++++++++++++-------- gemini_web2api/gemini.py | 18 ++++++++++++------ gemini_web2api/server.py | 9 +++++++++ tests/test_upstream_errors.py | 36 +++++++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 14 deletions(-) create mode 100644 tests/test_upstream_errors.py diff --git a/gemini_web2api.py b/gemini_web2api.py index 766c49c..ec12e51 100644 --- a/gemini_web2api.py +++ b/gemini_web2api.py @@ -404,17 +404,17 @@ def build_stream_request(): buf += chunk if "BardErrorInfo" in buf: import re as _re - m = _re.search(r'BardErrorInfo\s*\[(\d+)\]', buf) + m = _re.search(r'BardErrorInfo"?,?\s*\[(\d+)\]', buf) if m: - raise RuntimeError(f"Gemini upstream rejected request: BardErrorInfo [{m.group(1)}]") + raise RuntimeError(f"Gemini upstream error [{m.group(1)}]") while "\n" in buf: line, buf = buf.split("\n", 1) - if '"wrb.fr"' not in line or len(line) < 200: + if '"wrb.fr"' not in line: continue try: arr = json.loads(line) inner_str = arr[0][2] - if not inner_str or len(inner_str) < 50: + if not inner_str: continue inner2 = json.loads(inner_str) if isinstance(inner2, list) and len(inner2) > 4 and inner2[4]: @@ -453,17 +453,24 @@ def clean_gemini_text(text: str, strip: bool = True) -> str: def extract_response_text(raw: str) -> str: """Parse StreamGenerate response to extract final text.""" import re as _re - bard_err = _re.search(r'BardErrorInfo\s*\[(\d+)\]', raw) + bard_err = _re.search(r'BardErrorInfo"?,?\s*\[(\d+)\]', raw) if bard_err: - raise RuntimeError(f"Gemini upstream rejected request: BardErrorInfo [{bard_err.group(1)}]") + code = int(bard_err.group(1)) + hints = { + 1060: "IP temporarily blocked or region not supported", + 1037: "usage limit exceeded", + 1013: "temporary upstream error, retry later", + } + hint = hints.get(code, "upstream rejected request") + raise RuntimeError(f"Gemini upstream error [{code}]: {hint}") texts = [] for line in raw.split("\n"): - if '"wrb.fr"' not in line or len(line) < 200: + if '"wrb.fr"' not in line: continue try: arr = json.loads(line) inner_str = arr[0][2] - if not inner_str or len(inner_str) < 50: + if not inner_str: continue inner = json.loads(inner_str) if isinstance(inner, list) and len(inner) > 4 and inner[4]: @@ -838,6 +845,15 @@ def handle_chat(self, body: bytes): pass except Exception as e: log(f"Stream error: {e}") + # Emit finish chunk so clients don't hang on a dropped stream + try: + err_chunk = {"id": cid, "object": "chat.completion.chunk", "created": int(time.time()), + "model": model_name, "choices": [{"index": 0, "delta": {"content": f"[error] {e}"}, "finish_reason": "stop"}]} + self.wfile.write(f"data: {json.dumps(err_chunk, ensure_ascii=False)}\n\n".encode()) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + except Exception: + pass return # Non-streaming (or tool calling which needs full response) diff --git a/gemini_web2api/gemini.py b/gemini_web2api/gemini.py index 0be4fe0..caf5825 100644 --- a/gemini_web2api/gemini.py +++ b/gemini_web2api/gemini.py @@ -219,12 +219,12 @@ def clean_text(text: str, strip: bool = True) -> str: def _extract_texts_from_line(line: str) -> list: """Parse a single wrb.fr line and return list of text strings found.""" - if '"wrb.fr"' not in line or len(line) < 200: + if '"wrb.fr"' not in line: return [] try: arr = json.loads(line) inner_str = arr[0][2] - if not inner_str or len(inner_str) < 50: + if not inner_str: return [] inner = json.loads(inner_str) if not (isinstance(inner, list) and len(inner) > 4 and inner[4]): @@ -242,9 +242,15 @@ def _extract_texts_from_line(line: str) -> list: def extract_response_text(raw: str) -> str: """Parse full response to get final text.""" - bard_err = re.search(r'BardErrorInfo\s*\[(\d+)\]', raw) + bard_err = re.search(r'BardErrorInfo"?,?\s*\[(\d+)\]', raw) if bard_err: - raise RuntimeError(f"Gemini upstream rejected request: BardErrorInfo [{bard_err.group(1)}]") + code = int(bard_err.group(1)) + hints = { + 1060: "IP temporarily blocked or region not supported", + 1037: "usage limit exceeded", + 1013: "temporary upstream error, retry later", + } + raise RuntimeError(f"Gemini upstream error [{code}]: {hints.get(code, 'upstream rejected request')}") last_text = "" for line in raw.split("\n"): for t in _extract_texts_from_line(line): @@ -315,10 +321,10 @@ def generate_stream(prompt: str, model_id: int, think_mode: int, file_refs: list for chunk in resp.iter_text(): buf += chunk if "BardErrorInfo" in buf: - bard_err = re.search(r'BardErrorInfo\s*\[(\d+)\]', buf) + bard_err = re.search(r'BardErrorInfo"?,?\s*\[(\d+)\]', buf) if bard_err: raise RuntimeError( - f"Gemini upstream rejected request: BardErrorInfo [{bard_err.group(1)}]" + f"Gemini upstream error [{bard_err.group(1)}]" ) while "\n" in buf: line, buf = buf.split("\n", 1) diff --git a/gemini_web2api/server.py b/gemini_web2api/server.py index 3fc35b6..6c524dc 100644 --- a/gemini_web2api/server.py +++ b/gemini_web2api/server.py @@ -229,6 +229,15 @@ def _handle_chat(self, body: bytes): pass except Exception as e: log(f"Stream error: {e}") + # Emit finish chunk so clients don't hang on a dropped stream + try: + err_chunk = {"id": cid, "object": "chat.completion.chunk", "created": int(time.time()), + "model": model_name, "choices": [{"index": 0, "delta": {"content": f"[error] {e}"}, "finish_reason": "stop"}]} + self.wfile.write(f"data: {json.dumps(err_chunk, ensure_ascii=False)}\n\n".encode()) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + except Exception: + pass return try: diff --git a/tests/test_upstream_errors.py b/tests/test_upstream_errors.py new file mode 100644 index 0000000..2137c4d --- /dev/null +++ b/tests/test_upstream_errors.py @@ -0,0 +1,36 @@ +"""Upstream error detection and parsing edge cases.""" +import json +import unittest + +from gemini_web2api.gemini import extract_response_text, _extract_texts_from_line + + +class UpstreamErrorTests(unittest.TestCase): + def test_bard_error_formats(self): + # New JSPB format (2026-08): application.BardErrorInfo",[1060]] + for code in (1060, 1037, 1013, 1050): + raw = ( + ')]}\n\n121\n[["wrb.fr",null,null,null,null,[9,null,' + '[["type.googleapis.com/assistant.boq.bard.application.BardErrorInfo",[' + + str(code) + ']]]]]]' + ) + with self.assertRaises(RuntimeError) as ctx: + extract_response_text(raw) + self.assertIn(f"[{code}]", str(ctx.exception)) + # Old format still detected + with self.assertRaises(RuntimeError): + extract_response_text("junk BardErrorInfo [1037] junk") + + def test_short_wrb_line_parsed(self): + # Regression: lines under 200 chars used to be skipped entirely + inner = [None, ["c_1", "r_1"], None, None, [["cid", ["hi"]]]] + line = '[["wrb.fr",null,' + json.dumps(json.dumps(inner)) + ']]' + texts = _extract_texts_from_line(line) + self.assertEqual(texts, ["hi"]) + + def test_empty_raw(self): + self.assertEqual(extract_response_text(""), "") + + +if __name__ == "__main__": + unittest.main() From 98a36ca28ee5fb048c9f4e1fc2e21b92eaee841d Mon Sep 17 00:00:00 2001 From: Bu Date: Tue, 25 Aug 2026 11:17:45 +0300 Subject: [PATCH 03/17] chore: gitignore Netscape cookies.txt and config backup --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index c8dd796..7251f2b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ cookie.json .idea .DS_Store gemini-auth.json + +cookies.txt +config_backup.json From f5ea2913b1ee6cfe2780e3d58eb6622e2abc05a3 Mon Sep 17 00:00:00 2001 From: Bu Date: Tue, 25 Aug 2026 11:18:42 +0300 Subject: [PATCH 04/17] fix(upstream): fail fast on hard BardErrorInfo rejections in streams StreamGenerate streaming paths retried BardErrorInfo rejections three times pointlessly - an IP block (1060) or quota rejection (1037) does not clear within a retry loop. Raise immediately instead; connection and transient errors keep their retry behaviour. 1060 responses now return in ~1s instead of after the full retry cycle. --- gemini_web2api.py | 3 +++ gemini_web2api/gemini.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/gemini_web2api.py b/gemini_web2api.py index ec12e51..ae14de4 100644 --- a/gemini_web2api.py +++ b/gemini_web2api.py @@ -430,6 +430,9 @@ def build_stream_request(): except (json.JSONDecodeError, IndexError, TypeError): pass except Exception as e: + # Hard upstream rejections (BardErrorInfo) - retrying is futile + if "Gemini upstream error" in str(e): + raise status = getattr(getattr(e, "response", None), "status_code", 0) if HAS_HTTPX and status in (400, 405) and not prev_text and update_bl_if_needed(): log("BL/XSRF refreshed, falling back to non-streaming for this request") diff --git a/gemini_web2api/gemini.py b/gemini_web2api/gemini.py index caf5825..45b8315 100644 --- a/gemini_web2api/gemini.py +++ b/gemini_web2api/gemini.py @@ -339,6 +339,9 @@ def generate_stream(prompt: str, model_id: int, think_mode: int, file_refs: list yield delta return except Exception as e: + # Hard upstream rejections (BardErrorInfo) - retrying is futile + if "Gemini upstream error" in str(e): + raise status = getattr(getattr(e, "response", None), "status_code", 0) if status in (400, 405) and not emitted_raw_text and refresh_bl_and_xsrf(): log("Stream retrying with refreshed BL/XSRF...") From 0af20f79fc068d3c3f3f280e1b563bf34846dfe1 Mon Sep 17 00:00:00 2001 From: Bu Date: Tue, 25 Aug 2026 11:23:45 +0300 Subject: [PATCH 05/17] docs(upstream): add recovery guidance to IP block error message --- gemini_web2api.py | 2 +- gemini_web2api/gemini.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gemini_web2api.py b/gemini_web2api.py index ae14de4..a06d558 100644 --- a/gemini_web2api.py +++ b/gemini_web2api.py @@ -460,7 +460,7 @@ def extract_response_text(raw: str) -> str: if bard_err: code = int(bard_err.group(1)) hints = { - 1060: "IP temporarily blocked or region not supported", + 1060: "IP temporarily blocked or region not supported - use a proxy/different network or wait", 1037: "usage limit exceeded", 1013: "temporary upstream error, retry later", } diff --git a/gemini_web2api/gemini.py b/gemini_web2api/gemini.py index 45b8315..6ff23fb 100644 --- a/gemini_web2api/gemini.py +++ b/gemini_web2api/gemini.py @@ -246,7 +246,7 @@ def extract_response_text(raw: str) -> str: if bard_err: code = int(bard_err.group(1)) hints = { - 1060: "IP temporarily blocked or region not supported", + 1060: "IP temporarily blocked or region not supported - use a proxy/different network or wait", 1037: "usage limit exceeded", 1013: "temporary upstream error, retry later", } From 962bcf9dbec84aa1bae936de6c79cfef58428eea Mon Sep 17 00:00:00 2001 From: Bu Date: Tue, 25 Aug 2026 11:25:45 +0300 Subject: [PATCH 06/17] fix(server): return 400 for invalid JSON request bodies Malformed JSON fell through the generic handler and produced HTTP 500; clients cannot distinguish their own bad request from a server fault. Matches the modular package, which already answers 400 invalid JSON. --- gemini_web2api.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/gemini_web2api.py b/gemini_web2api.py index a06d558..6f76451 100644 --- a/gemini_web2api.py +++ b/gemini_web2api.py @@ -749,6 +749,11 @@ def do_POST(self): self.send_json({"error": "not found"}, 404) except (BrokenPipeError, ConnectionResetError): pass + except json.JSONDecodeError as e: + try: + self.send_json({"error": {"message": f"invalid JSON: {e}"}}, 400) + except: + pass except Exception as e: log(f"POST error: {e}") try: From c349471e0fa31547eb61d6f0fefa05f629d63404 Mon Sep 17 00:00:00 2001 From: Bu Date: Tue, 25 Aug 2026 11:28:58 +0300 Subject: [PATCH 07/17] test: cover SSE error finish chunk on upstream failure Live-server test that a failing generate_stream (BardErrorInfo 1060) yields the partial content, an [error] finish chunk and [DONE], so clients terminate instead of hanging on a dropped stream. --- tests/test_upstream_errors.py | 60 +++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/test_upstream_errors.py b/tests/test_upstream_errors.py index 2137c4d..a7e1781 100644 --- a/tests/test_upstream_errors.py +++ b/tests/test_upstream_errors.py @@ -1,5 +1,7 @@ """Upstream error detection and parsing edge cases.""" +import http.client import json +import threading import unittest from gemini_web2api.gemini import extract_response_text, _extract_texts_from_line @@ -32,5 +34,63 @@ def test_empty_raw(self): self.assertEqual(extract_response_text(""), "") +class StreamErrorChunkTests(unittest.TestCase): + """Upstream failures during SSE streaming must end with a finish chunk + [DONE].""" + + @classmethod + def setUpClass(cls): + from gemini_web2api.server import GeminiHandler, ThreadedServer + cls.server = ThreadedServer(("127.0.0.1", 0), GeminiHandler) + cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True) + cls.thread.start() + cls.port = cls.server.server_address[1] + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + cls.server.server_close() + cls.thread.join(timeout=5) + + def setUp(self): + from gemini_web2api.config import CONFIG + self.original_config = dict(CONFIG) + CONFIG["api_keys"] = [] + CONFIG["log_requests"] = False + + def tearDown(self): + from gemini_web2api.config import CONFIG + CONFIG.clear() + CONFIG.update(self.original_config) + + def test_stream_error_emits_finish_chunk(self): + from unittest import mock + + def failing_stream(*args, **kwargs): + yield "partial " + raise RuntimeError("Gemini upstream error [1060]: IP temporarily blocked") + + with mock.patch("gemini_web2api.server.generate_stream", side_effect=failing_stream): + conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5) + conn.request( + "POST", + "/v1/chat/completions", + body=json.dumps({ + "model": "gemini-3.6-flash", + "stream": True, + "messages": [{"role": "user", "content": "hi"}], + }), + headers={"Content-Type": "application/json"}, + ) + resp = conn.getresponse() + body = resp.read().decode() + conn.close() + + self.assertEqual(resp.status, 200) + self.assertIn("partial ", body) + self.assertIn("[error] Gemini upstream error [1060]", body) + self.assertIn('"finish_reason": "stop"', body) + self.assertIn("data: [DONE]", body) + + if __name__ == "__main__": unittest.main() From 63db2ed6d2a41d28368d9d55e4a130bdfd114ed0 Mon Sep 17 00:00:00 2001 From: Bu Date: Tue, 25 Aug 2026 11:31:49 +0300 Subject: [PATCH 08/17] test: add mocked success-path integration test Feed server.generate() a Russian answer and assert the full OpenAI completion shape survives JSON encoding end to end. --- tests/test_upstream_errors.py | 50 +++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/test_upstream_errors.py b/tests/test_upstream_errors.py index a7e1781..4fbeb42 100644 --- a/tests/test_upstream_errors.py +++ b/tests/test_upstream_errors.py @@ -92,5 +92,55 @@ def failing_stream(*args, **kwargs): self.assertIn("data: [DONE]", body) + + +class NonStreamSuccessTests(unittest.TestCase): + """Mocked upstream success: raw response -> OpenAI completion shape.""" + + @classmethod + def setUpClass(cls): + from gemini_web2api.server import GeminiHandler, ThreadedServer + cls.server = ThreadedServer(("127.0.0.1", 0), GeminiHandler) + cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True) + cls.thread.start() + cls.port = cls.server.server_address[1] + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + cls.server.server_close() + cls.thread.join(timeout=5) + + def setUp(self): + from gemini_web2api.config import CONFIG + self.original_config = dict(CONFIG) + CONFIG["api_keys"] = [] + CONFIG["log_requests"] = False + + def tearDown(self): + from gemini_web2api.config import CONFIG + CONFIG.clear() + CONFIG.update(self.original_config) + + def test_non_stream_success_shape(self): + from unittest import mock + # server.generate() returns already-extracted text; feed it the parsed expectation + with mock.patch("gemini_web2api.server.generate", return_value="Раз, два, три, четыре, пять."): + status, _, body = self._post({"model": "gemini-3.6-flash", + "messages": [{"role": "user", "content": "hi"}]}) + self.assertEqual(status, 200) + data = json.loads(body) + self.assertEqual(data["choices"][0]["message"]["content"], "\u0420\u0430\u0437, \u0434\u0432\u0430, \u0442\u0440\u0438, \u0447\u0435\u0442\u044b\u0440\u0435, \u043f\u044f\u0442\u044c.") + + def _post(self, payload): + conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5) + conn.request("POST", "/v1/chat/completions", body=json.dumps(payload), + headers={"Content-Type": "application/json"}) + resp = conn.getresponse() + body = resp.read().decode() + conn.close() + return resp.status, dict(resp.getheaders()), body + + if __name__ == "__main__": unittest.main() From f722b881bd2b3f941ad9b4916d6558a3039be1e3 Mon Sep 17 00:00:00 2001 From: Bu Date: Tue, 25 Aug 2026 11:38:46 +0300 Subject: [PATCH 09/17] docs: Netscape cookies.txt works directly, no conversion needed --- README.md | 6 +++++- README_CN.md | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2de681f..c959415 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,11 @@ Or use the JSON format: {"cookie": "SID=xxx; HSID=xxx; SSID=xxx; APISID=xxx; SAPISID=xxx; __Secure-1PSID=xxx", "sapisid": "your_sapisid_value"} ``` -**Alternative (browser extension)**: Use any "Export Cookies" extension to export cookies for `gemini.google.com` in Netscape format, then convert to the single-line format above. +**Alternative (browser extension)**: Use any "Export Cookies" extension to export cookies for `gemini.google.com` in Netscape format (`cookies.txt`). The proxy parses Netscape-format files directly — no conversion needed: + +``` +python gemini_web2api.py --cookie-file cookies.txt +``` ### Authenticated account path and XSRF token diff --git a/README_CN.md b/README_CN.md index 0958651..23deab1 100644 --- a/README_CN.md +++ b/README_CN.md @@ -120,7 +120,11 @@ SID=你的SID值; HSID=你的HSID值; SSID=你的SSID值; APISID=你的APISID值 {"cookie": "SID=xxx; HSID=xxx; SSID=xxx; APISID=xxx; SAPISID=xxx; __Secure-1PSID=xxx", "sapisid": "你的SAPISID值"} ``` -**替代方案 (浏览器扩展)**: 使用任意 "Export Cookies" 扩展导出 `gemini.google.com` 的 cookie, 然后转换为上述单行格式. +**替代方案 (浏览器扩展)**: 使用任意 "Export Cookies" 扩展导出 `gemini.google.com` 的 cookie (Netscape 格式, `cookies.txt`). 代理可直接解析 Netscape 格式文件, 无需转换: + +``` +python gemini_web2api.py --cookie-file cookies.txt +``` ### 登录账号路径与 XSRF Token From ad820bce39c40772433a6b3f10e2f4691d2b8c8d Mon Sep 17 00:00:00 2001 From: Bu Date: Tue, 25 Aug 2026 11:44:18 +0300 Subject: [PATCH 10/17] fix(cookie): include file size in cookie cache key mtime-only caching can miss a same-second rewrite on filesystems with 1s timestamp resolution; (mtime, size) catches content changes in one write. --- gemini_web2api/gemini.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gemini_web2api/gemini.py b/gemini_web2api/gemini.py index 6ff23fb..d533b60 100644 --- a/gemini_web2api/gemini.py +++ b/gemini_web2api/gemini.py @@ -53,8 +53,8 @@ def load_cookie() -> tuple: if not cookie_file or not os.path.exists(cookie_file): return "", None try: - mtime = os.path.getmtime(cookie_file) - if mtime == _cookie_cache["mtime"] and _cookie_cache["str"]: + st = os.stat(cookie_file) + if (st.st_mtime, st.st_size) == (_cookie_cache["mtime"], _cookie_cache.get("size", -1)) and _cookie_cache["str"]: return _cookie_cache["str"], _cookie_cache["sapisid"] with open(cookie_file, "r") as f: content = f.read().strip() @@ -80,7 +80,7 @@ def load_cookie() -> tuple: cookie_str = content pairs = dict(p.split("=", 1) for p in cookie_str.split("; ") if "=" in p) sapisid = pairs.get("SAPISID", "") - _cookie_cache.update({"str": cookie_str, "sapisid": sapisid or None, "mtime": mtime}) + _cookie_cache.update({"str": cookie_str, "sapisid": sapisid or None, "mtime": st.st_mtime, "size": st.st_size}) return cookie_str, sapisid if sapisid else None except Exception as e: log(f"Cookie load error: {e}") From 7bd40368668b05a880bc58e7cb3792ce92654763 Mon Sep 17 00:00:00 2001 From: Bu Date: Tue, 25 Aug 2026 11:45:46 +0300 Subject: [PATCH 11/17] feat: add probe_upstream.py - one-shot upstream reachability check Anonymous StreamGenerate probe; prints blocked/UNBLOCKED and exits nonzero while the IP is blocked. Useful to tell 'Google blocked this IP' apart from a proxy regression before debugging code. --- probe_upstream.py | 54 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100755 probe_upstream.py diff --git a/probe_upstream.py b/probe_upstream.py new file mode 100755 index 0000000..82354f1 --- /dev/null +++ b/probe_upstream.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""One-shot probe: is Gemini StreamGenerate reachable from this IP? + +Prints 'blocked' (BardErrorInfo present) or 'UNBLOCKED' plus response size. +Exit code 1 while blocked - usable from cron/CI. No cookies, no config needed. +""" +import json +import re +import ssl +import sys +import time +import urllib.parse +import urllib.request +import uuid + +UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + + +def probe() -> bool: + ctx = ssl.create_default_context() + html = urllib.request.urlopen( + urllib.request.Request("https://gemini.google.com/app", headers={"User-Agent": UA}), + context=ctx, timeout=15).read().decode("utf-8", errors="replace") + bl = re.search(r'(boq_assistant-bard-web-server_\d+\.\d+_p\d+)', html) + if not bl: + print(f"{time.strftime('%H:%M:%S')} no BL in page - layout changed?") + return False + inner = [None] * 81 + inner[0] = ["hi", 0, None, None, None, None, 0] + inner[1] = ["en"] + inner[2] = ["", "", "", None, None, None, None, None, None, ""] + inner[6] = [1]; inner[7] = 1; inner[10] = 1; inner[11] = 0 + inner[17] = [[0]]; inner[18] = 0; inner[27] = 1; inner[30] = [4] + inner[41] = [1]; inner[53] = 0; inner[59] = str(uuid.uuid4()).upper() + inner[61] = []; inner[68] = 1; inner[79] = 1; inner[80] = 1 + body = urllib.parse.urlencode({"at": "", "f.req": json.dumps([None, json.dumps(inner)])}).encode() + url = (f"https://gemini.google.com/_/BardChatUi/data/" + f"assistant.lamda.BardFrontendService/StreamGenerate" + f"?bl={bl.group(1)}&hl=en&_reqid={int(time.time()) % 1000000}&rt=c") + req = urllib.request.Request(url, data=body, headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Origin": "https://gemini.google.com", + "Referer": "https://gemini.google.com/", + "X-Same-Domain": "1", + "User-Agent": UA}, method="POST") + resp = urllib.request.urlopen(req, context=ctx, timeout=45) + raw = resp.read().decode("utf-8", errors="replace") + ok = "BardErrorInfo" not in raw + print(f"{time.strftime('%H:%M:%S')} {'UNBLOCKED' if ok else 'blocked'} ({len(raw)}b)") + return ok + + +if __name__ == "__main__": + sys.exit(0 if probe() else 1) From ea35e0bb4767876735b1a4c845a5782e5a67b037 Mon Sep 17 00:00:00 2001 From: Bu Date: Tue, 25 Aug 2026 11:46:35 +0300 Subject: [PATCH 12/17] docs: mention probe_upstream.py in rate-limit troubleshooting --- README.md | 6 +++++- README_CN.md | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c959415..e202112 100644 --- a/README.md +++ b/README.md @@ -275,7 +275,11 @@ resp = client.chat.completions.create( - **Image upload may require cookies**: Multimodal input uses Gemini Web's image upload endpoint. If anonymous upload fails, configure a Gemini cookie. - **Not real Pro/Ultra**: Without a paid subscription cookie, `gemini-3.1-pro` routes to the same Flash model. The "Pro" label is a UI preference, not a backend model switch. - **Single-turn only**: Each request is an independent conversation. Multi-turn context is simulated by including previous messages in the prompt. -- **Rate limits**: Google may throttle high-frequency requests. The server retries automatically but sustained heavy use may be blocked. +- **Rate limits**: Google may throttle high-frequency requests. The server retries automatically but sustained heavy use may be blocked (upstream error `1060` = IP temporarily blocked - use a proxy/different network or wait). To check whether the IP is currently blocked before debugging the proxy itself, run: + +```bash +python probe_upstream.py +``` ## Requirements diff --git a/README_CN.md b/README_CN.md index 23deab1..be0891b 100644 --- a/README_CN.md +++ b/README_CN.md @@ -248,7 +248,11 @@ resp = client.chat.completions.create( - **图片上传可能需要 Cookie**: 多模态输入使用 Gemini 网页端图片上传接口。匿名上传失败时, 请配置 Gemini cookie。 - **Pro/Ultra 非真实路由**: 无付费订阅 cookie 时, `gemini-3.1-pro` 实际路由到 Flash 模型. "Pro" 只是 UI 偏好标签. - **单轮对话**: 每次请求是独立对话, 多轮上下文通过在 prompt 中包含历史消息模拟. -- **频率限制**: Google 可能限制高频请求, server 会自动重试但持续高负载可能被封. +- **频率限制**: Google 可能限制高频请求, server 会自动重试但持续高负载可能被封 (upstream 错误 `1060` = IP 被临时封禁 - 使用代理/更换网络或等待). 排查代理前可先确认 IP 是否被封: + +```bash +python probe_upstream.py +``` ## 系统要求 From ebec871b0b77a5cd7d6fc8684075131c73415c1c Mon Sep 17 00:00:00 2001 From: Bu Date: Tue, 25 Aug 2026 11:49:23 +0300 Subject: [PATCH 13/17] feat(models): port gemini-3.1-pro-enhanced to single-file build The modular package gained an extra-fields mechanism (inner[31]/inner[80] payload overrides for the enhanced Pro variant) that the single-file script never received, leaving the two builds listing different models (8 vs 9). Thread extra_fields through resolve/generate/stream paths and widen the payload array to 102 slots, matching the package. --- gemini_web2api.py | 44 +++++++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/gemini_web2api.py b/gemini_web2api.py index 6f76451..44c3625 100644 --- a/gemini_web2api.py +++ b/gemini_web2api.py @@ -91,6 +91,10 @@ "mode": 3, "think": 4, "desc": "Pro model (requires cookie for real routing)", }, + "gemini-3.1-pro-enhanced": { + "mode": 3, "think": 4, "extra": {31: 2, 80: 3}, + "desc": "Pro with enhanced output (experimental)", + }, "gemini-auto": { "mode": 4, "think": 4, "desc": "Auto model selection", @@ -240,9 +244,9 @@ def upload_images(images: list) -> list: # ─── Gemini Protocol ───────────────────────────────────────────────────────── -def gemini_stream_generate(prompt: str, model_id: int, think_mode: int, file_refs: list = None) -> str: +def gemini_stream_generate(prompt: str, model_id: int, think_mode: int, file_refs: list = None, extra_fields: dict = None) -> str: """Send prompt to Gemini StreamGenerate with retry.""" - inner = [None] * 80 + inner = [None] * 102 if file_refs: refs = [[None, None, ref] for ref in file_refs] inner[0] = [prompt, 0, None, refs, None, None, 0] @@ -264,6 +268,9 @@ def gemini_stream_generate(prompt: str, model_id: int, think_mode: int, file_ref inner[61] = [] inner[68] = 1 inner[79] = model_id + if extra_fields: + for k, v in extra_fields.items(): + inner[k] = v outer = [None, json.dumps(inner)] @@ -327,9 +334,9 @@ def build_request(): raise last_err -def gemini_stream_generate_iter(prompt: str, model_id: int, think_mode: int, file_refs: list = None): +def gemini_stream_generate_iter(prompt: str, model_id: int, think_mode: int, file_refs: list = None, extra_fields: dict = None): """Send prompt and yield incremental text deltas using httpx streaming.""" - inner = [None] * 80 + inner = [None] * 102 if file_refs: refs = [[None, None, ref] for ref in file_refs] inner[0] = [prompt, 0, None, refs, None, None, 0] @@ -351,6 +358,9 @@ def gemini_stream_generate_iter(prompt: str, model_id: int, think_mode: int, fil inner[61] = [] inner[68] = 1 inner[79] = model_id + if extra_fields: + for k, v in extra_fields.items(): + inner[k] = v outer = [None, json.dumps(inner)] @@ -386,7 +396,7 @@ def build_stream_request(): if not HAS_HTTPX: # Fallback: non-streaming with urllib - raw = gemini_stream_generate(prompt, model_id, think_mode, file_refs) + raw = gemini_stream_generate(prompt, model_id, think_mode, file_refs, extra_fields) text = extract_response_text(raw) if text: yield text @@ -436,7 +446,7 @@ def build_stream_request(): status = getattr(getattr(e, "response", None), "status_code", 0) if HAS_HTTPX and status in (400, 405) and not prev_text and update_bl_if_needed(): log("BL/XSRF refreshed, falling back to non-streaming for this request") - raw = gemini_stream_generate(prompt, model_id, think_mode, file_refs) + raw = gemini_stream_generate(prompt, model_id, think_mode, file_refs, extra_fields) text = extract_response_text(raw) if text: yield text @@ -794,11 +804,11 @@ def _resolve_model(self, model_name): think_override = int(think_str) cfg = MODELS.get(model_name) if not cfg: - return None, None, None, f"Unknown model: {model_name}" - return model_name, cfg["mode"], (think_override if think_override is not None else cfg["think"]), None + return None, None, None, f"Unknown model: {model_name}", None + return model_name, cfg["mode"], (think_override if think_override is not None else cfg["think"]), None, cfg.get("extra") - def _call_gemini(self, prompt, model_id, think_mode, tools, file_refs=None): - raw = gemini_stream_generate(prompt, model_id, think_mode, file_refs) + def _call_gemini(self, prompt, model_id, think_mode, tools, file_refs=None, extra_fields=None): + raw = gemini_stream_generate(prompt, model_id, think_mode, file_refs, extra_fields) text = extract_response_text(raw) tool_calls = None if tools and text: @@ -807,7 +817,7 @@ def _call_gemini(self, prompt, model_id, think_mode, tools, file_refs=None): def handle_chat(self, body: bytes): req = json.loads(body) - model_name, model_id, think_mode, err = self._resolve_model( + model_name, model_id, think_mode, err, extra_fields = self._resolve_model( req.get("model", CONFIG["default_model"])) if err: self.send_json({"error": {"message": err}}, 400) @@ -838,7 +848,7 @@ def handle_chat(self, body: bytes): first_chunk = {"id": cid, "object": "chat.completion.chunk", "created": int(time.time()), "model": model_name, "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}]} self.wfile.write(f"data: {json.dumps(first_chunk)}\n\n".encode()) - for delta_text in gemini_stream_generate_iter(prompt, model_id, think_mode, file_refs): + for delta_text in gemini_stream_generate_iter(prompt, model_id, think_mode, file_refs, extra_fields): chunk = {"id": cid, "object": "chat.completion.chunk", "created": int(time.time()), "model": model_name, "choices": [{"index": 0, "delta": {"content": delta_text}, "finish_reason": None}]} self.wfile.write(f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n".encode()) @@ -866,7 +876,7 @@ def handle_chat(self, body: bytes): # Non-streaming (or tool calling which needs full response) try: - text, tool_calls = self._call_gemini(prompt, model_id, think_mode, tools, file_refs) + text, tool_calls = self._call_gemini(prompt, model_id, think_mode, tools, file_refs, extra_fields) except Exception as e: self.send_json({"error": {"message": f"upstream error: {e}"}}, 502) return @@ -900,7 +910,7 @@ def handle_chat(self, body: bytes): def handle_responses(self, body: bytes): """OpenAI Responses API for Codex CLI compatibility.""" req = json.loads(body) - model_name, model_id, think_mode, err = self._resolve_model( + model_name, model_id, think_mode, err, extra_fields = self._resolve_model( req.get("model", CONFIG["default_model"])) if err: self.send_json({"error": {"message": err}}, 400) @@ -955,7 +965,7 @@ def handle_responses(self, body: bytes): try: file_refs = upload_images(images) - text, tool_calls = self._call_gemini(prompt, model_id, think_mode, tools, file_refs) + text, tool_calls = self._call_gemini(prompt, model_id, think_mode, tools, file_refs, extra_fields) except Exception as e: self.send_json({"error": {"message": f"upstream error: {e}"}}, 502) return @@ -1043,7 +1053,7 @@ def _handle_google_generate(self, body: bytes, stream: bool): self.send_json({"error": {"message": "model not specified in path"}}, 400) return - model_name, model_id, think_mode, err = self._resolve_model(model_name) + model_name, model_id, think_mode, err, extra_fields = self._resolve_model(model_name) if err: self.send_json({"error": {"message": err}}, 400) return @@ -1055,7 +1065,7 @@ def _handle_google_generate(self, body: bytes, stream: bool): try: file_refs = upload_images(images) - text, _ = self._call_gemini(prompt, model_id, think_mode, None, file_refs) + text, _ = self._call_gemini(prompt, model_id, think_mode, None, file_refs, extra_fields) except Exception as e: self.send_json({"error": {"message": f"upstream error: {e}"}}, 502) return From 78602b9f57ccb9b178296def5a726a49e1e11826 Mon Sep 17 00:00:00 2001 From: Bu Date: Tue, 25 Aug 2026 11:50:46 +0300 Subject: [PATCH 14/17] docs: sync model tables with current model list (9 models) --- README.md | 4 +++- README_CN.md | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e202112..938d04e 100644 --- a/README.md +++ b/README.md @@ -88,11 +88,13 @@ Supports Google native API endpoints: | Model | Description | Output | |-------|-------------|--------| -| `gemini-3.6-flash` | All-around model (latest) | ~12k chars | +| `gemini-3.7-flash` | Latest all-around model | ~12k chars | +| `gemini-3.6-flash` | All-around model | ~12k chars | | `gemini-3.5-flash` | Alias for gemini-3.6-flash | ~12k chars | | `gemini-3.5-flash-thinking` | Extended thinking, longest output | **~20k chars** | | `gemini-3.5-flash-thinking-lite` | Adaptive thinking depth | ~15k chars | | `gemini-3.1-pro` | Advanced math & code (needs cookie) | ~12k chars | +| `gemini-3.1-pro-enhanced` | Pro with enhanced output (experimental) | varies | | `gemini-auto` | Auto model selection | varies | | `gemini-flash-lite` | Fastest answers, lightweight | ~10k chars | diff --git a/README_CN.md b/README_CN.md index be0891b..005aa7c 100644 --- a/README_CN.md +++ b/README_CN.md @@ -78,11 +78,13 @@ gemini | 模型 | 说明 | 输出量 | |------|------|--------| -| `gemini-3.6-flash` | 全能模型 (最新) | ~1.2万字 | +| `gemini-3.7-flash` | 最新全能模型 | ~1.2万字 | +| `gemini-3.6-flash` | 全能模型 | ~1.2万字 | | `gemini-3.5-flash` | gemini-3.6-flash 别名 | ~1.2万字 | | `gemini-3.5-flash-thinking` | 扩展思考, 最长输出 | **~2万字** | | `gemini-3.5-flash-thinking-lite` | 自适应思考深度 | ~1.5万字 | | `gemini-3.1-pro` | 高级数学与代码 (需 cookie) | ~1.2万字 | +| `gemini-3.1-pro-enhanced` | Pro 增强输出 (实验性) | 视情况 | | `gemini-auto` | 自动选择模型 | 不定 | | `gemini-flash-lite` | 最快响应, 轻量 | ~1万字 | From ad4da2e3fb2b38ba8801ec5ae73bff9a0c93645f Mon Sep 17 00:00:00 2001 From: Bu Date: Tue, 25 Aug 2026 11:52:15 +0300 Subject: [PATCH 15/17] fix(upstream): keep retries for transient BardErrorInfo 1013 The fail-fast guard stopped retrying every BardErrorInfo, but 1013 is documented as transient and clears on retry; only hard rejections (1060 IP block, 1037 quota) skip the retry loop. --- gemini_web2api.py | 5 +++-- gemini_web2api/gemini.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/gemini_web2api.py b/gemini_web2api.py index 44c3625..d6af253 100644 --- a/gemini_web2api.py +++ b/gemini_web2api.py @@ -440,8 +440,9 @@ def build_stream_request(): except (json.JSONDecodeError, IndexError, TypeError): pass except Exception as e: - # Hard upstream rejections (BardErrorInfo) - retrying is futile - if "Gemini upstream error" in str(e): + # Hard upstream rejections (BardErrorInfo) - retrying is futile, + # except 1013 which is transient per upstream behavior + if "Gemini upstream error" in str(e) and "[1013]" not in str(e): raise status = getattr(getattr(e, "response", None), "status_code", 0) if HAS_HTTPX and status in (400, 405) and not prev_text and update_bl_if_needed(): diff --git a/gemini_web2api/gemini.py b/gemini_web2api/gemini.py index d533b60..70343ee 100644 --- a/gemini_web2api/gemini.py +++ b/gemini_web2api/gemini.py @@ -339,8 +339,9 @@ def generate_stream(prompt: str, model_id: int, think_mode: int, file_refs: list yield delta return except Exception as e: - # Hard upstream rejections (BardErrorInfo) - retrying is futile - if "Gemini upstream error" in str(e): + # Hard upstream rejections (BardErrorInfo) - retrying is futile, + # except 1013 which is transient per upstream behavior + if "Gemini upstream error" in str(e) and "[1013]" not in str(e): raise status = getattr(getattr(e, "response", None), "status_code", 0) if status in (400, 405) and not emitted_raw_text and refresh_bl_and_xsrf(): From 397b5a734687b3fe51552ecc2ecc4447eab6eb22 Mon Sep 17 00:00:00 2001 From: Bu Date: Tue, 25 Aug 2026 13:30:15 +0300 Subject: [PATCH 16/17] fix(upstream): fail fast on HTTP 429 instead of retrying Retrying an IP rate limit amplifies it. Surface 429 immediately with guidance so clients back off, matching the fail-fast behavior used for hard BardErrorInfo rejections. --- gemini_web2api.py | 2 ++ gemini_web2api/gemini.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/gemini_web2api.py b/gemini_web2api.py index d6af253..55fb55c 100644 --- a/gemini_web2api.py +++ b/gemini_web2api.py @@ -318,6 +318,8 @@ def build_request(): resp = urllib.request.urlopen(req, context=ctx, timeout=CONFIG["request_timeout_sec"]) return resp.read().decode("utf-8", errors="replace") except urllib.error.HTTPError as e: + if e.code == 429: + raise RuntimeError("Gemini upstream rate-limited this IP (HTTP 429); retrying immediately would extend the block") if e.code in (400, 405) and update_bl_if_needed(): log("Retrying with refreshed BL/XSRF...") last_err = e diff --git a/gemini_web2api/gemini.py b/gemini_web2api/gemini.py index 70343ee..47fd6ca 100644 --- a/gemini_web2api/gemini.py +++ b/gemini_web2api/gemini.py @@ -282,6 +282,8 @@ def generate(prompt: str, model_id: int, think_mode: int, file_refs: list = None raw = resp.read().decode("utf-8", errors="replace") return extract_response_text(raw) except urllib.error.HTTPError as e: + if e.code == 429: + raise RuntimeError("Gemini upstream rate-limited this IP (HTTP 429); retrying immediately would extend the block") if e.code in (400, 405) and refresh_bl_and_xsrf(): log("Retrying with refreshed BL/XSRF...") last_err = e From 28b44dcd42e16d9da87c7dc458d812c05bc2583c Mon Sep 17 00:00:00 2001 From: Bu Date: Tue, 25 Aug 2026 19:44:01 +0300 Subject: [PATCH 17/17] fix(upstream): recognize error code 1185 in live traffic --- gemini_web2api.py | 1 + gemini_web2api/gemini.py | 1 + 2 files changed, 2 insertions(+) diff --git a/gemini_web2api.py b/gemini_web2api.py index 55fb55c..0c061ef 100644 --- a/gemini_web2api.py +++ b/gemini_web2api.py @@ -476,6 +476,7 @@ def extract_response_text(raw: str) -> str: 1060: "IP temporarily blocked or region not supported - use a proxy/different network or wait", 1037: "usage limit exceeded", 1013: "temporary upstream error, retry later", + 1185: "upstream rejected request", } hint = hints.get(code, "upstream rejected request") raise RuntimeError(f"Gemini upstream error [{code}]: {hint}") diff --git a/gemini_web2api/gemini.py b/gemini_web2api/gemini.py index 47fd6ca..b0582fc 100644 --- a/gemini_web2api/gemini.py +++ b/gemini_web2api/gemini.py @@ -249,6 +249,7 @@ def extract_response_text(raw: str) -> str: 1060: "IP temporarily blocked or region not supported - use a proxy/different network or wait", 1037: "usage limit exceeded", 1013: "temporary upstream error, retry later", + 1185: "upstream rejected request", } raise RuntimeError(f"Gemini upstream error [{code}]: {hints.get(code, 'upstream rejected request')}") last_text = ""