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 diff --git a/README.md b/README.md index 2de681f..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 | @@ -130,7 +132,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 @@ -271,7 +277,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 0958651..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万字 | @@ -120,7 +122,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 @@ -244,7 +250,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 +``` ## 系统要求 diff --git a/gemini_web2api.py b/gemini_web2api.py index 1f73f6e..0c061ef 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", @@ -127,6 +131,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 +179,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 +196,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 +208,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 @@ -220,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] @@ -244,39 +268,44 @@ 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)] - 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 +318,10 @@ 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 == 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 continue last_err = e @@ -311,9 +336,9 @@ def gemini_stream_generate(prompt: str, model_id: int, think_mode: int, file_ref 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] @@ -335,39 +360,45 @@ 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)] - 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") 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 @@ -377,6 +408,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 = "" @@ -384,17 +416,17 @@ def gemini_stream_generate_iter(prompt: str, model_id: int, think_mode: int, fil 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]: @@ -410,14 +442,18 @@ 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 + # 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(): + log("BL/XSRF refreshed, falling back to non-streaming for this request") + raw = gemini_stream_generate(prompt, model_id, think_mode, file_refs, extra_fields) + text = extract_response_text(raw) + if text: + yield text + return raise @@ -433,17 +469,25 @@ 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 - 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}") 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]: @@ -719,6 +763,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: @@ -759,11 +808,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: @@ -772,7 +821,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) @@ -803,7 +852,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()) @@ -818,11 +867,20 @@ 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) 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 @@ -856,7 +914,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) @@ -911,7 +969,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 @@ -999,7 +1057,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 @@ -1011,7 +1069,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 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..b0582fc 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 @@ -51,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() @@ -60,17 +62,66 @@ 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) 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}") 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() @@ -168,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]): @@ -191,9 +242,16 @@ 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 - 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 = "" for line in raw.split("\n"): for t in _extract_texts_from_line(line): @@ -204,15 +262,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 +282,17 @@ 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 == 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 + 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,25 +309,25 @@ 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 = "" 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) @@ -273,6 +342,15 @@ 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, + # 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(): + 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}") 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/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) diff --git a/tests/test_upstream_errors.py b/tests/test_upstream_errors.py new file mode 100644 index 0000000..4fbeb42 --- /dev/null +++ b/tests/test_upstream_errors.py @@ -0,0 +1,146 @@ +"""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 + + +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(""), "") + + +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) + + + + +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()