Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
],
"cookie_file": null,
"proxy": null,
"proxy_pool": [],
"log_requests": true,
"temporary_chats": false
}
1 change: 1 addition & 0 deletions gemini_web2api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"log_requests": True,
"cookie_file": None,
"proxy": None,
"proxy_pool": [],
"api_keys": [],
"temporary_chats": False,
}
Expand Down
51 changes: 39 additions & 12 deletions gemini_web2api/gemini.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import ssl
import os
import hashlib
import threading

try:
import httpx
Expand All @@ -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):
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -223,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:
Expand All @@ -243,11 +268,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()
Expand All @@ -271,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
Expand Down
19 changes: 18 additions & 1 deletion tests/test_modular_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down