test_llm.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  1. """Tests for the LLM client and chat session (no network)."""
  2. import pytest
  3. from stt_daemon.assistant.llm import ChatSession, LlmClient, LlmError
  4. from stt_daemon.config import LlmConfig
  5. def _make_client(responder, cfg=None):
  6. cfg = cfg or LlmConfig()
  7. calls: list[dict] = []
  8. def opener(payload, timeout):
  9. calls.append(payload)
  10. return responder(payload)
  11. return LlmClient(cfg, opener=opener), calls
  12. def _reply(content="", reasoning=None, tool_calls=None, finish="stop"):
  13. message = {"role": "assistant", "content": content}
  14. if reasoning is not None:
  15. message["reasoning_content"] = reasoning
  16. if tool_calls:
  17. message["tool_calls"] = tool_calls
  18. return {"choices": [{"message": message, "finish_reason": finish}]}
  19. def test_reasoning_content_is_ignored():
  20. client, _ = _make_client(
  21. lambda _p: _reply(content="It is noon.", reasoning="thinking hard about it")
  22. )
  23. response = client.chat([{"role": "user", "content": "hi"}])
  24. assert response.content == "It is noon."
  25. assert "thinking hard" not in response.content
  26. def test_cache_prompt_and_endpoint_are_sent():
  27. client, calls = _make_client(lambda _p: _reply(content="ok"))
  28. client.chat([{"role": "user", "content": "hi"}])
  29. assert calls[0]["body"]["cache_prompt"] is True
  30. assert calls[0]["body"]["stream"] is False
  31. assert calls[0]["__url__"] == LlmConfig().endpoint
  32. def test_tools_sent_only_when_provided():
  33. client, calls = _make_client(lambda _p: _reply(content="ok"))
  34. client.chat([{"role": "user", "content": "hi"}])
  35. assert "tools" not in calls[0]["body"]
  36. client.chat(
  37. [{"role": "user", "content": "hi"}],
  38. tools=[{"type": "function", "function": {"name": "get_datetime"}}],
  39. )
  40. assert calls[1]["body"]["tools"][0]["function"]["name"] == "get_datetime"
  41. assert calls[1]["body"]["tool_choice"] == "auto"
  42. def test_tool_calls_are_parsed():
  43. client, _ = _make_client(
  44. lambda _p: _reply(
  45. tool_calls=[
  46. {
  47. "id": "1",
  48. "type": "function",
  49. "function": {"name": "get_datetime", "arguments": "{}"},
  50. }
  51. ],
  52. finish="tool_calls",
  53. )
  54. )
  55. response = client.chat([{"role": "user", "content": "time?"}], tools=[{"type": "function"}])
  56. assert len(response.tool_calls) == 1
  57. assert response.tool_calls[0].name == "get_datetime"
  58. assert response.finish_reason == "tool_calls"
  59. def test_missing_choices_raises():
  60. client, _ = _make_client(lambda _p: {"choices": []})
  61. with pytest.raises(LlmError):
  62. client.chat([{"role": "user", "content": "hi"}])
  63. def test_chat_session_appends_and_keeps_prefix():
  64. client, calls = _make_client(lambda _p: _reply(content="hello"))
  65. session = ChatSession(LlmConfig(), client)
  66. assert session.send("hi") == "hello"
  67. session.send("again")
  68. assert [m["role"] for m in session.history] == [
  69. "user",
  70. "assistant",
  71. "user",
  72. "assistant",
  73. ]
  74. first = calls[0]["body"]["messages"]
  75. second = calls[1]["body"]["messages"]
  76. assert second[: len(first)] == first # append-only, prefix preserved
  77. assert second[0]["content"] == LlmConfig().system_prompt_chat
  78. def test_chat_session_trims_in_blocks_and_keeps_system_prompt():
  79. cfg = LlmConfig(
  80. max_context_tokens=1330,
  81. max_tokens=1024,
  82. compaction_safety_margin=256,
  83. trim_block_turns=1,
  84. compaction_enabled=False,
  85. ) # prompt_budget == 50
  86. client, calls = _make_client(lambda _p: _reply(content="ok"))
  87. session = ChatSession(cfg, client)
  88. for _ in range(10):
  89. session.send("x" * 40)
  90. assert len(session.history) <= 6
  91. assert calls[-1]["body"]["messages"][0]["content"] == cfg.system_prompt_chat
  92. def test_chat_session_reset_forgets_history():
  93. client, _ = _make_client(lambda _p: _reply(content="ok"))
  94. session = ChatSession(LlmConfig(), client)
  95. session.send("hi")
  96. session.reset()
  97. assert session.history == []
  98. def test_chat_session_drops_reply_when_reset_mid_flight():
  99. holder: list[ChatSession] = []
  100. def opener(_payload, _timeout):
  101. holder[0].reset()
  102. return _reply(content="late reply")
  103. holder.append(ChatSession(LlmConfig(), LlmClient(LlmConfig(), opener=opener)))
  104. assert holder[0].send("hi") == ""
  105. assert holder[0].history == []
  106. # ---------------------------------------------------------------------------
  107. # Phase C tests
  108. # ---------------------------------------------------------------------------
  109. def _compact_cfg(
  110. *,
  111. budget_chars=200,
  112. trigger_ratio=0.8,
  113. keep_turns=1,
  114. compaction_max_tokens=64,
  115. compaction_timeout_s=5.0,
  116. compaction_input_tokens=8192,
  117. compaction_enabled=True,
  118. trim_block_turns=1,
  119. ):
  120. """Return a small LlmConfig whose prompt_budget == budget_chars // 4 tokens.
  121. prompt_budget = max_context_tokens - max_tokens - compaction_safety_margin
  122. We want budget_chars // 4 tokens, so:
  123. max_context_tokens = (budget_chars // 4) + max_tokens + compaction_safety_margin
  124. """
  125. max_tokens = 16
  126. compaction_safety_margin = 0
  127. max_context_tokens = (budget_chars // 4) + max_tokens + compaction_safety_margin
  128. return LlmConfig(
  129. max_context_tokens=max_context_tokens,
  130. max_tokens=max_tokens,
  131. compaction_safety_margin=compaction_safety_margin,
  132. compaction_enabled=compaction_enabled,
  133. compaction_trigger_ratio=trigger_ratio,
  134. compaction_keep_turns=keep_turns,
  135. compaction_max_tokens=compaction_max_tokens,
  136. compaction_timeout_s=compaction_timeout_s,
  137. compaction_input_tokens=compaction_input_tokens,
  138. trim_block_turns=trim_block_turns,
  139. system_prompt_chat="SYS",
  140. system_prompt_command="CMD",
  141. compaction_system_prompt="COMPACT",
  142. )
  143. def test_compaction_fires_and_window_shrinks():
  144. """Crossing the trigger compacts: window shrinks, summary present in next prompt."""
  145. cfg = _compact_cfg(budget_chars=200, trigger_ratio=0.8, keep_turns=1)
  146. # prompt_budget = 200//4 = 50 tokens; trigger at 40 tokens (0.8 * 50)
  147. summary_text = "summary of old turns"
  148. call_index = 0
  149. def responder(payload):
  150. nonlocal call_index
  151. msgs = payload["body"]["messages"]
  152. call_index += 1
  153. # Summarizer calls have the compaction system prompt.
  154. if msgs[0]["content"] == "COMPACT":
  155. return _reply(content=summary_text)
  156. return _reply(content="ok")
  157. client, calls = _make_client(responder, cfg)
  158. session = ChatSession(cfg, client)
  159. # Send enough turns to exceed the trigger (each "x"*40 = 40 chars = 10 tokens).
  160. # After 4 turns (user+assistant pairs = 8 messages * 10 tokens = 80 tokens > 40 trigger).
  161. for _ in range(4):
  162. session.send("x" * 40)
  163. # After compaction the window must be smaller than the full history.
  164. assert len(session.history) <= 4 # keep_turns=1 → keep=2 messages
  165. # The summary must be set.
  166. assert session.summary != ""
  167. # The next chat call must include the summary as a second system message,
  168. # with the chat system prompt FIRST.
  169. session.send("after compaction")
  170. chat_calls = [c for c in calls if c["body"]["messages"][0]["content"] == "SYS"]
  171. last_chat = chat_calls[-1]["body"]["messages"]
  172. assert last_chat[0]["role"] == "system"
  173. assert last_chat[0]["content"] == "SYS"
  174. assert last_chat[1]["role"] == "system"
  175. assert "Earlier conversation summary:" in last_chat[1]["content"]
  176. assert summary_text in last_chat[1]["content"]
  177. def test_summary_never_appended_to_history():
  178. """The summary text must not appear as a history entry."""
  179. cfg = _compact_cfg(budget_chars=200, trigger_ratio=0.8, keep_turns=1)
  180. summary_text = "SUMMARY_SENTINEL"
  181. def responder(payload):
  182. msgs = payload["body"]["messages"]
  183. if msgs[0]["content"] == "COMPACT":
  184. return _reply(content=summary_text)
  185. return _reply(content="ok")
  186. client, _ = _make_client(responder, cfg)
  187. session = ChatSession(cfg, client)
  188. for _ in range(4):
  189. session.send("x" * 40)
  190. for msg in session.history:
  191. assert summary_text not in msg["content"]
  192. def test_summarizer_failure_falls_back_to_trim():
  193. """On summarizer failure the session falls back to _trim() and stays within budget."""
  194. cfg = _compact_cfg(budget_chars=200, trigger_ratio=0.8, keep_turns=1)
  195. def responder(payload):
  196. msgs = payload["body"]["messages"]
  197. if msgs[0]["content"] == "COMPACT":
  198. return _reply(content="", finish="stop") # empty → failure
  199. return _reply(content="ok")
  200. client, _ = _make_client(responder, cfg)
  201. session = ChatSession(cfg, client)
  202. for _ in range(4):
  203. session.send("x" * 40)
  204. # Summary must remain empty (fallback used trim, not compaction).
  205. assert session.summary == ""
  206. # History must be bounded within the budget.
  207. assert session._estimate_tokens() <= cfg.prompt_budget
  208. def test_summarizer_length_finish_reason_falls_back_to_trim():
  209. """finish_reason == 'length' on the summarizer is treated as failure."""
  210. cfg = _compact_cfg(budget_chars=200, trigger_ratio=0.8, keep_turns=1)
  211. def responder(payload):
  212. msgs = payload["body"]["messages"]
  213. if msgs[0]["content"] == "COMPACT":
  214. return _reply(content="partial summary", finish="length")
  215. return _reply(content="ok")
  216. client, _ = _make_client(responder, cfg)
  217. session = ChatSession(cfg, client)
  218. for _ in range(4):
  219. session.send("x" * 40)
  220. assert session.summary == ""
  221. assert session._estimate_tokens() <= cfg.prompt_budget
  222. def test_summarizer_llmerror_falls_back_to_trim():
  223. """LlmError from the summarizer falls back to _trim()."""
  224. cfg = _compact_cfg(budget_chars=200, trigger_ratio=0.8, keep_turns=1)
  225. def responder(payload):
  226. msgs = payload["body"]["messages"]
  227. if msgs[0]["content"] == "COMPACT":
  228. raise LlmError("network error")
  229. return _reply(content="ok")
  230. client, _ = _make_client(responder, cfg)
  231. session = ChatSession(cfg, client)
  232. for _ in range(4):
  233. session.send("x" * 40)
  234. assert session.summary == ""
  235. assert session._estimate_tokens() <= cfg.prompt_budget
  236. def test_reset_clears_summary():
  237. """reset() clears both history and summary."""
  238. cfg = _compact_cfg(budget_chars=200, trigger_ratio=0.8, keep_turns=1)
  239. summary_text = "old summary"
  240. def responder(payload):
  241. msgs = payload["body"]["messages"]
  242. if msgs[0]["content"] == "COMPACT":
  243. return _reply(content=summary_text)
  244. return _reply(content="ok")
  245. client, _ = _make_client(responder, cfg)
  246. session = ChatSession(cfg, client)
  247. for _ in range(4):
  248. session.send("x" * 40)
  249. assert session.summary != ""
  250. session.reset()
  251. assert session.history == []
  252. assert session.summary == ""
  253. def test_compaction_disabled_never_calls_summarizer():
  254. """compaction_enabled=False never calls the summarizer and still trims."""
  255. cfg = _compact_cfg(
  256. budget_chars=200,
  257. trigger_ratio=0.8,
  258. keep_turns=1,
  259. compaction_enabled=False,
  260. )
  261. summarizer_called = False
  262. def responder(payload):
  263. nonlocal summarizer_called
  264. msgs = payload["body"]["messages"]
  265. if msgs[0]["content"] == "COMPACT":
  266. summarizer_called = True
  267. return _reply(content="should not happen")
  268. return _reply(content="ok")
  269. client, _ = _make_client(responder, cfg)
  270. session = ChatSession(cfg, client)
  271. for _ in range(6):
  272. session.send("x" * 40)
  273. assert not summarizer_called
  274. assert session.summary == ""
  275. # History must still be bounded (trim ran).
  276. assert session._estimate_tokens() <= cfg.prompt_budget
  277. def test_nothing_to_evict_skips_summarizer():
  278. """When len(history) <= keep, compaction is skipped and trim runs instead."""
  279. # keep_turns=10 → keep=20 messages; we only send 2 turns (4 messages).
  280. cfg = _compact_cfg(budget_chars=200, trigger_ratio=0.8, keep_turns=10)
  281. summarizer_called = False
  282. def responder(payload):
  283. nonlocal summarizer_called
  284. msgs = payload["body"]["messages"]
  285. if msgs[0]["content"] == "COMPACT":
  286. summarizer_called = True
  287. return _reply(content="should not happen")
  288. return _reply(content="ok")
  289. client, _ = _make_client(responder, cfg)
  290. session = ChatSession(cfg, client)
  291. # Push over the trigger but keep history shorter than keep=20.
  292. for _ in range(4):
  293. session.send("x" * 40)
  294. assert not summarizer_called
  295. def test_chunk_and_fold_large_evicted():
  296. """Large evicted list triggers chunk-and-fold (more than one summarizer call).
  297. budget_chars=160 → prompt_budget=40 tokens, trigger at 32 tokens (0.8 * 40).
  298. Each turn: user='x'*40 (40 chars) + assistant='ok' (2 chars) = ~10 tokens.
  299. Compaction fires at turn 4 with 6 evicted messages.
  300. compaction_input_tokens=1 → char_limit=4 chars per chunk, so each 40-char
  301. or 2-char message is its own chunk → 6 summarizer calls for 6 chunks.
  302. """
  303. cfg = _compact_cfg(
  304. budget_chars=160,
  305. trigger_ratio=0.8,
  306. keep_turns=1,
  307. compaction_input_tokens=1, # 1 token * 4 = 4 chars per chunk → each msg is a chunk
  308. )
  309. summarizer_calls = []
  310. def responder(payload):
  311. msgs = payload["body"]["messages"]
  312. if msgs[0]["content"] == "COMPACT":
  313. summarizer_calls.append(payload)
  314. return _reply(content="folded")
  315. return _reply(content="ok")
  316. client, _ = _make_client(responder, cfg)
  317. session = ChatSession(cfg, client)
  318. # 4 turns → 8 messages → fires compaction with 6 evicted (keep=2).
  319. for _ in range(4):
  320. session.send("x" * 40)
  321. # With compaction_input_tokens=1 (4 chars/chunk) and 40-char messages,
  322. # each message is its own chunk → multiple summarizer calls.
  323. assert len(summarizer_calls) > 1
  324. def test_chat_per_request_max_tokens_override():
  325. """LlmClient.chat sends per-request max_tokens when given, cfg default otherwise."""
  326. cfg = LlmConfig(max_tokens=1024, timeout_s=60.0)
  327. client, calls = _make_client(lambda _p: _reply(content="ok"), cfg)
  328. # Default: uses cfg.max_tokens.
  329. client.chat([{"role": "user", "content": "hi"}])
  330. assert calls[0]["body"]["max_tokens"] == 1024
  331. # Override: uses the supplied value.
  332. client.chat([{"role": "user", "content": "hi"}], max_tokens=128)
  333. assert calls[1]["body"]["max_tokens"] == 128
  334. def test_chat_per_request_timeout_override():
  335. """LlmClient.chat uses per-request timeout_s when given, cfg default otherwise."""
  336. cfg = LlmConfig(max_tokens=1024, timeout_s=60.0)
  337. recorded_timeouts: list[float] = []
  338. def opener(payload, timeout):
  339. recorded_timeouts.append(timeout)
  340. return _reply(content="ok")
  341. client = LlmClient(cfg, opener=opener)
  342. # Default timeout.
  343. client.chat([{"role": "user", "content": "hi"}])
  344. assert recorded_timeouts[0] == 60.0
  345. # Per-request override.
  346. client.chat([{"role": "user", "content": "hi"}], timeout_s=5.0)
  347. assert recorded_timeouts[1] == 5.0
  348. def test_summarizer_uses_compaction_max_tokens_and_timeout():
  349. """The summarizer call uses compaction_max_tokens and compaction_timeout_s."""
  350. cfg = _compact_cfg(
  351. budget_chars=200,
  352. trigger_ratio=0.8,
  353. keep_turns=1,
  354. compaction_max_tokens=42,
  355. compaction_timeout_s=7.0,
  356. )
  357. recorded: list[dict] = []
  358. def opener(payload, timeout):
  359. msgs = payload["body"]["messages"]
  360. if msgs[0]["content"] == "COMPACT":
  361. recorded.append({"max_tokens": payload["body"]["max_tokens"], "timeout": timeout})
  362. return _reply(content="summary")
  363. return _reply(content="ok")
  364. client = LlmClient(cfg, opener=opener)
  365. session = ChatSession(cfg, client)
  366. for _ in range(4):
  367. session.send("x" * 40)
  368. assert len(recorded) >= 1
  369. assert recorded[0]["max_tokens"] == 42
  370. assert recorded[0]["timeout"] == 7.0
  371. def test_notify_called_on_compaction():
  372. """notify('compacted', turns=N) is called after a successful compaction."""
  373. cfg = _compact_cfg(budget_chars=200, trigger_ratio=0.8, keep_turns=1)
  374. notifications: list[tuple] = []
  375. def notify(kind, **fields):
  376. notifications.append((kind, fields))
  377. def responder(payload):
  378. msgs = payload["body"]["messages"]
  379. if msgs[0]["content"] == "COMPACT":
  380. return _reply(content="summary")
  381. return _reply(content="ok")
  382. client = LlmClient(cfg, opener=lambda p, t: responder(p))
  383. session = ChatSession(cfg, client, notify=notify)
  384. for _ in range(4):
  385. session.send("x" * 40)
  386. assert any(kind == "compacted" for kind, _ in notifications)
  387. compacted = [(k, f) for k, f in notifications if k == "compacted"]
  388. assert compacted[0][1]["turns"] > 0
  389. def test_notify_not_called_when_none():
  390. """ChatSession works without a notify callback (default None)."""
  391. cfg = _compact_cfg(budget_chars=200, trigger_ratio=0.8, keep_turns=1)
  392. def responder(payload):
  393. msgs = payload["body"]["messages"]
  394. if msgs[0]["content"] == "COMPACT":
  395. return _reply(content="summary")
  396. return _reply(content="ok")
  397. client = LlmClient(cfg, opener=lambda p, t: responder(p))
  398. session = ChatSession(cfg, client) # no notify
  399. # Must not raise.
  400. for _ in range(4):
  401. session.send("x" * 40)
  402. def test_system_prompt_is_first_message_after_compaction():
  403. """After compaction the chat system prompt is always the first message."""
  404. cfg = _compact_cfg(budget_chars=200, trigger_ratio=0.8, keep_turns=1)
  405. def responder(payload):
  406. msgs = payload["body"]["messages"]
  407. if msgs[0]["content"] == "COMPACT":
  408. return _reply(content="summary text")
  409. return _reply(content="ok")
  410. client, calls = _make_client(responder, cfg)
  411. session = ChatSession(cfg, client)
  412. for _ in range(4):
  413. session.send("x" * 40)
  414. # Find the last chat call (system prompt == "SYS", not "COMPACT").
  415. chat_calls = [c for c in calls if c["body"]["messages"][0]["content"] == "SYS"]
  416. assert chat_calls, "no chat calls found after compaction"
  417. last_msgs = chat_calls[-1]["body"]["messages"]
  418. assert last_msgs[0]["role"] == "system"
  419. assert last_msgs[0]["content"] == "SYS"
  420. def test_chat_session_truncated_empty_reply_notifies_and_is_not_appended():
  421. """A length-limited empty reply is reported and never stored as an empty turn."""
  422. notifications: list[tuple] = []
  423. def notify(kind, **fields):
  424. notifications.append((kind, fields))
  425. client, _ = _make_client(lambda _p: _reply(content="", finish="length"))
  426. session = ChatSession(LlmConfig(), client, notify=notify)
  427. assert session.send("hi") == ""
  428. assert session.history == [{"role": "user", "content": "hi"}]
  429. assert notifications == [("truncated", {"message": "response truncated"})]
  430. def test_chat_session_truncated_partial_reply_is_appended_and_notified():
  431. """A truncated but non-empty reply is kept and reported as truncated."""
  432. notifications: list[tuple] = []
  433. def notify(kind, **fields):
  434. notifications.append((kind, fields))
  435. client, _ = _make_client(lambda _p: _reply(content="partial answer", finish="length"))
  436. session = ChatSession(LlmConfig(), client, notify=notify)
  437. assert session.send("hi") == "partial answer"
  438. assert session.history[-1] == {"role": "assistant", "content": "partial answer"}
  439. assert any(kind == "truncated" for kind, _ in notifications)
  440. def test_on_io_receives_request_then_response():
  441. """chat(..., on_io=cb) reports the request (url+body) then the response payload."""
  442. client, _ = _make_client(lambda _p: _reply(content="ok"))
  443. seen: list[dict] = []
  444. response = client.chat([{"role": "user", "content": "hi"}], on_io=seen.append)
  445. assert response.content == "ok"
  446. assert [event["phase"] for event in seen] == ["request", "response"]
  447. assert seen[0]["url"] == LlmConfig().endpoint
  448. assert seen[0]["body"]["messages"] == [{"role": "user", "content": "hi"}]
  449. assert seen[0]["body"]["max_tokens"] == LlmConfig().max_tokens
  450. assert seen[1]["payload"]["choices"][0]["message"]["content"] == "ok"
  451. assert seen[1]["error"] is None
  452. def test_on_io_response_phase_reports_opener_error():
  453. """An opener raising still yields a response phase with the error set."""
  454. import urllib.error
  455. def opener(_payload, _timeout):
  456. raise urllib.error.URLError("down")
  457. client = LlmClient(LlmConfig(), opener=opener)
  458. seen: list[dict] = []
  459. with pytest.raises(LlmError):
  460. client.chat([{"role": "user", "content": "hi"}], on_io=seen.append)
  461. assert [event["phase"] for event in seen] == ["request", "response"]
  462. assert seen[1]["payload"] is None
  463. assert "down" in seen[1]["error"]
  464. def test_raising_on_io_does_not_break_chat():
  465. """A throwing on_io callback is swallowed and the reply still parses."""
  466. def bad(_event):
  467. raise RuntimeError("boom")
  468. client, _ = _make_client(lambda _p: _reply(content="ok"))
  469. assert client.chat([{"role": "user", "content": "hi"}], on_io=bad).content == "ok"