2a9111c58c
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---
118 lines
3.5 KiB
Python
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 |