fischer-agentkit/src/agentkit/server/admin/llm_config_service.py

438 lines
16 KiB
Python

"""LlmConfigService — runtime CRUD for LLM providers/fallbacks (U5).
This module wraps the YAML read/write logic from
:mod:`agentkit.server.routes.settings` as a reusable service. Web UI
routes (``/api/v1/admin/llm/*``) and the CLI ``agentkit admin llm``
sub-app both call into :class:`LlmConfigService` rather than touching
the YAML directly, keeping the validation rules (duplicate-provider,
fallback-in-use guard, API-key masking) in one place.
The service writes back to ``agentkit.yaml`` using the existing
:func:`_write_yaml_config` helper (which preserves ``${VAR}`` env-var
references and comments via ruamel.yaml). Concurrent writes are
serialized with :func:`fcntl.flock` to prevent corruption.
The service is a module-level singleton (see
:func:`get_llm_config_service`) so tests can inject a custom instance
via :func:`set_llm_config_service`.
"""
from __future__ import annotations
import fcntl
import logging
import os
import re
from pathlib import Path
from typing import Any
import yaml
from agentkit.server.routes.settings import (
_mask_api_key,
_read_yaml_config,
_write_env_var,
_write_yaml_config,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
#: Regex matching ``${ENV_VAR}`` references in YAML api_key fields.
_ENV_REF_RE = re.compile(r"^\$\{([^}]+)\}$")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _provider_to_dict(name: str, pconf: dict[str, Any]) -> dict[str, Any]:
"""Convert a raw YAML provider dict to a masked response dict.
The ``api_key`` field is masked via :func:`_mask_api_key`. The
``models`` field is preserved as-is (it's already a dict in YAML).
"""
raw_key = pconf.get("api_key", "")
# Resolve ${VAR} references for masking — we want to show the
# masked *resolved* value, not the literal "${VAR}" string.
resolved_key = _resolve_env_var(raw_key)
return {
"name": name,
"type": pconf.get("type", "openai"),
"api_key": _mask_api_key(resolved_key),
"base_url": pconf.get("base_url", ""),
"models": pconf.get("models", {}) or {},
"max_tokens": pconf.get("max_tokens", 4096),
"timeout": pconf.get("timeout", 60.0),
}
def _resolve_env_var(value: str) -> str:
"""Resolve a ``${VAR}`` reference to its env value (if set).
If ``value`` is ``${OPENAI_API_KEY}`` and ``OPENAI_API_KEY`` is set
in the environment, returns the env value. Otherwise returns the
input unchanged.
"""
if not isinstance(value, str):
return ""
match = _ENV_REF_RE.match(value)
if match:
var_name = match.group(1).split(":-")[0]
return os.environ.get(var_name, value)
return value
def _env_var_name_for_provider(name: str) -> str:
"""Derive a sensible env var name from a provider name."""
return f"{name.upper().replace('-', '_')}_API_KEY"
# ---------------------------------------------------------------------------
# File-locking context manager
# ---------------------------------------------------------------------------
class _YamlLock:
"""Context manager that acquires an exclusive ``fcntl.flock`` on the
YAML file's sibling lockfile (``<config_path>.lock``).
The lockfile is created on demand and is not removed after release
(it's reused on subsequent writes). Holding the lock prevents
concurrent writers from corrupting the YAML file.
"""
def __init__(self, config_path: Path) -> None:
self._lock_path = config_path.with_suffix(config_path.suffix + ".lock")
self._fh = None
def __enter__(self) -> "_YamlLock":
self._fh = open(self._lock_path, "w")
fcntl.flock(self._fh.fileno(), fcntl.LOCK_EX)
return self
def __exit__(self, exc_type, exc, tb) -> None:
if self._fh is not None:
fcntl.flock(self._fh.fileno(), fcntl.LOCK_UN)
self._fh.close()
self._fh = None
# ---------------------------------------------------------------------------
# Service
# ---------------------------------------------------------------------------
class LlmConfigService:
"""CRUD for LLM providers, API keys, and fallback chains.
All methods read/write the YAML file at ``self._config_path``.
Writes are serialized via :class:`_YamlLock` (``fcntl.flock``) to
prevent concurrent corruption. The existing ``watch_config()``
mechanism detects the file change and rebuilds the
:class:`LLMGateway` — no explicit notification is needed.
API keys are NEVER returned in full — every response masks them via
:func:`_mask_api_key`. New plaintext keys are written to ``.env``
(next to the YAML) and the YAML stores a ``${ENV_VAR}`` reference.
"""
def __init__(self, config_path: Path) -> None:
self._config_path = Path(config_path)
# ------------------------------------------------------------------
# Provider CRUD
# ------------------------------------------------------------------
def list_providers(self) -> list[dict[str, Any]]:
"""Return all providers with masked API keys."""
data = self._read()
providers = data.get("llm", {}).get("providers", {}) or {}
return [_provider_to_dict(name, pconf) for name, pconf in providers.items()]
def get_provider(self, name: str) -> dict[str, Any] | None:
"""Return a single provider by name (masked key), or ``None``."""
data = self._read()
pconf = data.get("llm", {}).get("providers", {}).get(name)
if pconf is None:
return None
return _provider_to_dict(name, pconf)
def create_provider(
self,
name: str,
provider_type: str,
api_key: str,
base_url: str = "",
models: dict[str, Any] | None = None,
max_tokens: int = 4096,
timeout: float = 60.0,
) -> dict[str, Any]:
"""Add a new provider to the YAML file.
Args:
name: Provider name (must be unique within the YAML).
provider_type: Provider type (``openai`` / ``anthropic`` /
``gemini`` / ``doubao`` / ``wenxin`` / ``yuanbao``).
api_key: Plaintext API key. Stored in ``.env`` as
``{NAME}_API_KEY``; the YAML stores ``${...}`` ref.
base_url: Optional base URL override.
models: Optional models dict (e.g. ``{"gpt-4o": {}}``).
max_tokens: Default max tokens for completions.
timeout: Request timeout in seconds.
Returns:
The newly-created provider dict (masked key).
Raises:
ValueError: If a provider with the same name already exists.
"""
with _YamlLock(self._config_path):
data = self._read()
data.setdefault("llm", {}).setdefault("providers", {})
if name in data["llm"]["providers"]:
raise ValueError(f"Provider {name!r} already exists")
env_key = _env_var_name_for_provider(name)
_write_env_var(str(self._config_path), env_key, api_key)
pconf: dict[str, Any] = {
"type": provider_type,
"api_key": f"${{{env_key}}}",
"base_url": base_url,
"max_tokens": max_tokens,
"timeout": timeout,
}
if models is not None:
pconf["models"] = models
data["llm"]["providers"][name] = pconf
self._write(data)
created = self.get_provider(name)
assert created is not None # we just inserted it
return created
def update_provider(
self,
name: str,
provider_type: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
models: dict[str, Any] | None = None,
max_tokens: int | None = None,
timeout: float | None = None,
) -> dict[str, Any]:
"""Partially update a provider.
Only the provided fields are updated. If ``api_key`` is a masked
value (starts with ``****``), it is ignored — the existing key
is preserved.
Raises:
ValueError: If the provider does not exist.
"""
with _YamlLock(self._config_path):
data = self._read()
providers = data.get("llm", {}).get("providers", {}) or {}
if name not in providers:
raise ValueError(f"Provider {name!r} not found")
pconf = providers[name]
if provider_type is not None:
pconf["type"] = provider_type
if base_url is not None:
pconf["base_url"] = base_url
if models is not None:
pconf["models"] = models
if max_tokens is not None:
pconf["max_tokens"] = max_tokens
if timeout is not None:
pconf["timeout"] = timeout
if api_key is not None and not api_key.startswith("****"):
# New plaintext key — write to .env, keep ${VAR} ref in YAML.
existing_key = str(pconf.get("api_key", ""))
env_match = _ENV_REF_RE.match(existing_key)
if env_match:
env_key = env_match.group(1).split(":-")[0]
else:
env_key = _env_var_name_for_provider(name)
_write_env_var(str(self._config_path), env_key, api_key)
pconf["api_key"] = f"${{{env_key}}}"
providers[name] = pconf
data.setdefault("llm", {})["providers"] = providers
self._write(data)
updated = self.get_provider(name)
assert updated is not None
return updated
def delete_provider(self, name: str) -> bool:
"""Remove a provider from the YAML file.
Returns:
``True`` if the provider was deleted.
Raises:
ValueError: If the provider does not exist, or if it is
referenced by any fallback chain (removing it would
break the chain).
"""
with _YamlLock(self._config_path):
data = self._read()
providers = data.get("llm", {}).get("providers", {}) or {}
if name not in providers:
raise ValueError(f"Provider {name!r} not found")
# Guard: refuse to delete if referenced in any fallback chain.
fallbacks = data.get("llm", {}).get("fallbacks", {}) or {}
for model, chain in fallbacks.items():
if isinstance(chain, list) and name in chain:
raise ValueError(
f"Provider {name!r} is used in fallback chain for model "
f"{model!r}; remove the fallback first"
)
del providers[name]
data.setdefault("llm", {})["providers"] = providers
self._write(data)
return True
# ------------------------------------------------------------------
# API key management
# ------------------------------------------------------------------
def set_api_key(self, provider_name: str, api_key: str) -> dict[str, Any]:
"""Set the API key for a provider.
The plaintext key is written to ``.env`` (as
``{NAME}_API_KEY``) and the YAML stores a ``${...}`` reference.
If the provider does not yet exist in the YAML, a stub entry is
created with ``type=openai`` and default settings.
Returns:
The updated provider dict (masked key).
"""
with _YamlLock(self._config_path):
data = self._read()
data.setdefault("llm", {}).setdefault("providers", {})
providers = data["llm"]["providers"]
pconf = providers.get(provider_name, {})
existing_key = str(pconf.get("api_key", ""))
env_match = _ENV_REF_RE.match(existing_key)
if env_match:
env_key = env_match.group(1).split(":-")[0]
else:
env_key = _env_var_name_for_provider(provider_name)
_write_env_var(str(self._config_path), env_key, api_key)
pconf["api_key"] = f"${{{env_key}}}"
if "type" not in pconf:
pconf["type"] = "openai"
providers[provider_name] = pconf
self._write(data)
updated = self.get_provider(provider_name)
assert updated is not None
return updated
# ------------------------------------------------------------------
# Fallback chain management
# ------------------------------------------------------------------
def get_fallbacks(self) -> dict[str, list[str]]:
"""Return the fallback chains (model → list of provider/model refs)."""
data = self._read()
fallbacks = data.get("llm", {}).get("fallbacks", {}) or {}
# Normalize: ensure all values are lists.
return {
str(model): list(chain) if isinstance(chain, list) else [str(chain)]
for model, chain in fallbacks.items()
}
def set_fallback(self, model: str, chain: list[str]) -> dict[str, Any]:
"""Set the fallback chain for a model.
Returns:
``{"model": model, "chain": chain}`` dict.
"""
with _YamlLock(self._config_path):
data = self._read()
data.setdefault("llm", {}).setdefault("fallbacks", {})
data["llm"]["fallbacks"][model] = list(chain)
self._write(data)
return {"model": model, "chain": list(chain)}
def delete_fallback(self, model: str) -> bool:
"""Delete the fallback chain for a model.
Returns:
``True`` if a chain was deleted, ``False`` if no chain
existed for ``model``.
"""
with _YamlLock(self._config_path):
data = self._read()
fallbacks = data.get("llm", {}).get("fallbacks", {}) or {}
if model not in fallbacks:
return False
del fallbacks[model]
data.setdefault("llm", {})["fallbacks"] = fallbacks
self._write(data)
return True
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _read(self) -> dict[str, Any]:
"""Read the YAML config (returns ``{}`` if file is missing/empty)."""
if not self._config_path.exists():
return {}
return _read_yaml_config(str(self._config_path))
def _write(self, data: dict[str, Any]) -> None:
"""Write the full config dict back to the YAML file."""
_write_yaml_config(str(self._config_path), data)
# ---------------------------------------------------------------------------
# Module-level singleton (overridable in tests via set_llm_config_service)
# ---------------------------------------------------------------------------
_llm_config_service: LlmConfigService | None = None
def get_llm_config_service(config_path: Path | str | None = None) -> LlmConfigService:
"""Return the process-wide :class:`LlmConfigService`.
On first call, ``config_path`` must be provided (or the service
will raise ``ValueError``). Subsequent calls ignore ``config_path``
and return the cached singleton.
"""
global _llm_config_service
if _llm_config_service is None:
if config_path is None:
raise ValueError("config_path is required to initialize LlmConfigService on first call")
_llm_config_service = LlmConfigService(Path(config_path))
return _llm_config_service
def set_llm_config_service(service: LlmConfigService | None) -> None:
"""Inject a custom :class:`LlmConfigService` (used by tests)."""
global _llm_config_service
_llm_config_service = service
# Re-export yaml for tests that need to construct sample config files.
__all__ = [
"LlmConfigService",
"get_llm_config_service",
"set_llm_config_service",
"yaml",
]