Files
markitect-main/markitect/llm/config.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

109 lines
3.2 KiB
Python

"""
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)