generated from coulomb/repo-seed
Copy markitect.llm module into standalone llm_connect package. All markitect.* imports replaced with llm_connect.* equivalents. LLMError base class inlined (no markitect.exceptions dependency). Verified: from llm_connect import create_adapter works. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
109 lines
3.2 KiB
Python
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)
|