pouluo commited on
Commit
3cf7437
·
1 Parent(s): 5cb4a35

v2.0: curl_cffi Chrome TLS fingerprinting + upstream model migration

Browse files

- Add curl_cffi for Chrome 124 TLS impersonation (fallback to urllib)
- Migrate model selection from integer mode to x-goog-ext hex ID headers
- Sync model IDs from upstream xwteam/gemini2api v1.6.15
- Add bracket-depth response frame scanner (replaces line-by-line parser)
- Add Chrome-like header ordering with Sec-Ch-Ua/Sec-Fetch-*
- Add request jitter (50-300ms) to mimic human behavior
- Stable public model names: gemini-pro, gemini-flash, gemini-flash-thinking
- 17 legacy model name aliases for backward compatibility
- Filter googleusercontent placeholder URLs from responses
- Update Dockerfile with libcurl4-openssl-dev for HF Spaces
- Add requirements.txt with curl_cffi==0.7.4

Files changed (3) hide show
  1. Dockerfile +14 -8
  2. gemini_web2api.py +423 -81
  3. requirements.txt +1 -0
Dockerfile CHANGED
@@ -1,12 +1,18 @@
1
- FROM python:3.11-slim
2
 
3
- WORKDIR /app
 
 
 
4
 
5
- # 项目本身零依赖(全用 Python 标准库),不需要 pip install
6
- COPY gemini_web2api.py .
7
 
8
- # HF Spaces 默认把外部流量路由到 7860
9
- EXPOSE 7860
10
 
11
- # 跑起来,host 默认就是 0.0.0.0
12
- CMD ["python", "gemini_web2api.py", "--port", "7860"]
 
 
 
 
 
1
+ FROM python:3.11-slim
2
 
3
+ # System deps for curl_cffi (Chrome TLS fingerprint impersonation)
4
+ RUN apt-get update && apt-get install -y --no-install-recommends \
5
+ libcurl4-openssl-dev \
6
+ && apt-get clean && rm -rf /var/lib/apt/lists/*
7
 
8
+ WORKDIR /app
 
9
 
10
+ COPY requirements.txt .
11
+ RUN pip install --no-cache-dir -r requirements.txt
12
 
13
+ COPY gemini_web2api.py .
14
+
15
+ # HF Spaces routes external traffic to 7860
16
+ EXPOSE 7860
17
+
18
+ CMD ["python", "gemini_web2api.py", "--port", "7860"]
gemini_web2api.py CHANGED
@@ -3,7 +3,7 @@
3
  gemini-web2api - Gemini Web to OpenAI API proxy.
4
 
5
  Converts Google Gemini's web interface into an OpenAI-compatible API server.
6
- Zero authentication required. Works on any platform (Windows/macOS/Linux).
7
 
8
  Usage:
9
  python gemini_web2api.py [--port 8081] [--config config.json]
@@ -21,12 +21,26 @@ import sys
21
  import uuid
22
  import re
23
  import os
 
24
  import hashlib
25
  import argparse
26
  from http.server import HTTPServer, BaseHTTPRequestHandler
27
  from socketserver import ThreadingMixIn
 
28
 
29
- __version__ = "1.0.0"
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  # ─── Configuration ───────────────────────────────────────────────────────────
32
 
@@ -37,46 +51,139 @@ DEFAULT_CONFIG = {
37
  "retry_delay_sec": 2,
38
  "request_timeout_sec": 180,
39
  "gemini_bl": "boq_assistant-bard-web-server_20260525.09_p0",
40
- "default_model": "gemini-3.5-flash",
41
  "log_requests": True,
42
  "cookie_file": None,
43
  "proxy": None,
44
  "api_key": os.environ.get("API_KEY"), # Set via Hugging Face Secrets
 
 
 
 
 
 
45
  }
46
 
47
  CONFIG = dict(DEFAULT_CONFIG)
48
 
49
- # ─── Models ──────────────────────────────────────────────────────────────────
50
- # Mapping from JS source: MODE_CATEGORY enum (028-6eb337387583.js)
51
- # 1=FAST, 2=THINKING, 3=PRO, 4=AUTO, 5=FAST_DYNAMIC_THINKING, 6=FLASH_LITE
52
 
53
- MODELS = {
54
- "gemini-3.5-flash": {
55
- "mode": 1, "think": 4,
56
- "desc": "Fast general-purpose model",
 
 
 
57
  },
58
- "gemini-3.5-flash-thinking": {
59
- "mode": 2, "think": 0,
60
- "desc": "Deep thinking mode, longest output (~20k chars)",
61
  },
62
- "gemini-3.1-pro": {
63
- "mode": 3, "think": 4,
64
- "desc": "Pro model (requires cookie for real routing)",
65
  },
66
- "gemini-auto": {
67
- "mode": 4, "think": 4,
68
- "desc": "Auto model selection",
 
69
  },
70
- "gemini-3.5-flash-thinking-lite": {
71
- "mode": 5, "think": 0,
72
- "desc": "Dynamic thinking with adaptive depth",
73
  },
74
- "gemini-flash-lite": {
75
- "mode": 6, "think": 4,
76
- "desc": "Lightweight fast model",
77
  },
78
  }
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  # ─── Utilities ───────────────────────────────────────────────────────────────
81
 
82
  def log(msg: str):
@@ -85,6 +192,12 @@ def log(msg: str):
85
  sys.stderr.flush()
86
 
87
 
 
 
 
 
 
 
88
  def load_cookie() -> tuple:
89
  """Load cookie from file. Returns (cookie_str, sapisid)."""
90
  cookie_file = CONFIG.get("cookie_file")
@@ -115,10 +228,160 @@ def make_sapisidhash(sapisid: str) -> str:
115
  return f"SAPISIDHASH {ts}_{h}"
116
 
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  # ─── Gemini Protocol ─────────────────────────────────────────────────────────
119
 
120
- def gemini_stream_generate(prompt: str, model_id: int, think_mode: int) -> str:
121
- """Send prompt to Gemini StreamGenerate with retry."""
 
 
 
 
 
 
122
  inner = [None] * 80
123
  inner[0] = [prompt, 0, None, None, None, None, 0]
124
  inner[1] = ["en"]
@@ -127,7 +390,9 @@ def gemini_stream_generate(prompt: str, model_id: int, think_mode: int) -> str:
127
  inner[7] = 1
128
  inner[10] = 1
129
  inner[11] = 0
130
- inner[17] = [[think_mode]]
 
 
131
  inner[18] = 0
132
  inner[27] = 1
133
  inner[30] = [4]
@@ -136,76 +401,143 @@ def gemini_stream_generate(prompt: str, model_id: int, think_mode: int) -> str:
136
  inner[59] = str(uuid.uuid4())
137
  inner[61] = []
138
  inner[68] = 1
139
- inner[79] = model_id
 
140
 
141
  outer = [None, json.dumps(inner)]
142
  body = urllib.parse.urlencode({"f.req": json.dumps(outer)}).encode()
143
- reqid = int(time.time()) % 1000000
144
  url = (
145
  "https://gemini.google.com/_/BardChatUi/data/"
146
  "assistant.lamda.BardFrontendService/StreamGenerate"
147
  f"?bl={CONFIG['gemini_bl']}&hl=en&_reqid={reqid}&rt=c"
148
  )
149
- headers = {
150
- "Content-Type": "application/x-www-form-urlencoded",
151
- "Origin": "https://gemini.google.com",
152
- "Referer": "https://gemini.google.com/app",
153
- "X-Same-Domain": "1",
154
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
155
- }
156
 
 
 
 
 
 
 
 
 
 
 
 
157
  cookie_str, sapisid = load_cookie()
 
158
  if cookie_str:
 
 
 
 
 
 
159
  headers["Cookie"] = cookie_str
160
  if sapisid:
161
  headers["Authorization"] = make_sapisidhash(sapisid)
162
 
 
163
  last_err = None
164
  for attempt in range(CONFIG["retry_attempts"]):
165
  try:
166
- req = urllib.request.Request(url, data=body, headers=headers, method="POST")
167
- ctx = ssl.create_default_context()
168
- proxy = CONFIG.get("proxy")
169
- if proxy:
170
- opener = urllib.request.build_opener(
171
- urllib.request.ProxyHandler({"http": proxy, "https": proxy}),
172
- urllib.request.HTTPSHandler(context=ctx)
173
- )
174
- resp = opener.open(req, timeout=CONFIG["request_timeout_sec"])
175
- else:
176
- resp = urllib.request.urlopen(req, context=ctx, timeout=CONFIG["request_timeout_sec"])
177
- return resp.read().decode("utf-8", errors="replace")
178
  except Exception as e:
179
  last_err = e
180
  if attempt < CONFIG["retry_attempts"] - 1:
181
  log(f"Retry {attempt+1}/{CONFIG['retry_attempts']}: {e}")
182
- time.sleep(CONFIG["retry_delay_sec"])
183
  raise last_err
184
 
185
 
186
  def clean_gemini_text(text: str) -> str:
187
- """Remove internal code execution artifacts."""
 
188
  text = re.sub(
189
  r'```(?:python|javascript|text)\?code_(?:reference|stdout)&code_event_index=\d+\n.*?```\n?',
190
  '', text, flags=re.DOTALL
191
  )
 
 
 
 
 
192
  return text.strip()
193
 
194
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  def extract_response_text(raw: str) -> str:
196
- """Parse StreamGenerate response to extract final text."""
 
 
 
 
 
197
  texts = []
198
- for line in raw.split("\n"):
199
- if '"wrb.fr"' not in line or len(line) < 200:
200
- continue
201
  try:
202
- arr = json.loads(line)
203
- inner_str = arr[0][2]
204
- if not inner_str or len(inner_str) < 50:
 
205
  continue
206
- inner = json.loads(inner_str)
207
- if isinstance(inner, list) and len(inner) > 4 and inner[4]:
208
- for part in inner[4]:
209
  if isinstance(part, list) and len(part) > 1 and part[1]:
210
  if isinstance(part[1], list):
211
  for t in part[1]:
@@ -213,6 +545,8 @@ def extract_response_text(raw: str) -> str:
213
  texts.append(t)
214
  except (json.JSONDecodeError, IndexError, TypeError):
215
  pass
 
 
216
  text = ""
217
  for t in reversed(texts):
218
  if t.strip():
@@ -260,7 +594,7 @@ def messages_to_prompt(messages: list, tools: list = None) -> str:
260
  fn = tc.get("function", {})
261
  tc_strs.append(
262
  f'```tool_call\n{{"name": "{fn.get("name")}", '
263
- f'"arguments": {fn.get("arguments", "{}")}}}\n```'
264
  )
265
  parts.append(f"[Assistant]: {content or ''}\n" + "\n".join(tc_strs))
266
  else:
@@ -342,11 +676,16 @@ class GeminiHandler(BaseHTTPRequestHandler):
342
  self.send_json({"object": "list", "data": [
343
  {"id": n, "object": "model", "created": 1700000000,
344
  "owned_by": "google", "description": c["desc"]}
345
- for n, c in MODELS.items()
346
  ]})
347
  elif self.path == "/":
348
- self.send_json({"status": "ok", "version": __version__,
349
- "models": list(MODELS.keys())})
 
 
 
 
 
350
  else:
351
  self.send_json({"error": "not found"}, 404)
352
  except (BrokenPipeError, ConnectionResetError):
@@ -376,17 +715,13 @@ class GeminiHandler(BaseHTTPRequestHandler):
376
  pass
377
 
378
  def _resolve_model(self, model_name):
379
- think_override = None
380
- if "@think=" in model_name:
381
- model_name, think_str = model_name.rsplit("@think=", 1)
382
- think_override = int(think_str)
383
- cfg = MODELS.get(model_name)
384
- if not cfg:
385
- return None, None, None, f"Unknown model: {model_name}"
386
- return model_name, cfg["mode"], (think_override if think_override is not None else cfg["think"]), None
387
-
388
- def _call_gemini(self, prompt, model_id, think_mode, tools):
389
- raw = gemini_stream_generate(prompt, model_id, think_mode)
390
  text = extract_response_text(raw)
391
  tool_calls = None
392
  if tools and text:
@@ -395,7 +730,7 @@ class GeminiHandler(BaseHTTPRequestHandler):
395
 
396
  def handle_chat(self, body: bytes):
397
  req = json.loads(body)
398
- model_name, model_id, think_mode, err = self._resolve_model(
399
  req.get("model", CONFIG["default_model"]))
400
  if err:
401
  self.send_json({"error": {"message": err}}, 400)
@@ -408,7 +743,7 @@ class GeminiHandler(BaseHTTPRequestHandler):
408
  return
409
 
410
  try:
411
- text, tool_calls = self._call_gemini(prompt, model_id, think_mode, tools)
412
  except Exception as e:
413
  self.send_json({"error": {"message": f"upstream error: {e}"}}, 502)
414
  return
@@ -442,7 +777,7 @@ class GeminiHandler(BaseHTTPRequestHandler):
442
  def handle_responses(self, body: bytes):
443
  """OpenAI Responses API for Codex CLI compatibility."""
444
  req = json.loads(body)
445
- model_name, model_id, think_mode, err = self._resolve_model(
446
  req.get("model", CONFIG["default_model"]))
447
  if err:
448
  self.send_json({"error": {"message": err}}, 400)
@@ -497,7 +832,7 @@ class GeminiHandler(BaseHTTPRequestHandler):
497
  return
498
 
499
  try:
500
- text, tool_calls = self._call_gemini(prompt, model_id, think_mode, tools)
501
  except Exception as e:
502
  self.send_json({"error": {"message": f"upstream error: {e}"}}, 502)
503
  return
@@ -572,6 +907,9 @@ def main():
572
  if args.proxy:
573
  CONFIG["proxy"] = args.proxy
574
 
 
 
 
575
  class ThreadedServer(ThreadingMixIn, HTTPServer):
576
  daemon_threads = True
577
  allow_reuse_address = True
@@ -581,16 +919,20 @@ def main():
581
  print(f"gemini-web2api v{__version__}")
582
  print(f" Listening: http://0.0.0.0:{port}")
583
  print(f" Base URL: http://localhost:{port}/v1")
584
- print(f" Models: {', '.join(MODELS.keys())}")
 
 
585
  print(f" API Key: {'configured (set via API_KEY env)' if CONFIG.get('api_key') else 'none (open access)'}")
586
  print(f" Cookie: {'yes (' + CONFIG['cookie_file'] + ')' if CONFIG.get('cookie_file') else 'none (anonymous)'}")
587
  print(f" Proxy: {CONFIG.get('proxy') or 'none (uses system env HTTP_PROXY/HTTPS_PROXY)'}")
588
  print(f" Retry: {CONFIG['retry_attempts']}x / {CONFIG['retry_delay_sec']}s")
 
589
  print()
590
  try:
591
  server.serve_forever()
592
  except KeyboardInterrupt:
593
  print("\nStopped.")
 
594
  server.shutdown()
595
 
596
 
 
3
  gemini-web2api - Gemini Web to OpenAI API proxy.
4
 
5
  Converts Google Gemini's web interface into an OpenAI-compatible API server.
6
+ Uses curl_cffi for Chrome TLS fingerprint impersonation to avoid bot detection.
7
 
8
  Usage:
9
  python gemini_web2api.py [--port 8081] [--config config.json]
 
21
  import uuid
22
  import re
23
  import os
24
+ import random
25
  import hashlib
26
  import argparse
27
  from http.server import HTTPServer, BaseHTTPRequestHandler
28
  from socketserver import ThreadingMixIn
29
+ from collections import OrderedDict
30
 
31
+ __version__ = "2.0.0"
32
+
33
+ # ─── curl_cffi with fallback ────────────────────────────────────────────────
34
+ # curl_cffi provides Chrome TLS fingerprint impersonation, making requests
35
+ # indistinguishable from real Chrome browsers at the TLS layer.
36
+ # Falls back to stdlib urllib if not available (less stealthy).
37
+
38
+ try:
39
+ from curl_cffi.requests import Session as CurlSession
40
+ HAS_CURL_CFFI = True
41
+ except ImportError:
42
+ HAS_CURL_CFFI = False
43
+ CurlSession = None
44
 
45
  # ─── Configuration ───────────────────────────────────────────────────────────
46
 
 
51
  "retry_delay_sec": 2,
52
  "request_timeout_sec": 180,
53
  "gemini_bl": "boq_assistant-bard-web-server_20260525.09_p0",
54
+ "default_model": "gemini-flash",
55
  "log_requests": True,
56
  "cookie_file": None,
57
  "proxy": None,
58
  "api_key": os.environ.get("API_KEY"), # Set via Hugging Face Secrets
59
+ # Chrome fingerprint settings
60
+ "chrome_version": 124,
61
+ "impersonate_target": "chrome124",
62
+ # Request jitter (ms) - randomized delays to mimic human behavior
63
+ "jitter_min_ms": 50,
64
+ "jitter_max_ms": 300,
65
  }
66
 
67
  CONFIG = dict(DEFAULT_CONFIG)
68
 
69
+ # ─── Models (synced from upstream xwteam/gemini2api v1.6.15) ─────────────────
70
+ # Model selection via x-goog-ext-525001261-jspb header with hex model IDs.
71
+ # This replaces the old integer mode category approach.
72
 
73
+ MODEL_HEADER_KEY = "x-goog-ext-525001261-jspb"
74
+
75
+ GEMINI_MODELS = {
76
+ # Internal model name → routing info
77
+ "gemini-3-pro": {
78
+ "id": "9d8ca3786ebdfbea", "capacity": 1,
79
+ "desc": "Pro model (free tier)",
80
  },
81
+ "gemini-3-flash": {
82
+ "id": "fbb127bbb056c959", "capacity": 1,
83
+ "desc": "Fast general-purpose model",
84
  },
85
+ "gemini-3-flash-thinking": {
86
+ "id": "5bf011840784117a", "capacity": 1,
87
+ "desc": "Deep thinking mode",
88
  },
89
+ # Pro-only (paid tier) models
90
+ "gemini-3-pro-plus": {
91
+ "id": "e6fa609c3fa255c0", "capacity": 4,
92
+ "desc": "Pro+ model (requires subscription)",
93
  },
94
+ "gemini-3-flash-plus": {
95
+ "id": "56fdd199312815e2", "capacity": 4,
96
+ "desc": "Flash+ model (requires subscription)",
97
  },
98
+ "gemini-3-flash-thinking-plus": {
99
+ "id": "e051ce1aa80aa576", "capacity": 4,
100
+ "desc": "Thinking+ model (requires subscription)",
101
  },
102
  }
103
 
104
+ # Stable public model names (API contract - never change these)
105
+ # Maps public name → family → resolved to internal name
106
+ PUBLIC_MODELS = {
107
+ "gemini-pro": {"family": "pro", "default": "gemini-3-pro",
108
+ "desc": "Pro model for complex tasks"},
109
+ "gemini-flash": {"family": "flash", "default": "gemini-3-flash",
110
+ "desc": "Fast general-purpose model"},
111
+ "gemini-flash-thinking": {"family": "flash-thinking", "default": "gemini-3-flash-thinking",
112
+ "desc": "Deep thinking with extended output"},
113
+ }
114
+
115
+ # Legacy model name aliases → stable public name
116
+ MODEL_ALIASES = {
117
+ # Old names from your v1.0.0
118
+ "gemini-3.5-flash": "gemini-flash",
119
+ "gemini-3.5-flash-thinking": "gemini-flash-thinking",
120
+ "gemini-3.5-flash-thinking-lite": "gemini-flash-thinking",
121
+ "gemini-3.1-pro": "gemini-pro",
122
+ "gemini-auto": "gemini-flash",
123
+ "gemini-flash-lite": "gemini-flash",
124
+ # Upstream aliases
125
+ "gemini-2.5-pro": "gemini-pro",
126
+ "gemini-2.5-flash": "gemini-flash",
127
+ "gemini-2.5-flash-thinking": "gemini-flash-thinking",
128
+ "gemini-2.5-pro-preview-05-06": "gemini-pro",
129
+ "gemini-2.5-flash-preview-04-17": "gemini-flash",
130
+ "gemini-2.5-flash-preview-05-20": "gemini-flash",
131
+ "gemini-2.0-flash": "gemini-flash",
132
+ "gemini-2.0-flash-thinking": "gemini-flash-thinking",
133
+ "gemini-2.0-flash-lite": "gemini-flash",
134
+ "gemini-1.5-pro": "gemini-pro",
135
+ "gemini-1.5-flash": "gemini-flash",
136
+ }
137
+
138
+ # All model names exposed to clients (public names only for API stability)
139
+ EXPOSED_MODELS = PUBLIC_MODELS
140
+
141
+
142
+ def resolve_model(model_name: str) -> tuple:
143
+ """Resolve any model name to (public_name, internal_name, model_info, error).
144
+
145
+ Resolution chain:
146
+ 1. Legacy alias → public name
147
+ 2. Public name → internal name (via family default)
148
+ 3. Already an internal name → use directly
149
+ """
150
+ # Step 1: resolve aliases
151
+ name = MODEL_ALIASES.get(model_name, model_name)
152
+
153
+ # Step 2: if it's a public name, map to internal
154
+ if name in PUBLIC_MODELS:
155
+ pub = PUBLIC_MODELS[name]
156
+ internal = pub["default"]
157
+ info = GEMINI_MODELS.get(internal)
158
+ if not info:
159
+ return None, None, None, f"Internal model {internal} not found"
160
+ return name, internal, info, None
161
+
162
+ # Step 3: if it's already an internal name
163
+ if name in GEMINI_MODELS:
164
+ info = GEMINI_MODELS[name]
165
+ # Find the public name for this internal model
166
+ pub_name = name
167
+ for pn, pv in PUBLIC_MODELS.items():
168
+ if pv["default"] == name:
169
+ pub_name = pn
170
+ break
171
+ return pub_name, name, info, None
172
+
173
+ return None, None, None, f"Unknown model: {model_name}. Available: {', '.join(PUBLIC_MODELS.keys())}"
174
+
175
+
176
+ def build_model_headers(model_info: dict) -> dict:
177
+ """Build the x-goog-ext headers for model selection."""
178
+ if not model_info:
179
+ return {}
180
+ return {
181
+ MODEL_HEADER_KEY: f'[1,null,null,null,"{model_info["id"]}",null,null,0,[4],null,null,{model_info["capacity"]}]',
182
+ "x-goog-ext-73010989-jspb": "[0]",
183
+ "x-goog-ext-73010990-jspb": "[0]",
184
+ }
185
+
186
+
187
  # ─── Utilities ───────────────────────────────────────────────────────────────
188
 
189
  def log(msg: str):
 
192
  sys.stderr.flush()
193
 
194
 
195
+ def apply_jitter():
196
+ """Random delay to mimic human behavior."""
197
+ delay = random.uniform(CONFIG["jitter_min_ms"], CONFIG["jitter_max_ms"]) / 1000.0
198
+ time.sleep(delay)
199
+
200
+
201
  def load_cookie() -> tuple:
202
  """Load cookie from file. Returns (cookie_str, sapisid)."""
203
  cookie_file = CONFIG.get("cookie_file")
 
228
  return f"SAPISIDHASH {ts}_{h}"
229
 
230
 
231
+ # ─── Chrome-like Request Headers ─────────────────────────────────────────────
232
+
233
+ def build_chrome_headers(method: str = "POST", content_type: str = None) -> OrderedDict:
234
+ """Build Chrome-like request headers in the correct order.
235
+
236
+ Chrome sends headers in a specific order that differs from Python defaults.
237
+ Matching this order helps avoid fingerprint-based detection.
238
+ """
239
+ ver = CONFIG["chrome_version"]
240
+ headers = OrderedDict()
241
+
242
+ # Chrome header order (important for fingerprint matching)
243
+ headers["Host"] = "gemini.google.com"
244
+
245
+ if content_type:
246
+ headers["Content-Type"] = content_type
247
+
248
+ headers["Sec-Ch-Ua"] = f'"Chromium";v="{ver}", "Google Chrome";v="{ver}", "Not-A.Brand";v="99"'
249
+ headers["Sec-Ch-Ua-Mobile"] = "?0"
250
+ headers["Sec-Ch-Ua-Platform"] = '"Windows"'
251
+
252
+ headers["User-Agent"] = (
253
+ f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
254
+ f"AppleWebKit/537.36 (KHTML, like Gecko) "
255
+ f"Chrome/{ver}.0.0.0 Safari/537.36"
256
+ )
257
+
258
+ headers["X-Same-Domain"] = "1"
259
+ headers["Origin"] = "https://gemini.google.com"
260
+ headers["Referer"] = "https://gemini.google.com/app"
261
+
262
+ # Sec-Fetch headers (differ by method)
263
+ if method == "POST":
264
+ headers["Sec-Fetch-Site"] = "same-origin"
265
+ headers["Sec-Fetch-Mode"] = "cors"
266
+ headers["Sec-Fetch-Dest"] = "empty"
267
+ else:
268
+ headers["Sec-Fetch-Site"] = "same-origin"
269
+ headers["Sec-Fetch-Mode"] = "navigate"
270
+ headers["Sec-Fetch-Dest"] = "document"
271
+ headers["Sec-Fetch-User"] = "?1"
272
+
273
+ headers["Accept-Language"] = "en-US,en;q=0.9"
274
+ headers["Accept"] = "*/*"
275
+
276
+ return headers
277
+
278
+
279
+ # ─── HTTP Transport Layer ─────��──────────────────────────────────────────────
280
+
281
+ class GeminiHTTPClient:
282
+ """HTTP client with Chrome TLS fingerprint impersonation.
283
+
284
+ Uses curl_cffi when available for real Chrome TLS fingerprints.
285
+ Falls back to urllib.request (less stealthy but functional).
286
+ """
287
+
288
+ def __init__(self):
289
+ self._session = None
290
+ if HAS_CURL_CFFI:
291
+ target = CONFIG.get("impersonate_target", "chrome124")
292
+ self._session = CurlSession(
293
+ impersonate=target,
294
+ timeout=CONFIG["request_timeout_sec"],
295
+ )
296
+ log(f"HTTP transport: curl_cffi (impersonating {target})")
297
+ else:
298
+ log("HTTP transport: urllib (no TLS fingerprinting - less stealthy)")
299
+
300
+ def post(self, url: str, data: bytes, headers: dict, cookies: dict = None) -> str:
301
+ """POST request with Chrome fingerprint. Returns response text."""
302
+ if self._session:
303
+ return self._post_curl(url, data, headers, cookies)
304
+ else:
305
+ return self._post_urllib(url, data, headers, cookies)
306
+
307
+ def _post_curl(self, url: str, data: bytes, headers: dict, cookies: dict = None) -> str:
308
+ """POST via curl_cffi with Chrome TLS impersonation."""
309
+ # Clear internal cookie jar to prevent cross-domain cookie conflicts
310
+ # (same fix as upstream: google.com / gemini.google.com / accounts.google.com)
311
+ self._session.cookies.clear()
312
+
313
+ proxy = CONFIG.get("proxy")
314
+ proxies = {"http": proxy, "https": proxy} if proxy else None
315
+
316
+ resp = self._session.post(
317
+ url,
318
+ data=data,
319
+ headers=dict(headers), # curl_cffi needs plain dict
320
+ cookies=cookies or {},
321
+ proxies=proxies,
322
+ allow_redirects=True,
323
+ )
324
+
325
+ if resp.status_code != 200:
326
+ raise Exception(f"HTTP {resp.status_code}: {resp.text[:200]}")
327
+
328
+ return resp.text
329
+
330
+ def _post_urllib(self, url: str, data: bytes, headers: dict, cookies: dict = None) -> str:
331
+ """Fallback POST via urllib (no TLS fingerprinting)."""
332
+ # Merge cookies into headers
333
+ all_headers = dict(headers)
334
+ if cookies:
335
+ cookie_str = "; ".join(f"{k}={v}" for k, v in cookies.items())
336
+ existing = all_headers.get("Cookie", "")
337
+ if existing:
338
+ all_headers["Cookie"] = existing + "; " + cookie_str
339
+ else:
340
+ all_headers["Cookie"] = cookie_str
341
+
342
+ req = urllib.request.Request(url, data=data, headers=all_headers, method="POST")
343
+ ctx = ssl.create_default_context()
344
+ proxy = CONFIG.get("proxy")
345
+ if proxy:
346
+ opener = urllib.request.build_opener(
347
+ urllib.request.ProxyHandler({"http": proxy, "https": proxy}),
348
+ urllib.request.HTTPSHandler(context=ctx)
349
+ )
350
+ resp = opener.open(req, timeout=CONFIG["request_timeout_sec"])
351
+ else:
352
+ resp = urllib.request.urlopen(req, context=ctx, timeout=CONFIG["request_timeout_sec"])
353
+
354
+ return resp.read().decode("utf-8", errors="replace")
355
+
356
+ def close(self):
357
+ if self._session:
358
+ try:
359
+ self._session.close()
360
+ except Exception:
361
+ pass
362
+
363
+
364
+ # Global HTTP client (initialized in main)
365
+ _http_client: GeminiHTTPClient = None
366
+
367
+
368
+ def get_http_client() -> GeminiHTTPClient:
369
+ global _http_client
370
+ if _http_client is None:
371
+ _http_client = GeminiHTTPClient()
372
+ return _http_client
373
+
374
+
375
  # ─── Gemini Protocol ─────────────────────────────────────────────────────────
376
 
377
+ def gemini_stream_generate(prompt: str, model_info: dict) -> str:
378
+ """Send prompt to Gemini StreamGenerate with retry.
379
+
380
+ Uses the x-goog-ext-525001261-jspb header for model selection
381
+ (upstream approach) instead of the old integer mode category.
382
+ """
383
+ # Build the inner payload array
384
+ # The payload structure is from Gemini's batchexecute protocol
385
  inner = [None] * 80
386
  inner[0] = [prompt, 0, None, None, None, None, 0]
387
  inner[1] = ["en"]
 
390
  inner[7] = 1
391
  inner[10] = 1
392
  inner[11] = 0
393
+ # Think mode: 0 = thinking enabled, 4 = thinking disabled
394
+ # For thinking models, we enable thinking; for others, disable
395
+ inner[17] = [[0]] # Default: thinking enabled
396
  inner[18] = 0
397
  inner[27] = 1
398
  inner[30] = [4]
 
401
  inner[59] = str(uuid.uuid4())
402
  inner[61] = []
403
  inner[68] = 1
404
+ # Model is now set via HTTP header, not payload slot 79
405
+ # inner[79] is left as None
406
 
407
  outer = [None, json.dumps(inner)]
408
  body = urllib.parse.urlencode({"f.req": json.dumps(outer)}).encode()
409
+ reqid = random.randint(10000, 99999)
410
  url = (
411
  "https://gemini.google.com/_/BardChatUi/data/"
412
  "assistant.lamda.BardFrontendService/StreamGenerate"
413
  f"?bl={CONFIG['gemini_bl']}&hl=en&_reqid={reqid}&rt=c"
414
  )
 
 
 
 
 
 
 
415
 
416
+ # Build Chrome-like headers
417
+ headers = build_chrome_headers(
418
+ method="POST",
419
+ content_type="application/x-www-form-urlencoded",
420
+ )
421
+
422
+ # Add model selection headers
423
+ model_headers = build_model_headers(model_info)
424
+ headers.update(model_headers)
425
+
426
+ # Load and apply cookie
427
  cookie_str, sapisid = load_cookie()
428
+ cookies = {}
429
  if cookie_str:
430
+ # Parse cookie string into dict for curl_cffi
431
+ for pair in cookie_str.split("; "):
432
+ if "=" in pair:
433
+ k, v = pair.split("=", 1)
434
+ cookies[k.strip()] = v.strip()
435
+ # Also set as header for urllib fallback
436
  headers["Cookie"] = cookie_str
437
  if sapisid:
438
  headers["Authorization"] = make_sapisidhash(sapisid)
439
 
440
+ client = get_http_client()
441
  last_err = None
442
  for attempt in range(CONFIG["retry_attempts"]):
443
  try:
444
+ # Apply request jitter to mimic human behavior
445
+ if attempt > 0:
446
+ time.sleep(CONFIG["retry_delay_sec"])
447
+ apply_jitter()
448
+
449
+ return client.post(url, data=body, headers=headers, cookies=cookies)
 
 
 
 
 
 
450
  except Exception as e:
451
  last_err = e
452
  if attempt < CONFIG["retry_attempts"] - 1:
453
  log(f"Retry {attempt+1}/{CONFIG['retry_attempts']}: {e}")
 
454
  raise last_err
455
 
456
 
457
  def clean_gemini_text(text: str) -> str:
458
+ """Remove internal code execution artifacts and image placeholders."""
459
+ # Remove code execution artifacts
460
  text = re.sub(
461
  r'```(?:python|javascript|text)\?code_(?:reference|stdout)&code_event_index=\d+\n.*?```\n?',
462
  '', text, flags=re.DOTALL
463
  )
464
+ # Remove googleusercontent placeholder URLs (image gen/retrieval/collection)
465
+ text = re.sub(
466
+ r'https?://googleusercontent\.com/(?:image_generation_content|image_retrieval|image_collection)[/\w]*\d*',
467
+ '', text
468
+ )
469
  return text.strip()
470
 
471
 
472
+ def _scan_complete_wrb_frames(buf: str) -> list:
473
+ """Extract complete wrb.fr frames using bracket-depth scanning.
474
+
475
+ This is the upstream's improved parser that correctly handles
476
+ partial chunks and escape sequences, replacing the old line-by-line approach.
477
+ """
478
+ frames = []
479
+ i = 0
480
+ n = len(buf)
481
+ while i < n:
482
+ start = buf.find('["wrb.fr"', i)
483
+ if start == -1:
484
+ break
485
+ # Bracket-depth scan to find matching close bracket
486
+ depth = 0
487
+ in_str = False
488
+ esc = False
489
+ end = -1
490
+ j = start
491
+ while j < n:
492
+ c = buf[j]
493
+ if in_str:
494
+ if esc:
495
+ esc = False
496
+ elif c == '\\':
497
+ esc = True
498
+ elif c == '"':
499
+ in_str = False
500
+ else:
501
+ if c == '"':
502
+ in_str = True
503
+ elif c == '[':
504
+ depth += 1
505
+ elif c == ']':
506
+ depth -= 1
507
+ if depth == 0:
508
+ end = j
509
+ break
510
+ j += 1
511
+ if end == -1:
512
+ break # Incomplete frame
513
+ elem_str = buf[start:end + 1]
514
+ try:
515
+ elem = json.loads(elem_str)
516
+ frames.append(elem)
517
+ except (json.JSONDecodeError, ValueError):
518
+ pass
519
+ i = end + 1
520
+ return frames
521
+
522
+
523
  def extract_response_text(raw: str) -> str:
524
+ """Parse StreamGenerate response to extract final text.
525
+
526
+ Uses the upstream's bracket-depth frame scanner for robustness.
527
+ """
528
+ frames = _scan_complete_wrb_frames(raw)
529
+
530
  texts = []
531
+ for elem in frames:
 
 
532
  try:
533
+ if not isinstance(elem, list) or len(elem) < 3 or elem[0] != "wrb.fr":
534
+ continue
535
+ rp = elem[2]
536
+ if not isinstance(rp, str) or len(rp) < 50:
537
  continue
538
+ payload = json.loads(rp)
539
+ if isinstance(payload, list) and len(payload) > 4 and payload[4]:
540
+ for part in payload[4]:
541
  if isinstance(part, list) and len(part) > 1 and part[1]:
542
  if isinstance(part[1], list):
543
  for t in part[1]:
 
545
  texts.append(t)
546
  except (json.JSONDecodeError, IndexError, TypeError):
547
  pass
548
+
549
+ # Take the last non-empty text (final/most complete response)
550
  text = ""
551
  for t in reversed(texts):
552
  if t.strip():
 
594
  fn = tc.get("function", {})
595
  tc_strs.append(
596
  f'```tool_call\n{{"name": "{fn.get("name")}", '
597
+ f'"arguments": {fn.get("arguments", "{}}")}}}\n```'
598
  )
599
  parts.append(f"[Assistant]: {content or ''}\n" + "\n".join(tc_strs))
600
  else:
 
676
  self.send_json({"object": "list", "data": [
677
  {"id": n, "object": "model", "created": 1700000000,
678
  "owned_by": "google", "description": c["desc"]}
679
+ for n, c in EXPOSED_MODELS.items()
680
  ]})
681
  elif self.path == "/":
682
+ self.send_json({
683
+ "status": "ok",
684
+ "version": __version__,
685
+ "transport": "curl_cffi" if HAS_CURL_CFFI else "urllib",
686
+ "models": list(EXPOSED_MODELS.keys()),
687
+ "aliases": list(MODEL_ALIASES.keys()),
688
+ })
689
  else:
690
  self.send_json({"error": "not found"}, 404)
691
  except (BrokenPipeError, ConnectionResetError):
 
715
  pass
716
 
717
  def _resolve_model(self, model_name):
718
+ pub_name, internal_name, model_info, err = resolve_model(model_name)
719
+ if err:
720
+ return None, None, err
721
+ return pub_name, model_info, None
722
+
723
+ def _call_gemini(self, prompt, model_info, tools):
724
+ raw = gemini_stream_generate(prompt, model_info)
 
 
 
 
725
  text = extract_response_text(raw)
726
  tool_calls = None
727
  if tools and text:
 
730
 
731
  def handle_chat(self, body: bytes):
732
  req = json.loads(body)
733
+ model_name, model_info, err = self._resolve_model(
734
  req.get("model", CONFIG["default_model"]))
735
  if err:
736
  self.send_json({"error": {"message": err}}, 400)
 
743
  return
744
 
745
  try:
746
+ text, tool_calls = self._call_gemini(prompt, model_info, tools)
747
  except Exception as e:
748
  self.send_json({"error": {"message": f"upstream error: {e}"}}, 502)
749
  return
 
777
  def handle_responses(self, body: bytes):
778
  """OpenAI Responses API for Codex CLI compatibility."""
779
  req = json.loads(body)
780
+ model_name, model_info, err = self._resolve_model(
781
  req.get("model", CONFIG["default_model"]))
782
  if err:
783
  self.send_json({"error": {"message": err}}, 400)
 
832
  return
833
 
834
  try:
835
+ text, tool_calls = self._call_gemini(prompt, model_info, tools)
836
  except Exception as e:
837
  self.send_json({"error": {"message": f"upstream error: {e}"}}, 502)
838
  return
 
907
  if args.proxy:
908
  CONFIG["proxy"] = args.proxy
909
 
910
+ # Initialize HTTP client
911
+ get_http_client()
912
+
913
  class ThreadedServer(ThreadingMixIn, HTTPServer):
914
  daemon_threads = True
915
  allow_reuse_address = True
 
919
  print(f"gemini-web2api v{__version__}")
920
  print(f" Listening: http://0.0.0.0:{port}")
921
  print(f" Base URL: http://localhost:{port}/v1")
922
+ print(f" Transport: {'curl_cffi (Chrome TLS fingerprint)' if HAS_CURL_CFFI else 'urllib (no fingerprint - install curl_cffi for stealth)'}")
923
+ print(f" Models: {', '.join(EXPOSED_MODELS.keys())}")
924
+ print(f" Aliases: {len(MODEL_ALIASES)} legacy names supported")
925
  print(f" API Key: {'configured (set via API_KEY env)' if CONFIG.get('api_key') else 'none (open access)'}")
926
  print(f" Cookie: {'yes (' + CONFIG['cookie_file'] + ')' if CONFIG.get('cookie_file') else 'none (anonymous)'}")
927
  print(f" Proxy: {CONFIG.get('proxy') or 'none (uses system env HTTP_PROXY/HTTPS_PROXY)'}")
928
  print(f" Retry: {CONFIG['retry_attempts']}x / {CONFIG['retry_delay_sec']}s")
929
+ print(f" Jitter: {CONFIG['jitter_min_ms']}-{CONFIG['jitter_max_ms']}ms")
930
  print()
931
  try:
932
  server.serve_forever()
933
  except KeyboardInterrupt:
934
  print("\nStopped.")
935
+ get_http_client().close()
936
  server.shutdown()
937
 
938
 
requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ curl_cffi==0.7.4