pouluo commited on
Commit
7e9c0ff
Β·
verified Β·
1 Parent(s): 3f12c57

Create gemini_web2api.py

Browse files
Files changed (1) hide show
  1. gemini_web2api.py +573 -0
gemini_web2api.py ADDED
@@ -0,0 +1,573 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
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]
10
+
11
+ Client configuration (Cherry Studio, ChatBox, etc.):
12
+ Base URL: http://localhost:8081/v1
13
+ API Key: (anything or empty)
14
+ """
15
+ import json
16
+ import urllib.request
17
+ import urllib.parse
18
+ import time
19
+ import ssl
20
+ 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
+
33
+ DEFAULT_CONFIG = {
34
+ "port": 8081,
35
+ "host": "0.0.0.0",
36
+ "retry_attempts": 3,
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
+ }
45
+
46
+ CONFIG = dict(DEFAULT_CONFIG)
47
+
48
+ # ─── Models ──────────────────────────────────────────────────────────────────
49
+ # Mapping from JS source: MODE_CATEGORY enum (028-6eb337387583.js)
50
+ # 1=FAST, 2=THINKING, 3=PRO, 4=AUTO, 5=FAST_DYNAMIC_THINKING, 6=FLASH_LITE
51
+
52
+ MODELS = {
53
+ "gemini-3.5-flash": {
54
+ "mode": 1, "think": 4,
55
+ "desc": "Fast general-purpose model",
56
+ },
57
+ "gemini-3.5-flash-thinking": {
58
+ "mode": 2, "think": 0,
59
+ "desc": "Deep thinking mode, longest output (~20k chars)",
60
+ },
61
+ "gemini-3.1-pro": {
62
+ "mode": 3, "think": 4,
63
+ "desc": "Pro model (requires cookie for real routing)",
64
+ },
65
+ "gemini-auto": {
66
+ "mode": 4, "think": 4,
67
+ "desc": "Auto model selection",
68
+ },
69
+ "gemini-3.5-flash-thinking-lite": {
70
+ "mode": 5, "think": 0,
71
+ "desc": "Dynamic thinking with adaptive depth",
72
+ },
73
+ "gemini-flash-lite": {
74
+ "mode": 6, "think": 4,
75
+ "desc": "Lightweight fast model",
76
+ },
77
+ }
78
+
79
+ # ─── Utilities ───────────────────────────────────────────────────────────────
80
+
81
+ def log(msg: str):
82
+ if CONFIG["log_requests"]:
83
+ sys.stderr.write(f"[{time.strftime('%H:%M:%S')}] {msg}\n")
84
+ sys.stderr.flush()
85
+
86
+
87
+ def load_cookie() -> tuple:
88
+ """Load cookie from file. Returns (cookie_str, sapisid)."""
89
+ cookie_file = CONFIG.get("cookie_file")
90
+ if not cookie_file:
91
+ return "", None
92
+ if not os.path.exists(cookie_file):
93
+ return "", None
94
+ try:
95
+ with open(cookie_file, "r") as f:
96
+ content = f.read().strip()
97
+ if content.startswith("{"):
98
+ data = json.loads(content)
99
+ cookie_str = data.get("cookie", "")
100
+ sapisid = data.get("sapisid", "")
101
+ else:
102
+ cookie_str = content
103
+ pairs = dict(p.split("=", 1) for p in cookie_str.split("; ") if "=" in p)
104
+ sapisid = pairs.get("SAPISID", "")
105
+ return cookie_str, sapisid if sapisid else None
106
+ except Exception as e:
107
+ log(f"Cookie load error: {e}")
108
+ return "", None
109
+
110
+
111
+ def make_sapisidhash(sapisid: str) -> str:
112
+ ts = int(time.time())
113
+ h = hashlib.sha1(f"{ts} {sapisid} https://gemini.google.com".encode()).hexdigest()
114
+ return f"SAPISIDHASH {ts}_{h}"
115
+
116
+
117
+ # ─── Gemini Protocol ─────────────────────────────────────────────────────────
118
+
119
+ def gemini_stream_generate(prompt: str, model_id: int, think_mode: int) -> str:
120
+ """Send prompt to Gemini StreamGenerate with retry."""
121
+ inner = [None] * 80
122
+ inner[0] = [prompt, 0, None, None, None, None, 0]
123
+ inner[1] = ["en"]
124
+ inner[2] = ["", "", "", None, None, None, None, None, None, ""]
125
+ inner[6] = [0]
126
+ inner[7] = 1
127
+ inner[10] = 1
128
+ inner[11] = 0
129
+ inner[17] = [[think_mode]]
130
+ inner[18] = 0
131
+ inner[27] = 1
132
+ inner[30] = [4]
133
+ inner[41] = [2]
134
+ inner[53] = 0
135
+ inner[59] = str(uuid.uuid4())
136
+ inner[61] = []
137
+ inner[68] = 1
138
+ inner[79] = model_id
139
+
140
+ outer = [None, json.dumps(inner)]
141
+ body = urllib.parse.urlencode({"f.req": json.dumps(outer)}).encode()
142
+ reqid = int(time.time()) % 1000000
143
+ url = (
144
+ "https://gemini.google.com/_/BardChatUi/data/"
145
+ "assistant.lamda.BardFrontendService/StreamGenerate"
146
+ f"?bl={CONFIG['gemini_bl']}&hl=en&_reqid={reqid}&rt=c"
147
+ )
148
+ headers = {
149
+ "Content-Type": "application/x-www-form-urlencoded",
150
+ "Origin": "https://gemini.google.com",
151
+ "Referer": "https://gemini.google.com/app",
152
+ "X-Same-Domain": "1",
153
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
154
+ }
155
+
156
+ cookie_str, sapisid = load_cookie()
157
+ if cookie_str:
158
+ headers["Cookie"] = cookie_str
159
+ if sapisid:
160
+ headers["Authorization"] = make_sapisidhash(sapisid)
161
+
162
+ last_err = None
163
+ for attempt in range(CONFIG["retry_attempts"]):
164
+ try:
165
+ req = urllib.request.Request(url, data=body, headers=headers, method="POST")
166
+ ctx = ssl.create_default_context()
167
+ proxy = CONFIG.get("proxy")
168
+ if proxy:
169
+ opener = urllib.request.build_opener(
170
+ urllib.request.ProxyHandler({"http": proxy, "https": proxy}),
171
+ urllib.request.HTTPSHandler(context=ctx)
172
+ )
173
+ resp = opener.open(req, timeout=CONFIG["request_timeout_sec"])
174
+ else:
175
+ resp = urllib.request.urlopen(req, context=ctx, timeout=CONFIG["request_timeout_sec"])
176
+ return resp.read().decode("utf-8", errors="replace")
177
+ except Exception as e:
178
+ last_err = e
179
+ if attempt < CONFIG["retry_attempts"] - 1:
180
+ log(f"Retry {attempt+1}/{CONFIG['retry_attempts']}: {e}")
181
+ time.sleep(CONFIG["retry_delay_sec"])
182
+ raise last_err
183
+
184
+
185
+ def clean_gemini_text(text: str) -> str:
186
+ """Remove internal code execution artifacts."""
187
+ text = re.sub(
188
+ r'```(?:python|javascript|text)\?code_(?:reference|stdout)&code_event_index=\d+\n.*?```\n?',
189
+ '', text, flags=re.DOTALL
190
+ )
191
+ return text.strip()
192
+
193
+
194
+ def extract_response_text(raw: str) -> str:
195
+ """Parse StreamGenerate response to extract final text."""
196
+ texts = []
197
+ for line in raw.split("\n"):
198
+ if '"wrb.fr"' not in line or len(line) < 200:
199
+ continue
200
+ try:
201
+ arr = json.loads(line)
202
+ inner_str = arr[0][2]
203
+ if not inner_str or len(inner_str) < 50:
204
+ continue
205
+ inner = json.loads(inner_str)
206
+ if isinstance(inner, list) and len(inner) > 4 and inner[4]:
207
+ for part in inner[4]:
208
+ if isinstance(part, list) and len(part) > 1 and part[1]:
209
+ if isinstance(part[1], list):
210
+ for t in part[1]:
211
+ if isinstance(t, str) and len(t) > 0:
212
+ texts.append(t)
213
+ except (json.JSONDecodeError, IndexError, TypeError):
214
+ pass
215
+ text = ""
216
+ for t in reversed(texts):
217
+ if t.strip():
218
+ text = t
219
+ break
220
+ return clean_gemini_text(text)
221
+
222
+
223
+ # ─── OpenAI Format Helpers ───────────────────────────────────────────────────
224
+
225
+ def messages_to_prompt(messages: list, tools: list = None) -> str:
226
+ """Convert OpenAI messages to prompt string."""
227
+ parts = []
228
+ if tools:
229
+ tool_defs = []
230
+ for tool in tools:
231
+ fn = tool.get("function", tool) if tool.get("type") == "function" else tool
232
+ tool_defs.append({
233
+ "name": fn.get("name", tool.get("name", "")),
234
+ "description": fn.get("description", tool.get("description", "")),
235
+ "parameters": fn.get("parameters", tool.get("parameters", {})),
236
+ })
237
+ if tool_defs:
238
+ parts.append(
239
+ "[System instruction]: You have access to tools. "
240
+ "To call a tool, respond with:\n"
241
+ '```tool_call\n{"name": "func_name", "arguments": {...}}\n```\n'
242
+ "Only use tool_call blocks when needed.\n\n"
243
+ f"Available tools:\n{json.dumps(tool_defs, indent=2)}"
244
+ )
245
+ for msg in messages:
246
+ role = msg.get("role", "user")
247
+ content = msg.get("content", "")
248
+ if isinstance(content, list):
249
+ content = " ".join(
250
+ c.get("text", "") for c in content
251
+ if c.get("type") in ("text", "input_text")
252
+ )
253
+ if role == "system":
254
+ parts.append(f"[System instruction]: {content}")
255
+ elif role == "assistant":
256
+ if msg.get("tool_calls"):
257
+ tc_strs = []
258
+ for tc in msg["tool_calls"]:
259
+ fn = tc.get("function", {})
260
+ tc_strs.append(
261
+ f'```tool_call\n{{"name": "{fn.get("name")}", '
262
+ f'"arguments": {fn.get("arguments", "{}")}}}\n```'
263
+ )
264
+ parts.append(f"[Assistant]: {content or ''}\n" + "\n".join(tc_strs))
265
+ else:
266
+ parts.append(f"[Assistant]: {content}")
267
+ elif role == "tool":
268
+ parts.append(f"[Tool result for {msg.get('name', '')}]: {content}")
269
+ else:
270
+ parts.append(content if content else "")
271
+ return "\n\n".join(p for p in parts if p)
272
+
273
+
274
+ def parse_tool_calls(text: str) -> tuple:
275
+ """Extract tool_call blocks. Returns (clean_text, tool_calls_list)."""
276
+ tool_calls = []
277
+ pattern = r'```tool_call\s*\n(.*?)\n```'
278
+ for match in re.findall(pattern, text, re.DOTALL):
279
+ try:
280
+ data = json.loads(match.strip())
281
+ tool_calls.append({
282
+ "id": f"call_{uuid.uuid4().hex[:8]}",
283
+ "type": "function",
284
+ "function": {
285
+ "name": data["name"],
286
+ "arguments": json.dumps(data.get("arguments", {}), ensure_ascii=False),
287
+ },
288
+ })
289
+ except (json.JSONDecodeError, KeyError):
290
+ pass
291
+ clean = re.sub(pattern, '', text, flags=re.DOTALL).strip()
292
+ return clean, tool_calls
293
+
294
+
295
+ # ─── HTTP Handler ────────────────────────────────────────────────────────────
296
+
297
+ class GeminiHandler(BaseHTTPRequestHandler):
298
+ def log_message(self, fmt, *args):
299
+ log(fmt % args)
300
+
301
+ def send_json(self, data, status=200):
302
+ body = json.dumps(data, ensure_ascii=False).encode()
303
+ self.send_response(status)
304
+ self.send_header("Content-Type", "application/json")
305
+ self.send_header("Access-Control-Allow-Origin", "*")
306
+ self.send_header("Content-Length", str(len(body)))
307
+ self.end_headers()
308
+ self.wfile.write(body)
309
+
310
+ def do_OPTIONS(self):
311
+ self.send_response(204)
312
+ self.send_header("Access-Control-Allow-Origin", "*")
313
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
314
+ self.send_header("Access-Control-Allow-Headers", "*")
315
+ self.end_headers()
316
+
317
+ def do_GET(self):
318
+ try:
319
+ if self.path == "/v1/models":
320
+ self.send_json({"object": "list", "data": [
321
+ {"id": n, "object": "model", "created": 1700000000,
322
+ "owned_by": "google", "description": c["desc"]}
323
+ for n, c in MODELS.items()
324
+ ]})
325
+ elif self.path == "/":
326
+ self.send_json({"status": "ok", "version": __version__,
327
+ "models": list(MODELS.keys())})
328
+ else:
329
+ self.send_json({"error": "not found"}, 404)
330
+ except (BrokenPipeError, ConnectionResetError):
331
+ pass
332
+ except Exception as e:
333
+ log(f"GET error: {e}")
334
+
335
+ def do_POST(self):
336
+ try:
337
+ length = int(self.headers.get("Content-Length", 0))
338
+ body = self.rfile.read(length) if length else b""
339
+ if self.path == "/v1/chat/completions":
340
+ self.handle_chat(body)
341
+ elif self.path == "/v1/responses":
342
+ self.handle_responses(body)
343
+ else:
344
+ self.send_json({"error": "not found"}, 404)
345
+ except (BrokenPipeError, ConnectionResetError):
346
+ pass
347
+ except Exception as e:
348
+ log(f"POST error: {e}")
349
+ try:
350
+ self.send_json({"error": {"message": str(e)}}, 500)
351
+ except:
352
+ pass
353
+
354
+ def _resolve_model(self, model_name):
355
+ think_override = None
356
+ if "@think=" in model_name:
357
+ model_name, think_str = model_name.rsplit("@think=", 1)
358
+ think_override = int(think_str)
359
+ cfg = MODELS.get(model_name)
360
+ if not cfg:
361
+ return None, None, None, f"Unknown model: {model_name}"
362
+ return model_name, cfg["mode"], (think_override if think_override is not None else cfg["think"]), None
363
+
364
+ def _call_gemini(self, prompt, model_id, think_mode, tools):
365
+ raw = gemini_stream_generate(prompt, model_id, think_mode)
366
+ text = extract_response_text(raw)
367
+ tool_calls = None
368
+ if tools and text:
369
+ text, tool_calls = parse_tool_calls(text)
370
+ return text or "", tool_calls
371
+
372
+ def handle_chat(self, body: bytes):
373
+ req = json.loads(body)
374
+ model_name, model_id, think_mode, err = self._resolve_model(
375
+ req.get("model", CONFIG["default_model"]))
376
+ if err:
377
+ self.send_json({"error": {"message": err}}, 400)
378
+ return
379
+
380
+ tools = req.get("tools")
381
+ prompt = messages_to_prompt(req.get("messages", []), tools)
382
+ if not prompt.strip():
383
+ self.send_json({"error": {"message": "empty prompt"}}, 400)
384
+ return
385
+
386
+ try:
387
+ text, tool_calls = self._call_gemini(prompt, model_id, think_mode, tools)
388
+ except Exception as e:
389
+ self.send_json({"error": {"message": f"upstream error: {e}"}}, 502)
390
+ return
391
+
392
+ cid = f"chatcmpl-{uuid.uuid4().hex[:12]}"
393
+ msg = {"role": "assistant", "content": text or None}
394
+ if tool_calls:
395
+ msg["tool_calls"] = tool_calls
396
+ finish = "tool_calls" if tool_calls else "stop"
397
+
398
+ if req.get("stream"):
399
+ self.send_response(200)
400
+ self.send_header("Content-Type", "text/event-stream")
401
+ self.send_header("Cache-Control", "no-cache")
402
+ self.send_header("Access-Control-Allow-Origin", "*")
403
+ self.end_headers()
404
+ chunk = {"id": cid, "object": "chat.completion.chunk", "created": int(time.time()),
405
+ "model": model_name, "choices": [{"index": 0, "delta": msg, "finish_reason": finish}]}
406
+ self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode())
407
+ self.wfile.write(b"data: [DONE]\n\n")
408
+ self.wfile.flush()
409
+ else:
410
+ self.send_json({
411
+ "id": cid, "object": "chat.completion", "created": int(time.time()),
412
+ "model": model_name,
413
+ "choices": [{"index": 0, "message": msg, "finish_reason": finish}],
414
+ "usage": {"prompt_tokens": len(prompt)//4, "completion_tokens": len(text)//4,
415
+ "total_tokens": (len(prompt)+len(text))//4},
416
+ })
417
+
418
+ def handle_responses(self, body: bytes):
419
+ """OpenAI Responses API for Codex CLI compatibility."""
420
+ req = json.loads(body)
421
+ model_name, model_id, think_mode, err = self._resolve_model(
422
+ req.get("model", CONFIG["default_model"]))
423
+ if err:
424
+ self.send_json({"error": {"message": err}}, 400)
425
+ return
426
+
427
+ input_items = req.get("input", [])
428
+ tools = req.get("tools")
429
+
430
+ messages = []
431
+ if req.get("instructions"):
432
+ messages.append({"role": "system", "content": req["instructions"]})
433
+ if isinstance(input_items, str):
434
+ messages.append({"role": "user", "content": input_items})
435
+ elif isinstance(input_items, list):
436
+ for item in input_items:
437
+ if isinstance(item, str):
438
+ messages.append({"role": "user", "content": item})
439
+ elif isinstance(item, dict):
440
+ if item.get("type") == "function_call_output":
441
+ messages.append({"role": "tool", "tool_call_id": item.get("call_id", ""),
442
+ "name": item.get("name", ""), "content": item.get("output", "")})
443
+ elif item.get("role") == "assistant" or (item.get("type") == "message" and item.get("role") == "assistant"):
444
+ cp = item.get("content", [])
445
+ text_acc, tc_list = "", []
446
+ if isinstance(cp, list):
447
+ for c in cp:
448
+ if isinstance(c, dict):
449
+ if c.get("type") == "output_text": text_acc += c.get("text", "")
450
+ elif c.get("type") == "function_call": tc_list.append(c)
451
+ elif isinstance(cp, str):
452
+ text_acc = cp
453
+ m = {"role": "assistant", "content": text_acc or None}
454
+ if tc_list:
455
+ m["tool_calls"] = [{"id": tc.get("call_id", f"call_{i}"), "type": "function",
456
+ "function": {"name": tc.get("name",""), "arguments": tc.get("arguments","{}")}}
457
+ for i, tc in enumerate(tc_list)]
458
+ messages.append(m)
459
+ else:
460
+ role = item.get("role", "user")
461
+ content = item.get("content", "")
462
+ if isinstance(content, list):
463
+ content = " ".join(c.get("text", "") for c in content if c.get("type") in ("text", "input_text"))
464
+ messages.append({"role": role, "content": content})
465
+
466
+ if tools:
467
+ tools = [{"type": "function", "function": {"name": t["name"], "description": t.get("description", ""), "parameters": t.get("parameters", {})}}
468
+ if t.get("type") == "function" and "function" not in t else t for t in tools]
469
+
470
+ prompt = messages_to_prompt(messages, tools)
471
+ if not prompt.strip():
472
+ self.send_json({"error": {"message": "empty input"}}, 400)
473
+ return
474
+
475
+ try:
476
+ text, tool_calls = self._call_gemini(prompt, model_id, think_mode, tools)
477
+ except Exception as e:
478
+ self.send_json({"error": {"message": f"upstream error: {e}"}}, 502)
479
+ return
480
+
481
+ rid = f"resp_{uuid.uuid4().hex[:16]}"
482
+ mid = f"msg_{uuid.uuid4().hex[:12]}"
483
+ output = []
484
+ if tool_calls:
485
+ for tc in tool_calls:
486
+ output.append({"type": "function_call", "id": tc["id"], "call_id": tc["id"],
487
+ "name": tc["function"]["name"], "arguments": tc["function"]["arguments"], "status": "completed"})
488
+ if text or not tool_calls:
489
+ output.append({"type": "message", "id": mid, "role": "assistant", "status": "completed",
490
+ "content": [{"type": "output_text", "text": text or "", "annotations": []}]})
491
+
492
+ if req.get("stream"):
493
+ self.send_response(200)
494
+ self.send_header("Content-Type", "text/event-stream")
495
+ self.send_header("Cache-Control", "no-cache")
496
+ self.send_header("Access-Control-Allow-Origin", "*")
497
+ self.end_headers()
498
+ ev = {"type": "response.created", "response": {"id": rid, "object": "response", "status": "in_progress", "model": model_name, "output": []}}
499
+ self.wfile.write(f"event: response.created\ndata: {json.dumps(ev)}\n\n".encode())
500
+ for item in output:
501
+ if item["type"] == "function_call":
502
+ ev = {"type": "response.function_call_arguments.done", "item_id": item["id"], "call_id": item["call_id"], "name": item["name"], "arguments": item["arguments"]}
503
+ self.wfile.write(f"event: response.function_call_arguments.done\ndata: {json.dumps(ev)}\n\n".encode())
504
+ elif item["type"] == "message":
505
+ for ci, cp in enumerate(item["content"]):
506
+ ev = {"type": "response.output_text.done", "item_id": item["id"], "content_index": ci, "text": cp["text"]}
507
+ self.wfile.write(f"event: response.output_text.done\ndata: {json.dumps(ev)}\n\n".encode())
508
+ resp_obj = {"id": rid, "object": "response", "status": "completed", "model": model_name, "output": output,
509
+ "usage": {"input_tokens": len(prompt)//4, "output_tokens": len(text)//4, "total_tokens": (len(prompt)+len(text))//4}}
510
+ self.wfile.write(f"event: response.completed\ndata: {json.dumps({'type': 'response.completed', 'response': resp_obj})}\n\n".encode())
511
+ self.wfile.flush()
512
+ else:
513
+ self.send_json({"id": rid, "object": "response", "created_at": int(time.time()), "status": "completed",
514
+ "model": model_name, "output": output,
515
+ "usage": {"input_tokens": len(prompt)//4, "output_tokens": len(text)//4, "total_tokens": (len(prompt)+len(text))//4}})
516
+
517
+
518
+ # ─── Main ────────────────────────────────────────────────────────────────────
519
+
520
+ def load_config(path: str):
521
+ if path and os.path.exists(path):
522
+ with open(path) as f:
523
+ CONFIG.update(json.load(f))
524
+ log(f"Config loaded: {path}")
525
+
526
+
527
+ def main():
528
+ parser = argparse.ArgumentParser(description="Gemini Web to OpenAI API")
529
+ parser.add_argument("--port", type=int, default=None)
530
+ parser.add_argument("--config", type=str, default=None)
531
+ parser.add_argument("--cookie-file", type=str, default=None, help="Path to cookie file")
532
+ parser.add_argument("--proxy", type=str, default=None, help="HTTP proxy, e.g. http://127.0.0.1:7890")
533
+ parser.add_argument("--version", action="version", version=f"gemini-web2api {__version__}")
534
+ args = parser.parse_args()
535
+
536
+ config_path = args.config or os.environ.get("GEMINI_WEB2API_CONFIG")
537
+ if not config_path:
538
+ for p in ["./config.json", os.path.expanduser("~/.config/gemini-web2api/config.json")]:
539
+ if os.path.exists(p):
540
+ config_path = p
541
+ break
542
+ load_config(config_path)
543
+
544
+ if args.port:
545
+ CONFIG["port"] = args.port
546
+ if args.cookie_file:
547
+ CONFIG["cookie_file"] = args.cookie_file
548
+ if args.proxy:
549
+ CONFIG["proxy"] = args.proxy
550
+
551
+ class ThreadedServer(ThreadingMixIn, HTTPServer):
552
+ daemon_threads = True
553
+ allow_reuse_address = True
554
+
555
+ port = CONFIG["port"]
556
+ server = ThreadedServer((CONFIG["host"], port), GeminiHandler)
557
+ print(f"gemini-web2api v{__version__}")
558
+ print(f" Listening: http://0.0.0.0:{port}")
559
+ print(f" Base URL: http://localhost:{port}/v1")
560
+ print(f" Models: {', '.join(MODELS.keys())}")
561
+ print(f" Cookie: {'yes (' + CONFIG['cookie_file'] + ')' if CONFIG.get('cookie_file') else 'none (anonymous)'}")
562
+ print(f" Proxy: {CONFIG.get('proxy') or 'none (uses system env HTTP_PROXY/HTTPS_PROXY)'}")
563
+ print(f" Retry: {CONFIG['retry_attempts']}x / {CONFIG['retry_delay_sec']}s")
564
+ print()
565
+ try:
566
+ server.serve_forever()
567
+ except KeyboardInterrupt:
568
+ print("\nStopped.")
569
+ server.shutdown()
570
+
571
+
572
+ if __name__ == "__main__":
573
+ main()