Sophomores commited on
Commit
d27caee
·
1 Parent(s): 85919ae

fix: code review issues (P1-P3)

Browse files

- Cookie file caching with mtime check (no disk read per request)
- Streaming retry logic (same as non-streaming)
- Reusable httpx.Client and ssl context (no per-request allocation)
- Shared _extract_texts_from_line() eliminates parsing duplication
- _start_sse() helper eliminates 4x SSE header repetition
- _usage() helper for token estimation
- _parse_body() with proper 400 on invalid JSON
- ValueError guard on @think = parameter
- Single-pass regex in parse_tool_calls
- Fixed __main__.py httpx detection (was always showing 'fallback')
- Removed unused base64 import from gemini.py

gemini_web2api/__main__.py CHANGED
@@ -4,6 +4,7 @@ import os
4
 
5
  from .config import CONFIG, load_config, find_config
6
  from .models import MODELS
 
7
  from .server import GeminiHandler, ThreadedServer
8
  from . import __version__
9
 
@@ -36,7 +37,7 @@ def main():
36
  print(f" Models: {', '.join(MODELS.keys())}")
37
  print(f" Cookie: {'yes' if CONFIG.get('cookie_file') else 'none (anonymous)'}")
38
  print(f" Proxy: {CONFIG.get('proxy') or 'system env'}")
39
- print(f" Streaming: {'httpx (true)' if 'httpx' in dir() else 'fallback'}")
40
  print()
41
  try:
42
  server.serve_forever()
 
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
 
 
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()
gemini_web2api/gemini.py CHANGED
@@ -8,7 +8,6 @@ import urllib.parse
8
  import ssl
9
  import os
10
  import hashlib
11
- import base64
12
 
13
  try:
14
  import httpx
@@ -18,6 +17,10 @@ except ImportError:
18
 
19
  from .config import CONFIG
20
 
 
 
 
 
21
 
22
  def log(msg: str):
23
  if CONFIG["log_requests"]:
@@ -26,12 +29,31 @@ def log(msg: str):
26
  sys.stderr.flush()
27
 
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  def load_cookie() -> tuple:
30
- """Load cookie from file. Returns (cookie_str, sapisid)."""
31
  cookie_file = CONFIG.get("cookie_file")
32
  if not cookie_file or not os.path.exists(cookie_file):
33
  return "", None
34
  try:
 
 
 
35
  with open(cookie_file, "r") as f:
36
  content = f.read().strip()
37
  if content.startswith("{"):
@@ -42,10 +64,11 @@ def load_cookie() -> tuple:
42
  cookie_str = content
43
  pairs = dict(p.split("=", 1) for p in cookie_str.split("; ") if "=" in p)
44
  sapisid = pairs.get("SAPISID", "")
 
45
  return cookie_str, sapisid if sapisid else None
46
  except Exception as e:
47
  log(f"Cookie load error: {e}")
48
- return "", None
49
 
50
 
51
  def make_sapisidhash(sapisid: str) -> str:
@@ -71,7 +94,6 @@ def _build_headers() -> dict:
71
 
72
 
73
  def _build_payload(prompt: str, model_id: int, think_mode: int, image_b64: str = None) -> str:
74
- """Build StreamGenerate request payload."""
75
  inner = [None] * 80
76
  inner[0] = [prompt, 0, None, None, None, None, 0]
77
  inner[1] = ["en"]
@@ -90,10 +112,7 @@ def _build_payload(prompt: str, model_id: int, think_mode: int, image_b64: str =
90
  inner[61] = []
91
  inner[68] = 1
92
  inner[79] = model_id
93
-
94
- if image_b64:
95
- inner[12] = [[["data:image/jpeg;base64," + image_b64, 1]]]
96
-
97
  outer = [None, json.dumps(inner)]
98
  return urllib.parse.urlencode({"f.req": json.dumps(outer)})
99
 
@@ -108,55 +127,57 @@ def _get_url() -> str:
108
 
109
 
110
  def clean_text(text: str) -> str:
111
- """Remove internal code execution artifacts."""
112
- text = re.sub(
113
  r'```(?:python|javascript|text)\?code_(?:reference|stdout)&code_event_index=\d+\n.*?```\n?',
114
  '', text, flags=re.DOTALL
115
- )
116
- return text.strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
 
119
  def extract_response_text(raw: str) -> str:
120
- """Parse StreamGenerate response to extract final text."""
121
- texts = []
122
  for line in raw.split("\n"):
123
- if '"wrb.fr"' not in line or len(line) < 200:
124
- continue
125
- try:
126
- arr = json.loads(line)
127
- inner_str = arr[0][2]
128
- if not inner_str or len(inner_str) < 50:
129
- continue
130
- inner = json.loads(inner_str)
131
- if isinstance(inner, list) and len(inner) > 4 and inner[4]:
132
- for part in inner[4]:
133
- if isinstance(part, list) and len(part) > 1 and part[1]:
134
- if isinstance(part[1], list):
135
- for t in part[1]:
136
- if isinstance(t, str) and len(t) > 0:
137
- texts.append(t)
138
- except (json.JSONDecodeError, IndexError, TypeError):
139
- pass
140
- text = ""
141
- for t in reversed(texts):
142
- if t.strip():
143
- text = t
144
- break
145
- return clean_text(text)
146
 
147
 
148
  def generate(prompt: str, model_id: int, think_mode: int, image_b64: str = None) -> str:
149
- """Non-streaming: send and return full text response."""
150
  body = _build_payload(prompt, model_id, think_mode, image_b64).encode()
151
  url = _get_url()
152
  headers = _build_headers()
 
 
153
 
154
  last_err = None
155
  for attempt in range(CONFIG["retry_attempts"]):
156
  try:
157
  req = urllib.request.Request(url, data=body, headers=headers, method="POST")
158
- ctx = ssl.create_default_context()
159
- proxy = CONFIG.get("proxy")
160
  if proxy:
161
  opener = urllib.request.build_opener(
162
  urllib.request.ProxyHandler({"http": proxy, "https": proxy}),
@@ -176,44 +197,38 @@ def generate(prompt: str, model_id: int, think_mode: int, image_b64: str = None)
176
 
177
 
178
  def generate_stream(prompt: str, model_id: int, think_mode: int, image_b64: str = None):
179
- """Streaming: yield incremental text deltas via httpx."""
180
- body = _build_payload(prompt, model_id, think_mode, image_b64)
181
- url = _get_url()
182
- headers = _build_headers()
183
- proxy = CONFIG.get("proxy")
184
-
185
  if not HAS_HTTPX:
186
  text = generate(prompt, model_id, think_mode, image_b64)
187
  if text:
188
  yield text
189
  return
190
 
191
- prev_text = ""
192
- transport = httpx.HTTPTransport(proxy=proxy) if proxy else None
193
- with httpx.Client(transport=transport, timeout=CONFIG["request_timeout_sec"], verify=True) as client:
194
- with client.stream("POST", url, content=body, headers=headers) as resp:
195
- buf = ""
196
- for chunk in resp.iter_text():
197
- buf += chunk
198
- while "\n" in buf:
199
- line, buf = buf.split("\n", 1)
200
- if '"wrb.fr"' not in line or len(line) < 200:
201
- continue
202
- try:
203
- arr = json.loads(line)
204
- inner_str = arr[0][2]
205
- if not inner_str or len(inner_str) < 50:
206
- continue
207
- inner2 = json.loads(inner_str)
208
- if isinstance(inner2, list) and len(inner2) > 4 and inner2[4]:
209
- for part in inner2[4]:
210
- if isinstance(part, list) and len(part) > 1 and part[1] and isinstance(part[1], list):
211
- for t in part[1]:
212
- if isinstance(t, str) and len(t) > len(prev_text):
213
- delta = t[len(prev_text):]
214
- delta = clean_text(delta)
215
- if delta:
216
- yield delta
217
- prev_text = t
218
- except (json.JSONDecodeError, IndexError, TypeError):
219
- pass
 
8
  import ssl
9
  import os
10
  import hashlib
 
11
 
12
  try:
13
  import httpx
 
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"]:
 
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("{"):
 
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:
 
94
 
95
 
96
  def _build_payload(prompt: str, model_id: int, think_mode: int, image_b64: str = None) -> str:
 
97
  inner = [None] * 80
98
  inner[0] = [prompt, 0, None, None, None, None, 0]
99
  inner[1] = ["en"]
 
112
  inner[61] = []
113
  inner[68] = 1
114
  inner[79] = model_id
115
+ # Note: multimodal image support requires separate upload API (not yet implemented)
 
 
 
116
  outer = [None, json.dumps(inner)]
117
  return urllib.parse.urlencode({"f.req": json.dumps(outer)})
118
 
 
127
 
128
 
129
  def clean_text(text: str) -> str:
130
+ return re.sub(
 
131
  r'```(?:python|javascript|text)\?code_(?:reference|stdout)&code_event_index=\d+\n.*?```\n?',
132
  '', text, flags=re.DOTALL
133
+ ).strip()
134
+
135
+
136
+ def _extract_texts_from_line(line: str) -> list:
137
+ """Parse a single wrb.fr line and return list of text strings found."""
138
+ if '"wrb.fr"' not in line or len(line) < 200:
139
+ return []
140
+ try:
141
+ arr = json.loads(line)
142
+ inner_str = arr[0][2]
143
+ if not inner_str or len(inner_str) < 50:
144
+ return []
145
+ inner = json.loads(inner_str)
146
+ if not (isinstance(inner, list) and len(inner) > 4 and inner[4]):
147
+ return []
148
+ texts = []
149
+ for part in inner[4]:
150
+ if isinstance(part, list) and len(part) > 1 and part[1] and isinstance(part[1], list):
151
+ for t in part[1]:
152
+ if isinstance(t, str) and t:
153
+ texts.append(t)
154
+ return texts
155
+ except (json.JSONDecodeError, IndexError, TypeError):
156
+ return []
157
 
158
 
159
  def extract_response_text(raw: str) -> str:
160
+ """Parse full response to get final text."""
161
+ last_text = ""
162
  for line in raw.split("\n"):
163
+ for t in _extract_texts_from_line(line):
164
+ if len(t) > len(last_text):
165
+ last_text = t
166
+ return clean_text(last_text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
 
168
 
169
  def generate(prompt: str, model_id: int, think_mode: int, image_b64: str = None) -> str:
170
+ """Non-streaming generation with retry."""
171
  body = _build_payload(prompt, model_id, think_mode, image_b64).encode()
172
  url = _get_url()
173
  headers = _build_headers()
174
+ ctx = _get_ssl_ctx()
175
+ proxy = CONFIG.get("proxy")
176
 
177
  last_err = None
178
  for attempt in range(CONFIG["retry_attempts"]):
179
  try:
180
  req = urllib.request.Request(url, data=body, headers=headers, method="POST")
 
 
181
  if proxy:
182
  opener = urllib.request.build_opener(
183
  urllib.request.ProxyHandler({"http": proxy, "https": proxy}),
 
197
 
198
 
199
  def generate_stream(prompt: str, model_id: int, think_mode: int, image_b64: str = None):
200
+ """Streaming generation via httpx with retry on connection failure."""
 
 
 
 
 
201
  if not HAS_HTTPX:
202
  text = generate(prompt, model_id, think_mode, image_b64)
203
  if text:
204
  yield text
205
  return
206
 
207
+ body = _build_payload(prompt, model_id, think_mode, image_b64)
208
+ url = _get_url()
209
+ headers = _build_headers()
210
+ client = _get_httpx_client()
211
+
212
+ last_err = None
213
+ for attempt in range(CONFIG["retry_attempts"]):
214
+ try:
215
+ prev_text = ""
216
+ with client.stream("POST", url, content=body, headers=headers) as resp:
217
+ buf = ""
218
+ for chunk in resp.iter_text():
219
+ buf += chunk
220
+ while "\n" in buf:
221
+ line, buf = buf.split("\n", 1)
222
+ for t in _extract_texts_from_line(line):
223
+ if len(t) > len(prev_text):
224
+ delta = clean_text(t[len(prev_text):])
225
+ if delta:
226
+ yield delta
227
+ prev_text = t
228
+ return
229
+ except Exception as e:
230
+ last_err = e
231
+ if attempt < CONFIG["retry_attempts"] - 1:
232
+ log(f"Stream retry {attempt+1}/{CONFIG['retry_attempts']}: {e}")
233
+ time.sleep(CONFIG["retry_delay_sec"])
234
+ raise last_err
 
gemini_web2api/models.py CHANGED
@@ -36,7 +36,10 @@ def resolve_model(model_name: str, default: str = "gemini-3.5-flash"):
36
  think_override = None
37
  if "@think=" in model_name:
38
  model_name, think_str = model_name.rsplit("@think=", 1)
39
- think_override = int(think_str)
 
 
 
40
  cfg = MODELS.get(model_name)
41
  if not cfg:
42
  return None, None, None, f"Unknown model: {model_name}"
 
36
  think_override = None
37
  if "@think=" in model_name:
38
  model_name, think_str = model_name.rsplit("@think=", 1)
39
+ try:
40
+ think_override = int(think_str)
41
+ except ValueError:
42
+ return None, None, None, f"Invalid think level: {think_str}"
43
  cfg = MODELS.get(model_name)
44
  if not cfg:
45
  return None, None, None, f"Unknown model: {model_name}"
gemini_web2api/server.py CHANGED
@@ -3,7 +3,6 @@ import json
3
  import time
4
  import uuid
5
  import re
6
- import sys
7
  from http.server import HTTPServer, BaseHTTPRequestHandler
8
  from socketserver import ThreadingMixIn
9
 
@@ -14,6 +13,12 @@ from .tools import messages_to_prompt, parse_tool_calls
14
  from . import __version__
15
 
16
 
 
 
 
 
 
 
17
  class GeminiHandler(BaseHTTPRequestHandler):
18
  def log_message(self, fmt, *args):
19
  log(fmt % args)
@@ -27,6 +32,19 @@ class GeminiHandler(BaseHTTPRequestHandler):
27
  self.end_headers()
28
  self.wfile.write(body)
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  def _authorized(self):
31
  keys = CONFIG.get("api_keys") or []
32
  if not keys:
@@ -95,7 +113,10 @@ class GeminiHandler(BaseHTTPRequestHandler):
95
  # ─── /v1/chat/completions ─────────────────────────────────────────────────
96
 
97
  def _handle_chat(self, body: bytes):
98
- req = json.loads(body)
 
 
 
99
  model_name, model_id, think_mode, err = resolve_model(
100
  req.get("model", CONFIG["default_model"]))
101
  if err:
@@ -113,11 +134,7 @@ class GeminiHandler(BaseHTTPRequestHandler):
113
 
114
  if stream and not tools:
115
  try:
116
- self.send_response(200)
117
- self.send_header("Content-Type", "text/event-stream")
118
- self.send_header("Cache-Control", "no-cache")
119
- self.send_header("Access-Control-Allow-Origin", "*")
120
- self.end_headers()
121
  for delta in generate_stream(prompt, model_id, think_mode, image_b64):
122
  chunk = {"id": cid, "object": "chat.completion.chunk", "created": int(time.time()),
123
  "model": model_name, "choices": [{"index": 0, "delta": {"content": delta}, "finish_reason": None}]}
@@ -147,11 +164,7 @@ class GeminiHandler(BaseHTTPRequestHandler):
147
  finish = "tool_calls" if tool_calls else "stop"
148
 
149
  if stream:
150
- self.send_response(200)
151
- self.send_header("Content-Type", "text/event-stream")
152
- self.send_header("Cache-Control", "no-cache")
153
- self.send_header("Access-Control-Allow-Origin", "*")
154
- self.end_headers()
155
  chunk = {"id": cid, "object": "chat.completion.chunk", "created": int(time.time()),
156
  "model": model_name, "choices": [{"index": 0, "delta": msg, "finish_reason": finish}]}
157
  self.wfile.write(f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n".encode())
@@ -169,7 +182,10 @@ class GeminiHandler(BaseHTTPRequestHandler):
169
  # ─── /v1/responses (Codex CLI) ───────────────────────────────────────────
170
 
171
  def _handle_responses(self, body: bytes):
172
- req = json.loads(body)
 
 
 
173
  model_name, model_id, think_mode, err = resolve_model(
174
  req.get("model", CONFIG["default_model"]))
175
  if err:
@@ -272,7 +288,10 @@ class GeminiHandler(BaseHTTPRequestHandler):
272
  # ─── /v1beta/models (Google Gemini CLI) ──────────────────────────────────
273
 
274
  def _handle_google_generate(self, body: bytes, stream: bool):
275
- req = json.loads(body)
 
 
 
276
  m = re.match(r'/v1beta/models/([^:?]+)', self.path)
277
  model_name = m.group(1) if m else CONFIG["default_model"]
278
  model_name, model_id, think_mode, err = resolve_model(model_name)
 
3
  import time
4
  import uuid
5
  import re
 
6
  from http.server import HTTPServer, BaseHTTPRequestHandler
7
  from socketserver import ThreadingMixIn
8
 
 
13
  from . import __version__
14
 
15
 
16
+ def _usage(prompt: str, text: str) -> dict:
17
+ p = len(prompt) // 4
18
+ c = len(text or "") // 4
19
+ return {"prompt_tokens": p, "completion_tokens": c, "total_tokens": p + c}
20
+
21
+
22
  class GeminiHandler(BaseHTTPRequestHandler):
23
  def log_message(self, fmt, *args):
24
  log(fmt % args)
 
32
  self.end_headers()
33
  self.wfile.write(body)
34
 
35
+ def _start_sse(self):
36
+ self.send_response(200)
37
+ self.send_header("Content-Type", "text/event-stream")
38
+ self.send_header("Cache-Control", "no-cache")
39
+ self.send_header("Access-Control-Allow-Origin", "*")
40
+ self.end_headers()
41
+
42
+ def _parse_body(self, body: bytes) -> dict:
43
+ try:
44
+ return json.loads(body)
45
+ except (json.JSONDecodeError, ValueError):
46
+ return None
47
+
48
  def _authorized(self):
49
  keys = CONFIG.get("api_keys") or []
50
  if not keys:
 
113
  # ─── /v1/chat/completions ─────────────────────────────────────────────────
114
 
115
  def _handle_chat(self, body: bytes):
116
+ req = self._parse_body(body)
117
+ if req is None:
118
+ self.send_json({"error": {"message": "invalid JSON"}}, 400)
119
+ return
120
  model_name, model_id, think_mode, err = resolve_model(
121
  req.get("model", CONFIG["default_model"]))
122
  if err:
 
134
 
135
  if stream and not tools:
136
  try:
137
+ self._start_sse()
 
 
 
 
138
  for delta in generate_stream(prompt, model_id, think_mode, image_b64):
139
  chunk = {"id": cid, "object": "chat.completion.chunk", "created": int(time.time()),
140
  "model": model_name, "choices": [{"index": 0, "delta": {"content": delta}, "finish_reason": None}]}
 
164
  finish = "tool_calls" if tool_calls else "stop"
165
 
166
  if stream:
167
+ self._start_sse()
 
 
 
 
168
  chunk = {"id": cid, "object": "chat.completion.chunk", "created": int(time.time()),
169
  "model": model_name, "choices": [{"index": 0, "delta": msg, "finish_reason": finish}]}
170
  self.wfile.write(f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n".encode())
 
182
  # ─── /v1/responses (Codex CLI) ───────────────────────────────────────────
183
 
184
  def _handle_responses(self, body: bytes):
185
+ req = self._parse_body(body)
186
+ if req is None:
187
+ self.send_json({"error": {"message": "invalid JSON"}}, 400)
188
+ return
189
  model_name, model_id, think_mode, err = resolve_model(
190
  req.get("model", CONFIG["default_model"]))
191
  if err:
 
288
  # ─── /v1beta/models (Google Gemini CLI) ──────────────────────────────────
289
 
290
  def _handle_google_generate(self, body: bytes, stream: bool):
291
+ req = self._parse_body(body)
292
+ if req is None:
293
+ self.send_json({"error": {"message": "invalid JSON"}}, 400)
294
+ return
295
  m = re.match(r'/v1beta/models/([^:?]+)', self.path)
296
  model_name = m.group(1) if m else CONFIG["default_model"]
297
  model_name, model_id, think_mode, err = resolve_model(model_name)
gemini_web2api/tools.py CHANGED
@@ -76,9 +76,13 @@ def parse_tool_calls(text: str) -> tuple:
76
  """Extract tool_call blocks. Returns (clean_text, tool_calls_list)."""
77
  tool_calls = []
78
  pattern = r'```tool_call\s*\n(.*?)\n```'
79
- for match in re.findall(pattern, text, re.DOTALL):
 
 
 
 
80
  try:
81
- data = json.loads(match.strip())
82
  tool_calls.append({
83
  "id": f"call_{uuid.uuid4().hex[:8]}",
84
  "type": "function",
@@ -89,7 +93,8 @@ def parse_tool_calls(text: str) -> tuple:
89
  })
90
  except (json.JSONDecodeError, KeyError):
91
  pass
92
- clean = re.sub(pattern, '', text, flags=re.DOTALL).strip()
 
93
  return clean, tool_calls
94
 
95
 
 
76
  """Extract tool_call blocks. Returns (clean_text, tool_calls_list)."""
77
  tool_calls = []
78
  pattern = r'```tool_call\s*\n(.*?)\n```'
79
+ clean_parts = []
80
+ last_end = 0
81
+ for m in re.finditer(pattern, text, re.DOTALL):
82
+ clean_parts.append(text[last_end:m.start()])
83
+ last_end = m.end()
84
  try:
85
+ data = json.loads(m.group(1).strip())
86
  tool_calls.append({
87
  "id": f"call_{uuid.uuid4().hex[:8]}",
88
  "type": "function",
 
93
  })
94
  except (json.JSONDecodeError, KeyError):
95
  pass
96
+ clean_parts.append(text[last_end:])
97
+ clean = "".join(clean_parts).strip()
98
  return clean, tool_calls
99
 
100