markitect-main/markitect/llm/config.py

109 lines
3.2 KiB
Python
Raw Normal View History

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