test_deepseek_transport.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. """Production clients against a synthetic gateway; no real settings or network."""
  2. import contextlib
  3. import io
  4. import json
  5. import os
  6. from pathlib import Path
  7. import tempfile
  8. import threading
  9. import unittest
  10. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  11. from unittest.mock import patch
  12. from scripts import deploy_check
  13. from src.step1_knowledge_prepare.summarize import Config, chat_endpoint, load_config, transport, RequestFailure
  14. from src.step3_question_answer.streaming import stream_transport
  15. class EndpointTests(unittest.TestCase):
  16. def test_base_and_complete_urls_preserve_query(self):
  17. for base, expected in (
  18. ('https://example.invalid', 'https://example.invalid/chat/completions'),
  19. ('https://example.invalid/v1/', 'https://example.invalid/v1/chat/completions'),
  20. ('https://example.invalid/chat/completions', 'https://example.invalid/chat/completions'),
  21. ('http://example.invalid:123/proxy/', 'http://example.invalid:123/proxy/chat/completions'),
  22. ('http://example.invalid/proxy/chat/completions/', 'http://example.invalid/proxy/chat/completions'),
  23. ):
  24. query = '?servertype=deepseek&proxyToken=fake%2B%26%2f&x=1&x=2'
  25. with self.subTest(base=base):
  26. self.assertEqual(chat_endpoint(base + query), expected + query)
  27. def test_invalid_urls_have_safe_errors(self):
  28. for url in ('file:///secret', 'http://user:secret@example.invalid',
  29. 'http://example.invalid:bad', 'http://example.invalid/#secret',
  30. 'http://example.invalid/\nsecret', 'http://', 'http://example.invalid\\secret'):
  31. with self.subTest(url=url), self.assertRaisesRegex(ValueError, '^invalid_deepseek_base_url$'):
  32. chat_endpoint(url)
  33. class GatewayTests(unittest.TestCase):
  34. def setUp(self):
  35. self.calls = []
  36. self.mode = 'normal'
  37. self.request_headers = []
  38. self.first_received = threading.Event()
  39. test = self
  40. class Handler(BaseHTTPRequestHandler):
  41. protocol_version = 'HTTP/1.1'
  42. def log_message(self, *_):
  43. pass
  44. def do_POST(self):
  45. payload = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
  46. test.calls.append((self.path, self.headers.get('Authorization'), payload))
  47. test.request_headers.append(dict(self.headers))
  48. if test.mode == 'redirect':
  49. self.send_response(302)
  50. self.send_header('Location', '/other')
  51. self.send_header('Content-Length', '0')
  52. self.end_headers()
  53. return
  54. self.send_response(200)
  55. if test.mode == 'truncated':
  56. self.send_header('Transfer-Encoding', 'chunked')
  57. self.end_headers()
  58. self.wfile.write(b'broken\r\n')
  59. self.close_connection = True
  60. return
  61. if payload.get('stream'):
  62. self.send_header('Content-Type', 'text/event-stream')
  63. self.send_header('Transfer-Encoding', 'chunked')
  64. self.end_headers()
  65. def write_chunk(data):
  66. self.wfile.write(f'{len(data):x}\r\n'.encode() + data + b'\r\n')
  67. self.wfile.flush()
  68. if test.mode == 'incremental':
  69. write_chunk(b'data: {"choices":[{"delta":{"content":"first"}}]}\n\n')
  70. if not test.first_received.wait(2):
  71. self.close_connection = True
  72. return
  73. frame = {'choices': [{'delta': {'content': 'OK'}, 'finish_reason': 'stop'}]}
  74. write_chunk(('data: ' + json.dumps(frame) + '\n\ndata: [DONE]\n\n').encode())
  75. if test.mode == 'done_truncated':
  76. self.close_connection = True
  77. else:
  78. self.wfile.write(b'0\r\n\r\n')
  79. else:
  80. body = b'{"choices":[{"message":{"content":"OK"}}]}'
  81. self.send_header('Content-Length', str(len(body)))
  82. self.end_headers()
  83. self.wfile.write(body)
  84. self.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
  85. self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
  86. self.thread.start()
  87. self.temp = tempfile.TemporaryDirectory()
  88. self.root = Path(self.temp.name)
  89. self.url = (f'http://127.0.0.1:{self.server.server_port}/proxy/chat/completions'
  90. '?servertype=deepseek&proxyToken=fake%2B%26value')
  91. (self.root / '.env').write_text('DEEPSEEK_BASE_URL=' + self.url +
  92. '\nDEEPSEEK_API_KEY=fake-key\nDEEPSEEK_MODEL=fake-model\n', encoding='utf-8')
  93. self.env = patch.dict(os.environ, {'HTTP_PROXY': 'http://127.0.0.1:1',
  94. 'http_proxy': 'http://127.0.0.1:1', 'NO_PROXY': '', 'no_proxy': ''}, clear=True)
  95. self.env.start()
  96. def tearDown(self):
  97. self.env.stop()
  98. self.server.shutdown()
  99. self.server.server_close()
  100. self.thread.join()
  101. self.temp.cleanup()
  102. def test_production_json_stream_and_deployment_check(self):
  103. config = load_config(self.root / '.env')
  104. payload = {'model': config.model, 'messages': [{'role': 'user', 'content': 'synthetic'}]}
  105. self.assertEqual(transport(payload, config)['choices'][0]['message']['content'], 'OK')
  106. parts = []
  107. stream_transport(payload, config, parts.append)
  108. self.assertEqual(parts, ['OK'])
  109. output = io.StringIO()
  110. with contextlib.redirect_stdout(output):
  111. self.assertEqual(deploy_check.run(self.root, action='check-deepseek'), 0)
  112. self.assertTrue(json.loads(output.getvalue())['checks']['deepseek']['ok'])
  113. self.assertEqual(len(self.calls), 3)
  114. for headers in self.request_headers:
  115. self.assertEqual(headers['Connection'], 'keep-alive')
  116. self.assertEqual(headers['Accept'], '*/*')
  117. for path, auth, body in self.calls:
  118. self.assertEqual(path, '/proxy/chat/completions?servertype=deepseek&proxyToken=fake%2B%26value')
  119. self.assertEqual(auth, 'Bearer fake-key')
  120. self.assertEqual(body['model'], 'fake-model')
  121. self.assertTrue(body['messages'])
  122. self.assertNotIn('fake-key', repr(config))
  123. self.assertNotIn('proxyToken', output.getvalue())
  124. def test_truncated_response_and_redirect_report_safe_codes(self):
  125. for mode, code in (('truncated', 'response_incomplete'), ('redirect', 'http_302')):
  126. self.mode = mode
  127. output = io.StringIO()
  128. with contextlib.redirect_stdout(output):
  129. self.assertEqual(deploy_check.run(self.root, action='check-deepseek'), 1)
  130. self.assertEqual(json.loads(output.getvalue())['checks']['deepseek']['code'], code)
  131. stream_code = code
  132. with self.assertRaisesRegex(RequestFailure, '^' + stream_code + '$'):
  133. stream_transport({}, load_config(self.root / '.env'), lambda _: None)
  134. self.assertEqual(len(self.calls), 4)
  135. def test_first_delta_is_delivered_before_server_finishes(self):
  136. self.mode = 'incremental'
  137. parts = []
  138. def consume(text):
  139. parts.append(text)
  140. self.first_received.set()
  141. response = stream_transport({}, load_config(self.root / '.env'), consume)
  142. self.assertEqual(parts, ['first', 'OK'])
  143. self.assertEqual(response['choices'][0]['message']['content'], 'firstOK')
  144. def test_done_does_not_mask_broken_chunked_end(self):
  145. self.mode = 'done_truncated'
  146. with self.assertRaisesRegex(RequestFailure, '^response_incomplete$'):
  147. stream_transport({}, load_config(self.root / '.env'), lambda _: None)
  148. def test_gateway_connections_closed_on_timeout_and_callback_failure(self):
  149. from src.step1_knowledge_prepare import summarize
  150. config = load_config(self.root / '.env')
  151. with patch.object(summarize, 'HTTPConnection') as factory:
  152. factory.return_value.getresponse.side_effect = TimeoutError('secret-url')
  153. with self.assertRaisesRegex(RequestFailure, '^request_timeout$'):
  154. transport({}, config)
  155. factory.return_value.close.assert_called_once()
  156. with patch.object(summarize, 'HTTPConnection') as factory:
  157. response = factory.return_value.getresponse.return_value
  158. response.__enter__.return_value = response
  159. response.status = 200
  160. response.read1.return_value = b'data: {"choices":[{"delta":{"content":"x"}}]}\n\n'
  161. def cancel(_):
  162. raise RequestFailure('stream_cancelled', False)
  163. with self.assertRaisesRegex(RequestFailure, '^stream_cancelled$'):
  164. stream_transport({}, config, cancel)
  165. factory.return_value.close.assert_called_once()