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