"""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