264 lines
9.7 KiB
Python
264 lines
9.7 KiB
Python
"""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
|