Replaces LICENSE (GPLv3 -> AGPLv3) and prepends `SPDX-License-Identifier: AGPL-3.0-or-later` to every source file across decnet/, decnet_web/, tests/, scripts/, and tools/. Rationale: closes the GPLv3 ASP loophole so any party operating a modified DECNET as a network service must offer their modified source. Personal copyright (Samuel Paschuan) + inbound=outbound contributions make a future unilateral relicense infeasible. - LICENSE: full AGPL-3.0 text (gnu.org/licenses/agpl-3.0.txt) - COPYRIGHT: project copyright notice - tools/add_spdx_headers.py: idempotent header injector (shebang- and PEP 263-aware) Touches 1565 source files (.py, .ts, .tsx, .js, .jsx, .css, .sh). No behavior change; comments only.
131 lines
4.4 KiB
Python
131 lines
4.4 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
"""DB-backed LLM provider configuration for the realism subsystem.
|
|
|
|
The module holds a process-level cached backend that callers obtain via
|
|
:func:`decnet.realism.llm.factory.get_llm`. The cache is populated by:
|
|
|
|
* The API process: :func:`load_from_db` called on first GET, then
|
|
``apply`` on each successful PUT.
|
|
* The orchestrator worker: :func:`load_from_db` called on the same
|
|
periodic tick that refreshes planner weights.
|
|
|
|
``get_llm()`` falls back to the env-var path when the cache is ``None``
|
|
(i.e. the DB row does not exist yet or has never been loaded).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from typing import Any, Optional
|
|
|
|
from pydantic import BaseModel, Field, field_validator
|
|
|
|
from decnet.logging import get_logger
|
|
|
|
log = get_logger("realism.llm.config")
|
|
|
|
_SUPPORTED_PROVIDERS = {"ollama", "fake"}
|
|
_HTTP_RE = re.compile(r"^https?://", re.IGNORECASE)
|
|
|
|
# Process-level singleton — rebuilt by apply(), read by get_llm().
|
|
_cached_backend: Optional[Any] = None
|
|
|
|
_CONFIG_KEY = "llm"
|
|
|
|
|
|
class LLMConfig(BaseModel):
|
|
"""Operator-tunable LLM provider settings stored in ``realism_config``."""
|
|
|
|
provider: str = Field(default="ollama")
|
|
base_url: Optional[str] = Field(default=None)
|
|
model: str = Field(default="llama3.1")
|
|
timeout: float = Field(default=60.0, gt=0)
|
|
# Never returned to callers — encrypted Fernet token, write-only.
|
|
api_key_ciphertext: Optional[str] = Field(default=None)
|
|
|
|
@field_validator("provider")
|
|
@classmethod
|
|
def _validate_provider(cls, v: str) -> str:
|
|
if v not in _SUPPORTED_PROVIDERS:
|
|
raise ValueError(
|
|
f"provider must be one of {sorted(_SUPPORTED_PROVIDERS)}, got {v!r}"
|
|
)
|
|
return v
|
|
|
|
@field_validator("base_url")
|
|
@classmethod
|
|
def _validate_base_url(cls, v: Optional[str]) -> Optional[str]:
|
|
if v is None or v == "":
|
|
return None
|
|
if not _HTTP_RE.match(v):
|
|
raise ValueError("base_url must start with http:// or https://")
|
|
return v.rstrip("/")
|
|
|
|
|
|
def get_cached_backend() -> Optional[Any]:
|
|
"""Return the cached LLMBackend, or ``None`` if not yet hydrated."""
|
|
return _cached_backend
|
|
|
|
|
|
def apply(cfg: LLMConfig) -> None:
|
|
"""Build a backend from *cfg* and install it as the process cache.
|
|
|
|
Existing circuit-breaker state is NOT reset — don't wipe a tripped
|
|
breaker just because the operator tuned a URL.
|
|
"""
|
|
global _cached_backend
|
|
|
|
if cfg.provider == "fake":
|
|
from decnet.realism.llm.impl.fake import FakeBackend
|
|
_cached_backend = FakeBackend(model="fake-model")
|
|
log.info("realism.llm.config: applied provider=fake")
|
|
return
|
|
|
|
if cfg.provider == "ollama":
|
|
api_key: Optional[str] = None
|
|
if cfg.api_key_ciphertext:
|
|
try:
|
|
from decnet.web.db.secrets import decrypt_secret
|
|
api_key = decrypt_secret(cfg.api_key_ciphertext)
|
|
except RuntimeError as exc:
|
|
log.warning(
|
|
"realism.llm.config: DECNET_SECRET_KEY unavailable, "
|
|
"api_key will not be passed to backend: %s", exc,
|
|
)
|
|
|
|
from decnet.realism.llm.impl.ollama import OllamaBackend
|
|
_cached_backend = OllamaBackend(
|
|
model=cfg.model,
|
|
timeout=cfg.timeout,
|
|
base_url=cfg.base_url,
|
|
api_key=api_key,
|
|
)
|
|
log.info(
|
|
"realism.llm.config: applied provider=ollama model=%s base_url=%s",
|
|
cfg.model, cfg.base_url or "(subprocess)",
|
|
)
|
|
return
|
|
|
|
raise ValueError(f"apply: unsupported provider {cfg.provider!r}")
|
|
|
|
|
|
async def load_from_db(repo: Any) -> Optional[LLMConfig]:
|
|
"""Load the ``key='llm'`` RealismConfig row and return a parsed config.
|
|
|
|
Returns ``None`` when the row doesn't exist or the JSON is malformed;
|
|
callers fall back to env-var defaults in both cases.
|
|
"""
|
|
try:
|
|
row = await repo.get_realism_config(_CONFIG_KEY)
|
|
except Exception as exc: # noqa: BLE001
|
|
log.warning("realism.llm.config: DB read failed: %s", exc)
|
|
return None
|
|
if row is None:
|
|
return None
|
|
try:
|
|
data = json.loads(row.get("value") or "{}")
|
|
return LLMConfig(**data)
|
|
except Exception as exc: # noqa: BLE001
|
|
log.warning("realism.llm.config: malformed config row: %s", exc)
|
|
return None
|