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>
125 lines
4.5 KiB
Python
125 lines
4.5 KiB
Python
"""Tests for markitect.llm.claude_code."""
|
|
|
|
import subprocess
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
|
|
from markitect.llm.claude_code import ClaudeCodeAdapter
|
|
from markitect.llm.exceptions import LLMSubprocessError, LLMTimeoutError
|
|
from markitect.prompts.execution.models import RunConfig, LLMResponse
|
|
|
|
|
|
class TestClaudeCodeAdapter:
|
|
@mock.patch("markitect.llm.claude_code.subprocess.run")
|
|
def test_execute_prompt_success(self, mock_run):
|
|
mock_run.return_value = subprocess.CompletedProcess(
|
|
args=["claude", "--print"],
|
|
returncode=0,
|
|
stdout="Generated output text",
|
|
stderr="",
|
|
)
|
|
adapter = ClaudeCodeAdapter()
|
|
config = RunConfig()
|
|
|
|
resp = adapter.execute_prompt("Write something", config)
|
|
|
|
assert isinstance(resp, LLMResponse)
|
|
assert resp.content == "Generated output text"
|
|
assert resp.model == "claude-code-cli"
|
|
assert resp.usage["prompt_tokens"] > 0
|
|
assert resp.usage["completion_tokens"] > 0
|
|
assert resp.metadata["provider"] == "claude-code"
|
|
|
|
@mock.patch("markitect.llm.claude_code.subprocess.run")
|
|
def test_prompt_passed_via_stdin(self, mock_run):
|
|
mock_run.return_value = subprocess.CompletedProcess(
|
|
args=["claude", "--print"],
|
|
returncode=0,
|
|
stdout="ok",
|
|
stderr="",
|
|
)
|
|
adapter = ClaudeCodeAdapter()
|
|
config = RunConfig()
|
|
|
|
adapter.execute_prompt("My big prompt", config)
|
|
|
|
call_kwargs = mock_run.call_args[1]
|
|
assert call_kwargs["input"] == "My big prompt"
|
|
assert call_kwargs["text"] is True
|
|
|
|
@mock.patch("markitect.llm.claude_code.subprocess.run")
|
|
def test_model_flag_passed(self, mock_run):
|
|
mock_run.return_value = subprocess.CompletedProcess(
|
|
args=["claude", "--print", "--model", "opus"],
|
|
returncode=0,
|
|
stdout="ok",
|
|
stderr="",
|
|
)
|
|
adapter = ClaudeCodeAdapter(model="opus")
|
|
config = RunConfig()
|
|
|
|
adapter.execute_prompt("Test", config)
|
|
|
|
cmd = mock_run.call_args[0][0]
|
|
assert "--model" in cmd
|
|
assert "opus" in cmd
|
|
|
|
@mock.patch("markitect.llm.claude_code.subprocess.run")
|
|
def test_nonzero_exit_raises(self, mock_run):
|
|
mock_run.return_value = subprocess.CompletedProcess(
|
|
args=["claude", "--print"],
|
|
returncode=1,
|
|
stdout="",
|
|
stderr="Error: something went wrong",
|
|
)
|
|
adapter = ClaudeCodeAdapter()
|
|
config = RunConfig()
|
|
|
|
with pytest.raises(LLMSubprocessError) as exc_info:
|
|
adapter.execute_prompt("Test", config)
|
|
assert exc_info.value.return_code == 1
|
|
assert "something went wrong" in exc_info.value.stderr
|
|
|
|
@mock.patch("markitect.llm.claude_code.subprocess.run")
|
|
def test_timeout_raises(self, mock_run):
|
|
mock_run.side_effect = subprocess.TimeoutExpired(cmd="claude", timeout=300)
|
|
adapter = ClaudeCodeAdapter()
|
|
config = RunConfig(timeout_seconds=300)
|
|
|
|
with pytest.raises(LLMTimeoutError):
|
|
adapter.execute_prompt("Test", config)
|
|
|
|
@mock.patch("markitect.llm.claude_code.subprocess.run")
|
|
def test_validate_config_success(self, mock_run):
|
|
mock_run.return_value = subprocess.CompletedProcess(
|
|
args=["claude", "--version"],
|
|
returncode=0,
|
|
stdout="1.0.0",
|
|
stderr="",
|
|
)
|
|
adapter = ClaudeCodeAdapter()
|
|
assert adapter.validate_config(RunConfig()) is True
|
|
|
|
@mock.patch("markitect.llm.claude_code.subprocess.run")
|
|
def test_validate_config_missing_cli(self, mock_run):
|
|
mock_run.side_effect = FileNotFoundError("claude not found")
|
|
adapter = ClaudeCodeAdapter()
|
|
assert adapter.validate_config(RunConfig()) is False
|
|
|
|
@mock.patch("markitect.llm.claude_code.subprocess.run")
|
|
def test_token_estimation(self, mock_run):
|
|
# 20 chars -> ~5 tokens, 40 chars -> ~10 tokens
|
|
mock_run.return_value = subprocess.CompletedProcess(
|
|
args=["claude", "--print"],
|
|
returncode=0,
|
|
stdout="a" * 40,
|
|
stderr="",
|
|
)
|
|
adapter = ClaudeCodeAdapter()
|
|
config = RunConfig()
|
|
|
|
resp = adapter.execute_prompt("b" * 20, config)
|
|
assert resp.usage["prompt_tokens"] == 5
|
|
assert resp.usage["completion_tokens"] == 10
|