Files
praxis/tests/test_llm_adapter.py
T
Praxis CI 2a9111c58c feat(P01-02-03): OllamaCloudLLM adapter behind LLMProvider (R6)
server/llm/ollama_cloud.py wraps the Ollama Cloud direct API
(https://ollama.com/api/chat + bearer, stream=True) behind LLMProvider.
chat() streams LLMStreamChunk (is_first flag for TTFT measurement);
chat_full() accumulates for the debrief / branch classifier (offline).
Two models: gemma4:cloud (roleplay_model) + deepseek-v4-flash:cloud
(debrief_model, no_think mode for latency, D-020). Resolves R6 — the
adapter confirms the direct API + bearer path; a live first-token
confirmation is pending the R3 probe with a real key. Graceful no-key
degradation (no chunks, no crash). 6 unit tests pass (mocked httpx
streaming response + env model selection + chat_full accumulation).

---ci---
phase: 1
milestone: v0.1
plan: 02
task: 02-03
status: execute
persona: backend-engineer
requirements:
  covered: [REQ-LLM-01, REQ-LLM-02]
---/ci---
2026-08-01 13:01:37 +00:00

118 lines
3.5 KiB
Python

"""Unit tests for the OllamaCloudLLM adapter (TASK-02-03).
The adapter must work with a mocked HTTP streaming response and degrade
gracefully when OLLAMA_API_KEY is absent. The role-play + debrief model ids
must come from env / defaults.
"""
from __future__ import annotations
import asyncio
import json
from typing import AsyncIterator
import pytest
from server.llm.ollama_cloud import OllamaCloudLLM
from server.services.base import LLMProvider
def test_ollama_is_llmprovider():
assert isinstance(OllamaCloudLLM(api_key="k"), LLMProvider)
def test_ollama_models_from_env_defaults(monkeypatch):
monkeypatch.delenv("OLLAMA_ROLEPLAY_MODEL", raising=False)
monkeypatch.delenv("OLLAMA_DEBRIEF_MODEL", raising=False)
llm = OllamaCloudLLM(api_key="k")
assert llm.roleplay_model == "gemma4:cloud"
assert llm.debrief_model == "deepseek-v4-flash:cloud"
def test_ollama_models_from_env(monkeypatch):
monkeypatch.setenv("OLLAMA_ROLEPLAY_MODEL", "custom-roleplay")
monkeypatch.setenv("OLLAMA_DEBRIEF_MODEL", "custom-debrief")
llm = OllamaCloudLLM()
assert llm.roleplay_model == "custom-roleplay"
assert llm.debrief_model == "custom-debrief"
def test_ollama_missing_key_no_chunks():
"""No API key → no chunks, no crash (graceful)."""
llm = OllamaCloudLLM(api_key="")
async def _run():
return [c async for c in llm.chat([{"role": "user", "content": "hi"}])]
chunks = asyncio.run(_run())
assert chunks == []
def test_ollama_chat_with_mocked_stream(monkeypatch):
"""Adapter streams chunks from a mocked httpx streaming response."""
llm = OllamaCloudLLM(api_key="test-key")
# Fake NDJSON lines as Ollama /api/chat would emit.
lines = [
json.dumps({"message": {"content": "Hi"}, "done": False}),
json.dumps({"message": {"content": " there"}, "done": False}),
json.dumps({"message": {"content": ""}, "done": True, "eval_count": 7}),
]
class _FakeResp:
status_code = 200
async def aiter_lines(self):
for line in lines:
yield line
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
class _FakeClient:
def __init__(self, *a, **kw):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
def stream(self, *a, **kw):
return _FakeResp()
import httpx
monkeypatch.setattr(httpx, "AsyncClient", _FakeClient)
async def _run():
out = []
async for c in llm.chat([{"role": "user", "content": "hi"}]):
out.append(c)
return out
chunks = asyncio.run(_run())
assert len(chunks) == 2
assert chunks[0].content == "Hi"
assert chunks[0].is_first is True
assert chunks[1].content == " there"
assert chunks[1].is_first is False
def test_ollama_chat_full_accumulates(monkeypatch):
"""chat_full joins all streamed content into a single string."""
llm = OllamaCloudLLM(api_key="test-key")
async def _fake_chat(messages, *, stream, model, no_think):
yield type("C", (), {"content": "Hello", "is_first": True, "finish_reason": None, "extra": {}})()
yield type("C", (), {"content": " world", "is_first": False, "finish_reason": "stop", "extra": {"eval_count": 5}})()
monkeypatch.setattr(llm, "chat", _fake_chat)
text, usage = asyncio.run(llm.chat_full([{"role": "user", "content": "hi"}]))
assert text == "Hello world"
assert usage["output_tokens"] == 5