"""Unit tests for M08 — LLM async client (`loosecanvas.llm_client`). These tests use ``httpx.MockTransport`` so NO live llama.cpp server is required. Any test that needs a real server is guarded with ``pytest.mark.skipif``. """ from __future__ import annotations import json import os from typing import Any import httpx import pytest from loosecanvas.llm_client import LLMClient, LLMClientError, strip_thought_blocks LIVE_ENV_VAR = "LOOSECANVAS_RUN_LLM_INTEGRATION" def _chat_response(content: str) -> dict[str, Any]: return {"choices": [{"message": {"content": content}}]} # --------------------------------------------------------------------------- # # base_url resolution # # --------------------------------------------------------------------------- # def test_default_base_url_is_8080(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("LLAMA_CPP_PORT", raising=False) client = LLMClient() assert client.base_url == "http://localhost:8080" def test_base_url_reads_llama_cpp_port(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("LLAMA_CPP_PORT", "9999") client = LLMClient() assert client.base_url == "http://localhost:9999" def test_explicit_base_url_overrides_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("LLAMA_CPP_PORT", "9999") client = LLMClient(base_url="http://override:1234") assert client.base_url == "http://override:1234" # --------------------------------------------------------------------------- # # complete() request body # # --------------------------------------------------------------------------- # async def test_complete_posts_to_chat_completions_with_defaults() -> None: captured: dict[str, Any] = {} def handler(request: httpx.Request) -> httpx.Response: captured["url"] = str(request.url) captured["body"] = json.loads(request.content) return httpx.Response(200, json=_chat_response("hi")) client = LLMClient(base_url="http://test", transport=httpx.MockTransport(handler)) result = await client.complete([{"role": "user", "content": "x"}]) assert result == "hi" assert captured["url"] == "http://test/v1/chat/completions" body = captured["body"] assert body["chat_template_kwargs"] == {"enable_thinking": False} assert body["stop"] == [""] # max_tokens is omitted by default (un-capped generation up to n_ctx). assert "max_tokens" not in body assert body["temperature"] == 1.0 assert body["top_p"] == 0.95 assert body["top_k"] == 64 assert "response_format" not in body async def test_complete_includes_max_tokens_when_supplied() -> None: captured: dict[str, Any] = {} def handler(request: httpx.Request) -> httpx.Response: captured["body"] = json.loads(request.content) return httpx.Response(200, json=_chat_response("ok")) client = LLMClient(base_url="http://test", transport=httpx.MockTransport(handler)) await client.complete([{"role": "user", "content": "x"}], max_tokens=42) assert captured["body"]["max_tokens"] == 42 async def test_complete_omits_response_format_when_none() -> None: captured: dict[str, Any] = {} def handler(request: httpx.Request) -> httpx.Response: captured["body"] = json.loads(request.content) return httpx.Response(200, json=_chat_response("ok")) client = LLMClient(base_url="http://test", transport=httpx.MockTransport(handler)) await client.complete([{"role": "user", "content": "x"}]) assert "response_format" not in captured["body"] async def test_complete_includes_response_format_when_supplied() -> None: captured: dict[str, Any] = {} response_format = { "type": "json_schema", "json_schema": { "name": "scene_plan", "strict": True, "schema": {"type": "object"}, }, } def handler(request: httpx.Request) -> httpx.Response: captured["body"] = json.loads(request.content) return httpx.Response(200, json=_chat_response("ok")) client = LLMClient(base_url="http://test", transport=httpx.MockTransport(handler)) await client.complete( [{"role": "user", "content": "x"}], response_format=response_format ) assert captured["body"]["response_format"] == response_format async def test_complete_passes_enable_thinking_true() -> None: captured: dict[str, Any] = {} def handler(request: httpx.Request) -> httpx.Response: captured["body"] = json.loads(request.content) return httpx.Response(200, json=_chat_response("ok")) client = LLMClient(base_url="http://test", transport=httpx.MockTransport(handler)) await client.complete([{"role": "user", "content": "x"}], enable_thinking=True) assert captured["body"]["chat_template_kwargs"] == {"enable_thinking": True} async def test_complete_returns_message_content() -> None: def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=_chat_response('{"v": "YES"}')) client = LLMClient(base_url="http://test", transport=httpx.MockTransport(handler)) result = await client.complete([{"role": "user", "content": "x"}]) assert result == '{"v": "YES"}' # --------------------------------------------------------------------------- # # retry / error semantics # # --------------------------------------------------------------------------- # async def test_4xx_raises_without_retry() -> None: calls: list[int] = [] def handler(request: httpx.Request) -> httpx.Response: calls.append(1) return httpx.Response(400, json={"error": "bad request"}) client = LLMClient(base_url="http://test", transport=httpx.MockTransport(handler)) with pytest.raises(LLMClientError): await client.complete([{"role": "user", "content": "x"}]) assert len(calls) == 1 async def test_single_503_then_200_succeeds() -> None: calls: list[int] = [] def handler(request: httpx.Request) -> httpx.Response: calls.append(1) if len(calls) == 1: return httpx.Response(503, json={"error": "busy"}) return httpx.Response(200, json=_chat_response("recovered")) client = LLMClient(base_url="http://test", transport=httpx.MockTransport(handler)) result = await client.complete([{"role": "user", "content": "x"}]) assert result == "recovered" assert len(calls) == 2 async def test_two_consecutive_503_raise() -> None: calls: list[int] = [] def handler(request: httpx.Request) -> httpx.Response: calls.append(1) return httpx.Response(503, json={"error": "busy"}) client = LLMClient(base_url="http://test", transport=httpx.MockTransport(handler)) with pytest.raises(LLMClientError): await client.complete([{"role": "user", "content": "x"}]) assert len(calls) == 2 # --------------------------------------------------------------------------- # # strip_thought_blocks # # --------------------------------------------------------------------------- # def test_strip_thought_blocks_removes_block() -> None: text = '<|channel>thought\nsome internal reasoning{"v": "YES"}' assert strip_thought_blocks(text) == '{"v": "YES"}' def test_strip_thought_blocks_noop_on_clean_text() -> None: text = '{"v": "YES"}' assert strip_thought_blocks(text) == '{"v": "YES"}' # --------------------------------------------------------------------------- # # live-server-only (skipped unless explicitly enabled) # # --------------------------------------------------------------------------- # @pytest.mark.skipif( os.environ.get(LIVE_ENV_VAR) != "1", reason=f"Set {LIVE_ENV_VAR}=1 to run live llama.cpp readiness checks.", ) async def test_wait_for_ready_against_live_server() -> None: client = LLMClient() await client.wait_for_ready(timeout=10.0)