test_deepseek_proxy_stream.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. """Standalone SSE diagnostic using http.client and keep-alive.
  2. Run: .venv/bin/python scripts/test_deepseek_proxy_stream.py
  3. No OpenAI SDK path joining, retries, or response cache is used.
  4. """
  5. from __future__ import annotations
  6. import argparse
  7. from http.client import HTTPConnection, HTTPSConnection, IncompleteRead
  8. import json
  9. import os
  10. import re
  11. import socket
  12. import sys
  13. import time
  14. from pathlib import Path
  15. from urllib.parse import parse_qsl, quote, quote_plus, urlsplit, urlunsplit
  16. def load_env_values(path: Path) -> dict[str, str]:
  17. """Read just the three diagnostic settings, with no third-party dependency."""
  18. values = {}
  19. if not path.is_file():
  20. return values
  21. for line in path.read_text(encoding="utf-8-sig").splitlines():
  22. line = line.strip()
  23. if line.startswith("export "):
  24. line = line[7:]
  25. if not line or line.startswith("#") or "=" not in line:
  26. continue
  27. key, value = line.split("=", 1)
  28. key, value = key.strip(), value.strip()
  29. if key not in {"DEEPSEEK_BASE_URL", "DEEPSEEK_API_KEY", "DEEPSEEK_MODEL"}:
  30. continue
  31. if value.startswith(("'", '"')):
  32. quote_char = value[0]
  33. end = value.find(quote_char, 1)
  34. if end < 0 or (value[end + 1:].strip() and not value[end + 1:].strip().startswith("#")):
  35. raise ValueError("invalid_env_quoting")
  36. value = value[1:end]
  37. else:
  38. value = value.split(" #", 1)[0].strip()
  39. values[key] = value
  40. return values
  41. def _redact(value: str, secrets: list[str]) -> str:
  42. for secret in sorted(set(secrets), key=len, reverse=True):
  43. if secret:
  44. for variant in (secret, quote(secret, safe=""), quote_plus(secret)):
  45. value = value.replace(variant, "[REDACTED]")
  46. value = re.sub(r"(?i)(proxyToken[\"'\s]*[:=][\"'\s]*)[^&\s\"'<>]+", r"\1[REDACTED]", value)
  47. return re.sub(r"(?i)(Bearer\s+)\S+", r"\1[REDACTED]", value)
  48. def probe(*, timeout: float = 30, method: str = "POST") -> dict:
  49. """Return safe diagnostics; environment variables override the root .env."""
  50. if method != "POST":
  51. return {"ok": False, "code": "invalid_method"}
  52. env_file = Path(__file__).resolve().parents[1] / ".env"
  53. try:
  54. values = load_env_values(env_file)
  55. except (OSError, ValueError):
  56. return {"ok": False, "code": "invalid_env_file", "hint": ".env无法读取或引号格式无效"}
  57. values.update({key: os.environ[key] for key in (
  58. "DEEPSEEK_BASE_URL", "DEEPSEEK_API_KEY", "DEEPSEEK_MODEL"
  59. ) if key in os.environ})
  60. url = values.get("DEEPSEEK_BASE_URL", "").strip()
  61. api_key = values.get("DEEPSEEK_API_KEY", "").strip()
  62. model = values.get("DEEPSEEK_MODEL", "deepseek-flash").strip()
  63. result = {
  64. "ok": False,
  65. "env_file_exists": env_file.is_file(),
  66. "api_key_configured": bool(api_key),
  67. "environment_proxy_configured": any(os.environ.get(k) for k in (
  68. "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"
  69. )),
  70. "use_environment_proxy": False,
  71. "transport": "http.client",
  72. "request_connection": "keep-alive",
  73. "stream": True,
  74. }
  75. secrets = [api_key]
  76. try:
  77. parts = urlsplit(url)
  78. params = parse_qsl(parts.query, keep_blank_values=True)
  79. secrets.extend(value for key, value in params if key.lower() == "proxytoken")
  80. if (parts.scheme not in ("http", "https") or not parts.hostname
  81. or parts.username or parts.password or parts.fragment
  82. or any(char.isspace() for char in url)):
  83. raise ValueError
  84. _ = parts.port
  85. if parts.path.rstrip("/").endswith("/chat/completions"):
  86. parts = parts._replace(path=parts.path.rstrip("/"))
  87. url = urlunsplit(parts)
  88. except ValueError:
  89. return {**result, "code": "invalid_base_url", "hint": "DEEPSEEK_BASE_URL须为单行完整HTTP(S)代理URL"}
  90. result.update(endpoint=f"{parts.scheme}://{parts.netloc}{parts.path}",
  91. query_keys=sorted({key for key, _ in params}),
  92. model=_redact(model, secrets), method=method,
  93. chat_payload_sent=method == "POST")
  94. query = dict(params)
  95. if query.get("servertype") != "deepseek" or not query.get("proxyToken"):
  96. return {**result, "code": "missing_proxy_params", "hint": "URL须包含servertype=deepseek及非空proxyToken,注意参数大小写"}
  97. payload = {
  98. "model": model,
  99. "messages": [{"role": "user", "content": "1+1等于多少"}],
  100. "max_tokens": 128,
  101. "stream": True,
  102. }
  103. headers = {"Accept": "*/*", "Connection": "keep-alive"}
  104. if method == "POST":
  105. headers["Content-Type"] = "application/json"
  106. if api_key:
  107. headers["Authorization"] = f"Bearer {api_key}"
  108. # GET probes the documented gateway URL only. Do not guess how a custom
  109. # gateway encodes upstream chat requests in its query parameters.
  110. request_body = json.dumps(payload, ensure_ascii=False).encode("utf-8") if method == "POST" else None
  111. started = time.perf_counter()
  112. connection = None
  113. try:
  114. connection_type = HTTPSConnection if parts.scheme == "https" else HTTPConnection
  115. connection = connection_type(parts.hostname, parts.port, timeout=timeout)
  116. target = (parts.path or "/") + ("?" + parts.query if parts.query else "")
  117. # Direct connection: no environment proxy, redirects, or automatic retry.
  118. connection.request(method, target, body=request_body, headers=headers)
  119. response = connection.getresponse()
  120. with response:
  121. result["http_status"] = response.status
  122. result["http_ok"] = 200 <= response.status < 300
  123. result["content_type"] = _redact(response.headers.get("Content-Type", ""), secrets)
  124. result["transfer_encoding"] = _redact(response.headers.get("Transfer-Encoding", ""), secrets)[:120]
  125. length = response.headers.get("Content-Length", "")
  126. expected_length = int(length) if length.isascii() and length.isdecimal() else None
  127. result["content_length"] = expected_length
  128. if result['http_ok'] and result['content_type'].split(';')[0].strip().lower() == 'text/event-stream':
  129. read_sse(response, result, secrets, started, timeout)
  130. return result
  131. result["response_complete"] = True
  132. try:
  133. raw = response.read(1024 * 1024 + 1)
  134. except IncompleteRead as exc:
  135. # Malformed/unfinished chunked responses can contain the useful
  136. # gateway error text in partial; do not discard that evidence.
  137. raw = exc.partial
  138. result.update(response_complete=False, error_type="IncompleteRead")
  139. if (expected_length is not None and not result["transfer_encoding"]
  140. and len(raw) < expected_length):
  141. result.update(response_complete=False, error_type="ContentLengthMismatch")
  142. result["received_bytes"] = len(raw)
  143. if len(raw) > 1024 * 1024:
  144. result["code"] = "response_too_large"
  145. return result
  146. body = raw.decode("utf-8", errors="replace")
  147. result["response_preview"] = _redact(body, secrets)[:1200]
  148. if "authentication fails" in body.lower() and "governor" in body.lower():
  149. result.update(code="proxy_authentication_failed", hint="代理已响应但鉴权失败,请核对参数大小写、token及代理权限")
  150. elif not 200 <= result["http_status"] < 300:
  151. result["code"] = "http_error"
  152. elif not result["response_complete"]:
  153. result["code"] = "response_incomplete"
  154. else:
  155. try:
  156. data = json.loads(body)
  157. content = data["choices"][0]["message"]["content"]
  158. if not isinstance(content, str) or not content.strip():
  159. raise ValueError
  160. result.update(code="unexpected_response", hint="请求stream=true,但返回普通JSON;代理未返回SSE流")
  161. except (ValueError, KeyError, TypeError, IndexError):
  162. result.update(code="unexpected_response", hint="HTTP成功但不是含非空choices[0].message.content的聊天响应;需核对代理返回协议")
  163. if not result["response_complete"]:
  164. result["hint"] = (
  165. f"已收到HTTP {result['http_status']}响应头,但响应体传输不完整;"
  166. + ("未取得可解析的正文。" if not raw else "response_preview为已收到的脱敏片段。")
  167. + "HTTP 200不代表聊天成功,请代理同事检查重复Transfer-Encoding、分块编码及结束标记"
  168. )
  169. except (TimeoutError, socket.timeout):
  170. result["code"] = "timeout"
  171. except OSError as exc:
  172. result["code"] = "connection_failed"
  173. result["error_type"] = type(exc).__name__
  174. except Exception as exc:
  175. # Do not expose exceptions containing the complete URL or credentials.
  176. result.update(code="request_failed", error_type=type(exc).__name__)
  177. finally:
  178. if connection is not None:
  179. connection.close()
  180. result["elapsed_seconds"] = round(time.perf_counter() - started, 3)
  181. return result
  182. def read_sse(response, result, secrets, started, timeout):
  183. """Parse bounded SSE events incrementally, then verify HTTP framing to EOF."""
  184. result.update(response_complete=False, done_received=False, finish_reason=None,
  185. content_chunks=0, received_bytes=0, first_content_seconds=None,
  186. last_content_seconds=None, content_arrival_seconds=[])
  187. pending = b''
  188. event = []
  189. content = []
  190. invalid = False
  191. provider_error = False
  192. def dispatch():
  193. nonlocal invalid, provider_error
  194. if not event:
  195. return
  196. value = b'\n'.join(event).decode('utf-8')
  197. event.clear()
  198. if result['done_received']:
  199. invalid = True
  200. return
  201. if value == '[DONE]':
  202. result['done_received'] = True
  203. return
  204. data = json.loads(value)
  205. if not isinstance(data, dict):
  206. invalid = True
  207. return
  208. if 'error' in data:
  209. provider_error = True
  210. return
  211. choices = data.get('choices')
  212. if choices == []: # Optional usage frame.
  213. return
  214. if not isinstance(choices, list) or len(choices) != 1 or not isinstance(choices[0], dict):
  215. invalid = True
  216. return
  217. choice = choices[0]
  218. delta = choice.get('delta', {})
  219. if choice.get('index', 0) != 0 or not isinstance(delta, dict):
  220. invalid = True
  221. return
  222. text = delta.get('content')
  223. if text is not None and not isinstance(text, str):
  224. invalid = True
  225. return
  226. if text:
  227. if result['finish_reason'] is not None:
  228. invalid = True
  229. content.append(text)
  230. elapsed = round(time.perf_counter() - started, 3)
  231. result['content_chunks'] += 1
  232. if result['first_content_seconds'] is None:
  233. result['first_content_seconds'] = elapsed
  234. result['last_content_seconds'] = elapsed
  235. if len(result['content_arrival_seconds']) < 100:
  236. result['content_arrival_seconds'].append(elapsed)
  237. finish = choice.get('finish_reason')
  238. if finish is not None:
  239. if not isinstance(finish, str) or result['finish_reason'] is not None:
  240. invalid = True
  241. result['finish_reason'] = _redact(str(finish), secrets)[:80]
  242. try:
  243. while True:
  244. if time.perf_counter() - started > timeout:
  245. raise TimeoutError
  246. chunk = response.read1(4096)
  247. if not chunk:
  248. break
  249. result['received_bytes'] += len(chunk)
  250. if result['received_bytes'] > 1024 * 1024:
  251. result['code'] = 'response_too_large'
  252. return
  253. pending += chunk
  254. while b'\n' in pending:
  255. line, pending = pending.split(b'\n', 1)
  256. line = line.rstrip(b'\r')
  257. if not line:
  258. dispatch()
  259. elif line.startswith(b'data:'):
  260. value = line[5:]
  261. event.append(value[1:] if value.startswith(b' ') else value)
  262. if result['content_length'] is not None and not result['transfer_encoding'] and result['received_bytes'] != result['content_length']:
  263. raise IncompleteRead(b'')
  264. result['response_complete'] = True
  265. if pending or event:
  266. invalid = True
  267. if provider_error:
  268. result['code'] = 'provider_error'
  269. elif invalid:
  270. result['code'] = 'invalid_sse'
  271. elif not result['done_received'] or result['finish_reason'] is None:
  272. result['code'] = 'stream_incomplete'
  273. elif result['finish_reason'] != 'stop':
  274. result['code'] = 'incomplete_completion'
  275. elif not ''.join(content).strip():
  276. result['code'] = 'empty_stream_content'
  277. else:
  278. result.update(ok=True, code='stream_success')
  279. except IncompleteRead:
  280. result.update(code='response_incomplete', error_type='IncompleteRead')
  281. except (ValueError, UnicodeError):
  282. result['code'] = 'invalid_sse'
  283. finally:
  284. result['assistant_text'] = _redact(''.join(content), secrets)[:1200]
  285. result['hint'] = '到达时间仅供检查缓冲;单次短回答不能证明代理始终实时透传。成功需完整HTTP响应、非空正文、finish_reason=stop及[DONE]'
  286. def main() -> int:
  287. parser = argparse.ArgumentParser(description="读取.env,以keep-alive POST测试DeepSeek SSE流")
  288. parser.add_argument("--timeout", type=float, default=30, help="请求超时秒数,默认30")
  289. parser.add_argument("--method", choices=("POST",), default="POST")
  290. args = parser.parse_args()
  291. if not 0 < args.timeout <= 120:
  292. parser.error("--timeout须大于0且不超过120秒")
  293. result = probe(timeout=args.timeout, method=args.method)
  294. print(json.dumps(result, ensure_ascii=False, indent=2))
  295. return 0 if result["ok"] else 1
  296. if __name__ == "__main__":
  297. sys.exit(main())