Implements markitect/llm/ package with concrete LLMAdapter implementations:
- OpenRouterAdapter: HTTP via urllib with retry/backoff on 429/5xx
- ClaudeCodeAdapter: subprocess-based Claude CLI with stdin piping
- Factory pattern: create_adapter("openrouter") or create_adapter("claude-code")
- API key resolution chain: constructor > env var > project-root key file
- 42 unit tests, 2 integration tests (gated on API key / CLI availability)
Also adds the infospace-with-history example with Wealth of Nations VSM
analysis pipeline, templates, schemas, source chapters, and processed
output for chapters 1-2. process_chapters.py now supports --provider
and --model flags for automatic LLM-driven processing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
76 lines
2.9 KiB
Python
76 lines
2.9 KiB
Python
"""Tests for markitect.llm._http."""
|
|
|
|
import json
|
|
import urllib.error
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
|
|
from markitect.llm._http import post_json
|
|
from markitect.llm.exceptions import LLMAPIError, LLMRateLimitError, LLMTimeoutError
|
|
|
|
|
|
class TestPostJson:
|
|
def _mock_urlopen(self, response_body: dict, status: int = 200):
|
|
"""Return a context-manager mock for urllib.request.urlopen."""
|
|
body_bytes = json.dumps(response_body).encode()
|
|
resp = mock.MagicMock()
|
|
resp.read.return_value = body_bytes
|
|
resp.__enter__ = mock.MagicMock(return_value=resp)
|
|
resp.__exit__ = mock.MagicMock(return_value=False)
|
|
return resp
|
|
|
|
@mock.patch("markitect.llm._http.urllib.request.urlopen")
|
|
def test_success(self, mock_urlopen):
|
|
expected = {"choices": [{"message": {"content": "hi"}}]}
|
|
mock_urlopen.return_value = self._mock_urlopen(expected)
|
|
result = post_json("https://api.test/v1", {"prompt": "hello"})
|
|
assert result == expected
|
|
|
|
@mock.patch("markitect.llm._http.urllib.request.urlopen")
|
|
def test_429_raises_rate_limit(self, mock_urlopen):
|
|
exc = urllib.error.HTTPError(
|
|
"https://api.test/v1", 429, "Too Many Requests", {}, None
|
|
)
|
|
exc.read = mock.MagicMock(return_value=b"rate limited")
|
|
mock_urlopen.side_effect = exc
|
|
|
|
with pytest.raises(LLMRateLimitError) as exc_info:
|
|
post_json("https://api.test/v1", {})
|
|
assert exc_info.value.status_code == 429
|
|
|
|
@mock.patch("markitect.llm._http.urllib.request.urlopen")
|
|
def test_500_raises_api_error(self, mock_urlopen):
|
|
exc = urllib.error.HTTPError(
|
|
"https://api.test/v1", 500, "Internal Server Error", {}, None
|
|
)
|
|
exc.read = mock.MagicMock(return_value=b"server error")
|
|
mock_urlopen.side_effect = exc
|
|
|
|
with pytest.raises(LLMAPIError) as exc_info:
|
|
post_json("https://api.test/v1", {})
|
|
assert exc_info.value.status_code == 500
|
|
|
|
@mock.patch("markitect.llm._http.urllib.request.urlopen")
|
|
def test_timeout_raises_timeout_error(self, mock_urlopen):
|
|
exc = urllib.error.URLError("timed out")
|
|
mock_urlopen.side_effect = exc
|
|
|
|
with pytest.raises(LLMTimeoutError):
|
|
post_json("https://api.test/v1", {})
|
|
|
|
@mock.patch("markitect.llm._http.urllib.request.urlopen")
|
|
def test_generic_url_error_raises_api_error(self, mock_urlopen):
|
|
exc = urllib.error.URLError("connection refused")
|
|
mock_urlopen.side_effect = exc
|
|
|
|
with pytest.raises(LLMAPIError):
|
|
post_json("https://api.test/v1", {})
|
|
|
|
@mock.patch("markitect.llm._http.urllib.request.urlopen")
|
|
def test_python_timeout_error(self, mock_urlopen):
|
|
mock_urlopen.side_effect = TimeoutError("timed out")
|
|
|
|
with pytest.raises(LLMTimeoutError):
|
|
post_json("https://api.test/v1", {})
|