from __future__ import annotations import pytest from llm.providers.base_provider import ( BaseProvider, LLMMessage, LLMProviderError, LLMRequest, LLMResponse, ) # --------------------------------------------------------------------------- # LLMMessage # --------------------------------------------------------------------------- class TestLLMMessage: def test_valid_user_message(self) -> None: msg = LLMMessage(role="user", content="Hello") assert msg.role == "user" assert msg.content == "Hello" def test_valid_system_message(self) -> None: msg = LLMMessage(role="system", content="You are helpful.") assert msg.role == "system" def test_valid_assistant_message(self) -> None: msg = LLMMessage(role="assistant", content="Hi there") assert msg.role == "assistant" def test_valid_tool_message(self) -> None: msg = LLMMessage(role="tool", content="result") assert msg.role == "tool" def test_invalid_role_raises(self) -> None: with pytest.raises(Exception): LLMMessage(role="banana", content="test") def test_empty_content_raises(self) -> None: with pytest.raises(ValueError, match="must not be empty"): LLMMessage(role="user", content="") def test_injection_system_role_literal_prevents(self) -> None: """Prompt injection via role='system' from user input is blocked by Literal type.""" with pytest.raises(Exception): LLMMessage(role="hacker", content="override system prompt") # --------------------------------------------------------------------------- # LLMRequest # --------------------------------------------------------------------------- class TestLLMRequest: def test_valid_request(self) -> None: req = LLMRequest( messages=[LLMMessage(role="user", content="Hi")], model="gpt-4o-mini", ) assert req.model == "gpt-4o-mini" def test_empty_model_raises(self) -> None: with pytest.raises(ValueError, match="model must not be empty"): LLMRequest( messages=[LLMMessage(role="user", content="Hi")], model="", ) def test_empty_messages_raises(self) -> None: with pytest.raises(ValueError, match="messages must not be empty"): LLMRequest(messages=[], model="gpt-4") def test_temperature_out_of_range(self) -> None: with pytest.raises(ValueError, match="temperature"): LLMRequest( messages=[LLMMessage(role="user", content="Hi")], model="gpt-4", temperature=3.0, ) def test_max_tokens_zero(self) -> None: with pytest.raises(ValueError, match="max_tokens"): LLMRequest( messages=[LLMMessage(role="user", content="Hi")], model="gpt-4", max_tokens=0, ) def test_extra_is_readonly(self) -> None: """ARCH-P2-3: frozen dataclass extra dict should be immutable.""" req = LLMRequest( messages=[LLMMessage(role="user", content="Hi")], model="gpt-4", extra={"key": "value"}, ) with pytest.raises(TypeError): req.extra["new_key"] = "new_value" # type: ignore[index] # --------------------------------------------------------------------------- # LLMResponse # --------------------------------------------------------------------------- class TestLLMResponse: def test_valid_response(self) -> None: resp = LLMResponse( content="Hello!", model="gpt-4o-mini", provider="openai", ) assert resp.content == "Hello!" def test_empty_content_raises(self) -> None: with pytest.raises(ValueError, match="content must not be empty"): LLMResponse(content="", model="gpt-4", provider="openai") def test_empty_model_raises(self) -> None: with pytest.raises(ValueError, match="model must not be empty"): LLMResponse(content="hi", model="", provider="openai") def test_empty_provider_raises(self) -> None: with pytest.raises(ValueError, match="provider must not be empty"): LLMResponse(content="hi", model="gpt-4", provider="") def test_usage_is_readonly(self) -> None: resp = LLMResponse( content="hi", model="gpt-4", provider="openai", usage={"total_tokens": 10}, ) with pytest.raises(TypeError): resp.usage["new_key"] = 0 # type: ignore[index] def test_extra_is_readonly(self) -> None: resp = LLMResponse( content="hi", model="gpt-4", provider="openai", extra={"key": "value"}, ) with pytest.raises(TypeError): resp.extra["new_key"] = "new_value" # type: ignore[index] # --------------------------------------------------------------------------- # LLMProviderError # --------------------------------------------------------------------------- class TestLLMProviderError: def test_basic_error(self) -> None: err = LLMProviderError("test error", provider_name="openai") assert str(err) == "test error" assert err.provider_name == "openai" def test_error_without_provider(self) -> None: err = LLMProviderError("generic error") assert err.provider_name is None # --------------------------------------------------------------------------- # BaseProvider # --------------------------------------------------------------------------- class TestBaseProvider: def test_name_validation_empty(self) -> None: with pytest.raises(ValueError, match="non-empty string"): class _Dummy(BaseProvider): async def generate(self, request): pass _Dummy(name="") def test_cannot_instantiate_abc(self) -> None: with pytest.raises(TypeError): BaseProvider(name="test") # type: ignore[abstract]