llm-connect/llm_connect/config.py
tegwick e1ca67e509
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 7s
Resolve the OpenRouter key from OpenBao before the environment
auto tries platform/suppliers/openrouter/llm-connect, then the live
activity-core workload lane, then OPENROUTER_API_KEY. A denied token
falls back to the environment. LLM_CONNECT_KEY_SOURCE selects env or
openbao alone.

Assistant: grok
Assistant-Session: 01a11ab6-1534-7d63-8606-662b92a80ed6
2026-10-08 19:35:00 +02:00

182 lines
5.9 KiB
Python

"""
LLM configuration and API key resolution.
"""
import os
import re
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@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 = "moonshotai/kimi-k3"
api_key: str | None = 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)
# Future supplier custody, then the live llm-connect workload lane.
_OPENROUTER_BAO_CANDIDATES: tuple[tuple[str, str], ...] = (
("platform/suppliers/openrouter/llm-connect", "OPENROUTER_API_KEY"),
(
"platform/workloads/activity-core/llm-connect/llm-connect-provider-secrets",
"OPENROUTER_API_KEY",
),
)
_BAO_PATH_RE = re.compile(r"[A-Za-z0-9_./-]+")
_BAO_FIELD_RE = re.compile(r"[A-Za-z0-9_]+")
_bao_misses: set[tuple[str, str]] = set()
def clear_openbao_key_cache() -> None:
"""Forget OpenBao misses. Tests use this so a later stub can succeed."""
_bao_misses.clear()
def resolve_api_key(
explicit: str | None = None,
env_var: str = "OPENROUTER_API_KEY",
key_file_paths: list[Path] | None = None,
) -> str | None:
"""Return an API key from the first available source.
An explicit argument always wins. Otherwise ``LLM_CONNECT_KEY_SOURCE``
selects the order. ``auto`` (the default) tries OpenBao, then the
environment, then a key file. ``env`` skips OpenBao. ``openbao`` uses
OpenBao only.
OpenBao is read with ``bao kv get -field=...``. A missing binary, a
denied token, or an empty field is skipped. The value is not logged.
"""
if explicit:
return explicit
source = os.environ.get("LLM_CONNECT_KEY_SOURCE", "auto").strip().lower()
if source not in {"auto", "env", "openbao"}:
source = "auto"
if source == "openbao":
return _openbao_api_key(env_var)
if source == "env":
return _env_or_file_api_key(env_var, key_file_paths)
return _openbao_api_key(env_var) or _env_or_file_api_key(env_var, key_file_paths)
def _env_or_file_api_key(env_var: str, key_file_paths: list[Path] | None) -> str | None:
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 _openbao_api_key(env_var: str) -> str | None:
"""Read one field from OpenBao. Return ``None`` when nothing is readable."""
if env_var != "OPENROUTER_API_KEY":
return None
candidates: list[tuple[str, str]] = []
custom = os.environ.get("LLM_CONNECT_OPENROUTER_BAO_PATH", "").strip()
if custom:
field = os.environ.get("LLM_CONNECT_OPENROUTER_BAO_FIELD", "OPENROUTER_API_KEY").strip()
candidates.append((custom, field))
candidates.extend(_OPENROUTER_BAO_CANDIDATES)
for path, field in candidates:
value = _bao_kv_field(path, field)
if value:
return value
return None
def _bao_kv_field(path: str, field: str) -> str | None:
if not _BAO_PATH_RE.fullmatch(path) or not _BAO_FIELD_RE.fullmatch(field):
return None
key = (path, field)
if key in _bao_misses:
return None
try:
proc = subprocess.run(
["bao", "kv", "get", f"-field={field}", path],
capture_output=True,
text=True,
timeout=5,
check=False,
)
except (OSError, subprocess.TimeoutExpired):
_bao_misses.add(key)
return None
if proc.returncode != 0:
_bao_misses.add(key)
return None
value = proc.stdout.strip()
if not value:
_bao_misses.add(key)
return None
return value
def find_project_root(start: Path | None = None) -> Path | None:
"""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: str | None = None,
api_key: str | None = 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` (OpenBao, then env var, then 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 "moonshotai/kimi-k3",
"api_key": resolved_key,
}
defaults.update(overrides)
return LLMConfig(**defaults)