lmstudio_thinking_probe.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. #!/usr/bin/env python3
  2. import argparse
  3. import json
  4. import os
  5. import sys
  6. import time
  7. import urllib.error
  8. import urllib.request
  9. from datetime import datetime
  10. from pathlib import Path
  11. OPENAI_COMPAT_TESTS = [
  12. {
  13. "name": "official_disable",
  14. "model": "qwen/qwen3.6-35b-a3b",
  15. "enable_thinking": False,
  16. },
  17. {
  18. "name": "official_enable",
  19. "model": "qwen/qwen3.6-35b-a3b",
  20. "enable_thinking": True,
  21. },
  22. {
  23. "name": "hauhau_disable",
  24. "model": "qwen3.6-35b-a3b-uncensored-hauhaucs-aggressive",
  25. "enable_thinking": False,
  26. },
  27. {
  28. "name": "hauhau_enable",
  29. "model": "qwen3.6-35b-a3b-uncensored-hauhaucs-aggressive",
  30. "enable_thinking": True,
  31. },
  32. ]
  33. NATIVE_TESTS = [
  34. {
  35. "name": "official_reasoning_off",
  36. "model": "qwen/qwen3.6-35b-a3b",
  37. "reasoning": "off",
  38. },
  39. {
  40. "name": "official_reasoning_on",
  41. "model": "qwen/qwen3.6-35b-a3b",
  42. "reasoning": "on",
  43. },
  44. {
  45. "name": "hauhau_reasoning_off",
  46. "model": "qwen3.6-35b-a3b-uncensored-hauhaucs-aggressive",
  47. "reasoning": "off",
  48. },
  49. {
  50. "name": "hauhau_reasoning_on",
  51. "model": "qwen3.6-35b-a3b-uncensored-hauhaucs-aggressive",
  52. "reasoning": "on",
  53. },
  54. {
  55. "name": "wrapped_reasoning_off",
  56. "model": "local/qwen3.6-35b-a3b-hauhaucs-aggressive",
  57. "reasoning": "off",
  58. },
  59. {
  60. "name": "wrapped_reasoning_on",
  61. "model": "local/qwen3.6-35b-a3b-hauhaucs-aggressive",
  62. "reasoning": "on",
  63. },
  64. ]
  65. def post_json(url: str, payload: dict, timeout: int) -> tuple[int, str]:
  66. body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
  67. request = urllib.request.Request(
  68. url,
  69. data=body,
  70. headers={"Content-Type": "application/json"},
  71. method="POST",
  72. )
  73. try:
  74. with urllib.request.urlopen(request, timeout=timeout) as response:
  75. return response.status, response.read().decode("utf-8", errors="replace")
  76. except urllib.error.HTTPError as exc:
  77. return exc.code, exc.read().decode("utf-8", errors="replace")
  78. def get_json(url: str, timeout: int) -> tuple[int, str]:
  79. request = urllib.request.Request(url, method="GET")
  80. try:
  81. with urllib.request.urlopen(request, timeout=timeout) as response:
  82. return response.status, response.read().decode("utf-8", errors="replace")
  83. except urllib.error.HTTPError as exc:
  84. return exc.code, exc.read().decode("utf-8", errors="replace")
  85. def parse_json_maybe(raw: str):
  86. try:
  87. return json.loads(raw)
  88. except json.JSONDecodeError:
  89. return None
  90. def summarize_response(test: dict, status_code: int, raw_text: str) -> dict:
  91. parsed = parse_json_maybe(raw_text)
  92. summary = {
  93. "test": test["name"],
  94. "model": test["model"],
  95. "enable_thinking": test["enable_thinking"],
  96. "http_status": status_code,
  97. "json_valid": parsed is not None,
  98. "error": None,
  99. "finish_reason": None,
  100. "content": None,
  101. "reasoning_content": None,
  102. "contains_think_tag_in_content": None,
  103. "contains_think_tag_in_reasoning": None,
  104. "usage": None,
  105. }
  106. if parsed is None:
  107. summary["error"] = "response_not_json"
  108. return summary
  109. if "error" in parsed:
  110. summary["error"] = parsed["error"]
  111. choices = parsed.get("choices") or []
  112. if not choices:
  113. return summary
  114. choice = choices[0]
  115. message = choice.get("message") or {}
  116. content = message.get("content")
  117. reasoning = message.get("reasoning_content")
  118. summary["finish_reason"] = choice.get("finish_reason")
  119. summary["content"] = content
  120. summary["reasoning_content"] = reasoning
  121. summary["contains_think_tag_in_content"] = (
  122. isinstance(content, str) and ("<think>" in content or "</think>" in content)
  123. )
  124. summary["contains_think_tag_in_reasoning"] = (
  125. isinstance(reasoning, str) and ("<think>" in reasoning or "</think>" in reasoning)
  126. )
  127. summary["usage"] = parsed.get("usage")
  128. return summary
  129. def summarize_native_response(test: dict, status_code: int, raw_text: str) -> dict:
  130. parsed = parse_json_maybe(raw_text)
  131. summary = {
  132. "test": test["name"],
  133. "model": test["model"],
  134. "reasoning": test["reasoning"],
  135. "http_status": status_code,
  136. "json_valid": parsed is not None,
  137. "error": None,
  138. "message_content": [],
  139. "reasoning_content": [],
  140. "output_types": [],
  141. "stats": None,
  142. }
  143. if parsed is None:
  144. summary["error"] = "response_not_json"
  145. return summary
  146. if "error" in parsed:
  147. summary["error"] = parsed["error"]
  148. output = parsed.get("output") or []
  149. summary["output_types"] = [item.get("type") for item in output]
  150. for item in output:
  151. if item.get("type") == "message":
  152. summary["message_content"].append(item.get("content"))
  153. elif item.get("type") == "reasoning":
  154. summary["reasoning_content"].append(item.get("content"))
  155. summary["stats"] = parsed.get("stats")
  156. return summary
  157. def ensure_dir(path: Path) -> None:
  158. path.mkdir(parents=True, exist_ok=True)
  159. def write_text(path: Path, text: str) -> None:
  160. path.write_text(text, encoding="utf-8")
  161. def write_json(path: Path, obj) -> None:
  162. path.write_text(json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8")
  163. def main() -> int:
  164. parser = argparse.ArgumentParser()
  165. parser.add_argument("--host", default="127.0.0.1")
  166. parser.add_argument("--port", type=int, default=7860)
  167. parser.add_argument("--timeout", type=int, default=1800)
  168. parser.add_argument("--max-tokens", type=int, default=256)
  169. parser.add_argument(
  170. "--prompt",
  171. default="Compute 317 * 29. Give the final answer only.",
  172. )
  173. parser.add_argument(
  174. "--output-root",
  175. default="artifacts/lmstudio-thinking-probe",
  176. )
  177. args = parser.parse_args()
  178. timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
  179. run_dir = Path(args.output_root) / timestamp
  180. ensure_dir(run_dir)
  181. base_url = f"http://{args.host}:{args.port}/v1"
  182. metadata = {
  183. "base_url": base_url,
  184. "started_at": datetime.now().isoformat(),
  185. "cwd": os.getcwd(),
  186. "python": sys.version,
  187. "openai_compat_tests": OPENAI_COMPAT_TESTS,
  188. "native_tests": NATIVE_TESTS,
  189. "prompt": args.prompt,
  190. "timeout": args.timeout,
  191. "max_tokens": args.max_tokens,
  192. }
  193. write_json(run_dir / "run_metadata.json", metadata)
  194. status_code, models_raw = get_json(f"{base_url}/models", timeout=30)
  195. write_text(run_dir / "models.raw.json", models_raw)
  196. write_json(
  197. run_dir / "models.summary.json",
  198. {
  199. "http_status": status_code,
  200. "json_valid": parse_json_maybe(models_raw) is not None,
  201. },
  202. )
  203. all_summaries = []
  204. for index, test in enumerate(OPENAI_COMPAT_TESTS, start=1):
  205. payload = {
  206. "model": test["model"],
  207. "messages": [{"role": "user", "content": args.prompt}],
  208. "temperature": 0.2,
  209. "max_tokens": args.max_tokens,
  210. "stream": False,
  211. "enable_thinking": test["enable_thinking"],
  212. }
  213. prefix = f"{index:02d}_{test['name']}"
  214. write_json(run_dir / f"{prefix}.request.json", payload)
  215. started = time.time()
  216. status_code, raw_text = post_json(
  217. f"{base_url}/chat/completions",
  218. payload,
  219. timeout=args.timeout,
  220. )
  221. elapsed = time.time() - started
  222. write_text(run_dir / f"{prefix}.response.raw.json", raw_text)
  223. summary = summarize_response(test, status_code, raw_text)
  224. summary["elapsed_seconds"] = round(elapsed, 3)
  225. write_json(run_dir / f"{prefix}.summary.json", summary)
  226. all_summaries.append(summary)
  227. native_summaries = []
  228. for index, test in enumerate(NATIVE_TESTS, start=1):
  229. payload = {
  230. "model": test["model"],
  231. "input": args.prompt,
  232. "temperature": 0.2,
  233. "max_output_tokens": args.max_tokens,
  234. "reasoning": test["reasoning"],
  235. "store": False,
  236. }
  237. prefix = f"native_{index:02d}_{test['name']}"
  238. write_json(run_dir / f"{prefix}.request.json", payload)
  239. started = time.time()
  240. status_code, raw_text = post_json(
  241. f"http://{args.host}:{args.port}/api/v1/chat",
  242. payload,
  243. timeout=args.timeout,
  244. )
  245. elapsed = time.time() - started
  246. write_text(run_dir / f"{prefix}.response.raw.json", raw_text)
  247. summary = summarize_native_response(test, status_code, raw_text)
  248. summary["elapsed_seconds"] = round(elapsed, 3)
  249. write_json(run_dir / f"{prefix}.summary.json", summary)
  250. native_summaries.append(summary)
  251. report_lines = [
  252. "# LM Studio Thinking Probe",
  253. "",
  254. f"- Base URL: `{base_url}`",
  255. f"- Prompt: `{args.prompt}`",
  256. f"- Run dir: `{run_dir}`",
  257. "",
  258. "| Test | HTTP | enable_thinking | finish | reasoning_content | think tags in content | content |",
  259. "| --- | --- | --- | --- | --- | --- | --- |",
  260. ]
  261. for summary in all_summaries:
  262. content = summary["content"]
  263. if isinstance(content, str):
  264. content = content.replace("\n", "\\n")
  265. content = content[:80]
  266. reasoning_present = summary["reasoning_content"] is not None
  267. report_lines.append(
  268. "| {test} | {http_status} | {enable_thinking} | {finish_reason} | {reasoning_present} | {contains_think_tag_in_content} | {content} |".format(
  269. test=summary["test"],
  270. http_status=summary["http_status"],
  271. enable_thinking=summary["enable_thinking"],
  272. finish_reason=summary["finish_reason"],
  273. reasoning_present=reasoning_present,
  274. contains_think_tag_in_content=summary["contains_think_tag_in_content"],
  275. content=content,
  276. )
  277. )
  278. report_lines.extend(
  279. [
  280. "",
  281. "## Native API",
  282. "",
  283. "| Test | HTTP | reasoning | reasoning items | message items | stats |",
  284. "| --- | --- | --- | --- | --- | --- |",
  285. ]
  286. )
  287. for summary in native_summaries:
  288. report_lines.append(
  289. "| {test} | {http_status} | {reasoning} | {reasoning_count} | {message_count} | {stats} |".format(
  290. test=summary["test"],
  291. http_status=summary["http_status"],
  292. reasoning=summary["reasoning"],
  293. reasoning_count=len(summary["reasoning_content"]),
  294. message_count=len(summary["message_content"]),
  295. stats=str(summary["stats"]).replace("\n", " "),
  296. )
  297. )
  298. write_text(run_dir / "report.md", "\n".join(report_lines) + "\n")
  299. write_json(
  300. run_dir / "all_summaries.json",
  301. {
  302. "completed_at": datetime.now().isoformat(),
  303. "openai_compat_results": all_summaries,
  304. "native_results": native_summaries,
  305. },
  306. )
  307. print(run_dir)
  308. return 0
  309. if __name__ == "__main__":
  310. raise SystemExit(main())