feat(realism/llm): GET/PUT /api/v1/realism/llm + worker hot-reload tick
This commit is contained in:
263
tests/web/test_api_llm.py
Normal file
263
tests/web/test_api_llm.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""Tests for GET/PUT /api/v1/realism/llm."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from fastapi import HTTPException
|
||||
|
||||
import decnet.realism.llm.config as _cfg_mod
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_llm_cache():
|
||||
"""Each test starts with no cached backend."""
|
||||
_cfg_mod._cached_backend = None
|
||||
yield
|
||||
_cfg_mod._cached_backend = None
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fernet_key(monkeypatch) -> str:
|
||||
key = Fernet.generate_key().decode()
|
||||
monkeypatch.setenv("DECNET_SECRET_KEY", key)
|
||||
return key
|
||||
|
||||
|
||||
# ── GET ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetLLMConfig:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_defaults_when_no_row(self):
|
||||
from decnet.web.router.realism.api_llm import get_llm_config, _hydrated
|
||||
import decnet.web.router.realism.api_llm as _mod
|
||||
_mod._hydrated = False
|
||||
|
||||
with patch("decnet.web.router.realism.api_llm.repo") as mock_repo:
|
||||
mock_repo.get_realism_config = AsyncMock(return_value=None)
|
||||
result = await get_llm_config(user={"uuid": "u1", "role": "viewer"})
|
||||
|
||||
assert result["provider"] == "ollama"
|
||||
assert result["model"] == "llama3.1"
|
||||
assert result["api_key_set"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_stored_config(self):
|
||||
from decnet.web.router.realism.api_llm import get_llm_config
|
||||
import decnet.web.router.realism.api_llm as _mod
|
||||
_mod._hydrated = False
|
||||
|
||||
row_value = json.dumps({
|
||||
"provider": "ollama",
|
||||
"base_url": "http://10.0.0.1:11434",
|
||||
"model": "phi3",
|
||||
"timeout": 30.0,
|
||||
})
|
||||
with patch("decnet.web.router.realism.api_llm.repo") as mock_repo:
|
||||
mock_repo.get_realism_config = AsyncMock(
|
||||
return_value={"value": row_value}
|
||||
)
|
||||
result = await get_llm_config(user={"uuid": "u1", "role": "viewer"})
|
||||
|
||||
assert result["provider"] == "ollama"
|
||||
assert result["base_url"] == "http://10.0.0.1:11434"
|
||||
assert result["model"] == "phi3"
|
||||
assert result["api_key_set"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_key_set_true_when_ciphertext_present(self):
|
||||
from decnet.web.router.realism.api_llm import get_llm_config
|
||||
import decnet.web.router.realism.api_llm as _mod
|
||||
_mod._hydrated = False
|
||||
|
||||
row_value = json.dumps({
|
||||
"provider": "ollama",
|
||||
"model": "llama3.1",
|
||||
"api_key_ciphertext": "gAAAAABxxx",
|
||||
})
|
||||
with patch("decnet.web.router.realism.api_llm.repo") as mock_repo:
|
||||
mock_repo.get_realism_config = AsyncMock(
|
||||
return_value={"value": row_value}
|
||||
)
|
||||
result = await get_llm_config(user={"uuid": "u1", "role": "viewer"})
|
||||
|
||||
assert result["api_key_set"] is True
|
||||
assert "api_key_ciphertext" not in result
|
||||
assert "api_key" not in result
|
||||
|
||||
|
||||
# ── PUT ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPutLLMConfig:
|
||||
@pytest.mark.asyncio
|
||||
async def test_saves_and_applies_config(self):
|
||||
from decnet.web.router.realism.api_llm import put_llm_config
|
||||
from decnet.realism.llm.impl.ollama import OllamaBackend
|
||||
|
||||
with patch("decnet.web.router.realism.api_llm.repo") as mock_repo:
|
||||
mock_repo.get_realism_config = AsyncMock(return_value=None)
|
||||
mock_repo.set_realism_config = AsyncMock()
|
||||
|
||||
result = await put_llm_config(
|
||||
body={"provider": "ollama", "model": "phi3", "timeout": 45.0},
|
||||
user={"uuid": "admin-1", "role": "admin"},
|
||||
)
|
||||
|
||||
assert result["provider"] == "ollama"
|
||||
assert result["model"] == "phi3"
|
||||
assert result["timeout"] == 45.0
|
||||
mock_repo.set_realism_config.assert_called_once()
|
||||
assert isinstance(_cfg_mod.get_cached_backend(), OllamaBackend)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merges_partial_update(self):
|
||||
from decnet.web.router.realism.api_llm import put_llm_config
|
||||
|
||||
existing = json.dumps({
|
||||
"provider": "ollama", "model": "llama3.1",
|
||||
"base_url": "http://10.0.0.1:11434",
|
||||
})
|
||||
with patch("decnet.web.router.realism.api_llm.repo") as mock_repo:
|
||||
mock_repo.get_realism_config = AsyncMock(
|
||||
return_value={"value": existing}
|
||||
)
|
||||
mock_repo.set_realism_config = AsyncMock()
|
||||
|
||||
result = await put_llm_config(
|
||||
body={"model": "qwen2:7b"},
|
||||
user={"uuid": "admin-1", "role": "admin"},
|
||||
)
|
||||
|
||||
assert result["model"] == "qwen2:7b"
|
||||
assert result["base_url"] == "http://10.0.0.1:11434"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_key_encrypted_and_not_returned(self, fernet_key):
|
||||
from decnet.web.router.realism.api_llm import put_llm_config
|
||||
from decnet.web.db.secrets import decrypt_secret
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
async def _capture_set(key, value):
|
||||
captured["value"] = value
|
||||
|
||||
with patch("decnet.web.router.realism.api_llm.repo") as mock_repo:
|
||||
mock_repo.get_realism_config = AsyncMock(return_value=None)
|
||||
mock_repo.set_realism_config = AsyncMock(side_effect=_capture_set)
|
||||
|
||||
result = await put_llm_config(
|
||||
body={"provider": "ollama", "api_key": "sk-secret-key"},
|
||||
user={"uuid": "admin-1", "role": "admin"},
|
||||
)
|
||||
|
||||
assert result["api_key_set"] is True
|
||||
assert "api_key" not in result
|
||||
stored = json.loads(captured["value"])
|
||||
assert stored["api_key_ciphertext"] != "sk-secret-key"
|
||||
assert decrypt_secret(stored["api_key_ciphertext"]) == "sk-secret-key"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_api_key_clears_ciphertext(self):
|
||||
from decnet.web.router.realism.api_llm import put_llm_config
|
||||
|
||||
existing = json.dumps({
|
||||
"provider": "ollama", "model": "llama3.1",
|
||||
"api_key_ciphertext": "gAAAAABxxx",
|
||||
})
|
||||
captured: dict = {}
|
||||
|
||||
async def _cap(key, value):
|
||||
captured["value"] = value
|
||||
|
||||
with patch("decnet.web.router.realism.api_llm.repo") as mock_repo:
|
||||
mock_repo.get_realism_config = AsyncMock(
|
||||
return_value={"value": existing}
|
||||
)
|
||||
mock_repo.set_realism_config = AsyncMock(side_effect=_cap)
|
||||
|
||||
result = await put_llm_config(
|
||||
body={"api_key": ""},
|
||||
user={"uuid": "admin-1", "role": "admin"},
|
||||
)
|
||||
|
||||
assert result["api_key_set"] is False
|
||||
stored = json.loads(captured["value"])
|
||||
assert "api_key_ciphertext" not in stored
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_absent_api_key_leaves_existing_ciphertext(self):
|
||||
from decnet.web.router.realism.api_llm import put_llm_config
|
||||
|
||||
existing = json.dumps({
|
||||
"provider": "ollama", "model": "llama3.1",
|
||||
"api_key_ciphertext": "gAAAAABxxx",
|
||||
})
|
||||
captured: dict = {}
|
||||
|
||||
async def _cap(key, value):
|
||||
captured["value"] = value
|
||||
|
||||
with patch("decnet.web.router.realism.api_llm.repo") as mock_repo:
|
||||
mock_repo.get_realism_config = AsyncMock(
|
||||
return_value={"value": existing}
|
||||
)
|
||||
mock_repo.set_realism_config = AsyncMock(side_effect=_cap)
|
||||
|
||||
result = await put_llm_config(
|
||||
body={"model": "phi3"},
|
||||
user={"uuid": "admin-1", "role": "admin"},
|
||||
)
|
||||
|
||||
assert result["api_key_set"] is True
|
||||
stored = json.loads(captured["value"])
|
||||
assert stored["api_key_ciphertext"] == "gAAAAABxxx"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_provider_returns_400(self):
|
||||
from decnet.web.router.realism.api_llm import put_llm_config
|
||||
|
||||
with patch("decnet.web.router.realism.api_llm.repo") as mock_repo:
|
||||
mock_repo.get_realism_config = AsyncMock(return_value=None)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await put_llm_config(
|
||||
body={"provider": "vllm-someday"},
|
||||
user={"uuid": "admin-1", "role": "admin"},
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_base_url_returns_400(self):
|
||||
from decnet.web.router.realism.api_llm import put_llm_config
|
||||
|
||||
with patch("decnet.web.router.realism.api_llm.repo") as mock_repo:
|
||||
mock_repo.get_realism_config = AsyncMock(return_value=None)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await put_llm_config(
|
||||
body={"base_url": "ollama://host"},
|
||||
user={"uuid": "admin-1", "role": "admin"},
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_secret_key_returns_500(self, monkeypatch):
|
||||
from decnet.web.router.realism.api_llm import put_llm_config
|
||||
monkeypatch.delenv("DECNET_SECRET_KEY", raising=False)
|
||||
|
||||
with patch("decnet.web.router.realism.api_llm.repo") as mock_repo:
|
||||
mock_repo.get_realism_config = AsyncMock(return_value=None)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await put_llm_config(
|
||||
body={"api_key": "sk-whatever"},
|
||||
user={"uuid": "admin-1", "role": "admin"},
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
Reference in New Issue
Block a user