"""Tests for ``alfred.agent.llm.deepseek.DeepSeekClient``. Thin wrapper around DeepSeek's OpenAI-compatible ``/v1/chat/completions`` endpoint. The client validates message shape, POSTs JSON with bearer auth, and translates ``requests`` exceptions into ``LLMAPIError``. Coverage: - ``TestInit`` — explicit args win over settings; missing api_key / base_url raise ``LLMConfigurationError``. - ``TestCompleteValidation`` — empty list, non-dict element, missing role, unknown role, missing content all raise ``ValueError``. - ``TestCompleteHappyPath`` — POSTs to correct URL with bearer header, returns ``choices[0].message`` verbatim, threads ``tools`` into payload. - ``TestCompleteErrors`` — Timeout, HTTPError (with/without JSON body), RequestException, malformed response (missing ``choices`` / ``message``, ``TypeError`` from parsing) are all wrapped as ``LLMAPIError``. """ from __future__ import annotations from unittest.mock import MagicMock, patch import pytest from requests.exceptions import HTTPError, RequestException, Timeout from alfred.agent.llm.deepseek import DeepSeekClient from alfred.agent.llm.exceptions import LLMAPIError, LLMConfigurationError from alfred.settings import Settings def _settings(**overrides) -> Settings: base = { "deepseek_api_key": "test-key", "deepseek_base_url": "https://api.deepseek.test", "deepseek_model": "deepseek-chat", "request_timeout": 30, "llm_temperature": 0.2, } base.update(overrides) return Settings(**base) # --------------------------------------------------------------------------- # # Init # # --------------------------------------------------------------------------- # class TestInit: def test_defaults_from_settings(self): s = _settings() c = DeepSeekClient(settings=s) assert c.api_key == "test-key" assert c.base_url == "https://api.deepseek.test" assert c.model == "deepseek-chat" assert c.timeout == 30 def test_explicit_args_override_settings(self): s = _settings() c = DeepSeekClient( api_key="override-key", base_url="https://other.example", model="other-model", timeout=99, settings=s, ) assert c.api_key == "override-key" assert c.base_url == "https://other.example" assert c.model == "other-model" assert c.timeout == 99 def test_missing_api_key_raises(self): s = _settings(deepseek_api_key=None) with pytest.raises(LLMConfigurationError, match="API key"): DeepSeekClient(settings=s) def test_missing_base_url_raises(self): s = _settings(deepseek_base_url="") with pytest.raises(LLMConfigurationError, match="base URL"): DeepSeekClient(settings=s) # --------------------------------------------------------------------------- # # complete — message validation # # --------------------------------------------------------------------------- # @pytest.fixture def client(): return DeepSeekClient(settings=_settings()) class TestCompleteValidation: def test_empty_messages_raises(self, client): with pytest.raises(ValueError, match="empty"): client.complete([]) def test_non_dict_element_raises(self, client): with pytest.raises(ValueError, match="must be a dict"): client.complete(["not a dict"]) # type: ignore[list-item] def test_missing_role_raises(self, client): with pytest.raises(ValueError, match="'role' key"): client.complete([{"content": "hi"}]) def test_invalid_role_raises(self, client): with pytest.raises(ValueError, match="Invalid role"): client.complete([{"role": "robot", "content": "beep"}]) def test_missing_content_for_non_tool_role_raises(self, client): with pytest.raises(ValueError, match="'content' key"): client.complete([{"role": "user"}]) def test_tool_role_allowed_without_content(self, client): # 'tool' role is exempt from the content requirement; this should not # raise during validation. We patch out the network call to verify the # validator passes through. with patch("alfred.agent.llm.deepseek.requests.post") as mock_post: mock_post.return_value = MagicMock( raise_for_status=MagicMock(), json=MagicMock( return_value={ "choices": [{"message": {"role": "assistant", "content": "ok"}}] } ), ) out = client.complete( [{"role": "tool", "tool_call_id": "abc", "name": "x"}] ) assert out["content"] == "ok" # --------------------------------------------------------------------------- # # complete — happy path # # --------------------------------------------------------------------------- # class TestCompleteHappyPath: def test_posts_to_correct_url_with_bearer(self, client): with patch("alfred.agent.llm.deepseek.requests.post") as mock_post: mock_post.return_value = MagicMock( raise_for_status=MagicMock(), json=MagicMock( return_value={ "choices": [{"message": {"role": "assistant", "content": "hi"}}] } ), ) client.complete([{"role": "user", "content": "hello"}]) args, kwargs = mock_post.call_args assert args[0] == "https://api.deepseek.test/v1/chat/completions" assert kwargs["headers"]["Authorization"] == "Bearer test-key" assert kwargs["headers"]["Content-Type"] == "application/json" assert kwargs["timeout"] == 30 payload = kwargs["json"] assert payload["model"] == "deepseek-chat" assert payload["temperature"] == 0.2 assert payload["messages"] == [{"role": "user", "content": "hello"}] assert "tools" not in payload def test_returns_message_verbatim(self, client): message = { "role": "assistant", "content": "answer", "tool_calls": [{"id": "x", "type": "function"}], } with patch("alfred.agent.llm.deepseek.requests.post") as mock_post: mock_post.return_value = MagicMock( raise_for_status=MagicMock(), json=MagicMock(return_value={"choices": [{"message": message}]}), ) out = client.complete([{"role": "user", "content": "q"}]) assert out == message def test_tools_threaded_into_payload(self, client): tools = [{"type": "function", "function": {"name": "foo"}}] with patch("alfred.agent.llm.deepseek.requests.post") as mock_post: mock_post.return_value = MagicMock( raise_for_status=MagicMock(), json=MagicMock( return_value={ "choices": [{"message": {"role": "assistant", "content": ""}}] } ), ) client.complete([{"role": "user", "content": "q"}], tools=tools) payload = mock_post.call_args.kwargs["json"] assert payload["tools"] == tools # --------------------------------------------------------------------------- # # complete — error translation # # --------------------------------------------------------------------------- # class TestCompleteErrors: def test_timeout_wrapped(self, client): with patch( "alfred.agent.llm.deepseek.requests.post", side_effect=Timeout("read timeout"), ): with pytest.raises(LLMAPIError, match="timeout"): client.complete([{"role": "user", "content": "q"}]) def test_http_error_with_json_body_extracts_message(self, client): resp = MagicMock() resp.json.return_value = {"error": {"message": "rate limited"}} err = HTTPError("boom") err.response = resp post_resp = MagicMock(raise_for_status=MagicMock(side_effect=err)) with patch("alfred.agent.llm.deepseek.requests.post", return_value=post_resp): with pytest.raises(LLMAPIError, match="rate limited"): client.complete([{"role": "user", "content": "q"}]) def test_http_error_with_non_json_body_falls_back_to_str(self, client): resp = MagicMock() resp.json.side_effect = ValueError("not json") err = HTTPError("boom 500") err.response = resp post_resp = MagicMock(raise_for_status=MagicMock(side_effect=err)) with patch("alfred.agent.llm.deepseek.requests.post", return_value=post_resp): with pytest.raises(LLMAPIError, match="DeepSeek API error"): client.complete([{"role": "user", "content": "q"}]) def test_http_error_without_response(self, client): err = HTTPError("boom") err.response = None post_resp = MagicMock(raise_for_status=MagicMock(side_effect=err)) with patch("alfred.agent.llm.deepseek.requests.post", return_value=post_resp): with pytest.raises(LLMAPIError, match="HTTP error"): client.complete([{"role": "user", "content": "q"}]) def test_request_exception_wrapped(self, client): with patch( "alfred.agent.llm.deepseek.requests.post", side_effect=RequestException("conn refused"), ): with pytest.raises(LLMAPIError, match="Failed to connect"): client.complete([{"role": "user", "content": "q"}]) def test_missing_choices_raises(self, client): with patch("alfred.agent.llm.deepseek.requests.post") as mock_post: mock_post.return_value = MagicMock( raise_for_status=MagicMock(), json=MagicMock(return_value={}), ) with pytest.raises(LLMAPIError, match="choices"): client.complete([{"role": "user", "content": "q"}]) def test_empty_choices_raises(self, client): with patch("alfred.agent.llm.deepseek.requests.post") as mock_post: mock_post.return_value = MagicMock( raise_for_status=MagicMock(), json=MagicMock(return_value={"choices": []}), ) with pytest.raises(LLMAPIError, match="choices"): client.complete([{"role": "user", "content": "q"}]) def test_missing_message_in_choice_raises(self, client): with patch("alfred.agent.llm.deepseek.requests.post") as mock_post: mock_post.return_value = MagicMock( raise_for_status=MagicMock(), json=MagicMock(return_value={"choices": [{}]}), ) with pytest.raises(LLMAPIError, match="message"): client.complete([{"role": "user", "content": "q"}]) def test_malformed_response_typeerror_wrapped(self, client): # If choices[0] is not subscriptable as a dict, a TypeError surfaces # and is caught + wrapped. with patch("alfred.agent.llm.deepseek.requests.post") as mock_post: mock_post.return_value = MagicMock( raise_for_status=MagicMock(), json=MagicMock(return_value={"choices": ["not a dict"]}), ) with pytest.raises(LLMAPIError, match="Invalid API response"): client.complete([{"role": "user", "content": "q"}])