test_deepseek_proxy.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. """Read .env and diagnose the fixed DeepSeek gateway (POST by default).
  2. Run on the deployment server: uv run --frozen python scripts/test_deepseek_proxy.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 not in {"GET", "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. }
  74. secrets = [api_key]
  75. try:
  76. parts = urlsplit(url)
  77. params = parse_qsl(parts.query, keep_blank_values=True)
  78. secrets.extend(value for key, value in params if key.lower() == "proxytoken")
  79. if (parts.scheme not in ("http", "https") or not parts.hostname
  80. or parts.username or parts.password or parts.fragment
  81. or any(char.isspace() for char in url)):
  82. raise ValueError
  83. _ = parts.port
  84. if parts.path.rstrip("/").endswith("/chat/completions"):
  85. parts = parts._replace(path=parts.path.rstrip("/"))
  86. url = urlunsplit(parts)
  87. except ValueError:
  88. return {**result, "code": "invalid_base_url", "hint": "DEEPSEEK_BASE_URL须为单行完整HTTP(S)代理URL"}
  89. result.update(endpoint=f"{parts.scheme}://{parts.netloc}{parts.path}",
  90. query_keys=sorted({key for key, _ in params}),
  91. model=_redact(model, secrets), method=method,
  92. chat_payload_sent=method == "POST")
  93. query = dict(params)
  94. if query.get("servertype") != "deepseek" or not query.get("proxyToken"):
  95. return {**result, "code": "missing_proxy_params", "hint": "URL须包含servertype=deepseek及非空proxyToken,注意参数大小写"}
  96. payload = {
  97. "model": model,
  98. "messages": [{"role": "user", "content": "1+1等于多少"}],
  99. "max_tokens": 128,
  100. "stream": False,
  101. }
  102. headers = {"Accept": "application/json", "Connection": "keep-alive"}
  103. if method == "POST":
  104. headers["Content-Type"] = "application/json"
  105. if api_key:
  106. headers["Authorization"] = f"Bearer {api_key}"
  107. # GET probes the documented gateway URL only. Do not guess how a custom
  108. # gateway encodes upstream chat requests in its query parameters.
  109. request_body = json.dumps(payload, ensure_ascii=False).encode("utf-8") if method == "POST" else None
  110. started = time.perf_counter()
  111. connection = None
  112. try:
  113. connection_type = HTTPSConnection if parts.scheme == "https" else HTTPConnection
  114. connection = connection_type(parts.hostname, parts.port, timeout=timeout)
  115. target = (parts.path or "/") + ("?" + parts.query if parts.query else "")
  116. # Direct connection: no environment proxy, redirects, or automatic retry.
  117. connection.request(method, target, body=request_body, headers=headers)
  118. response = connection.getresponse()
  119. with response:
  120. result["http_status"] = response.status
  121. result["http_ok"] = 200 <= response.status < 300
  122. result["content_type"] = _redact(response.headers.get("Content-Type", ""), secrets)
  123. result["transfer_encoding"] = _redact(response.headers.get("Transfer-Encoding", ""), secrets)[:120]
  124. length = response.headers.get("Content-Length", "")
  125. expected_length = int(length) if length.isascii() and length.isdecimal() else None
  126. result["content_length"] = expected_length
  127. result["response_complete"] = True
  128. try:
  129. raw = response.read(1024 * 1024 + 1)
  130. except IncompleteRead as exc:
  131. # Malformed/unfinished chunked responses can contain the useful
  132. # gateway error text in partial; do not discard that evidence.
  133. raw = exc.partial
  134. result.update(response_complete=False, error_type="IncompleteRead")
  135. if (expected_length is not None and not result["transfer_encoding"]
  136. and len(raw) < expected_length):
  137. result.update(response_complete=False, error_type="ContentLengthMismatch")
  138. result["received_bytes"] = len(raw)
  139. if len(raw) > 1024 * 1024:
  140. result["code"] = "response_too_large"
  141. return result
  142. body = raw.decode("utf-8", errors="replace")
  143. result["response_preview"] = _redact(body, secrets)[:1200]
  144. if "authentication fails" in body.lower() and "governor" in body.lower():
  145. result.update(code="proxy_authentication_failed", hint="代理已响应但鉴权失败,请核对参数大小写、token及代理权限")
  146. elif not 200 <= result["http_status"] < 300:
  147. result["code"] = "http_error"
  148. elif not result["response_complete"]:
  149. result["code"] = "response_incomplete"
  150. else:
  151. try:
  152. data = json.loads(body)
  153. content = data["choices"][0]["message"]["content"]
  154. if not isinstance(content, str) or not content.strip():
  155. raise ValueError
  156. result.update(ok=True, code="chat_success", assistant_text=_redact(content, secrets)[:1200])
  157. except (ValueError, KeyError, TypeError, IndexError):
  158. result.update(code="unexpected_response", hint="HTTP成功但不是含非空choices[0].message.content的聊天响应;需核对代理返回协议")
  159. if not result["response_complete"]:
  160. result["hint"] = (
  161. f"已收到HTTP {result['http_status']}响应头,但响应体传输不完整;"
  162. + ("未取得可解析的正文。" if not raw else "response_preview为已收到的脱敏片段。")
  163. + "HTTP 200不代表聊天成功,请代理同事检查重复Transfer-Encoding、分块编码及结束标记"
  164. )
  165. except (TimeoutError, socket.timeout):
  166. result["code"] = "timeout"
  167. except OSError as exc:
  168. result["code"] = "connection_failed"
  169. result["error_type"] = type(exc).__name__
  170. except Exception as exc:
  171. # Do not expose exceptions containing the complete URL or credentials.
  172. result.update(code="request_failed", error_type=type(exc).__name__)
  173. finally:
  174. if connection is not None:
  175. connection.close()
  176. result["elapsed_seconds"] = round(time.perf_counter() - started, 3)
  177. return result
  178. def main() -> int:
  179. parser = argparse.ArgumentParser(description="读取.env,测试固定DeepSeek代理入口(默认POST,一次请求)")
  180. parser.add_argument("--timeout", type=float, default=30, help="请求超时秒数,默认30")
  181. parser.add_argument("--method", choices=("GET", "POST"), default="POST",
  182. help="默认POST发送最小聊天JSON;GET仅请求代理入口")
  183. args = parser.parse_args()
  184. if not 0 < args.timeout <= 120:
  185. parser.error("--timeout须大于0且不超过120秒")
  186. result = probe(timeout=args.timeout, method=args.method)
  187. print(json.dumps(result, ensure_ascii=False, indent=2))
  188. return 0 if result["ok"] else 1
  189. if __name__ == "__main__":
  190. sys.exit(main())