feat(llm): add LLM integration module with OpenRouter and Claude Code adapters

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>
This commit is contained in:
2026-02-11 01:17:58 +01:00
parent 360c3b1de2
commit fecc2fd4fa
82 changed files with 43767 additions and 0 deletions

View File

@@ -0,0 +1,143 @@
"""Tests for markitect.llm.openrouter."""
from unittest import mock
import pytest
from markitect.llm.openrouter import OpenRouterAdapter
from markitect.llm.config import LLMConfig
from markitect.llm.exceptions import LLMRateLimitError, LLMAPIError
from markitect.prompts.execution.models import RunConfig, LLMResponse
def _make_api_response(content="hello world", model="test-model"):
return {
"id": "gen-abc123",
"model": model,
"choices": [
{
"message": {"role": "assistant", "content": content},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
},
}
class TestOpenRouterAdapter:
def _adapter(self, **kwargs):
defaults = {"api_key": "sk-test", "model": "test/model"}
defaults.update(kwargs)
return OpenRouterAdapter(**defaults)
@mock.patch("markitect.llm.openrouter.post_json")
def test_execute_prompt_success(self, mock_post):
mock_post.return_value = _make_api_response("Generated text")
adapter = self._adapter()
config = RunConfig(model_name="test/model", temperature=0.5, max_tokens=100)
resp = adapter.execute_prompt("Write something", config)
assert isinstance(resp, LLMResponse)
assert resp.content == "Generated text"
assert resp.usage["prompt_tokens"] == 10
assert resp.usage["completion_tokens"] == 5
assert resp.metadata["provider"] == "openrouter"
assert "latency_seconds" in resp.metadata
@mock.patch("markitect.llm.openrouter.post_json")
def test_payload_structure(self, mock_post):
mock_post.return_value = _make_api_response()
adapter = self._adapter(system_prompt="You are helpful")
config = RunConfig(temperature=0.3, max_tokens=500)
adapter.execute_prompt("Test prompt", config)
call_args = mock_post.call_args
payload = call_args[0][1] # second positional arg
assert payload["model"] == "test/model"
assert payload["temperature"] == 0.3
assert payload["max_tokens"] == 500
assert len(payload["messages"]) == 2
assert payload["messages"][0]["role"] == "system"
assert payload["messages"][1]["role"] == "user"
@mock.patch("markitect.llm.openrouter.post_json")
def test_no_system_prompt(self, mock_post):
mock_post.return_value = _make_api_response()
adapter = self._adapter()
config = RunConfig()
adapter.execute_prompt("Test", config)
payload = mock_post.call_args[0][1]
assert len(payload["messages"]) == 1
assert payload["messages"][0]["role"] == "user"
@mock.patch("markitect.llm.openrouter.post_json")
@mock.patch("markitect.llm.openrouter.time.sleep")
def test_retry_on_429(self, mock_sleep, mock_post):
mock_post.side_effect = [
LLMRateLimitError("rate limited", status_code=429),
_make_api_response("after retry"),
]
adapter = self._adapter(max_retries=2)
config = RunConfig()
resp = adapter.execute_prompt("Test", config)
assert resp.content == "after retry"
assert mock_sleep.call_count == 1
@mock.patch("markitect.llm.openrouter.post_json")
@mock.patch("markitect.llm.openrouter.time.sleep")
def test_retry_on_5xx(self, mock_sleep, mock_post):
mock_post.side_effect = [
LLMAPIError("server error", status_code=502),
_make_api_response("recovered"),
]
adapter = self._adapter(max_retries=2)
config = RunConfig()
resp = adapter.execute_prompt("Test", config)
assert resp.content == "recovered"
@mock.patch("markitect.llm.openrouter.post_json")
def test_no_retry_on_4xx(self, mock_post):
mock_post.side_effect = LLMAPIError("bad request", status_code=400)
adapter = self._adapter(max_retries=2)
config = RunConfig()
with pytest.raises(LLMAPIError) as exc_info:
adapter.execute_prompt("Test", config)
assert exc_info.value.status_code == 400
@mock.patch("markitect.llm.openrouter.post_json")
@mock.patch("markitect.llm.openrouter.time.sleep")
def test_exhausted_retries_raises(self, mock_sleep, mock_post):
mock_post.side_effect = LLMRateLimitError("rate limited", status_code=429)
adapter = self._adapter(max_retries=1)
config = RunConfig()
with pytest.raises(LLMRateLimitError):
adapter.execute_prompt("Test", config)
def test_validate_config_no_key(self):
adapter = OpenRouterAdapter(api_key=None, model="test")
# Force key to None
adapter._api_key = None
config = RunConfig()
assert adapter.validate_config(config) is False
def test_validate_config_ok(self):
adapter = self._adapter()
config = RunConfig(temperature=0.5)
assert adapter.validate_config(config) is True
def test_validate_config_bad_temperature(self):
adapter = self._adapter()
config = RunConfig(temperature=3.0)
assert adapter.validate_config(config) is False