bluewinliang commited on
Commit
2c319e7
·
verified ·
1 Parent(s): d2e445a

Upload proxy_handler.py

Browse files
Files changed (1) hide show
  1. proxy_handler.py +47 -89
proxy_handler.py CHANGED
@@ -1,15 +1,13 @@
1
  """
2
  Proxy handler for Z.AI API requests
3
  """
4
- import json, logging, re, time, uuid
5
  from typing import AsyncGenerator, Dict, Any, Tuple, List
 
 
6
  import httpx
7
  from fastapi import HTTPException
8
  from fastapi.responses import StreamingResponse
9
- import hashlib
10
- import hmac
11
- import urllib.parse
12
- from datetime import datetime, timezone
13
 
14
  from config import settings
15
  from cookie_manager import cookie_manager
@@ -32,22 +30,26 @@ class ProxyHandler:
32
  if not self.client.is_closed:
33
  await self.client.aclose()
34
 
35
- # --- 新增方法:获取毫秒级时间戳 ---
36
  def _get_timestamp_millis(self) -> int:
37
  return int(time.time() * 1000)
38
 
39
- # --- 新增方法:解析JWT以获取用户ID ---
40
  def _parse_jwt_token(self, token: str) -> Dict[str, str]:
 
41
  try:
42
  parts = token.split('.')
43
  if len(parts) != 3:
44
  return {"userId": ""}
45
- payload = json.loads(base64.urlsafe_b64decode(parts[1] + '==').decode('utf-8'))
 
 
 
 
 
46
  return {"userId": payload.get("sub", "")}
47
- except Exception:
 
48
  return {"userId": ""}
49
 
50
- # --- 新增方法:生成签名 ---
51
  def _generate_signature(self, token: str, payload_str: str, mt: str) -> Tuple[str, int]:
52
  timestamp_ms = self._get_timestamp_millis()
53
  minute_bucket = str(timestamp_ms // 60000)
@@ -63,55 +65,25 @@ class ProxyHandler:
63
  return signature, timestamp_ms
64
 
65
  def _clean_thinking_content(self, text: str) -> str:
66
- """
67
- Aggressively cleans raw thinking content strings based on observed patterns
68
- from the Z.AI API.
69
- """
70
- if not text:
71
- return ""
72
-
73
  cleaned_text = text
74
-
75
- # 1. Remove specific unwanted blocks like tool calls and summaries.
76
  cleaned_text = re.sub(r'<summary>.*?</summary>', '', cleaned_text, flags=re.DOTALL)
77
  cleaned_text = re.sub(r'<glm_block.*?</glm_block>', '', cleaned_text, flags=re.DOTALL)
78
-
79
- # 2. **FIX**: Remove tag-like metadata containing `duration` attribute.
80
- # This handles the reported issue: `true" duration="0" ... >`
81
  cleaned_text = re.sub(r'<[^>]*duration="[^"]*"[^>]*>', '', cleaned_text)
82
-
83
- # 3. Remove specific structural tags, but keep the content between them.
84
- cleaned_text = cleaned_text.replace("</thinking>", "")
85
- cleaned_text = cleaned_text.replace("<Full>", "")
86
- cleaned_text = cleaned_text.replace("</Full>", "")
87
- # This regex handles <details>, <details open>, and </details>
88
  cleaned_text = re.sub(r'</?details[^>]*>', '', cleaned_text)
89
-
90
- # 4. Handle markdown blockquotes, preserving multi-level ones.
91
  cleaned_text = re.sub(r'^\s*>\s*(?!>)', '', cleaned_text, flags=re.MULTILINE)
92
-
93
- # 5. Remove other known text artifacts.
94
  cleaned_text = cleaned_text.replace("Thinking…", "")
95
-
96
- # 6. Final strip to clean up residual whitespace.
97
  return cleaned_text.strip()
98
 
99
  def _clean_answer_content(self, text: str) -> str:
100
- """
101
- Cleans unwanted tags from answer content.
102
- Does NOT strip whitespace to preserve markdown in streams.
103
- """
104
- if not text:
105
- return ""
106
- # Remove tool call blocks
107
  cleaned_text = re.sub(r'<glm_block.*?</glm_block>', '', text, flags=re.DOTALL)
108
- # Remove any residual details/summary blocks that might leak into the answer
109
  cleaned_text = re.sub(r'<details[^>]*>.*?</details>', '', cleaned_text, flags=re.DOTALL)
110
  cleaned_text = re.sub(r'<summary>.*?</summary>', '', cleaned_text, flags=re.DOTALL)
111
  return cleaned_text
112
 
113
  def _serialize_msgs(self, msgs) -> list:
114
- """Converts message objects to a list of dictionaries."""
115
  out = []
116
  for m in msgs:
117
  if hasattr(m, "dict"): out.append(m.dict())
@@ -120,7 +92,6 @@ class ProxyHandler:
120
  else: out.append({"role": getattr(m, "role", "user"), "content": getattr(m, "content", str(m))})
121
  return out
122
 
123
- # --- 重构 _prep_upstream 方法以加入签名逻辑 ---
124
  async def _prep_upstream(self, req: ChatCompletionRequest) -> Tuple[Dict[str, Any], Dict[str, str], str, str]:
125
  """Prepares the request body, headers, cookie, and URL for the upstream API."""
126
  ck = await cookie_manager.get_next_cookie()
@@ -134,22 +105,30 @@ class ProxyHandler:
134
 
135
  body = { "stream": True, "model": model, "messages": self._serialize_msgs(req.messages), "background_tasks": {"title_generation": True, "tags_generation": True}, "chat_id": chat_id, "features": {"image_generation": False, "code_interpreter": False, "web_search": False, "auto_web_search": False, "enable_thinking": True,}, "id": request_id, "mcp_servers": ["deep-web-search"], "model_item": {"id": model, "name": "GLM-4.6", "owned_by": "openai"}, "params": {}, "tool_servers": [], "variables": {"{{USER_NAME}}": "User", "{{USER_LOCATION}}": "Unknown", "{{CURRENT_DATETIME}}": time.strftime("%Y-%m-%d %H:%M:%S"),},}
136
 
137
- # 构造用于签名的负载
138
- timestamp = self._get_timestamp_millis()
139
- now = datetime.now(timezone.utc)
 
 
 
 
140
 
 
 
 
 
141
  payload_data = {
142
- 'timestamp': str(timestamp),
143
  'requestId': request_id,
144
  'user_id': user_id,
145
  'token': ck,
146
- 'user_agent': "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
147
  'current_url': f"https://chat.z.ai/c/{chat_id}",
148
  'pathname': f"/c/{chat_id}",
149
- 'timezone': 'UTC', # 简化时区处理
150
- 'timezone_offset': '0',
151
- 'local_time': now.isoformat(),
152
- 'utc_time': now.strftime('%a, %d %b %Y %H:%M:%S GMT'),
153
  'version': '0.0.1',
154
  'platform': 'web',
155
  'language': 'zh-CN',
@@ -181,14 +160,17 @@ class ProxyHandler:
181
  sorted_payload = ",".join([f"{k},{payload_data[k]}" for k in keys])
182
  url_params = urllib.parse.urlencode(payload_data)
183
 
184
- # 获取最后一条消息作为 mt
185
- last_message = req.messages[-1].content if req.messages else ""
 
 
 
186
 
187
- signature, sig_timestamp = self._generate_signature(ck, sorted_payload, last_message)
188
 
189
  final_url = f"{settings.UPSTREAM_URL}?{url_params}&signature_timestamp={sig_timestamp}"
190
 
191
- headers = { "Content-Type": "application/json", "Authorization": f"Bearer {ck}", "User-Agent": ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"), "Accept": "application/json, text/event-stream", "Accept-Language": "zh-CN", "sec-ch-ua": '"Not)A;Brand";v="8", "Chromium";v="138", "Google Chrome";v="138"', "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": '"macOS"', "x-fe-version": "prod-fe-1.0.79", "X-Signature": signature, "Origin": "https://chat.z.ai", "Referer": "https://chat.z.ai/",}
192
 
193
  return body, headers, ck, final_url
194
 
@@ -200,7 +182,6 @@ class ProxyHandler:
200
  think_open = False
201
  yielded_think_buffer = ""
202
  current_raw_thinking = ""
203
- # **FIX**: State to handle the transition from thinking to answer
204
  is_first_answer_chunk = True
205
 
206
  async def yield_delta(content_type: str, text: str):
@@ -209,19 +190,15 @@ class ProxyHandler:
209
  if not think_open:
210
  yield f"data: {json.dumps({'id': comp_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': req.model, 'choices': [{'index': 0, 'delta': {'content': '<think>'}, 'finish_reason': None}]})}\n\n"
211
  think_open = True
212
-
213
  cleaned_full_text = self._clean_thinking_content(text)
214
- delta_to_send = cleaned_full_text[len(yielded_think_buffer):] if cleaned_full_text.startswith(yielded_think_buffer) else cleaned_full_text
215
-
216
  if delta_to_send:
217
  yield f"data: {json.dumps({'id': comp_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': req.model, 'choices': [{'index': 0, 'delta': {'content': delta_to_send}, 'finish_reason': None}]})}\n\n"
218
  yielded_think_buffer = cleaned_full_text
219
-
220
  elif content_type == "answer":
221
  if think_open:
222
  yield f"data: {json.dumps({'id': comp_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': req.model, 'choices': [{'index': 0, 'delta': {'content': '</think>'}, 'finish_reason': None}]})}\n\n"
223
  think_open = False
224
-
225
  cleaned_text = self._clean_answer_content(text)
226
  if cleaned_text:
227
  yield f"data: {json.dumps({'id': comp_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': req.model, 'choices': [{'index': 0, 'delta': {'content': cleaned_text}, 'finish_reason': None}]})}\n\n"
@@ -230,6 +207,7 @@ class ProxyHandler:
230
  if resp.status_code != 200:
231
  await cookie_manager.mark_cookie_failed(ck); err_body = await resp.aread()
232
  err_msg = f"Error: {resp.status_code} - {err_body.decode(errors='ignore')}"
 
233
  err = {"id": comp_id, "object": "chat.completion.chunk", "created": int(time.time()), "model": req.model, "choices": [{"index": 0, "delta": {"content": err_msg}, "finish_reason": "stop"}],}
234
  yield f"data: {json.dumps(err)}\n\n"; yield "data: [DONE]\n\n"; return
235
  await cookie_manager.mark_cookie_success(ck)
@@ -238,7 +216,6 @@ class ProxyHandler:
238
  for line in raw.strip().split('\n'):
239
  line = line.strip()
240
  if not line.startswith('data: '): continue
241
-
242
  payload_str = line[6:]
243
  if payload_str == '[DONE]':
244
  if think_open:
@@ -248,34 +225,23 @@ class ProxyHandler:
248
  return
249
  try:
250
  dat = json.loads(payload_str).get("data", {})
251
- except (json.JSONDecodeError, AttributeError):
252
- continue
253
 
254
  phase = dat.get("phase")
255
  content_chunk = dat.get("delta_content") or dat.get("edit_content")
256
-
257
- if not content_chunk:
258
- continue
259
 
260
  if phase == "thinking":
261
- if dat.get("edit_content") is not None:
262
- current_raw_thinking = content_chunk
263
- else:
264
- current_raw_thinking += content_chunk
265
  async for item in yield_delta("thinking", current_raw_thinking):
266
  yield item
267
-
268
  elif phase == "answer":
269
  content_to_process = content_chunk
270
- # **FIX**: Special handling for the first answer chunk
271
  if is_first_answer_chunk:
272
- # The first answer chunk often contains leftover thinking content.
273
- # We split by '</details>' and only use the part after it.
274
  if '</details>' in content_to_process:
275
  parts = content_to_process.split('</details>', 1)
276
  content_to_process = parts[1] if len(parts) > 1 else ""
277
  is_first_answer_chunk = False
278
-
279
  if content_to_process:
280
  async for item in yield_delta("answer", content_to_process):
281
  yield item
@@ -291,6 +257,7 @@ class ProxyHandler:
291
  async with self.client.stream("POST", url, json=body, headers=headers) as resp:
292
  if resp.status_code != 200:
293
  await cookie_manager.mark_cookie_failed(ck); error_detail = await resp.text()
 
294
  raise HTTPException(resp.status_code, f"Upstream error: {error_detail}")
295
  await cookie_manager.mark_cookie_success(ck)
296
 
@@ -309,26 +276,18 @@ class ProxyHandler:
309
 
310
  phase = dat.get("phase")
311
  content_chunk = dat.get("delta_content") or dat.get("edit_content")
312
-
313
- if not content_chunk:
314
- continue
315
 
316
  if phase == "thinking":
317
- if dat.get("edit_content") is not None:
318
- current_raw_thinking = content_chunk
319
- else:
320
- current_raw_thinking += content_chunk
321
  last_thinking_content = current_raw_thinking
322
-
323
  elif phase == "answer":
324
  content_to_process = content_chunk
325
- # **FIX**: Apply same logic to non-stream mode
326
  if is_first_answer_chunk:
327
  if '</details>' in content_to_process:
328
  parts = content_to_process.split('</details>', 1)
329
  content_to_process = parts[1] if len(parts) > 1 else ""
330
  is_first_answer_chunk = False
331
-
332
  if content_to_process:
333
  raw_answer_parts.append(content_to_process)
334
  else:
@@ -336,7 +295,6 @@ class ProxyHandler:
336
  break
337
 
338
  full_answer = ''.join(raw_answer_parts)
339
- # The final cleaning is still useful for any other residual tags
340
  cleaned_ans_text = self._clean_answer_content(full_answer).strip()
341
  final_content = cleaned_ans_text
342
 
 
1
  """
2
  Proxy handler for Z.AI API requests
3
  """
4
+ import json, logging, re, time, uuid, base64, hashlib, hmac, urllib.parse
5
  from typing import AsyncGenerator, Dict, Any, Tuple, List
6
+ from datetime import datetime, timezone, timedelta
7
+
8
  import httpx
9
  from fastapi import HTTPException
10
  from fastapi.responses import StreamingResponse
 
 
 
 
11
 
12
  from config import settings
13
  from cookie_manager import cookie_manager
 
30
  if not self.client.is_closed:
31
  await self.client.aclose()
32
 
 
33
  def _get_timestamp_millis(self) -> int:
34
  return int(time.time() * 1000)
35
 
 
36
  def _parse_jwt_token(self, token: str) -> Dict[str, str]:
37
+ """A simple JWT payload decoder to get user ID."""
38
  try:
39
  parts = token.split('.')
40
  if len(parts) != 3:
41
  return {"userId": ""}
42
+ # Add padding if necessary
43
+ payload_b64 = parts[1]
44
+ payload_b64 += '=' * (-len(payload_b64) % 4)
45
+ payload_json = base64.urlsafe_b64decode(payload_b64).decode('utf-8')
46
+ payload = json.loads(payload_json)
47
+ # The JS code checks multiple keys, 'sub' is the most standard one.
48
  return {"userId": payload.get("sub", "")}
49
+ except Exception as e:
50
+ logger.warning(f"Failed to parse JWT token to get user ID: {e}")
51
  return {"userId": ""}
52
 
 
53
  def _generate_signature(self, token: str, payload_str: str, mt: str) -> Tuple[str, int]:
54
  timestamp_ms = self._get_timestamp_millis()
55
  minute_bucket = str(timestamp_ms // 60000)
 
65
  return signature, timestamp_ms
66
 
67
  def _clean_thinking_content(self, text: str) -> str:
68
+ if not text: return ""
 
 
 
 
 
 
69
  cleaned_text = text
 
 
70
  cleaned_text = re.sub(r'<summary>.*?</summary>', '', cleaned_text, flags=re.DOTALL)
71
  cleaned_text = re.sub(r'<glm_block.*?</glm_block>', '', cleaned_text, flags=re.DOTALL)
 
 
 
72
  cleaned_text = re.sub(r'<[^>]*duration="[^"]*"[^>]*>', '', cleaned_text)
73
+ cleaned_text = cleaned_text.replace("</thinking>", "").replace("<Full>", "").replace("</Full>", "")
 
 
 
 
 
74
  cleaned_text = re.sub(r'</?details[^>]*>', '', cleaned_text)
 
 
75
  cleaned_text = re.sub(r'^\s*>\s*(?!>)', '', cleaned_text, flags=re.MULTILINE)
 
 
76
  cleaned_text = cleaned_text.replace("Thinking…", "")
 
 
77
  return cleaned_text.strip()
78
 
79
  def _clean_answer_content(self, text: str) -> str:
80
+ if not text: return ""
 
 
 
 
 
 
81
  cleaned_text = re.sub(r'<glm_block.*?</glm_block>', '', text, flags=re.DOTALL)
 
82
  cleaned_text = re.sub(r'<details[^>]*>.*?</details>', '', cleaned_text, flags=re.DOTALL)
83
  cleaned_text = re.sub(r'<summary>.*?</summary>', '', cleaned_text, flags=re.DOTALL)
84
  return cleaned_text
85
 
86
  def _serialize_msgs(self, msgs) -> list:
 
87
  out = []
88
  for m in msgs:
89
  if hasattr(m, "dict"): out.append(m.dict())
 
92
  else: out.append({"role": getattr(m, "role", "user"), "content": getattr(m, "content", str(m))})
93
  return out
94
 
 
95
  async def _prep_upstream(self, req: ChatCompletionRequest) -> Tuple[Dict[str, Any], Dict[str, str], str, str]:
96
  """Prepares the request body, headers, cookie, and URL for the upstream API."""
97
  ck = await cookie_manager.get_next_cookie()
 
105
 
106
  body = { "stream": True, "model": model, "messages": self._serialize_msgs(req.messages), "background_tasks": {"title_generation": True, "tags_generation": True}, "chat_id": chat_id, "features": {"image_generation": False, "code_interpreter": False, "web_search": False, "auto_web_search": False, "enable_thinking": True,}, "id": request_id, "mcp_servers": ["deep-web-search"], "model_item": {"id": model, "name": "GLM-4.6", "owned_by": "openai"}, "params": {}, "tool_servers": [], "variables": {"{{USER_NAME}}": "User", "{{USER_LOCATION}}": "Unknown", "{{CURRENT_DATETIME}}": time.strftime("%Y-%m-%d %H:%M:%S"),},}
107
 
108
+ # --- FIX: Timezone-aware payload generation ---
109
+ # The JS code uses the local timezone. We'll simulate a common one (Asia/Shanghai, UTC+8).
110
+ # This is the most likely cause of the signature mismatch.
111
+ target_tz_offset_hours = 8
112
+ target_tz = timezone(timedelta(hours=target_tz_offset_hours))
113
+ now_local = datetime.now(target_tz)
114
+ now_utc = datetime.now(timezone.utc)
115
 
116
+ # JS getTimezoneOffset returns the difference in minutes from UTC, and the sign is inverted.
117
+ # e.g., for UTC+8, it returns -480.
118
+ timezone_offset_minutes = -target_tz_offset_hours * 60
119
+
120
  payload_data = {
121
+ 'timestamp': str(self._get_timestamp_millis()),
122
  'requestId': request_id,
123
  'user_id': user_id,
124
  'token': ck,
125
+ 'user_agent': "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
126
  'current_url': f"https://chat.z.ai/c/{chat_id}",
127
  'pathname': f"/c/{chat_id}",
128
+ 'timezone': 'Asia/Shanghai', # Use a specific timezone name
129
+ 'timezone_offset': str(timezone_offset_minutes),
130
+ 'local_time': now_local.isoformat(timespec='milliseconds'),
131
+ 'utc_time': now_utc.strftime('%a, %d %b %Y %H:%M:%S GMT'),
132
  'version': '0.0.1',
133
  'platform': 'web',
134
  'language': 'zh-CN',
 
160
  sorted_payload = ",".join([f"{k},{payload_data[k]}" for k in keys])
161
  url_params = urllib.parse.urlencode(payload_data)
162
 
163
+ last_message_content = ""
164
+ if req.messages:
165
+ last_message = req.messages[-1]
166
+ if isinstance(last_message.content, str):
167
+ last_message_content = last_message.content
168
 
169
+ signature, sig_timestamp = self._generate_signature(ck, sorted_payload, last_message_content)
170
 
171
  final_url = f"{settings.UPSTREAM_URL}?{url_params}&signature_timestamp={sig_timestamp}"
172
 
173
+ headers = { "Content-Type": "application/json", "Authorization": f"Bearer {ck}", "User-Agent": ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"), "Accept": "application/json, text/event-stream", "Accept-Language": "zh-CN", "sec-ch-ua": '"Not)A;Brand";v="8", "Chromium";v="141", "Google Chrome";v="141"', "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": '"macOS"', "x-fe-version": "prod-fe-1.0.79", "X-Signature": signature, "Origin": "https://chat.z.ai", "Referer": "https://chat.z.ai/",}
174
 
175
  return body, headers, ck, final_url
176
 
 
182
  think_open = False
183
  yielded_think_buffer = ""
184
  current_raw_thinking = ""
 
185
  is_first_answer_chunk = True
186
 
187
  async def yield_delta(content_type: str, text: str):
 
190
  if not think_open:
191
  yield f"data: {json.dumps({'id': comp_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': req.model, 'choices': [{'index': 0, 'delta': {'content': '<think>'}, 'finish_reason': None}]})}\n\n"
192
  think_open = True
 
193
  cleaned_full_text = self._clean_thinking_content(text)
194
+ delta_to_send = cleaned_full_text[len(yielded_think_buffer):]
 
195
  if delta_to_send:
196
  yield f"data: {json.dumps({'id': comp_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': req.model, 'choices': [{'index': 0, 'delta': {'content': delta_to_send}, 'finish_reason': None}]})}\n\n"
197
  yielded_think_buffer = cleaned_full_text
 
198
  elif content_type == "answer":
199
  if think_open:
200
  yield f"data: {json.dumps({'id': comp_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': req.model, 'choices': [{'index': 0, 'delta': {'content': '</think>'}, 'finish_reason': None}]})}\n\n"
201
  think_open = False
 
202
  cleaned_text = self._clean_answer_content(text)
203
  if cleaned_text:
204
  yield f"data: {json.dumps({'id': comp_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': req.model, 'choices': [{'index': 0, 'delta': {'content': cleaned_text}, 'finish_reason': None}]})}\n\n"
 
207
  if resp.status_code != 200:
208
  await cookie_manager.mark_cookie_failed(ck); err_body = await resp.aread()
209
  err_msg = f"Error: {resp.status_code} - {err_body.decode(errors='ignore')}"
210
+ logger.error(f"Upstream error: {err_msg}")
211
  err = {"id": comp_id, "object": "chat.completion.chunk", "created": int(time.time()), "model": req.model, "choices": [{"index": 0, "delta": {"content": err_msg}, "finish_reason": "stop"}],}
212
  yield f"data: {json.dumps(err)}\n\n"; yield "data: [DONE]\n\n"; return
213
  await cookie_manager.mark_cookie_success(ck)
 
216
  for line in raw.strip().split('\n'):
217
  line = line.strip()
218
  if not line.startswith('data: '): continue
 
219
  payload_str = line[6:]
220
  if payload_str == '[DONE]':
221
  if think_open:
 
225
  return
226
  try:
227
  dat = json.loads(payload_str).get("data", {})
228
+ except (json.JSONDecodeError, AttributeError): continue
 
229
 
230
  phase = dat.get("phase")
231
  content_chunk = dat.get("delta_content") or dat.get("edit_content")
232
+ if not content_chunk: continue
 
 
233
 
234
  if phase == "thinking":
235
+ current_raw_thinking = content_chunk if dat.get("edit_content") is not None else current_raw_thinking + content_chunk
 
 
 
236
  async for item in yield_delta("thinking", current_raw_thinking):
237
  yield item
 
238
  elif phase == "answer":
239
  content_to_process = content_chunk
 
240
  if is_first_answer_chunk:
 
 
241
  if '</details>' in content_to_process:
242
  parts = content_to_process.split('</details>', 1)
243
  content_to_process = parts[1] if len(parts) > 1 else ""
244
  is_first_answer_chunk = False
 
245
  if content_to_process:
246
  async for item in yield_delta("answer", content_to_process):
247
  yield item
 
257
  async with self.client.stream("POST", url, json=body, headers=headers) as resp:
258
  if resp.status_code != 200:
259
  await cookie_manager.mark_cookie_failed(ck); error_detail = await resp.text()
260
+ logger.error(f"Upstream error: {resp.status_code} - {error_detail}")
261
  raise HTTPException(resp.status_code, f"Upstream error: {error_detail}")
262
  await cookie_manager.mark_cookie_success(ck)
263
 
 
276
 
277
  phase = dat.get("phase")
278
  content_chunk = dat.get("delta_content") or dat.get("edit_content")
279
+ if not content_chunk: continue
 
 
280
 
281
  if phase == "thinking":
282
+ current_raw_thinking = content_chunk if dat.get("edit_content") is not None else current_raw_thinking + content_chunk
 
 
 
283
  last_thinking_content = current_raw_thinking
 
284
  elif phase == "answer":
285
  content_to_process = content_chunk
 
286
  if is_first_answer_chunk:
287
  if '</details>' in content_to_process:
288
  parts = content_to_process.split('</details>', 1)
289
  content_to_process = parts[1] if len(parts) > 1 else ""
290
  is_first_answer_chunk = False
 
291
  if content_to_process:
292
  raw_answer_parts.append(content_to_process)
293
  else:
 
295
  break
296
 
297
  full_answer = ''.join(raw_answer_parts)
 
298
  cleaned_ans_text = self._clean_answer_content(full_answer).strip()
299
  final_content = cleaned_ans_text
300