loosecanvas / tests /test_llm_client.py
Joshua Sundance Bailey
loosecanvas: local AI thought-mapping canvas with a trust-tagged knowledge graph
6d1438c
Raw
History Blame Contribute Delete
8.12 kB
"""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"] == ["<turn|>"]
# 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<channel|>{"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)