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

40
markitect/llm/__init__.py Normal file
View File

@@ -0,0 +1,40 @@
"""
markitect.llm — LLM integration adapters for MarkiTect.
Provides concrete :class:`LLMAdapter` implementations backed by
OpenRouter (HTTP) and Claude Code CLI (subprocess).
Quick start::
from markitect.llm import create_adapter
adapter = create_adapter("openrouter", model="anthropic/claude-sonnet-4")
response = adapter.execute_prompt(prompt, run_config)
"""
from markitect.llm.factory import create_adapter
from markitect.llm.openrouter import OpenRouterAdapter
from markitect.llm.claude_code import ClaudeCodeAdapter
from markitect.llm.config import LLMConfig, load_config
from markitect.llm.exceptions import (
LLMError,
LLMConfigurationError,
LLMAPIError,
LLMRateLimitError,
LLMTimeoutError,
LLMSubprocessError,
)
__all__ = [
"create_adapter",
"OpenRouterAdapter",
"ClaudeCodeAdapter",
"LLMConfig",
"load_config",
"LLMError",
"LLMConfigurationError",
"LLMAPIError",
"LLMRateLimitError",
"LLMTimeoutError",
"LLMSubprocessError",
]

79
markitect/llm/_http.py Normal file
View File

@@ -0,0 +1,79 @@
"""
Thin synchronous HTTP helper built on :mod:`urllib.request`.
Translates HTTP errors into typed :mod:`markitect.llm.exceptions`.
"""
import json
import urllib.request
import urllib.error
from typing import Dict, Any, Optional
from markitect.llm.exceptions import (
LLMAPIError,
LLMRateLimitError,
LLMTimeoutError,
)
def post_json(
url: str,
payload: Dict[str, Any],
headers: Optional[Dict[str, str]] = None,
timeout: int = 300,
) -> Dict[str, Any]:
"""POST *payload* as JSON and return the parsed response body.
Raises:
LLMRateLimitError: on HTTP 429
LLMAPIError: on other non-2xx responses
LLMTimeoutError: on socket / read timeout
"""
data = json.dumps(payload).encode()
req = urllib.request.Request(
url,
data=data,
headers={"Content-Type": "application/json", **(headers or {})},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read().decode()
return json.loads(body)
except urllib.error.HTTPError as exc:
body = ""
try:
body = exc.read().decode()
except Exception:
pass
if exc.code == 429:
raise LLMRateLimitError(
f"Rate limited (429) from {url}",
status_code=429,
response_body=body,
cause=exc,
) from exc
raise LLMAPIError(
f"HTTP {exc.code} from {url}",
status_code=exc.code,
response_body=body,
cause=exc,
) from exc
except urllib.error.URLError as exc:
if "timed out" in str(exc.reason):
raise LLMTimeoutError(
f"Request to {url} timed out after {timeout}s",
cause=exc,
) from exc
raise LLMAPIError(
f"URL error for {url}: {exc.reason}",
cause=exc,
) from exc
except TimeoutError as exc:
raise LLMTimeoutError(
f"Request to {url} timed out after {timeout}s",
cause=exc,
) from exc

View File

@@ -0,0 +1,16 @@
"""
Rough token estimation for backends that don't return usage data.
Uses the ~4 characters per token heuristic common across English LLM tokenizers.
"""
def estimate_tokens(text: str) -> int:
"""Estimate the number of tokens in *text*.
This is intentionally coarse — it is only used by the Claude Code CLI
adapter where real token counts are unavailable.
"""
if not text:
return 0
return max(1, len(text) // 4)

View File

@@ -0,0 +1,94 @@
"""
Claude Code CLI adapter — runs the ``claude`` CLI as a subprocess.
"""
import subprocess
from typing import Optional
from markitect.prompts.execution.llm_adapter import LLMAdapter
from markitect.prompts.execution.models import RunConfig, LLMResponse
from markitect.llm.config import LLMConfig
from markitect.llm._token_estimator import estimate_tokens
from markitect.llm.exceptions import (
LLMSubprocessError,
LLMTimeoutError,
)
class ClaudeCodeAdapter(LLMAdapter):
"""LLM adapter that shells out to the ``claude`` CLI with ``--print``.
The compiled prompt is piped via **stdin** to avoid shell argument
length limits (compiled prompts can exceed 30 KB).
"""
def __init__(
self,
cli_path: str = "claude",
model: Optional[str] = None,
config: Optional[LLMConfig] = None,
):
self._config = config or LLMConfig(provider="claude-code")
self._cli_path = cli_path or self._config.claude_cli_path
self._model = model
# ── LLMAdapter interface ────────────────────────────────────────
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
cmd = [self._cli_path, "--print"]
if self._model:
cmd.extend(["--model", self._model])
timeout = config.timeout_seconds or self._config.timeout_seconds
try:
result = subprocess.run(
cmd,
input=prompt,
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired as exc:
raise LLMTimeoutError(
f"claude CLI timed out after {timeout}s",
cause=exc,
) from exc
if result.returncode != 0:
raise LLMSubprocessError(
f"claude CLI exited with code {result.returncode}",
return_code=result.returncode,
stderr=result.stderr,
)
content = result.stdout
prompt_tokens = estimate_tokens(prompt)
completion_tokens = estimate_tokens(content)
return LLMResponse(
content=content,
model=self._model or "claude-code-cli",
usage={
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
},
finish_reason="stop",
metadata={
"provider": "claude-code",
"cli_path": self._cli_path,
},
)
def validate_config(self, config: RunConfig) -> bool:
try:
result = subprocess.run(
[self._cli_path, "--version"],
capture_output=True,
text=True,
timeout=10,
)
return result.returncode == 0
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return False

108
markitect/llm/config.py Normal file
View File

@@ -0,0 +1,108 @@
"""
LLM configuration and API key resolution.
"""
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, Dict, Any
import os
@dataclass
class LLMConfig:
"""Configuration for an LLM adapter.
Attributes:
provider: Backend identifier (``"openrouter"`` or ``"claude-code"``).
model: Model name / path sent to the provider.
api_key: Resolved API key (may be ``None`` for CLI backends).
api_base: Base URL for HTTP-based providers.
claude_cli_path: Path to the ``claude`` CLI binary.
timeout_seconds: Per-request timeout.
max_retries: Number of retry attempts on transient errors.
extra: Arbitrary provider-specific overrides.
"""
provider: str = "openrouter"
model: str = "anthropic/claude-sonnet-4"
api_key: Optional[str] = None
api_base: str = "https://openrouter.ai/api/v1"
claude_cli_path: str = "claude"
timeout_seconds: int = 300
max_retries: int = 3
extra: Dict[str, Any] = field(default_factory=dict)
def resolve_api_key(
explicit: Optional[str] = None,
env_var: str = "OPENROUTER_API_KEY",
key_file_paths: Optional[list[Path]] = None,
) -> Optional[str]:
"""Return an API key from the first available source.
Resolution order:
1. *explicit* argument (passed directly by caller)
2. Environment variable *env_var*
3. First readable file in *key_file_paths* whose content is non-empty
Returns ``None`` if no key can be found.
"""
if explicit:
return explicit
from_env = os.environ.get(env_var)
if from_env:
return from_env.strip()
for path in key_file_paths or []:
try:
text = path.read_text().strip()
if text:
return text
except OSError:
continue
return None
def find_project_root(start: Optional[Path] = None) -> Optional[Path]:
"""Walk up from *start* (default CWD) looking for ``pyproject.toml``.
Returns the directory containing the marker file, or ``None``.
"""
current = (start or Path.cwd()).resolve()
for directory in [current, *current.parents]:
if (directory / "pyproject.toml").is_file():
return directory
return None
def load_config(
provider: str = "openrouter",
model: Optional[str] = None,
api_key: Optional[str] = None,
**overrides: Any,
) -> LLMConfig:
"""Build an :class:`LLMConfig` with sensible defaults.
For the ``openrouter`` provider the API key is resolved via
:func:`resolve_api_key` (env var → project-root key file).
"""
root = find_project_root()
key_file_paths = [root / "apikey-openrouter.txt"] if root else []
resolved_key = api_key
if provider == "openrouter" and not resolved_key:
resolved_key = resolve_api_key(
explicit=None,
env_var="OPENROUTER_API_KEY",
key_file_paths=key_file_paths,
)
defaults: Dict[str, Any] = {
"provider": provider,
"model": model or "anthropic/claude-sonnet-4",
"api_key": resolved_key,
}
defaults.update(overrides)
return LLMConfig(**defaults)

View File

@@ -0,0 +1,71 @@
"""
LLM-specific exceptions.
Extends the MarkitectError hierarchy for LLM integration errors.
"""
from typing import Optional, Dict, Any
from markitect.exceptions import MarkitectError
class LLMError(MarkitectError):
"""Base exception for all LLM operations."""
pass
class LLMConfigurationError(LLMError):
"""Missing API key, invalid model name, or bad provider config."""
pass
class LLMAPIError(LLMError):
"""HTTP-level failure from an LLM provider API.
Attributes:
status_code: HTTP status code (e.g. 500, 502).
response_body: Raw response body text, if available.
"""
def __init__(
self,
message: str,
status_code: int = 0,
response_body: str = "",
cause: Optional[Exception] = None,
context: Optional[Dict[str, Any]] = None,
):
super().__init__(message, cause=cause, context=context)
self.status_code = status_code
self.response_body = response_body
class LLMRateLimitError(LLMAPIError):
"""429 Too Many Requests from the provider."""
pass
class LLMTimeoutError(LLMError):
"""Request or subprocess exceeded the configured timeout."""
pass
class LLMSubprocessError(LLMError):
"""Claude Code CLI subprocess failed.
Attributes:
return_code: Process exit code.
stderr: Captured stderr text.
"""
def __init__(
self,
message: str,
return_code: int = 1,
stderr: str = "",
cause: Optional[Exception] = None,
context: Optional[Dict[str, Any]] = None,
):
super().__init__(message, cause=cause, context=context)
self.return_code = return_code
self.stderr = stderr

58
markitect/llm/factory.py Normal file
View File

@@ -0,0 +1,58 @@
"""
Factory for creating LLM adapters by provider name.
"""
from typing import Optional, Dict, Any
from markitect.prompts.execution.llm_adapter import LLMAdapter
from markitect.llm.exceptions import LLMConfigurationError
# Lazy imports to avoid pulling in every adapter at module load time.
_PROVIDERS: Dict[str, str] = {
"openrouter": "markitect.llm.openrouter.OpenRouterAdapter",
"claude-code": "markitect.llm.claude_code.ClaudeCodeAdapter",
}
def create_adapter(
provider: str = "openrouter",
model: Optional[str] = None,
api_key: Optional[str] = None,
system_prompt: Optional[str] = None,
**kwargs: Any,
) -> LLMAdapter:
"""Instantiate an :class:`LLMAdapter` for the given *provider*.
Args:
provider: ``"openrouter"`` or ``"claude-code"``.
model: Model name (passed to the adapter constructor).
api_key: Explicit API key (OpenRouter only).
system_prompt: Optional system prompt (OpenRouter only).
**kwargs: Extra keyword arguments forwarded to the adapter.
Returns:
A ready-to-use :class:`LLMAdapter` instance.
Raises:
LLMConfigurationError: If *provider* is not recognised.
"""
if provider not in _PROVIDERS:
known = ", ".join(sorted(_PROVIDERS))
raise LLMConfigurationError(
f"Unknown LLM provider {provider!r}. Choose from: {known}",
context={"provider": provider},
)
# Lazy import
fqn = _PROVIDERS[provider]
module_path, class_name = fqn.rsplit(".", 1)
import importlib
mod = importlib.import_module(module_path)
cls = getattr(mod, class_name)
if provider == "openrouter":
return cls(model=model, api_key=api_key, system_prompt=system_prompt, **kwargs)
elif provider == "claude-code":
return cls(model=model, **kwargs)
else:
return cls(**kwargs) # pragma: no cover

139
markitect/llm/openrouter.py Normal file
View File

@@ -0,0 +1,139 @@
"""
OpenRouter adapter — calls the OpenAI-compatible chat completions API.
"""
import time
from typing import Optional, Dict, Any
from markitect.prompts.execution.llm_adapter import LLMAdapter
from markitect.prompts.execution.models import RunConfig, LLMResponse
from markitect.llm.config import LLMConfig, resolve_api_key, find_project_root
from markitect.llm._http import post_json
from markitect.llm.exceptions import (
LLMConfigurationError,
LLMAPIError,
LLMRateLimitError,
)
_DEFAULT_MODEL = "anthropic/claude-sonnet-4"
class OpenRouterAdapter(LLMAdapter):
"""LLM adapter that calls the OpenRouter chat completions endpoint.
Constructor args override values from *config*; *config* overrides
global defaults. The model used for a given call is resolved as:
``constructor model > RunConfig.model_name > default``.
"""
def __init__(
self,
model: Optional[str] = None,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
config: Optional[LLMConfig] = None,
system_prompt: Optional[str] = None,
extra_headers: Optional[Dict[str, str]] = None,
max_retries: Optional[int] = None,
):
self._config = config or LLMConfig()
self._model = model or self._config.model or _DEFAULT_MODEL
self._api_base = (api_base or self._config.api_base).rstrip("/")
self._system_prompt = system_prompt
self._extra_headers = extra_headers or {}
self._max_retries = max_retries if max_retries is not None else self._config.max_retries
# Resolve API key
root = find_project_root()
key_file_paths = [root / "apikey-openrouter.txt"] if root else []
self._api_key = resolve_api_key(
explicit=api_key or self._config.api_key,
env_var="OPENROUTER_API_KEY",
key_file_paths=key_file_paths,
)
# ── LLMAdapter interface ────────────────────────────────────────
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
model = self._model if self._model != _DEFAULT_MODEL else (config.model_name or self._model)
messages: list[Dict[str, str]] = []
if self._system_prompt:
messages.append({"role": "system", "content": self._system_prompt})
messages.append({"role": "user", "content": prompt})
payload: Dict[str, Any] = {
"model": model,
"messages": messages,
"temperature": config.temperature,
"max_tokens": config.max_tokens,
}
# Merge extra model_params from RunConfig
if config.model_params:
payload.update(config.model_params)
headers = {
"Authorization": f"Bearer {self._api_key}",
**self._extra_headers,
}
url = f"{self._api_base}/chat/completions"
start = time.time()
data = self._post_with_retries(url, payload, headers, config.timeout_seconds)
latency = time.time() - start
# Parse response
choice = data.get("choices", [{}])[0]
content = choice.get("message", {}).get("content", "")
finish_reason = choice.get("finish_reason", "stop")
usage = data.get("usage", {})
return LLMResponse(
content=content,
model=data.get("model", model),
usage={
"prompt_tokens": usage.get("prompt_tokens", 0),
"completion_tokens": usage.get("completion_tokens", 0),
"total_tokens": usage.get("total_tokens", 0),
},
finish_reason=finish_reason,
metadata={
"provider": "openrouter",
"latency_seconds": round(latency, 3),
"response_id": data.get("id", ""),
},
)
def validate_config(self, config: RunConfig) -> bool:
if not self._api_key:
return False
if not (self._model or config.model_name):
return False
if not (0.0 <= config.temperature <= 2.0):
return False
return True
# ── Internals ───────────────────────────────────────────────────
def _post_with_retries(
self,
url: str,
payload: Dict[str, Any],
headers: Dict[str, str],
timeout: int,
) -> Dict[str, Any]:
last_exc: Optional[Exception] = None
for attempt in range(self._max_retries + 1):
try:
return post_json(url, payload, headers, timeout=timeout)
except LLMRateLimitError as exc:
last_exc = exc
if attempt < self._max_retries:
time.sleep(2 ** attempt)
except LLMAPIError as exc:
if exc.status_code >= 500 and attempt < self._max_retries:
last_exc = exc
time.sleep(2 ** attempt)
else:
raise
raise last_exc # type: ignore[misc]