Looknicemm commited on
Commit
eebe980
Β·
verified Β·
1 Parent(s): 8f6a964

Upload folder using huggingface_hub

Browse files
gemini_web2api/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """gemini-web2api: Gemini Web to OpenAI API proxy."""
2
+ __version__ = "1.1.0"
gemini_web2api/__main__.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Entry point: python -m gemini_web2api"""
2
+ import argparse
3
+ import os
4
+
5
+ from .config import CONFIG, load_config, find_config
6
+ from .models import MODELS
7
+ from .gemini import HAS_HTTPX
8
+ from .server import GeminiHandler, ThreadedServer
9
+ from . import __version__
10
+
11
+
12
+ def main():
13
+ parser = argparse.ArgumentParser(description="Gemini Web to OpenAI API")
14
+ parser.add_argument("--port", type=int, default=None)
15
+ parser.add_argument("--config", type=str, default=None)
16
+ parser.add_argument("--cookie-file", type=str, default=None)
17
+ parser.add_argument("--proxy", type=str, default=None, help="HTTP proxy, e.g. http://127.0.0.1:7890")
18
+ parser.add_argument("--version", action="version", version=f"gemini-web2api {__version__}")
19
+ args = parser.parse_args()
20
+
21
+ config_path = args.config or os.environ.get("GEMINI_WEB2API_CONFIG") or find_config()
22
+ if config_path:
23
+ load_config(config_path)
24
+
25
+ if args.port:
26
+ CONFIG["port"] = args.port
27
+ if args.cookie_file:
28
+ CONFIG["cookie_file"] = args.cookie_file
29
+ if args.proxy:
30
+ CONFIG["proxy"] = args.proxy
31
+
32
+ port = CONFIG["port"]
33
+ server = ThreadedServer((CONFIG["host"], port), GeminiHandler)
34
+ print(f"gemini-web2api v{__version__}")
35
+ print(f" Listening: http://0.0.0.0:{port}")
36
+ print(f" Base URL: http://localhost:{port}/v1")
37
+ print(f" Models: {', '.join(MODELS.keys())}")
38
+ print(f" Cookie: {'yes' if CONFIG.get('cookie_file') else 'none (anonymous)'}")
39
+ print(f" Proxy: {CONFIG.get('proxy') or 'system env'}")
40
+ print(f" Streaming: {'httpx (true streaming)' if HAS_HTTPX else 'urllib (buffered)'}")
41
+ print()
42
+ try:
43
+ server.serve_forever()
44
+ except KeyboardInterrupt:
45
+ print("\nStopped.")
46
+ server.shutdown()
47
+
48
+
49
+ if __name__ == "__main__":
50
+ main()
gemini_web2api/config.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration management."""
2
+ import json
3
+ import os
4
+
5
+ DEFAULT_CONFIG = {
6
+ "port": 8081,
7
+ "host": "0.0.0.0",
8
+ "retry_attempts": 3,
9
+ "retry_delay_sec": 2,
10
+ "request_timeout_sec": 180,
11
+ "gemini_bl": "boq_assistant-bard-web-server_20260525.09_p0",
12
+ "auth_user": None,
13
+ "xsrf_token": None,
14
+ "default_model": "gemini-3.5-flash",
15
+ "log_requests": True,
16
+ "cookie_file": None,
17
+ "proxy": None,
18
+ "api_keys": [],
19
+ }
20
+
21
+ CONFIG = dict(DEFAULT_CONFIG)
22
+
23
+
24
+ def load_config(path: str = None):
25
+ """Load config from JSON file."""
26
+ if path and os.path.exists(path):
27
+ with open(path) as f:
28
+ CONFIG.update(json.load(f))
29
+ return CONFIG
30
+
31
+
32
+ def find_config():
33
+ """Search for config file in standard locations."""
34
+ for p in ["./config.json", os.path.expanduser("~/.config/gemini-web2api/config.json")]:
35
+ if os.path.exists(p):
36
+ return p
37
+ return None
gemini_web2api/gemini.py ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gemini StreamGenerate protocol implementation with httpx streaming."""
2
+ import json
3
+ import time
4
+ import uuid
5
+ import re
6
+ import urllib.request
7
+ import urllib.parse
8
+ import ssl
9
+ import os
10
+ import hashlib
11
+
12
+ try:
13
+ import httpx
14
+ HAS_HTTPX = True
15
+ except ImportError:
16
+ HAS_HTTPX = False
17
+
18
+ from .config import CONFIG
19
+
20
+ _ssl_ctx = None
21
+ _cookie_cache = {"str": "", "sapisid": None, "mtime": 0}
22
+ _httpx_client = None
23
+
24
+
25
+ def log(msg: str):
26
+ if CONFIG["log_requests"]:
27
+ import sys
28
+ sys.stderr.write(f"[{time.strftime('%H:%M:%S')}] {msg}\n")
29
+ sys.stderr.flush()
30
+
31
+
32
+ def _get_ssl_ctx():
33
+ global _ssl_ctx
34
+ if _ssl_ctx is None:
35
+ _ssl_ctx = ssl.create_default_context()
36
+ return _ssl_ctx
37
+
38
+
39
+ def _get_httpx_client():
40
+ global _httpx_client
41
+ if _httpx_client is None and HAS_HTTPX:
42
+ proxy = CONFIG.get("proxy")
43
+ transport = httpx.HTTPTransport(proxy=proxy) if proxy else None
44
+ _httpx_client = httpx.Client(transport=transport, timeout=CONFIG["request_timeout_sec"], verify=True)
45
+ return _httpx_client
46
+
47
+
48
+ def load_cookie() -> tuple:
49
+ """Load cookie from file with mtime-based caching."""
50
+ cookie_file = CONFIG.get("cookie_file")
51
+ if not cookie_file or not os.path.exists(cookie_file):
52
+ return "", None
53
+ try:
54
+ mtime = os.path.getmtime(cookie_file)
55
+ if mtime == _cookie_cache["mtime"] and _cookie_cache["str"]:
56
+ return _cookie_cache["str"], _cookie_cache["sapisid"]
57
+ with open(cookie_file, "r") as f:
58
+ content = f.read().strip()
59
+ if content.startswith("{"):
60
+ data = json.loads(content)
61
+ cookie_str = data.get("cookie", "")
62
+ sapisid = data.get("sapisid", "")
63
+ else:
64
+ cookie_str = content
65
+ pairs = dict(p.split("=", 1) for p in cookie_str.split("; ") if "=" in p)
66
+ sapisid = pairs.get("SAPISID", "")
67
+ _cookie_cache.update({"str": cookie_str, "sapisid": sapisid or None, "mtime": mtime})
68
+ return cookie_str, sapisid if sapisid else None
69
+ except Exception as e:
70
+ log(f"Cookie load error: {e}")
71
+ return _cookie_cache["str"], _cookie_cache["sapisid"]
72
+
73
+
74
+ def make_sapisidhash(sapisid: str) -> str:
75
+ ts = int(time.time())
76
+ h = hashlib.sha1(f"{ts} {sapisid} https://gemini.google.com".encode()).hexdigest()
77
+ return f"SAPISIDHASH {ts}_{h}"
78
+
79
+
80
+ def _account_prefix() -> str:
81
+ """Return the Gemini account path prefix for non-default Google accounts."""
82
+ auth_user = CONFIG.get("auth_user")
83
+ if auth_user is None or auth_user == "":
84
+ return ""
85
+ return f"/u/{auth_user}"
86
+
87
+
88
+ def _build_headers() -> dict:
89
+ account_prefix = _account_prefix()
90
+ headers = {
91
+ "Content-Type": "application/x-www-form-urlencoded",
92
+ "Origin": "https://gemini.google.com",
93
+ "Referer": f"https://gemini.google.com{account_prefix}/app",
94
+ "X-Same-Domain": "1",
95
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
96
+ }
97
+ if account_prefix:
98
+ headers["X-Goog-AuthUser"] = str(CONFIG["auth_user"])
99
+ cookie_str, sapisid = load_cookie()
100
+ if cookie_str:
101
+ headers["Cookie"] = cookie_str
102
+ if sapisid:
103
+ headers["Authorization"] = make_sapisidhash(sapisid)
104
+ return headers
105
+
106
+
107
+ def _build_payload(prompt: str, model_id: int, think_mode: int, file_refs: list = None, extra_fields: dict = None) -> str:
108
+ inner = [None] * 102
109
+ if file_refs:
110
+ refs = [[None, None, ref] for ref in file_refs]
111
+ inner[0] = [prompt, 0, None, refs, None, None, 0]
112
+ else:
113
+ inner[0] = [prompt, 0, None, None, None, None, 0]
114
+ inner[1] = ["en"]
115
+ inner[2] = ["", "", "", None, None, None, None, None, None, ""]
116
+ inner[6] = [0]
117
+ inner[7] = 1
118
+ inner[10] = 1
119
+ inner[11] = 0
120
+ inner[17] = [[think_mode]]
121
+ inner[18] = 0
122
+ inner[27] = 1
123
+ inner[30] = [4]
124
+ inner[41] = [2]
125
+ inner[53] = 0
126
+ inner[59] = str(uuid.uuid4())
127
+ inner[61] = []
128
+ inner[68] = 1
129
+ inner[79] = model_id
130
+ if extra_fields:
131
+ for k, v in extra_fields.items():
132
+ inner[k] = v
133
+ outer = [None, json.dumps(inner)]
134
+ params = {"f.req": json.dumps(outer)}
135
+ if CONFIG.get("xsrf_token"):
136
+ params["at"] = CONFIG["xsrf_token"]
137
+ return urllib.parse.urlencode(params)
138
+
139
+
140
+ def _get_url() -> str:
141
+ reqid = int(time.time()) % 1000000
142
+ account_prefix = _account_prefix()
143
+ return (
144
+ f"https://gemini.google.com{account_prefix}/_/BardChatUi/data/"
145
+ "assistant.lamda.BardFrontendService/StreamGenerate"
146
+ f"?bl={CONFIG['gemini_bl']}&hl=en&_reqid={reqid}&rt=c"
147
+ )
148
+
149
+
150
+ def clean_text(text: str) -> str:
151
+ text = re.sub(
152
+ r'```(?:python|javascript|text)\?code_(?:reference|stdout)&code_event_index=\d+\n.*?```\n?',
153
+ '', text, flags=re.DOTALL
154
+ )
155
+ text = re.sub(r'http://googleusercontent\.com/card_content/\d+\n?', '', text)
156
+ return text.strip()
157
+
158
+
159
+ def _extract_texts_from_line(line: str) -> list:
160
+ """Parse a single wrb.fr line and return list of text strings found."""
161
+ if '"wrb.fr"' not in line or len(line) < 200:
162
+ return []
163
+ try:
164
+ arr = json.loads(line)
165
+ inner_str = arr[0][2]
166
+ if not inner_str or len(inner_str) < 50:
167
+ return []
168
+ inner = json.loads(inner_str)
169
+ if not (isinstance(inner, list) and len(inner) > 4 and inner[4]):
170
+ return []
171
+ texts = []
172
+ for part in inner[4]:
173
+ if isinstance(part, list) and len(part) > 1 and part[1] and isinstance(part[1], list):
174
+ for t in part[1]:
175
+ if isinstance(t, str) and t:
176
+ texts.append(t)
177
+ return texts
178
+ except (json.JSONDecodeError, IndexError, TypeError):
179
+ return []
180
+
181
+
182
+ def extract_response_text(raw: str) -> str:
183
+ """Parse full response to get final text."""
184
+ last_text = ""
185
+ for line in raw.split("\n"):
186
+ for t in _extract_texts_from_line(line):
187
+ if len(t) > len(last_text):
188
+ last_text = t
189
+ return clean_text(last_text)
190
+
191
+
192
+ def generate(prompt: str, model_id: int, think_mode: int, file_refs: list = None, extra_fields: dict = None) -> str:
193
+ """Non-streaming generation with retry."""
194
+ body = _build_payload(prompt, model_id, think_mode, file_refs, extra_fields).encode()
195
+ url = _get_url()
196
+ headers = _build_headers()
197
+ ctx = _get_ssl_ctx()
198
+ proxy = CONFIG.get("proxy")
199
+
200
+ last_err = None
201
+ for attempt in range(CONFIG["retry_attempts"]):
202
+ try:
203
+ req = urllib.request.Request(url, data=body, headers=headers, method="POST")
204
+ if proxy:
205
+ opener = urllib.request.build_opener(
206
+ urllib.request.ProxyHandler({"http": proxy, "https": proxy}),
207
+ urllib.request.HTTPSHandler(context=ctx)
208
+ )
209
+ resp = opener.open(req, timeout=CONFIG["request_timeout_sec"])
210
+ else:
211
+ resp = urllib.request.urlopen(req, context=ctx, timeout=CONFIG["request_timeout_sec"])
212
+ raw = resp.read().decode("utf-8", errors="replace")
213
+ return extract_response_text(raw)
214
+ except Exception as e:
215
+ last_err = e
216
+ if attempt < CONFIG["retry_attempts"] - 1:
217
+ log(f"Retry {attempt+1}/{CONFIG['retry_attempts']}: {e}")
218
+ time.sleep(CONFIG["retry_delay_sec"])
219
+ raise last_err
220
+
221
+
222
+ def generate_stream(prompt: str, model_id: int, think_mode: int, file_refs: list = None, extra_fields: dict = None):
223
+ """Streaming generation via httpx with retry on connection failure."""
224
+ if not HAS_HTTPX:
225
+ text = generate(prompt, model_id, think_mode, file_refs, extra_fields)
226
+ if text:
227
+ yield text
228
+ return
229
+
230
+ body = _build_payload(prompt, model_id, think_mode, file_refs, extra_fields)
231
+ url = _get_url()
232
+ headers = _build_headers()
233
+ client = _get_httpx_client()
234
+
235
+ last_err = None
236
+ for attempt in range(CONFIG["retry_attempts"]):
237
+ try:
238
+ prev_text = ""
239
+ with client.stream("POST", url, content=body, headers=headers) as resp:
240
+ buf = ""
241
+ for chunk in resp.iter_text():
242
+ buf += chunk
243
+ while "\n" in buf:
244
+ line, buf = buf.split("\n", 1)
245
+ for t in _extract_texts_from_line(line):
246
+ if len(t) > len(prev_text):
247
+ delta = clean_text(t[len(prev_text):])
248
+ if delta:
249
+ yield delta
250
+ prev_text = t
251
+ return
252
+ except Exception as e:
253
+ last_err = e
254
+ if attempt < CONFIG["retry_attempts"] - 1:
255
+ log(f"Stream retry {attempt+1}/{CONFIG['retry_attempts']}: {e}")
256
+ time.sleep(CONFIG["retry_delay_sec"])
257
+ raise last_err
gemini_web2api/models.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Model definitions and mapping from Gemini frontend JS source."""
2
+
3
+ # MODE_CATEGORY enum from 028-6eb337387583.js:
4
+ # 1=FAST, 2=THINKING, 3=PRO, 4=AUTO, 5=FAST_DYNAMIC_THINKING, 6=FLASH_LITE
5
+
6
+ MODELS = {
7
+ "gemini-3.5-flash": {
8
+ "mode": 1, "think": 4,
9
+ "desc": "Fast general-purpose model",
10
+ },
11
+ "gemini-3.5-flash-thinking": {
12
+ "mode": 2, "think": 0,
13
+ "desc": "Deep thinking mode, longest output (~20k chars)",
14
+ },
15
+ "gemini-3.1-pro": {
16
+ "mode": 3, "think": 4,
17
+ "desc": "Pro model (requires cookie for real routing)",
18
+ },
19
+ "gemini-3.1-pro-enhanced": {
20
+ "mode": 3, "think": 4, "extra": {31: 2, 80: 3},
21
+ "desc": "Pro with enhanced output (experimental)",
22
+ },
23
+ "gemini-auto": {
24
+ "mode": 4, "think": 4,
25
+ "desc": "Auto model selection",
26
+ },
27
+ "gemini-3.5-flash-thinking-lite": {
28
+ "mode": 5, "think": 0,
29
+ "desc": "Dynamic thinking with adaptive depth",
30
+ },
31
+ "gemini-flash-lite": {
32
+ "mode": 6, "think": 4,
33
+ "desc": "Lightweight fast model",
34
+ },
35
+ }
36
+
37
+
38
+ def resolve_model(model_name: str, default: str = "gemini-3.5-flash"):
39
+ """Resolve model name to (name, mode_id, think_mode, error, extra_fields).
40
+
41
+ Unknown model names fall back to default rather than erroring,
42
+ since upstream clients may request arbitrary model identifiers.
43
+ """
44
+ think_override = None
45
+ if "@think=" in model_name:
46
+ model_name, think_str = model_name.rsplit("@think=", 1)
47
+ try:
48
+ think_override = int(think_str)
49
+ except ValueError:
50
+ return None, None, None, f"Invalid think level: {think_str}", None
51
+ cfg = MODELS.get(model_name)
52
+ if not cfg:
53
+ from .gemini import log
54
+ log(f"Unknown model '{model_name}', falling back to '{default}'")
55
+ model_name = default
56
+ cfg = MODELS[default]
57
+ mode_id = cfg["mode"]
58
+ think_mode = think_override if think_override is not None else cfg["think"]
59
+ extra = cfg.get("extra")
60
+ return model_name, mode_id, think_mode, None, extra
gemini_web2api/multimodal.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Multimodal: Scotty resumable upload for Gemini image input."""
2
+ import json
3
+ import base64
4
+ import urllib.request
5
+ import urllib.parse
6
+ import time
7
+ import ssl
8
+ import re
9
+
10
+ from .config import CONFIG
11
+ from .gemini import load_cookie, make_sapisidhash, _get_ssl_ctx, log
12
+
13
+
14
+ def _get_page_tokens() -> dict:
15
+ """Fetch WIZ_global_data tokens from Gemini page (Push-ID, X-Client-Pctx)."""
16
+ headers = {
17
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
18
+ }
19
+ cookie_str, sapisid = load_cookie()
20
+ if cookie_str:
21
+ headers["Cookie"] = cookie_str
22
+ try:
23
+ req = urllib.request.Request("https://gemini.google.com/app", headers=headers)
24
+ resp = urllib.request.urlopen(req, context=_get_ssl_ctx(), timeout=30)
25
+ html = resp.read().decode()
26
+ tokens = {}
27
+ for key, pattern in [
28
+ ("push_id", r'"qKIAYe":"([^"]+)"'),
29
+ ("pctx", r'"Ylro7b":"([^"]+)"'),
30
+ ("at", r'"thykhd":"([^"]+)"'),
31
+ ]:
32
+ m = re.search(pattern, html)
33
+ if m:
34
+ tokens[key] = m.group(1)
35
+ return tokens
36
+ except Exception as e:
37
+ log(f"Page token fetch failed: {e}")
38
+ return {}
39
+
40
+
41
+ _page_tokens_cache = {"tokens": {}, "ts": 0}
42
+
43
+
44
+ def _cached_page_tokens() -> dict:
45
+ now = time.time()
46
+ if now - _page_tokens_cache["ts"] > 600:
47
+ _page_tokens_cache["tokens"] = _get_page_tokens()
48
+ _page_tokens_cache["ts"] = now
49
+ return _page_tokens_cache["tokens"]
50
+
51
+
52
+ def upload_image(image_bytes: bytes, filename: str = "image.png", mime_type: str = "image/png") -> str:
53
+ """Upload image via Scotty resumable upload. Returns file reference path."""
54
+ tokens = _cached_page_tokens()
55
+ push_id = tokens.get("push_id", "feeds/mcudyrk2a4khkz")
56
+ pctx = tokens.get("pctx", "CgcSBWjK7pYx")
57
+
58
+ cookie_str, sapisid = load_cookie()
59
+ ctx = _get_ssl_ctx()
60
+ proxy = CONFIG.get("proxy")
61
+
62
+ # Step 1: Initiate resumable upload
63
+ start_headers = {
64
+ "Push-ID": push_id,
65
+ "X-Tenant-Id": "bard-storage",
66
+ "X-Client-Pctx": pctx,
67
+ "X-Goog-Upload-Header-Content-Length": str(len(image_bytes)),
68
+ "X-Goog-Upload-Header-Content-Type": mime_type,
69
+ "X-Goog-Upload-Protocol": "resumable",
70
+ "X-Goog-Upload-Command": "start",
71
+ "Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
72
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
73
+ }
74
+ if cookie_str:
75
+ start_headers["Cookie"] = cookie_str
76
+ if sapisid:
77
+ start_headers["Authorization"] = make_sapisidhash(sapisid)
78
+
79
+ start_url = "https://content-push.googleapis.com/upload/"
80
+ req = urllib.request.Request(start_url, data=b"", headers=start_headers, method="POST")
81
+
82
+ if proxy:
83
+ opener = urllib.request.build_opener(
84
+ urllib.request.ProxyHandler({"http": proxy, "https": proxy}),
85
+ urllib.request.HTTPSHandler(context=ctx)
86
+ )
87
+ resp = opener.open(req, timeout=30)
88
+ else:
89
+ resp = urllib.request.urlopen(req, context=ctx, timeout=30)
90
+
91
+ upload_url = resp.headers.get("X-Goog-Upload-URL") or resp.headers.get("x-goog-upload-url")
92
+ if not upload_url:
93
+ raise RuntimeError(f"No upload URL in response headers: {dict(resp.headers)}")
94
+
95
+ log(f"Upload session started: {upload_url[:80]}...")
96
+
97
+ # Step 2: Upload file data + finalize
98
+ upload_headers = {
99
+ "X-Goog-Upload-Command": "upload, finalize",
100
+ "X-Goog-Upload-Offset": "0",
101
+ "Content-Type": "application/octet-stream",
102
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
103
+ }
104
+
105
+ req2 = urllib.request.Request(upload_url, data=image_bytes, headers=upload_headers, method="POST")
106
+ if proxy:
107
+ resp2 = opener.open(req2, timeout=60)
108
+ else:
109
+ resp2 = urllib.request.urlopen(req2, context=ctx, timeout=60)
110
+
111
+ file_ref = resp2.read().decode().strip()
112
+ if not file_ref or not file_ref.startswith("/"):
113
+ raise RuntimeError(f"Invalid file reference: {file_ref[:100]}")
114
+
115
+ log(f"Image uploaded: {filename} -> {file_ref[:50]}...")
116
+ return file_ref
117
+
118
+
119
+ def fetch_image_bytes(url: str) -> bytes:
120
+ """Fetch image from URL."""
121
+ try:
122
+ req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
123
+ resp = urllib.request.urlopen(req, timeout=30)
124
+ return resp.read()
125
+ except Exception as e:
126
+ log(f"Image fetch failed: {e}")
127
+ return b""
gemini_web2api/server.py ADDED
@@ -0,0 +1,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HTTP server: OpenAI-compatible API endpoints."""
2
+ import json
3
+ import time
4
+ import uuid
5
+ import re
6
+ from http.server import HTTPServer, BaseHTTPRequestHandler
7
+ from socketserver import ThreadingMixIn
8
+
9
+ from .config import CONFIG
10
+ from .models import MODELS, resolve_model
11
+ from .gemini import generate, generate_stream, log
12
+ from .tools import messages_to_prompt, parse_tool_calls, google_contents_to_prompt, parse_google_function_calls
13
+ from .multimodal import upload_image, fetch_image_bytes
14
+ from . import __version__
15
+
16
+
17
+ def _usage(prompt: str, text: str) -> dict:
18
+ p = len(prompt) // 4
19
+ c = len(text or "") // 4
20
+ return {"prompt_tokens": p, "completion_tokens": c, "total_tokens": p + c}
21
+
22
+
23
+ def _upload_images(images: list) -> list:
24
+ """Upload images and return list of file references. Returns None if no images."""
25
+ if not images:
26
+ return None
27
+ file_refs = []
28
+ for item in images:
29
+ try:
30
+ if isinstance(item, tuple) and len(item) == 2:
31
+ data, mime = item
32
+ if isinstance(data, str):
33
+ data = fetch_image_bytes(data)
34
+ mime = mime or "image/png"
35
+ if data:
36
+ ref = upload_image(data, "image.png", mime or "image/png")
37
+ file_refs.append(ref)
38
+ except Exception as e:
39
+ log(f"Image upload failed: {e}")
40
+ return file_refs if file_refs else None
41
+
42
+
43
+ class GeminiHandler(BaseHTTPRequestHandler):
44
+ def log_message(self, fmt, *args):
45
+ log(fmt % args)
46
+
47
+ def send_json(self, data, status=200):
48
+ body = json.dumps(data, ensure_ascii=False).encode()
49
+ self.send_response(status)
50
+ self.send_header("Content-Type", "application/json")
51
+ self.send_header("Access-Control-Allow-Origin", "*")
52
+ self.send_header("Content-Length", str(len(body)))
53
+ self.end_headers()
54
+ self.wfile.write(body)
55
+
56
+ def _start_sse(self):
57
+ self.send_response(200)
58
+ self.send_header("Content-Type", "text/event-stream")
59
+ self.send_header("Cache-Control", "no-cache")
60
+ self.send_header("Access-Control-Allow-Origin", "*")
61
+ self.end_headers()
62
+
63
+ def _parse_body(self, body: bytes) -> dict:
64
+ try:
65
+ return json.loads(body)
66
+ except (json.JSONDecodeError, ValueError):
67
+ return None
68
+
69
+ def _authorized(self):
70
+ keys = CONFIG.get("api_keys") or []
71
+ if not keys:
72
+ return True
73
+ auth = self.headers.get("Authorization", "")
74
+ key = auth[7:] if auth.startswith("Bearer ") else self.headers.get("x-api-key", "")
75
+ return key in keys
76
+
77
+ def do_OPTIONS(self):
78
+ self.send_response(204)
79
+ self.send_header("Access-Control-Allow-Origin", "*")
80
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
81
+ self.send_header("Access-Control-Allow-Headers", "*")
82
+ self.end_headers()
83
+
84
+ def do_GET(self):
85
+ try:
86
+ if self.path.startswith("/v1/") and not self._authorized():
87
+ self.send_json({"error": {"message": "invalid api key"}}, 401)
88
+ return
89
+ if self.path == "/v1/models":
90
+ self.send_json({"object": "list", "data": [
91
+ {"id": n, "object": "model", "created": 1700000000,
92
+ "owned_by": "google", "description": c["desc"]}
93
+ for n, c in MODELS.items()
94
+ ]})
95
+ elif self.path.startswith("/v1beta/models"):
96
+ self.send_json({"models": [
97
+ {"name": f"models/{n}", "displayName": n, "description": c["desc"],
98
+ "supportedGenerationMethods": ["generateContent", "streamGenerateContent"]}
99
+ for n, c in MODELS.items()
100
+ ]})
101
+ elif self.path == "/":
102
+ self.send_json({"status": "ok", "version": __version__, "models": list(MODELS.keys())})
103
+ else:
104
+ self.send_json({"error": "not found"}, 404)
105
+ except (BrokenPipeError, ConnectionResetError):
106
+ pass
107
+
108
+ def do_POST(self):
109
+ try:
110
+ if self.path.startswith("/v1/") and not self._authorized():
111
+ self.send_json({"error": {"message": "invalid api key"}}, 401)
112
+ return
113
+ length = int(self.headers.get("Content-Length", 0))
114
+ body = self.rfile.read(length) if length else b""
115
+ if self.path == "/v1/chat/completions":
116
+ self._handle_chat(body)
117
+ elif self.path == "/v1/responses":
118
+ self._handle_responses(body)
119
+ elif ":generateContent" in self.path:
120
+ self._handle_google_generate(body, stream=False)
121
+ elif ":streamGenerateContent" in self.path:
122
+ self._handle_google_generate(body, stream=True)
123
+ else:
124
+ self.send_json({"error": "not found"}, 404)
125
+ except (BrokenPipeError, ConnectionResetError):
126
+ pass
127
+ except Exception as e:
128
+ log(f"POST error: {e}")
129
+ try:
130
+ self.send_json({"error": {"message": str(e)}}, 500)
131
+ except:
132
+ pass
133
+
134
+ # ─── /v1/chat/completions ─────────────────────────────────────────────────
135
+
136
+ def _handle_chat(self, body: bytes):
137
+ req = self._parse_body(body)
138
+ if req is None:
139
+ self.send_json({"error": {"message": "invalid JSON"}}, 400)
140
+ return
141
+ model_name, model_id, think_mode, err, extra_fields = resolve_model(
142
+ req.get("model", CONFIG["default_model"]))
143
+ if err:
144
+ self.send_json({"error": {"message": err}}, 400)
145
+ return
146
+
147
+ tools = req.get("tools")
148
+ tool_choice = req.get("tool_choice", "auto")
149
+ prompt, images = messages_to_prompt(req.get("messages", []), tools, tool_choice)
150
+ if not prompt.strip():
151
+ self.send_json({"error": {"message": "empty prompt"}}, 400)
152
+ return
153
+
154
+ stream = req.get("stream", False)
155
+ cid = f"chatcmpl-{uuid.uuid4().hex[:12]}"
156
+
157
+ if stream and (not tools or tool_choice == "none"):
158
+ try:
159
+ self._start_sse()
160
+ for delta in generate_stream(prompt, model_id, think_mode, _upload_images(images), extra_fields):
161
+ chunk = {"id": cid, "object": "chat.completion.chunk", "created": int(time.time()),
162
+ "model": model_name, "choices": [{"index": 0, "delta": {"content": delta}, "finish_reason": None}]}
163
+ self.wfile.write(f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n".encode())
164
+ self.wfile.flush()
165
+ end = {"id": cid, "object": "chat.completion.chunk", "created": int(time.time()),
166
+ "model": model_name, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]}
167
+ self.wfile.write(f"data: {json.dumps(end)}\n\n".encode())
168
+ self.wfile.write(b"data: [DONE]\n\n")
169
+ self.wfile.flush()
170
+ except (BrokenPipeError, ConnectionResetError):
171
+ pass
172
+ return
173
+
174
+ try:
175
+ text = generate(prompt, model_id, think_mode, _upload_images(images), extra_fields)
176
+ except Exception as e:
177
+ self.send_json({"error": {"message": f"upstream error: {e}"}}, 502)
178
+ return
179
+
180
+ tool_calls = None
181
+ if tools and text and tool_choice != "none":
182
+ text, tool_calls = parse_tool_calls(text)
183
+ msg = {"role": "assistant", "content": text or None}
184
+ if tool_calls:
185
+ msg["tool_calls"] = tool_calls
186
+ finish = "tool_calls" if tool_calls else "stop"
187
+
188
+ if stream:
189
+ self._start_sse()
190
+ chunk = {"id": cid, "object": "chat.completion.chunk", "created": int(time.time()),
191
+ "model": model_name, "choices": [{"index": 0, "delta": msg, "finish_reason": finish}]}
192
+ self.wfile.write(f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n".encode())
193
+ self.wfile.write(b"data: [DONE]\n\n")
194
+ self.wfile.flush()
195
+ else:
196
+ self.send_json({
197
+ "id": cid, "object": "chat.completion", "created": int(time.time()),
198
+ "model": model_name,
199
+ "choices": [{"index": 0, "message": msg, "finish_reason": finish}],
200
+ "usage": {"prompt_tokens": len(prompt)//4, "completion_tokens": len(text or "")//4,
201
+ "total_tokens": (len(prompt)+len(text or ""))//4},
202
+ })
203
+
204
+ # ─── /v1/responses (Codex CLI) ───────────────────────────────────────────
205
+
206
+ def _handle_responses(self, body: bytes):
207
+ req = self._parse_body(body)
208
+ if req is None:
209
+ self.send_json({"error": {"message": "invalid JSON"}}, 400)
210
+ return
211
+ model_name, model_id, think_mode, err, extra_fields = resolve_model(
212
+ req.get("model", CONFIG["default_model"]))
213
+ if err:
214
+ self.send_json({"error": {"message": err}}, 400)
215
+ return
216
+
217
+ input_items = req.get("input", [])
218
+ tools = req.get("tools")
219
+ messages = []
220
+ if req.get("instructions"):
221
+ messages.append({"role": "system", "content": req["instructions"]})
222
+ if isinstance(input_items, str):
223
+ messages.append({"role": "user", "content": input_items})
224
+ elif isinstance(input_items, list):
225
+ for item in input_items:
226
+ if isinstance(item, str):
227
+ messages.append({"role": "user", "content": item})
228
+ elif isinstance(item, dict):
229
+ if item.get("type") == "function_call_output":
230
+ messages.append({"role": "tool", "tool_call_id": item.get("call_id", ""),
231
+ "name": item.get("name", ""), "content": item.get("output", "")})
232
+ elif item.get("role") == "assistant" or (item.get("type") == "message" and item.get("role") == "assistant"):
233
+ cp = item.get("content", [])
234
+ text_acc, tc_list = "", []
235
+ if isinstance(cp, list):
236
+ for c in cp:
237
+ if isinstance(c, dict):
238
+ if c.get("type") == "output_text": text_acc += c.get("text", "")
239
+ elif c.get("type") == "function_call": tc_list.append(c)
240
+ elif isinstance(cp, str):
241
+ text_acc = cp
242
+ m = {"role": "assistant", "content": text_acc or None}
243
+ if tc_list:
244
+ m["tool_calls"] = [{"id": tc.get("call_id", f"call_{i}"), "type": "function",
245
+ "function": {"name": tc.get("name",""), "arguments": tc.get("arguments","{}")}}
246
+ for i, tc in enumerate(tc_list)]
247
+ messages.append(m)
248
+ else:
249
+ role = item.get("role", "user")
250
+ content = item.get("content", "")
251
+ if isinstance(content, list):
252
+ content = " ".join(c.get("text", "") for c in content if c.get("type") in ("text", "input_text"))
253
+ messages.append({"role": role, "content": content})
254
+
255
+ if tools:
256
+ tools = [{"type": "function", "function": {"name": t["name"], "description": t.get("description", ""), "parameters": t.get("parameters", {})}}
257
+ if t.get("type") == "function" and "function" not in t else t for t in tools]
258
+
259
+ tool_choice = req.get("tool_choice", "auto")
260
+ prompt, images = messages_to_prompt(messages, tools, tool_choice)
261
+ if not prompt.strip():
262
+ self.send_json({"error": {"message": "empty input"}}, 400)
263
+ return
264
+
265
+ try:
266
+ text = generate(prompt, model_id, think_mode, _upload_images(images), extra_fields)
267
+ except Exception as e:
268
+ self.send_json({"error": {"message": f"upstream error: {e}"}}, 502)
269
+ return
270
+
271
+ tool_calls = None
272
+ if tools and text and tool_choice != "none":
273
+ text, tool_calls = parse_tool_calls(text)
274
+
275
+ rid = f"resp_{uuid.uuid4().hex[:16]}"
276
+ mid = f"msg_{uuid.uuid4().hex[:12]}"
277
+ output = []
278
+ if tool_calls:
279
+ for tc in tool_calls:
280
+ output.append({"type": "function_call", "id": tc["id"], "call_id": tc["id"],
281
+ "name": tc["function"]["name"], "arguments": tc["function"]["arguments"], "status": "completed"})
282
+ if text or not tool_calls:
283
+ output.append({"type": "message", "id": mid, "role": "assistant", "status": "completed",
284
+ "content": [{"type": "output_text", "text": text or "", "annotations": []}]})
285
+
286
+ if req.get("stream"):
287
+ self.send_response(200)
288
+ self.send_header("Content-Type", "text/event-stream")
289
+ self.send_header("Cache-Control", "no-cache")
290
+ self.send_header("Access-Control-Allow-Origin", "*")
291
+ self.end_headers()
292
+ ev = {"type": "response.created", "response": {"id": rid, "object": "response", "status": "in_progress", "model": model_name, "output": []}}
293
+ self.wfile.write(f"event: response.created\ndata: {json.dumps(ev)}\n\n".encode())
294
+ for item in output:
295
+ if item["type"] == "function_call":
296
+ ev = {"type": "response.function_call_arguments.done", "item_id": item["id"], "call_id": item["call_id"], "name": item["name"], "arguments": item["arguments"]}
297
+ self.wfile.write(f"event: response.function_call_arguments.done\ndata: {json.dumps(ev)}\n\n".encode())
298
+ elif item["type"] == "message":
299
+ for ci, cp in enumerate(item["content"]):
300
+ ev = {"type": "response.output_text.done", "item_id": item["id"], "content_index": ci, "text": cp["text"]}
301
+ self.wfile.write(f"event: response.output_text.done\ndata: {json.dumps(ev)}\n\n".encode())
302
+ resp_obj = {"id": rid, "object": "response", "status": "completed", "model": model_name, "output": output,
303
+ "usage": {"input_tokens": len(prompt)//4, "output_tokens": len(text or "")//4, "total_tokens": (len(prompt)+len(text or ""))//4}}
304
+ self.wfile.write(f"event: response.completed\ndata: {json.dumps({'type': 'response.completed', 'response': resp_obj})}\n\n".encode())
305
+ self.wfile.flush()
306
+ else:
307
+ self.send_json({"id": rid, "object": "response", "created_at": int(time.time()), "status": "completed",
308
+ "model": model_name, "output": output,
309
+ "usage": {"input_tokens": len(prompt)//4, "output_tokens": len(text or "")//4, "total_tokens": (len(prompt)+len(text or ""))//4}})
310
+
311
+ # ─── /v1beta/models (Google Gemini CLI) ──────────────────────────────────
312
+
313
+ def _handle_google_generate(self, body: bytes, stream: bool):
314
+ req = self._parse_body(body)
315
+ if req is None:
316
+ self.send_json({"error": {"message": "invalid JSON"}}, 400)
317
+ return
318
+ m = re.match(r'/v1beta/models/([^:?]+)', self.path)
319
+ model_name = m.group(1) if m else CONFIG["default_model"]
320
+ model_name, model_id, think_mode, err, extra_fields = resolve_model(model_name)
321
+ if err:
322
+ self.send_json({"error": {"message": err}}, 400)
323
+ return
324
+
325
+ tool_config = req.get("toolConfig", {})
326
+ fc_mode = tool_config.get("functionCallingConfig", {}).get("mode", "AUTO")
327
+ has_tools = bool(req.get("tools")) and fc_mode != "NONE"
328
+ prompt, images = google_contents_to_prompt(req)
329
+ if not prompt.strip():
330
+ self.send_json({"error": {"message": "empty content"}}, 400)
331
+ return
332
+
333
+ file_refs = _upload_images(images)
334
+ log(f"Google API: model={model_name} stream={stream} tools={has_tools} prompt_len={len(prompt)}")
335
+
336
+ if stream and not has_tools:
337
+ try:
338
+ self._start_sse()
339
+ full_text = ""
340
+ for delta in generate_stream(prompt, model_id, think_mode, file_refs, extra_fields):
341
+ if not delta:
342
+ continue
343
+ full_text += delta
344
+ chunk_obj = {
345
+ "candidates": [{"content": {"parts": [{"text": delta}], "role": "model"}, "index": 0}],
346
+ "modelVersion": model_name,
347
+ }
348
+ self.wfile.write(f"data: {json.dumps(chunk_obj, ensure_ascii=False)}\n\n".encode())
349
+ self.wfile.flush()
350
+ final_chunk = {
351
+ "candidates": [{"finishReason": "STOP", "index": 0}],
352
+ "usageMetadata": {
353
+ "promptTokenCount": len(prompt) // 4,
354
+ "candidatesTokenCount": len(full_text) // 4,
355
+ "totalTokenCount": (len(prompt) + len(full_text)) // 4,
356
+ },
357
+ "modelVersion": model_name,
358
+ }
359
+ self.wfile.write(f"data: {json.dumps(final_chunk, ensure_ascii=False)}\n\n".encode())
360
+ self.wfile.flush()
361
+ except (BrokenPipeError, ConnectionResetError):
362
+ pass
363
+ return
364
+
365
+ try:
366
+ text = generate(prompt, model_id, think_mode, file_refs, extra_fields)
367
+ except Exception as e:
368
+ self.send_json({"error": {"message": f"upstream error: {e}"}}, 502)
369
+ return
370
+
371
+ if not text:
372
+ log("Warning: empty response from Gemini")
373
+
374
+ response_parts = []
375
+ if has_tools and text:
376
+ clean_text, function_calls = parse_google_function_calls(text)
377
+ if function_calls:
378
+ if clean_text:
379
+ response_parts.append({"text": clean_text})
380
+ for fc in function_calls:
381
+ response_parts.append({"functionCall": {"name": fc["name"], "args": fc["args"]}})
382
+ else:
383
+ response_parts.append({"text": text})
384
+ else:
385
+ response_parts.append({"text": text or "I apologize, but I was unable to generate a response. Please try again."})
386
+
387
+ candidate = {
388
+ "content": {"parts": response_parts, "role": "model"},
389
+ "finishReason": "STOP",
390
+ "index": 0,
391
+ }
392
+ usage = {
393
+ "promptTokenCount": len(prompt) // 4,
394
+ "candidatesTokenCount": len(text or "") // 4,
395
+ "totalTokenCount": (len(prompt) + len(text or "")) // 4,
396
+ }
397
+ response_obj = {
398
+ "candidates": [candidate],
399
+ "usageMetadata": usage,
400
+ "modelVersion": model_name,
401
+ }
402
+
403
+ if stream:
404
+ self._start_sse()
405
+ self.wfile.write(f"data: {json.dumps(response_obj, ensure_ascii=False)}\n\n".encode())
406
+ self.wfile.flush()
407
+ else:
408
+ self.send_json(response_obj)
409
+
410
+
411
+ class ThreadedServer(ThreadingMixIn, HTTPServer):
412
+ daemon_threads = True
413
+ allow_reuse_address = True
gemini_web2api/tools.py ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tool calling and multimodal message parsing."""
2
+ import json
3
+ import re
4
+ import uuid
5
+ import base64
6
+ import io
7
+
8
+ MAX_IMAGE_B64_SIZE = 50000 # ~37KB raw image
9
+
10
+
11
+ def _compress_b64_if_needed(b64: str) -> str:
12
+ """Compress image if base64 is too large for text embedding."""
13
+ if len(b64) <= MAX_IMAGE_B64_SIZE:
14
+ return b64
15
+ try:
16
+ from PIL import Image
17
+ img_data = base64.b64decode(b64)
18
+ img = Image.open(io.BytesIO(img_data))
19
+ # Resize to max 256px on longest side
20
+ max_dim = 256
21
+ ratio = min(max_dim / img.width, max_dim / img.height)
22
+ if ratio < 1:
23
+ img = img.resize((int(img.width * ratio), int(img.height * ratio)), Image.LANCZOS)
24
+ # Convert to JPEG with quality reduction
25
+ buf = io.BytesIO()
26
+ img.convert("RGB").save(buf, format="JPEG", quality=60)
27
+ compressed = base64.b64encode(buf.getvalue()).decode()
28
+ return compressed
29
+ except Exception:
30
+ # If PIL not available, truncate (model will get partial data)
31
+ return b64[:MAX_IMAGE_B64_SIZE]
32
+
33
+
34
+ def _build_tool_choice_instruction(tool_choice, tool_defs: list) -> str:
35
+ """Build tool_choice constraint instruction.
36
+
37
+ tool_choice values:
38
+ - "none": do not call any tool
39
+ - "auto": decide whether to call tools (default)
40
+ - "required": must call at least one tool
41
+ - {"type": "function", "function": {"name": "xxx"}}: must call specific tool
42
+ """
43
+ if tool_choice == "none":
44
+ return "\n\nIMPORTANT: Do NOT call any tools. Respond with text only."
45
+ if tool_choice == "required":
46
+ return "\n\nIMPORTANT: You MUST call at least one tool. Do not respond with text only."
47
+ if isinstance(tool_choice, dict):
48
+ fn_name = tool_choice.get("function", {}).get("name", "")
49
+ if fn_name:
50
+ return f'\n\nIMPORTANT: You MUST call the tool "{fn_name}". Do not call other tools.'
51
+ return ""
52
+
53
+
54
+ def messages_to_prompt(messages: list, tools: list = None, tool_choice=None) -> tuple:
55
+ """Convert OpenAI messages to (prompt_str, images_list).
56
+
57
+ Returns (prompt, images) where images is a list of (bytes, mime_type) tuples.
58
+ """
59
+ parts = []
60
+ images = []
61
+
62
+ if tools and tool_choice != "none":
63
+ tool_defs = []
64
+ for tool in tools:
65
+ fn = tool.get("function", tool) if tool.get("type") == "function" else tool
66
+ tool_defs.append({
67
+ "name": fn.get("name", tool.get("name", "")),
68
+ "description": fn.get("description", tool.get("description", "")),
69
+ "parameters": fn.get("parameters", tool.get("parameters", {})),
70
+ })
71
+ if tool_defs:
72
+ constraint = _build_tool_choice_instruction(tool_choice, tool_defs)
73
+ parts.append(
74
+ "# Tool Use\n\n"
75
+ "You can call the following tools. Call format:\n"
76
+ '```tool_call\n{"name": "func_name", "arguments": {...}}\n```\n'
77
+ "When calling tools, output ONLY the tool_call block(s).\n\n"
78
+ f"Available tools:\n{json.dumps(tool_defs, indent=2)}"
79
+ f"{constraint}"
80
+ )
81
+
82
+ for msg in messages:
83
+ role = msg.get("role", "user")
84
+ content = msg.get("content", "")
85
+
86
+ if isinstance(content, list):
87
+ text_parts = []
88
+ for c in content:
89
+ if c.get("type") in ("text", "input_text"):
90
+ text_parts.append(c.get("text", ""))
91
+ elif c.get("type") == "image_url":
92
+ text_parts.append("[Note: Image input not supported in this API. Please describe the image in text.]")
93
+ elif c.get("type") == "image":
94
+ text_parts.append("[Note: Image input not supported in this API. Please describe the image in text.]")
95
+ content = " ".join(text_parts)
96
+
97
+ if role == "system":
98
+ parts.append(f"[System instruction]: {content}")
99
+ elif role == "assistant":
100
+ if msg.get("tool_calls"):
101
+ tc_strs = []
102
+ for tc in msg["tool_calls"]:
103
+ fn = tc.get("function", {})
104
+ tc_strs.append(
105
+ f'```tool_call\n{{"name": "{fn.get("name")}", '
106
+ f'"arguments": {fn.get("arguments", "{}")}}}\n```'
107
+ )
108
+ parts.append(f"[Assistant]: {content or ''}\n" + "\n".join(tc_strs))
109
+ else:
110
+ parts.append(f"[Assistant]: {content}")
111
+ elif role == "tool":
112
+ parts.append(f"[Tool result for {msg.get('name', '')}]: {content}")
113
+ else:
114
+ parts.append(content if content else "")
115
+
116
+ prompt = "\n\n".join(p for p in parts if p)
117
+ return prompt, images
118
+
119
+
120
+ def parse_tool_calls(text: str) -> tuple:
121
+ """Extract tool_call blocks. Returns (clean_text, tool_calls_list)."""
122
+ tool_calls = []
123
+ pattern = r'```tool_call\s*\n(.*?)\n```'
124
+ clean_parts = []
125
+ last_end = 0
126
+ for m in re.finditer(pattern, text, re.DOTALL):
127
+ clean_parts.append(text[last_end:m.start()])
128
+ last_end = m.end()
129
+ try:
130
+ data = json.loads(m.group(1).strip())
131
+ tool_calls.append({
132
+ "id": f"call_{uuid.uuid4().hex[:8]}",
133
+ "type": "function",
134
+ "function": {
135
+ "name": data["name"],
136
+ "arguments": json.dumps(data.get("arguments", {}), ensure_ascii=False),
137
+ },
138
+ })
139
+ except (json.JSONDecodeError, KeyError):
140
+ pass
141
+ clean_parts.append(text[last_end:])
142
+ clean = "".join(clean_parts).strip()
143
+ return clean, tool_calls
144
+
145
+
146
+ # ─── Google Native API helpers ─────────────────────────────────────────────────
147
+
148
+
149
+ def build_tool_prompt(tool_defs: list) -> str:
150
+ """Build natural tool-use prompt for Gemini Web that avoids prompt-injection detection."""
151
+ tool_spec = json.dumps(tool_defs, indent=2, ensure_ascii=False)
152
+ return (
153
+ "# Tool Use\n\n"
154
+ "You can call the following tools to help accomplish tasks. "
155
+ "These tools connect to the user's local environment and will execute when called.\n\n"
156
+ "Call format (use this exact format):\n"
157
+ "```function_call\n"
158
+ '{"name": "<tool_name>", "args": {<arguments>}}\n'
159
+ "```\n\n"
160
+ "When calling tools:\n"
161
+ "- Output ONLY the function_call block(s), nothing else\n"
162
+ "- You may call multiple tools with multiple blocks\n"
163
+ "- After receiving a [Tool result for ...], use that data to answer the user\n\n"
164
+ f"Available tools:\n{tool_spec}"
165
+ )
166
+
167
+
168
+ def _google_tool_choice_instruction(req: dict) -> str:
169
+ """Extract tool_choice constraint from Google API toolConfig."""
170
+ tool_config = req.get("toolConfig", {})
171
+ fc_config = tool_config.get("functionCallingConfig", {})
172
+ mode = fc_config.get("mode", "AUTO")
173
+ allowed = fc_config.get("allowedFunctionNames", [])
174
+
175
+ if mode == "NONE":
176
+ return "\n\nIMPORTANT: Do NOT call any tools. Respond with text only."
177
+ if mode == "ANY":
178
+ if allowed:
179
+ names = ", ".join(f'"{n}"' for n in allowed)
180
+ return f"\n\nIMPORTANT: You MUST call one of these tools: {names}. Do not respond with text only."
181
+ return "\n\nIMPORTANT: You MUST call at least one tool. Do not respond with text only."
182
+ return ""
183
+
184
+
185
+ def google_contents_to_prompt(req: dict) -> tuple:
186
+ """Convert Google API contents/tools/systemInstruction to (prompt_str, images_list).
187
+
188
+ Returns (prompt, images) where images is a list of (bytes, mime_type) tuples.
189
+ """
190
+ parts = []
191
+ images = []
192
+
193
+ tool_config = req.get("toolConfig", {})
194
+ fc_mode = tool_config.get("functionCallingConfig", {}).get("mode", "AUTO")
195
+
196
+ tools = req.get("tools")
197
+ tool_defs = []
198
+ if tools and fc_mode != "NONE":
199
+ for tool_group in tools:
200
+ for fn in tool_group.get("functionDeclarations", []):
201
+ td = {"name": fn.get("name", ""), "description": fn.get("description", "")}
202
+ params = fn.get("parameters") or fn.get("parametersJsonSchema")
203
+ if params:
204
+ td["parameters"] = params
205
+ tool_defs.append(td)
206
+
207
+ sys_inst = req.get("systemInstruction")
208
+ if sys_inst:
209
+ sys_parts = sys_inst.get("parts", [])
210
+ sys_text = " ".join(p.get("text", "") for p in sys_parts if p.get("text"))
211
+ if sys_text:
212
+ if tool_defs:
213
+ constraint = _google_tool_choice_instruction(req)
214
+ parts.append(sys_text + "\n\n" + build_tool_prompt(tool_defs) + constraint)
215
+ else:
216
+ parts.append(sys_text)
217
+ elif tool_defs:
218
+ constraint = _google_tool_choice_instruction(req)
219
+ parts.append(build_tool_prompt(tool_defs) + constraint)
220
+
221
+ for content in req.get("contents", []):
222
+ role = content.get("role", "user")
223
+ msg_parts = []
224
+ for p in content.get("parts", []):
225
+ if p.get("text"):
226
+ msg_parts.append(p["text"])
227
+ elif p.get("inlineData"):
228
+ data = p["inlineData"]
229
+ mime = data.get("mimeType", "image/png")
230
+ images.append((base64.b64decode(data["data"]), mime))
231
+ elif p.get("functionCall"):
232
+ fc = p["functionCall"]
233
+ msg_parts.append(
234
+ f'```function_call\n{json.dumps({"name": fc["name"], "args": fc.get("args", {})}, ensure_ascii=False)}\n```'
235
+ )
236
+ elif p.get("functionResponse"):
237
+ fr = p["functionResponse"]
238
+ msg_parts.append(
239
+ f'[Tool result for {fr.get("name", "")}]: {json.dumps(fr.get("response", {}), ensure_ascii=False)}'
240
+ )
241
+ text = "\n".join(msg_parts)
242
+ if role == "model":
243
+ parts.append(f"[Assistant]: {text}")
244
+ else:
245
+ parts.append(text)
246
+
247
+ return "\n\n".join(p for p in parts if p), images
248
+
249
+
250
+ def parse_google_function_calls(text: str) -> tuple:
251
+ """Extract function_call blocks from model output.
252
+
253
+ Handles 3 formats:
254
+ 1. ```function_call\\n{...}\\n``` (standard)
255
+ 2. function_call\\n{...} (without backticks)
256
+ 3. Raw JSON with "name" + "args" keys
257
+
258
+ Returns (clean_text, [{"name": ..., "args": ...}])
259
+ """
260
+ function_calls = []
261
+ pattern1 = r'```function_call\s*\n(.*?)\n```'
262
+ pattern2 = r'(?:^|\n)function_call\s*\n(\{[^`]*?\})'
263
+ clean = text
264
+ for pattern in [pattern1, pattern2]:
265
+ for match in re.findall(pattern, clean, re.DOTALL):
266
+ try:
267
+ data = json.loads(match.strip())
268
+ if "name" in data:
269
+ function_calls.append({
270
+ "name": data["name"],
271
+ "args": data.get("args", data.get("arguments", {})),
272
+ })
273
+ except (json.JSONDecodeError, KeyError):
274
+ pass
275
+ clean = re.sub(pattern, '', clean, flags=re.DOTALL).strip()
276
+ if not function_calls and clean.strip().startswith("{"):
277
+ try:
278
+ data = json.loads(clean.strip())
279
+ if "name" in data and ("args" in data or "arguments" in data):
280
+ function_calls.append({
281
+ "name": data["name"],
282
+ "args": data.get("args", data.get("arguments", {})),
283
+ })
284
+ clean = ""
285
+ except (json.JSONDecodeError, KeyError):
286
+ pass
287
+ return clean, function_calls