Files
markitect-main/tests/unit/llm/test_claude_code.py
tegwick fecc2fd4fa 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>
2026-02-11 01:17:58 +01:00

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