smoke-python-runtime.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. #!/usr/bin/env python3
  2. """Keyless full-turn smoke for the SDK wrapper and direct NDJSON runtime use."""
  3. from __future__ import annotations
  4. import argparse
  5. import json
  6. import os
  7. import queue
  8. import subprocess
  9. import tempfile
  10. import threading
  11. import time
  12. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  13. from pathlib import Path
  14. from typing import Callable
  15. EXPECTED_TEXT = "runtime smoke ok"
  16. CUSTOM_CORDIS = """\
  17. - id: jsonrpc
  18. name: '@deepseek-ai/dsh-jsonrpc'
  19. - id: agent-core
  20. name: '@deepseek-ai/dsh-agent-core'
  21. - id: sessions
  22. name: '@deepseek-ai/dsh-session-persistence-jsonl'
  23. config:
  24. root: !!js process.env.DSH_SESSION_ROOT
  25. - id: bash
  26. name: '@deepseek-ai/dsh-bash-local'
  27. config:
  28. cwd: !!js process.env.DSH_CWD
  29. """
  30. class MockModelHandler(BaseHTTPRequestHandler):
  31. """Return one deterministic OpenAI-compatible streaming completion."""
  32. requests: list[dict[str, object]] = []
  33. def do_POST(self) -> None:
  34. content_length = int(self.headers.get("content-length", "0"))
  35. body = json.loads(self.rfile.read(content_length))
  36. self.requests.append(body)
  37. self.send_response(200)
  38. self.send_header("content-type", "text/event-stream")
  39. self.end_headers()
  40. chunks = [
  41. {"choices": [{"delta": {"role": "assistant", "content": None, "reasoning_content": ""}}]},
  42. {"choices": [{"delta": {"content": EXPECTED_TEXT}}]},
  43. {"choices": [{"delta": {"content": ""}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 3, "completion_tokens": 3}},
  44. ]
  45. for chunk in chunks:
  46. self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode())
  47. self.wfile.write(b"data: [DONE]\n\n")
  48. self.wfile.flush()
  49. def log_message(self, _format: str, *_args: object) -> None:
  50. return
  51. class MockModel:
  52. def __enter__(self) -> "MockModel":
  53. MockModelHandler.requests.clear()
  54. self.server = ThreadingHTTPServer(("127.0.0.1", 0), MockModelHandler)
  55. self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
  56. self.thread.start()
  57. host, port = self.server.server_address
  58. self.url = f"http://{host}:{port}"
  59. return self
  60. def __exit__(self, _exc_type: object, _exc: object, _tb: object) -> None:
  61. self.server.shutdown()
  62. self.server.server_close()
  63. self.thread.join(timeout=5)
  64. def main() -> None:
  65. parser = argparse.ArgumentParser(description=__doc__)
  66. parser.add_argument("--scenario", choices=("all", "sdk-default", "sdk-custom", "direct"), default="all")
  67. parser.add_argument("--exe", type=Path)
  68. args = parser.parse_args()
  69. if args.scenario in {"all", "sdk-custom", "direct"} and args.exe is None:
  70. parser.error("--exe is required for custom and direct scenarios")
  71. if args.exe is not None and not args.exe.is_file():
  72. parser.error(f"runtime executable does not exist: {args.exe}")
  73. with MockModel() as model:
  74. if args.scenario in {"all", "sdk-default"}:
  75. smoke_sdk_default(model.url)
  76. if args.scenario in {"all", "sdk-custom"}:
  77. assert args.exe is not None
  78. smoke_sdk_custom(model.url, args.exe.resolve())
  79. if args.scenario in {"all", "direct"}:
  80. assert args.exe is not None
  81. smoke_direct(model.url, args.exe.resolve())
  82. if not MockModelHandler.requests:
  83. raise AssertionError("mock model endpoint received no requests")
  84. print(f"smoke-python-runtime: {args.scenario} passed")
  85. def smoke_sdk_default(base_url: str) -> None:
  86. from deepseek_harness import DeepSeekHarness
  87. with tempfile.TemporaryDirectory(prefix="dsh-sdk-default-") as temporary:
  88. root = Path(temporary).resolve()
  89. sessions = root / "sessions"
  90. with DeepSeekHarness(
  91. model="smoke-model",
  92. cwd=str(root),
  93. session_root=str(sessions),
  94. api_key="sk-keyless-smoke",
  95. base_url=base_url,
  96. request_timeout_seconds=60,
  97. ) as harness:
  98. result = harness.run("reply with the smoke text", session_id="default-smoke")
  99. assert result.status == "ok", result
  100. assert result.final_response == EXPECTED_TEXT, result.final_response
  101. assert_session_log(sessions, root)
  102. def smoke_sdk_custom(base_url: str, executable: Path) -> None:
  103. from deepseek_harness import DeepSeekHarness
  104. with tempfile.TemporaryDirectory(prefix="dsh-sdk-custom-") as temporary:
  105. root = Path(temporary).resolve()
  106. sessions = root / "sessions"
  107. cordis = root / "cordis.yml"
  108. cordis.write_text(CUSTOM_CORDIS)
  109. with DeepSeekHarness(
  110. model="smoke-model",
  111. cwd=str(root),
  112. session_root=str(sessions),
  113. cordis=str(cordis),
  114. runtime_bin=str(executable),
  115. api_key="sk-keyless-smoke",
  116. base_url=base_url,
  117. request_timeout_seconds=60,
  118. ) as harness:
  119. result = harness.run("reply with the smoke text", session_id="custom-smoke")
  120. assert result.status == "ok", result
  121. assert result.final_response == EXPECTED_TEXT, result.final_response
  122. assert_session_log(sessions, root)
  123. def smoke_direct(base_url: str, executable: Path) -> None:
  124. with tempfile.TemporaryDirectory(prefix="dsh-direct-") as temporary:
  125. root = Path(temporary).resolve()
  126. sessions = root / "sessions"
  127. cordis = root / "cordis.yml"
  128. cordis.write_text(CUSTOM_CORDIS)
  129. environment = {
  130. **os.environ,
  131. "DSH_CORDIS_CONFIG": str(cordis),
  132. "DSH_SESSION_ROOT": str(sessions),
  133. "DSH_CWD": str(root),
  134. "DEEPSEEK_API_KEY": "sk-keyless-smoke",
  135. "DEEPSEEK_BASE_URL": base_url,
  136. }
  137. peer = RuntimePeer([str(executable)], root, environment)
  138. try:
  139. peer.send({"jsonrpc": "2.0", "id": "initialize", "method": "initialize", "params": {"cwd": str(root), "model": "smoke-model"}})
  140. peer.read_until(lambda message: message.get("id") == "initialize")
  141. peer.send({
  142. "jsonrpc": "2.0",
  143. "id": "prompt",
  144. "method": "session/prompt",
  145. "params": {"sessionId": "direct-smoke", "contentBlocks": [{"type": "text", "text": "reply with the smoke text"}]},
  146. })
  147. messages = peer.read_until(lambda message: message.get("id") == "prompt")
  148. if not any(message.get("method") == "session.finished" and message.get("params", {}).get("status") == "ok" for message in messages):
  149. messages.extend(peer.read_until(lambda message: message.get("method") == "session.finished"))
  150. event_text = json.dumps(messages)
  151. if EXPECTED_TEXT not in event_text:
  152. raise AssertionError(f"direct runtime emitted no final response: {messages}")
  153. peer.send({"jsonrpc": "2.0", "id": "shutdown", "method": "shutdown"})
  154. peer.read_until(lambda message: message.get("id") == "shutdown")
  155. finally:
  156. peer.close()
  157. assert_session_log(sessions, root)
  158. class RuntimePeer:
  159. def __init__(self, argv: list[str], cwd: Path, environment: dict[str, str]) -> None:
  160. self.process = subprocess.Popen(
  161. argv,
  162. cwd=cwd,
  163. env=environment,
  164. stdin=subprocess.PIPE,
  165. stdout=subprocess.PIPE,
  166. stderr=subprocess.PIPE,
  167. text=True,
  168. encoding="utf-8",
  169. bufsize=1,
  170. )
  171. self.stdout: queue.Queue[str | None] = queue.Queue()
  172. self.stderr: list[str] = []
  173. threading.Thread(target=self._read_stdout, daemon=True).start()
  174. threading.Thread(target=self._read_stderr, daemon=True).start()
  175. def send(self, message: dict[str, object]) -> None:
  176. if self.process.stdin is None:
  177. raise RuntimeError("runtime stdin is unavailable")
  178. self.process.stdin.write(json.dumps(message) + "\n")
  179. self.process.stdin.flush()
  180. def read_until(self, predicate: Callable[[dict[str, object]], bool]) -> list[dict[str, object]]:
  181. deadline = time.monotonic() + 60
  182. messages: list[dict[str, object]] = []
  183. while time.monotonic() < deadline:
  184. try:
  185. line = self.stdout.get(timeout=min(0.25, deadline - time.monotonic()))
  186. except queue.Empty:
  187. continue
  188. if line is None:
  189. raise RuntimeError(f"runtime exited before expected message; stderr: {''.join(self.stderr)}")
  190. try:
  191. message = json.loads(line)
  192. except json.JSONDecodeError:
  193. continue
  194. messages.append(message)
  195. if predicate(message):
  196. return messages
  197. raise TimeoutError(f"runtime timed out; messages={messages}; stderr={''.join(self.stderr)}")
  198. def close(self) -> None:
  199. if self.process.stdin is not None and not self.process.stdin.closed:
  200. self.process.stdin.close()
  201. try:
  202. self.process.wait(timeout=10)
  203. except subprocess.TimeoutExpired:
  204. self.process.kill()
  205. self.process.wait()
  206. if self.process.returncode not in {0, -15}:
  207. raise RuntimeError(f"runtime exited {self.process.returncode}; stderr: {''.join(self.stderr)}")
  208. def _read_stdout(self) -> None:
  209. assert self.process.stdout is not None
  210. for line in self.process.stdout:
  211. self.stdout.put(line)
  212. self.stdout.put(None)
  213. def _read_stderr(self) -> None:
  214. assert self.process.stderr is not None
  215. self.stderr.extend(self.process.stderr)
  216. def assert_session_log(sessions: Path, cwd: Path) -> None:
  217. logs = list(sessions.rglob("*.jsonl"))
  218. if len(logs) != 1:
  219. raise AssertionError(f"expected one JSONL session log under {sessions}, found {logs}")
  220. lines = logs[0].read_text().splitlines()
  221. header = json.loads(lines[0])
  222. if header.get("cwd") != str(cwd):
  223. raise AssertionError(f"session header cwd is not absolute/canonical: {header}")
  224. if EXPECTED_TEXT not in "\n".join(lines):
  225. raise AssertionError(f"session log has no final response: {logs[0]}")
  226. if __name__ == "__main__":
  227. main()