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:
108
markitect/llm/config.py
Normal file
108
markitect/llm/config.py
Normal 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)
|
||||
Reference in New Issue
Block a user