test_deepseek_proxy.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. import contextlib
  2. import importlib.util
  3. import io
  4. import json
  5. import os
  6. import threading
  7. import tempfile
  8. import unittest
  9. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  10. from pathlib import Path
  11. from unittest.mock import patch
  12. spec = importlib.util.spec_from_file_location(
  13. "deepseek_proxy_probe", Path(__file__).resolve().parents[1] / "test_deepseek_proxy.py"
  14. )
  15. probe_module = importlib.util.module_from_spec(spec)
  16. spec.loader.exec_module(probe_module)
  17. class DeepSeekProxyTests(unittest.TestCase):
  18. def setUp(self):
  19. self.requests = []
  20. self.status = 200
  21. self.body = json.dumps({"choices": [{"message": {"content": "OK"}}]}).encode()
  22. self.headers = {"Content-Type": "application/json"}
  23. test = self
  24. class Handler(BaseHTTPRequestHandler):
  25. protocol_version = 'HTTP/1.1'
  26. def send_body(self):
  27. self.send_response(test.status)
  28. for key, value in test.headers.items():
  29. self.send_header(key, value)
  30. if 'Transfer-Encoding' not in test.headers and 'Content-Length' not in test.headers:
  31. self.send_header('Content-Length', str(len(test.body)))
  32. else:
  33. # Broken framing cases deliberately terminate the response.
  34. self.close_connection = True
  35. self.end_headers()
  36. self.wfile.write(test.body)
  37. def do_GET(self):
  38. test.requests.append({
  39. "path": self.path, "method": "GET",
  40. "authorization": self.headers.get("Authorization"),
  41. "payload": None, "content_length": self.headers.get("Content-Length"),
  42. })
  43. self.send_body()
  44. def do_POST(self):
  45. test.requests.append({
  46. "path": self.path, "method": "POST",
  47. "connection": self.headers.get("Connection"),
  48. "authorization": self.headers.get("Authorization"),
  49. "payload": json.loads(self.rfile.read(int(self.headers["Content-Length"]))),
  50. })
  51. self.send_body()
  52. def log_message(self, *_args):
  53. pass
  54. self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
  55. self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
  56. self.thread.start()
  57. self.url = f"http://127.0.0.1:{self.server.server_port}/proxy/?servertype=deepseek&proxyToken=fake%2Btoken%26value"
  58. self.environment = {
  59. "DEEPSEEK_BASE_URL": self.url, "DEEPSEEK_API_KEY": "fake-api-key",
  60. "DEEPSEEK_MODEL": "test-model", "HTTP_PROXY": "http://invalid.example:1",
  61. }
  62. def tearDown(self):
  63. self.server.shutdown()
  64. self.server.server_close()
  65. self.thread.join(timeout=2)
  66. def run_probe(self, method=None):
  67. with patch.dict(os.environ, self.environment), patch.object(probe_module, "load_env_values", return_value={
  68. "DEEPSEEK_BASE_URL": "http://ignored.example/proxy/?serverType=deepseek&proxyToken=ignored",
  69. "DEEPSEEK_API_KEY": "ignored", "DEEPSEEK_MODEL": "ignored"
  70. }) as load:
  71. result = probe_module.probe(timeout=2, **({"method": method} if method else {}))
  72. self.assertEqual(load.call_args.args[0], Path(__file__).resolve().parents[2] / ".env")
  73. return result
  74. def test_actual_http_post_preserves_fixed_url_and_payload(self):
  75. result = self.run_probe()
  76. self.assertTrue(result["ok"], result)
  77. request = self.requests[0]
  78. self.assertEqual(request["path"], "/proxy/?servertype=deepseek&proxyToken=fake%2Btoken%26value",
  79. "固定代理入口不可追加/chat/completions或修改token")
  80. self.assertEqual(request["authorization"], "Bearer fake-api-key")
  81. self.assertEqual(request["payload"]["model"], "test-model")
  82. self.assertEqual(request["payload"]["messages"], [{"role": "user", "content": "1+1等于多少"}])
  83. self.assertEqual(request["connection"], "keep-alive")
  84. self.assertEqual(result["transport"], "http.client")
  85. self.assertFalse(request["payload"]["stream"])
  86. self.assertTrue(result["environment_proxy_configured"])
  87. self.assertNotIn("fake+token&value", json.dumps(result))
  88. self.assertEqual(len(self.requests), 1)
  89. def test_get_sends_exact_gateway_url_without_chat_body(self):
  90. result = self.run_probe("GET")
  91. self.assertEqual(result["method"], "GET")
  92. self.assertFalse(result["chat_payload_sent"])
  93. self.assertTrue(result["http_ok"])
  94. self.assertEqual(self.requests[0]["path"],
  95. "/proxy/?servertype=deepseek&proxyToken=fake%2Btoken%26value")
  96. self.assertEqual(self.requests[0]["method"], "GET")
  97. self.assertIsNone(self.requests[0]["content_length"])
  98. self.assertIsNone(self.requests[0]["payload"])
  99. self.assertEqual(len(self.requests), 1)
  100. def test_complete_chat_path_removes_trailing_slash_without_changing_query(self):
  101. self.environment['DEEPSEEK_BASE_URL'] = self.url.replace('/proxy/?', '/proxy/chat/completions/?')
  102. result = self.run_probe()
  103. self.assertTrue(result['ok'])
  104. self.assertEqual(self.requests[0]['path'],
  105. '/proxy/chat/completions?servertype=deepseek&proxyToken=fake%2Btoken%26value')
  106. self.assertTrue(result['endpoint'].endswith('/proxy/chat/completions'))
  107. def test_get_http_success_is_not_enough_to_verify_chat(self):
  108. self.body = b"gateway connected"
  109. result = self.run_probe("GET")
  110. self.assertTrue(result["http_ok"])
  111. self.assertFalse(result["ok"])
  112. self.assertEqual(result["code"], "unexpected_response")
  113. def test_gateway_auth_failure_redacts_echoed_credentials(self):
  114. self.status = 401
  115. self.body = b"Authentication Fails (governor) fake-api-key fake+token&value fake%2Btoken%26value"
  116. result = self.run_probe()
  117. self.assertEqual(result["code"], "proxy_authentication_failed")
  118. self.assertEqual(result["http_status"], 401)
  119. for secret in ("fake-api-key", "fake+token&value", "fake%2Btoken%26value"):
  120. self.assertNotIn(secret, json.dumps(result))
  121. self.assertEqual(len(self.requests), 1, "诊断只发一次请求,不自动重试")
  122. def test_http_200_non_chat_response_is_failure(self):
  123. for body in (b"gateway reachable", b'{"error":{"message":"denied"}}', b'{"choices":[]}'):
  124. with self.subTest(body=body):
  125. self.body = body
  126. result = self.run_probe()
  127. self.assertFalse(result["ok"])
  128. self.assertEqual(result["code"], "unexpected_response")
  129. def set_unfinished_chunked_body(self, body):
  130. self.headers["Transfer-Encoding"] = "chunked"
  131. # A complete data chunk followed by a closed connection, without the
  132. # mandatory zero-sized final chunk. This reproduces the gateway symptom.
  133. self.body = f"{len(body):X}\r\n".encode() + body + b"\r\n"
  134. def test_http_500_incomplete_read_preserves_redacted_error_body(self):
  135. self.status = 500
  136. error = b'{"error":"upstream failed fake-api-key fake+token&value fake%2Btoken%26value"}'
  137. self.set_unfinished_chunked_body(error)
  138. result = self.run_probe()
  139. self.assertEqual(result["http_status"], 500)
  140. self.assertEqual(result["code"], "http_error")
  141. self.assertEqual(result["error_type"], "IncompleteRead")
  142. self.assertFalse(result["response_complete"])
  143. self.assertEqual(result["received_bytes"], len(error))
  144. self.assertIn("upstream failed", result["response_preview"])
  145. for secret in ("fake-api-key", "fake+token&value", "fake%2Btoken%26value"):
  146. self.assertNotIn(secret, json.dumps(result))
  147. self.assertEqual(len(self.requests), 1)
  148. def test_incomplete_http_200_valid_json_cannot_report_chat_success(self):
  149. self.set_unfinished_chunked_body(self.body)
  150. result = self.run_probe()
  151. self.assertFalse(result["ok"], "响应体JSON合法也不能把不完整HTTP响应报成功")
  152. self.assertEqual(result["code"], "response_incomplete")
  153. self.assertIn("choices", result["response_preview"])
  154. def test_short_content_length_error_is_visible(self):
  155. self.status = 500
  156. self.body = b'{"error":"short body"}'
  157. self.headers["Content-Length"] = str(len(self.body) + 20)
  158. result = self.run_probe()
  159. self.assertEqual(result["code"], "http_error")
  160. self.assertEqual(result["error_type"], "ContentLengthMismatch")
  161. self.assertFalse(result["response_complete"])
  162. self.assertIn("short body", result["response_preview"])
  163. self.assertEqual(result["content_length"] - result["received_bytes"], 20)
  164. def test_redirect_is_reported_without_second_request(self):
  165. self.status = 302
  166. self.headers["Location"] = f"http://127.0.0.1:{self.server.server_port}/other"
  167. result = self.run_probe()
  168. self.assertEqual(result["code"], "http_error")
  169. self.assertEqual(len(self.requests), 1)
  170. def test_missing_api_key_can_probe_gateway_without_fake_bearer(self):
  171. self.environment["DEEPSEEK_API_KEY"] = ""
  172. result = self.run_probe()
  173. self.assertFalse(result["api_key_configured"])
  174. self.assertIsNone(self.requests[0]["authorization"])
  175. def test_invalid_and_wrong_case_params_do_not_send(self):
  176. for url, code in ((self.url.replace("servertype", "serverType"), "missing_proxy_params"),
  177. (self.url.replace("proxyToken", "proxytoken"), "missing_proxy_params"),
  178. ("http://gateway.example\n:123/proxy", "invalid_base_url"),
  179. ("http://gateway.example:bad/proxy", "invalid_base_url")):
  180. with self.subTest(code=code, url=url):
  181. self.environment["DEEPSEEK_BASE_URL"] = url
  182. self.assertEqual(self.run_probe()["code"], code)
  183. self.assertEqual(self.requests, [])
  184. def test_connection_failure_does_not_echo_full_url(self):
  185. with patch.object(probe_module, "HTTPConnection") as build:
  186. build.return_value.request.side_effect = ConnectionRefusedError(self.url)
  187. result = self.run_probe()
  188. build.return_value.close.assert_called_once()
  189. self.assertEqual(result["code"], "connection_failed")
  190. self.assertNotIn("fake", json.dumps(result))
  191. def test_https_timeout_closes_connection_without_secret_echo(self):
  192. self.environment['DEEPSEEK_BASE_URL'] = self.url.replace('http:', 'https:')
  193. with patch.object(probe_module, 'HTTPSConnection') as build:
  194. build.return_value.getresponse.side_effect = TimeoutError(self.url)
  195. result = self.run_probe()
  196. self.assertEqual(result['code'], 'timeout')
  197. build.return_value.close.assert_called_once()
  198. self.assertEqual(build.call_args.kwargs['timeout'], 2)
  199. self.assertNotIn('fake', json.dumps(result))
  200. def test_complete_chunked_response(self):
  201. self.set_unfinished_chunked_body(self.body)
  202. self.body += b'0\r\n\r\n'
  203. result = self.run_probe()
  204. self.assertTrue(result['ok'], result)
  205. self.assertTrue(result['response_complete'])
  206. def test_cli_exit_codes_and_safe_json(self):
  207. for ok in (True, False):
  208. output = io.StringIO()
  209. with patch.object(probe_module, "probe", return_value={"ok": ok}), patch.object(
  210. probe_module.sys, "argv", ["test_deepseek_proxy.py"]
  211. ), contextlib.redirect_stdout(output):
  212. self.assertEqual(probe_module.main(), 0 if ok else 1)
  213. self.assertEqual(json.loads(output.getvalue()), {"ok": ok})
  214. def test_cli_defaults_to_post_and_method_can_be_selected(self):
  215. for arguments, method in (([], "POST"), (["--method", "GET"], "GET"),
  216. (["--method", "POST"], "POST")):
  217. with patch.object(probe_module, "probe", return_value={"ok": True}) as probe, patch.object(
  218. probe_module.sys, "argv", ["test_deepseek_proxy.py", *arguments]
  219. ), contextlib.redirect_stdout(io.StringIO()):
  220. self.assertEqual(probe_module.main(), 0)
  221. self.assertEqual(probe.call_args.kwargs["method"], method)
  222. class EnvReadTests(unittest.TestCase):
  223. def test_synthetic_env_preserves_case_quoting_and_last_definition(self):
  224. with tempfile.TemporaryDirectory() as temporary:
  225. env_path = Path(temporary) / ".env"
  226. env_path.write_text(
  227. '# synthetic settings\nDEEPSEEK_BASE_URL=http://old.example\n'
  228. 'export DEEPSEEK_BASE_URL="http://gateway.example/proxy/?servertype=deepseek&proxyToken=fake-token" # comment\n'
  229. "DEEPSEEK_API_KEY='fake-key'\nDEEPSEEK_MODEL=deepseek-flash # comment\nUNRELATED=ignored\n",
  230. encoding="utf-8-sig",
  231. )
  232. values = probe_module.load_env_values(env_path)
  233. self.assertEqual(values["DEEPSEEK_BASE_URL"],
  234. "http://gateway.example/proxy/?servertype=deepseek&proxyToken=fake-token")
  235. self.assertEqual(values["DEEPSEEK_API_KEY"], "fake-key")
  236. self.assertEqual(values["DEEPSEEK_MODEL"], "deepseek-flash")
  237. self.assertEqual(len(values), 3)
  238. env_path.write_text('DEEPSEEK_BASE_URL="unclosed', encoding="utf-8")
  239. with self.assertRaisesRegex(ValueError, "invalid_env_quoting"):
  240. probe_module.load_env_values(env_path)
  241. if __name__ == "__main__":
  242. unittest.main()