From f6caa260ec54dc809c4b98be55b18dc035d9045c Mon Sep 17 00:00:00 2001 From: 2164312714-svg <2164312714-svg@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:48:26 +0800 Subject: [PATCH 1/2] feat: support rotating proxy pool --- README.md | 13 +++++++++++ README_CN.md | 12 +++++++++++ config.example.json | 1 + gemini_web2api/config.py | 1 + gemini_web2api/gemini.py | 44 ++++++++++++++++++++++++++++---------- tests/test_modular_sync.py | 19 +++++++++++++++- 6 files changed, 78 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 2de681f..f72c0cc 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,19 @@ python gemini_web2api.py --proxy http://127.0.0.1:7890 {"proxy": "http://127.0.0.1:7890"} ``` +For multiple HTTP/HTTPS proxies, use `proxy_pool`. Requests and retries rotate +through the entries in round-robin order; an empty pool falls back to `proxy` +or a direct connection. + +```json +{ + "proxy_pool": [ + "http://user:password@proxy-a.example:8080", + "http://user:password@proxy-b.example:8080" + ] +} +``` + **Method 3: Environment variable** (auto-detected) ```bash export HTTPS_PROXY=http://127.0.0.1:7890 diff --git a/README_CN.md b/README_CN.md index 0958651..61ac320 100644 --- a/README_CN.md +++ b/README_CN.md @@ -213,6 +213,18 @@ python gemini_web2api.py --proxy http://127.0.0.1:7890 {"proxy": "http://127.0.0.1:7890"} ``` +如果有多个 HTTP/HTTPS 代理,可使用 `proxy_pool`。每次请求和重试会按轮询 +顺序选择代理;列表为空时回退到 `proxy` 或直连。 + +```json +{ + "proxy_pool": [ + "http://user:password@proxy-a.example:8080", + "http://user:password@proxy-b.example:8080" + ] +} +``` + **方式 3: 环境变量** (自动检测) ```bash set HTTPS_PROXY=http://127.0.0.1:7890 diff --git a/config.example.json b/config.example.json index dd3c61f..10ab1ee 100644 --- a/config.example.json +++ b/config.example.json @@ -13,6 +13,7 @@ ], "cookie_file": null, "proxy": null, + "proxy_pool": [], "log_requests": true, "temporary_chats": false } diff --git a/gemini_web2api/config.py b/gemini_web2api/config.py index 5809f0b..6646e98 100644 --- a/gemini_web2api/config.py +++ b/gemini_web2api/config.py @@ -15,6 +15,7 @@ "log_requests": True, "cookie_file": None, "proxy": None, + "proxy_pool": [], "api_keys": [], "temporary_chats": False, } diff --git a/gemini_web2api/gemini.py b/gemini_web2api/gemini.py index d69b08d..e9809a5 100644 --- a/gemini_web2api/gemini.py +++ b/gemini_web2api/gemini.py @@ -8,6 +8,7 @@ import ssl import os import hashlib +import threading try: import httpx @@ -19,7 +20,9 @@ _ssl_ctx = None _cookie_cache = {"str": "", "sapisid": None, "mtime": 0} -_httpx_client = None +_httpx_clients = {} +_proxy_lock = threading.Lock() +_proxy_index = 0 def log(msg: str): @@ -36,13 +39,33 @@ def _get_ssl_ctx(): return _ssl_ctx -def _get_httpx_client(): - global _httpx_client - if _httpx_client is None and HAS_HTTPX: - proxy = CONFIG.get("proxy") +def _proxy_candidates(): + pool = CONFIG.get("proxy_pool") or [] + if isinstance(pool, str): + pool = [pool] + pool = [p.strip() for p in pool if isinstance(p, str) and p.strip()] + return pool or ([CONFIG["proxy"]] if CONFIG.get("proxy") else [None]) + + +def _next_proxy(): + """Round-robin proxy selection; None means direct connection.""" + global _proxy_index + candidates = _proxy_candidates() + with _proxy_lock: + proxy = candidates[_proxy_index % len(candidates)] + _proxy_index += 1 + return proxy + + +def _get_httpx_client(proxy): + if not HAS_HTTPX: + return None + if proxy not in _httpx_clients: transport = httpx.HTTPTransport(proxy=proxy) if proxy else None - _httpx_client = httpx.Client(transport=transport, timeout=CONFIG["request_timeout_sec"], verify=True) - return _httpx_client + _httpx_clients[proxy] = httpx.Client( + transport=transport, timeout=CONFIG["request_timeout_sec"], verify=True + ) + return _httpx_clients[proxy] def load_cookie() -> tuple: @@ -208,10 +231,9 @@ def generate(prompt: str, model_id: int, think_mode: int, file_refs: list = None url = _get_url() headers = _build_headers() ctx = _get_ssl_ctx() - proxy = CONFIG.get("proxy") - last_err = None for attempt in range(CONFIG["retry_attempts"]): + proxy = _next_proxy() try: req = urllib.request.Request(url, data=body, headers=headers, method="POST") if proxy: @@ -243,11 +265,11 @@ def generate_stream(prompt: str, model_id: int, think_mode: int, file_refs: list 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"]): + proxy = _next_proxy() + client = _get_httpx_client(proxy) try: with client.stream("POST", url, content=body, headers=headers) as resp: resp.raise_for_status() diff --git a/tests/test_modular_sync.py b/tests/test_modular_sync.py index 7479e92..4d8c3cc 100644 --- a/tests/test_modular_sync.py +++ b/tests/test_modular_sync.py @@ -7,7 +7,7 @@ from urllib.parse import parse_qs from gemini_web2api.config import CONFIG, DEFAULT_CONFIG -from gemini_web2api.gemini import _build_payload +from gemini_web2api.gemini import _build_payload, _next_proxy from gemini_web2api.server import GeminiHandler, ThreadedServer from gemini_web2api.tools import google_contents_to_prompt, messages_to_prompt @@ -68,6 +68,23 @@ def test_payload_includes_uploaded_image_refs(self): self.assertEqual(inner[0][3], [[None, None, "/uploaded/image-ref"]]) +class ProxyRotationTests(unittest.TestCase): + def setUp(self): + self.original_config = dict(CONFIG) + CONFIG["proxy_pool"] = ["http://proxy-a", "http://proxy-b"] + + def tearDown(self): + CONFIG.clear() + CONFIG.update(self.original_config) + + def test_proxy_pool_rotates_round_robin(self): + first = _next_proxy() + second = _next_proxy() + third = _next_proxy() + self.assertNotEqual(first, second) + self.assertEqual(first, third) + + class MessageParsingTests(unittest.TestCase): def test_messages_to_prompt_extracts_openai_image_url_data_url(self): image_data = base64.b64encode(b"fake png").decode() From ac4de1f40750c1c8c66cc1d541bd9087e1ccd4ab Mon Sep 17 00:00:00 2001 From: 2164312714-svg <2164312714-svg@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:21:18 +0800 Subject: [PATCH 2/2] fix: retry empty upstream responses --- gemini_web2api/gemini.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/gemini_web2api/gemini.py b/gemini_web2api/gemini.py index e9809a5..5e84579 100644 --- a/gemini_web2api/gemini.py +++ b/gemini_web2api/gemini.py @@ -245,7 +245,10 @@ def generate(prompt: str, model_id: int, think_mode: int, file_refs: list = None else: 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) + text = extract_response_text(raw) + if not text: + raise RuntimeError("Gemini upstream returned an empty response") + return text except Exception as e: last_err = e if attempt < CONFIG["retry_attempts"] - 1: @@ -293,6 +296,8 @@ def generate_stream(prompt: str, model_id: int, think_mode: int, file_refs: list emitted_raw_text = t if delta: yield delta + if not emitted_raw_text: + raise RuntimeError("Gemini upstream returned an empty response") return except Exception as e: last_err = e