Ctrl K
ComunidadGuías…

llm/warmup.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# SPDX-License-Identifier: BUSL-1.1
# Copyright (c) 2024-2026 Ali Sadeghi Aghili
"""Prime the model server's prefix cache so the first question is not the slow one.

Every SQL-generation request starts with the same bytes: the static prompt
prefix (:func:`prompt_engine.static_prefix.build_static_prefix`) is
byte-identical per data source, so a model server that caches prompt
prefixes (vLLM with ``--enable-prefix-caching``, llama.cpp's prompt cache)
only pays prefill for the short per-question tail. The cache starts empty,
though. After a restart of this server, or of the model server, the first
question pays the full prefill of the whole prefix -- about a minute on a
large local model -- and nothing before this module made that cost land on
anything but a user's question.

:func:`warm_prefix_cache` sends the model server one minimal request per
data source: the messages are exactly the cacheable prefix the real requests
start with, ``max_tokens`` is ``1`` and the temperature ``0``
(:meth:`llm.providers.OpenAIBackend.warm_prefix`). The server computes the
prefix's KV cache to answer it and keeps it.
:func:`start_background_warmup` runs that on a daemon thread from
``api/server.py``'s ``lifespan``, so the server accepts requests at once; a
question that arrives while the warm-up is still running is served as it
would have been without one (and on a server that shares in-flight prefix
blocks, may even benefit from it).

Why the prefix is not re-implemented here
-----------------------------------------
The warm-up prompt is taken from :func:`llm.router.build_prompt_segments` --
the one function both engines build real prompts with -- called with an
empty question. Its ``static_prefix`` is therefore the real requests'
prefix by construction: it cannot drift from it when the template, the
per-source scoping or the join-only rules change. ``tests/test_llm_warmup.py``
asserts that a real request's flattened prompt starts with exactly the text
the warm-up sent.

Keeping it warm
---------------
The start-up pass primes the cache once. On a model server shared with other
users the cache is evicted again whenever their traffic needs the memory, and
the next question pays the whole prefill. :class:`KeepWarm` (started from
``api/server.py``'s ``lifespan`` when ``LLM_PREFIX_KEEPWARM_SECONDS`` is above
zero) repeats the same pass every that many seconds. It is not a second
implementation of the warm-up: every pass is :func:`warm_prefix_cache`, with
three differences that only apply to it --

* a source whose prefix a real request used within the last interval is
  skipped (:mod:`llm.prefix_activity`; the request refreshed the cache, so
  under traffic the keep-warm sends nothing);
* a repeated outcome is logged once, then at ``DEBUG`` until it changes (an
  endpoint that stays down is one warning, not one per pass);
* it does nothing while ``paused()`` says so (maintenance mode) and stops at
  shutdown.

Failure policy
--------------
A warm-up is an optimisation. Every failure -- an unreachable endpoint, a
timeout, a server that rejects ``max_tokens=1``, a bad ``LLM_EXTRA_BODY``,
an untrusted endpoint, a configuration error -- is logged as one line and
swallowed; nothing here can stop the server starting or a question being
answered. The lines never contain prompt text, response text or keys: the
exception's class and, for an HTTP error, its status code are all that is
said about a failure.
"""

from __future__ import annotations

import logging
import threading
import time
from collections.abc import Callable
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from typing import Any

import config as cfg
from llm.prefix_activity import PrefixActivity, activity as _default_activity
from llm.router import (
    LLMRouter,
    PromptSegments,
    RemoteProviderNotAllowedError,
    build_prompt_segments,
)

logger = logging.getLogger(__name__)

#: Held for the whole of one warm-up pass, so a startup warm-up and a
#: manual ``POST /admin/llm/warmup`` never send the same prefix twice at
#: once.
_warmup_lock = threading.Lock()


@dataclass(frozen=True)
class SourceWarmup:
    """What happened when one data source's prefix was warmed.

    Parameters
    ----------
    source:
        The data source name, or ``None`` for the whole schema (a
        deployment with one data source).
    status:
        ``"warmed"``, ``"skipped"`` (nothing to warm, or not allowed to)
        or ``"failed"``.
    detail:
        Why it was skipped or failed, in words that carry no prompt text;
        ``None`` for ``"warmed"``.
    duration_ms:
        Wall-clock time spent on the request, or ``None`` when none was
        sent.
    prompt_tokens:
        ``usage.prompt_tokens`` the server reported for the warm-up
        request -- the size of the prefix in the model's own tokens.
    cached_tokens:
        How many of those the server already had cached, when it reports
        that (vLLM's ``--enable-prompt-tokens-details``); a value close to
        ``prompt_tokens`` means the cache was already warm.

    Examples
    --------
    >>> SourceWarmup("sales", "warmed", None, 41000, 4612, None).as_dict()["prompt_tokens"]
    4612
    """

    source: str | None
    status: str
    detail: str | None = None
    duration_ms: int | None = None
    prompt_tokens: int | None = None
    cached_tokens: int | None = None

    def as_dict(self) -> dict[str, Any]:
        """A JSON-ready copy, for ``POST /admin/llm/warmup``."""
        return asdict(self)


def _source_label(source: str | None) -> str:
    """The name a log line uses for *source*."""
    return source if source is not None else "(whole schema)"


def _describe_failure(exc: BaseException) -> str:
    """A log-safe description of *exc*: its class, plus the HTTP status if it has one.

    Exception *messages* are left out on purpose. Those of ``requests``
    carry URLs, and those of an endpoint's error body can echo what was
    sent.

    Examples
    --------
    >>> _describe_failure(TimeoutError("anything at all"))
    'TimeoutError'
    >>> import requests
    >>> r = requests.Response(); r.status_code = 400
    >>> _describe_failure(requests.HTTPError(response=r))
    'HTTPError (HTTP 400)'
    """
    text = type(exc).__name__
    status = getattr(getattr(exc, "response", None), "status_code", None)
    if isinstance(status, int):
        text += f" (HTTP {status})"
    return text


def warm_sources() -> tuple[str | None, ...]:
    """The data sources whose prefix a real request can start with.

    ``(None,)`` for a deployment with one data source, whose prompts are
    built for the whole schema; the source names, default first, when
    several are configured -- the same split
    :func:`prompt_engine.source_scope.scoped_source` makes.

    Examples
    --------
    >>> warm_sources()
    (None,)
    """
    import database.datasources as datasources

    names = datasources.datasource_names()
    return (None,) if len(names) < 2 else tuple(names)


def _prefix_segments(system_prompt: str, source: str | None) -> PromptSegments:
    """The cacheable part of a real prompt for *source*, and nothing else.

    Built by :func:`~llm.router.build_prompt_segments` with an empty
    question and context, so ``static_prefix`` is what real requests
    carry; it is empty when *source* is on the retrieval path (no stable
    prefix to warm).

    Examples
    --------
    >>> seg = _prefix_segments("You are a T-SQL expert.", None)
    >>> seg.static_prefix.startswith("\\nYou are a T-SQL expert.")
    True
    >>> seg.question, seg.session_context
    ('', '')
    """
    from core.models import RetrievalContext

    built = build_prompt_segments("", system_prompt, RetrievalContext(), source=source)
    return PromptSegments(static_prefix=built.static_prefix)


class _OutcomeLog:
    """Says each source's outcome once per streak, then only at ``DEBUG``.

    A keep-warm pass repeats every few minutes for as long as the server
    runs; an endpoint that is down, or a source on the retrieval path, must
    be one log line and not one per pass. The first time a source has an
    outcome *kind* it is logged at the level the call site chose; the same
    kind again is logged at ``DEBUG``; a different kind is a new streak and
    is logged at its level again. A success that ends a streak of failures
    says how many passes failed.
    """

    def __init__(self) -> None:
        self._lock = threading.Lock()
        self._state: dict[str | None, tuple[str, int]] = {}

    def failures_before(self, source: str | None) -> int:
        """How many consecutive failures *source* had up to now (0 if none)."""
        with self._lock:
            kind, count = self._state.get(source, ("", 0))
        return count if kind == "failed" else 0

    def emit(
        self, source: str | None, kind: str, level: int, message: str, *args: object,
    ) -> None:
        """Log *message* at *level* if *kind* starts a streak for *source*, else at DEBUG."""
        with self._lock:
            previous, count = self._state.get(source, ("", 0))
            new = previous != kind
            self._state[source] = (kind, 1 if new else count + 1)
        logger.log(level if new else logging.DEBUG, message, *args)


def _warm_one(
    router: LLMRouter,
    system_prompt: str,
    source: str | None,
    timeout: float,
    *,
    skip_used_within: float | None = None,
    outcomes: _OutcomeLog | None = None,
    activity: PrefixActivity | None = None,
) -> SourceWarmup:
    """Warm one source; never raises.

    Parameters
    ----------
    skip_used_within:
        When given (the keep-warm), a source whose prefix a real request
        used within this many seconds is skipped: that request already
        refreshed the server's cache. ``None`` (the start-up pass and
        ``POST /admin/llm/warmup``) always sends.
    outcomes:
        When given (the keep-warm), repeated outcomes are logged once per
        streak instead of every time.
    activity:
        The record of real requests; the process-wide one by default. A
        successful warm-up is recorded in it too, so a keep-warm pass right
        after a start-up or manual warm-up does not repeat it.
    """
    label = _source_label(source)
    activity = activity if activity is not None else _default_activity

    def say(kind: str, level: int, message: str, *args: object) -> None:
        if outcomes is not None:
            outcomes.emit(source, kind, level, message, *args)
        else:
            logger.log(level, message, *args)

    try:
        segments = _prefix_segments(system_prompt, source)
    except Exception as exc:  # noqa: BLE001 - a warm-up must not fail anything else
        say(
            "skipped:unbuildable", logging.WARNING,
            "LLM prefix warm-up: source=%s skipped, could not build the prefix (%s)",
            label, _describe_failure(exc),
        )
        return SourceWarmup(source, "skipped", f"could not build the prefix ({_describe_failure(exc)})")
    if not segments.static_prefix:
        say(
            "skipped:retrieval", logging.INFO,
            "LLM prefix warm-up: source=%s skipped, its prompt uses retrieval, not a static prefix",
            label,
        )
        return SourceWarmup(source, "skipped", "prompt uses retrieval, not a static prefix")
    if skip_used_within is not None:
        idle = activity.seconds_since_use(segments.static_prefix)
        if idle is not None and idle < skip_used_within:
            say(
                "skipped:recent", logging.DEBUG,
                "LLM prefix warm-up: source=%s skipped, a request used its prefix %.0f s ago",
                label, idle,
            )
            return SourceWarmup(
                source, "skipped", f"a request used this prefix {idle:.0f} s ago; it is warm",
            )

    start = time.perf_counter()
    try:
        report = router.warm_prefix(segments, timeout=timeout)
    except NotImplementedError:
        say(
            "skipped:no-cache", logging.INFO,
            "LLM prefix warm-up: source=%s skipped, the backend has no prefix cache", label,
        )
        return SourceWarmup(source, "skipped", "backend has no prefix cache to warm")
    except RemoteProviderNotAllowedError:
        say(
            "skipped:remote", logging.INFO,
            "LLM prefix warm-up: source=%s skipped, the endpoint is not trusted "
            "and LLM_ALLOW_REMOTE is off", label,
        )
        return SourceWarmup(source, "skipped", "endpoint not allowed (LLM_ALLOW_REMOTE is off)")
    except Exception as exc:  # noqa: BLE001 - every failure is logged and swallowed
        elapsed_ms = round((time.perf_counter() - start) * 1000)
        what = _describe_failure(exc)
        say(
            "failed", logging.WARNING,
            "LLM prefix warm-up: source=%s failed after %d ms (%s)", label, elapsed_ms, what,
        )
        return SourceWarmup(source, "failed", what, duration_ms=elapsed_ms)

    elapsed_ms = round((time.perf_counter() - start) * 1000)
    prompt_tokens = report.get("prompt_tokens")
    cached_tokens = report.get("cached_tokens")
    activity.note_use(segments.static_prefix)
    recovered = outcomes.failures_before(source) if outcomes is not None else 0
    say(
        "warmed", logging.INFO,
        "LLM prefix warm-up: source=%s warmed in %d ms, prompt_tokens=%s%s%s",
        label, elapsed_ms,
        prompt_tokens if prompt_tokens is not None else "unknown",
        f", cached_tokens={cached_tokens}" if cached_tokens is not None else "",
        f" (recovered after {recovered} failed pass(es))" if recovered else "",
    )
    return SourceWarmup(
        source, "warmed", None, duration_ms=elapsed_ms,
        prompt_tokens=prompt_tokens, cached_tokens=cached_tokens,
    )


def warm_prefix_cache(
    system_prompt: str,
    router_factory: Callable[[], LLMRouter],
    *,
    timeout_seconds: float | None = None,
    skip_used_within: float | None = None,
    outcomes: _OutcomeLog | None = None,
    activity: PrefixActivity | None = None,
    cancel: Callable[[], bool] | None = None,
) -> list[SourceWarmup] | None:
    """Warm the prefix cache for every data source, within one time budget.

    Sources are warmed one after another (a model server prefills them one
    after another anyway); each request is given what is left of the
    budget as its HTTP timeout, and a source reached after the budget is
    spent is skipped. Never raises.

    Parameters
    ----------
    system_prompt:
        The loaded system prompt, as ``api/server.py`` holds it.
    router_factory:
        Returns the :class:`~llm.router.LLMRouter` real requests use
        (``api.runner``'s agent's router). Called inside this function so
        that a failure to build it is logged rather than raised.
    timeout_seconds:
        Total budget in seconds; ``LLM_PREFIX_WARMUP_TIMEOUT_SECONDS`` when
        ``None``. The budget bounds each request's timeout, so a request
        that connects slowly and then reads slowly can overrun it by up to
        one more timeout; it is a bound, not a deadline to the millisecond.
    skip_used_within, outcomes, activity:
        What distinguishes a keep-warm pass (:class:`KeepWarm`) from the
        start-up one; see :func:`_warm_one`. The defaults are the start-up
        pass.
    cancel:
        Polled before each source; when it returns true the remaining
        sources are skipped (the keep-warm's shutdown).

    Returns
    -------
    list[SourceWarmup] | None
        One entry per source, in order. ``None`` when another warm-up is
        already running (nothing was sent).

    Examples
    --------
    >>> from llm.providers import MockBackend
    >>> router = LLMRouter(default_chain=[MockBackend()])
    >>> [r.status for r in warm_prefix_cache("You are a T-SQL expert.", lambda: router)]
    ['skipped']
    """
    if not _warmup_lock.acquire(blocking=False):
        logger.log(
            logging.DEBUG if outcomes is not None else logging.INFO,
            "LLM prefix warm-up: another warm-up is already running, not starting a second",
        )
        return None
    try:
        budget = float(
            timeout_seconds if timeout_seconds is not None
            else cfg.settings.llm_prefix_warmup_timeout_seconds
        )
        deadline = time.perf_counter() + budget
        try:
            router = router_factory()
            sources = warm_sources()
        except Exception as exc:  # noqa: BLE001 - nothing to warm, nothing to break
            message = "LLM prefix warm-up: not started, could not set up (%s)"
            if outcomes is not None:
                outcomes.emit(None, "setup", logging.WARNING, message, _describe_failure(exc))
            else:
                logger.warning(message, _describe_failure(exc))
            return [SourceWarmup(None, "skipped", f"could not set up ({_describe_failure(exc)})")]

        results: list[SourceWarmup] = []
        for source in sources:
            if cancel is not None and cancel():
                results.append(SourceWarmup(source, "skipped", "shutting down"))
                continue
            remaining = deadline - time.perf_counter()
            if remaining <= 0:
                logger.warning(
                    "LLM prefix warm-up: source=%s skipped, the %.0f s budget is spent",
                    _source_label(source), budget,
                )
                results.append(SourceWarmup(source, "skipped", "time budget exhausted"))
                continue
            results.append(_warm_one(
                router, system_prompt, source, remaining,
                skip_used_within=skip_used_within, outcomes=outcomes, activity=activity,
            ))
        return results
    finally:
        _warmup_lock.release()


def start_background_warmup(
    system_prompt: str,
    router_factory: Callable[[], LLMRouter],
) -> threading.Thread:
    """Run :func:`warm_prefix_cache` on a daemon thread and return at once.

    The thread is a daemon so that it can never keep the process alive at
    shutdown, and it catches everything, so a bug in the warm-up is a log
    line and not an unhandled-thread-exception traceback.

    Parameters
    ----------
    system_prompt, router_factory:
        As for :func:`warm_prefix_cache`.

    Returns
    -------
    threading.Thread
        The started thread (tests ``join`` it; the server ignores it).

    Examples
    --------
    >>> from llm.providers import MockBackend
    >>> router = LLMRouter(default_chain=[MockBackend()])
    >>> t = start_background_warmup("You are a T-SQL expert.", lambda: router)
    >>> t.join(5); t.is_alive()
    False
    """

    def _run() -> None:
        try:
            warm_prefix_cache(system_prompt, router_factory)
        except Exception as exc:  # noqa: BLE001 - belt and braces: warm_prefix_cache already catches
            logger.warning("LLM prefix warm-up: stopped unexpectedly (%s)", _describe_failure(exc))

    thread = threading.Thread(target=_run, name="llm-prefix-warmup", daemon=True)
    thread.start()
    return thread


# ---------------------------------------------------------------------------
# Keeping the cache warm
# ---------------------------------------------------------------------------

class KeepWarm:
    """Repeats the warm-up every *interval_seconds*, skipping what real traffic keeps warm.

    Each pass is :func:`warm_prefix_cache` (so the request, the
    data-governance gate, the time budget and the "retrieval path means
    nothing to warm" rule are the warm-up's own), with ``skip_used_within``
    set to the interval. See the module docstring's "Keeping it warm".

    The first pass runs one interval after :meth:`start`, not at once: the
    start-up warm-up (or the first question) is what primes a cold server.

    Parameters
    ----------
    system_prompt, router_factory:
        As for :func:`warm_prefix_cache`.
    interval_seconds:
        Seconds between passes, and the age under which a real request
        counts as having kept a prefix warm.
    paused:
        Returns true while passes must not send anything (maintenance mode).
        An exception from it counts as "not paused".
    activity:
        The record of real requests (:mod:`llm.prefix_activity`); the
        process-wide one by default, which is the one the router writes to.
    wait:
        ``wait(seconds) -> bool`` blocks for that long and returns true when
        the keep-warm was stopped meanwhile. The default waits on the stop
        event; tests pass a fake that advances a fake clock.
    now:
        Returns the current wall-clock time, for ``last_run_at``.

    Examples
    --------
    >>> from llm.providers import MockBackend
    >>> router = LLMRouter(default_chain=[MockBackend()])
    >>> keepwarm = KeepWarm("You are a T-SQL expert.", lambda: router, 60)
    >>> [r.status for r in keepwarm.run_once()]
    ['skipped']
    >>> keepwarm.status()["last_run_outcome"]
    'skipped'
    """

    def __init__(
        self,
        system_prompt: str,
        router_factory: Callable[[], LLMRouter],
        interval_seconds: float,
        *,
        paused: Callable[[], bool] | None = None,
        activity: PrefixActivity | None = None,
        wait: Callable[[float], bool] | None = None,
        now: Callable[[], datetime] | None = None,
    ) -> None:
        self._system_prompt = system_prompt
        self._router_factory = router_factory
        self.interval_seconds = float(interval_seconds)
        self._paused = paused
        self._activity = activity if activity is not None else _default_activity
        self._stop = threading.Event()
        self._wait = wait if wait is not None else self._stop.wait
        self._now = now if now is not None else (lambda: datetime.now(timezone.utc))
        self._outcomes = _OutcomeLog()
        self._thread: threading.Thread | None = None
        self._lock = threading.Lock()
        self._runs = 0
        self._last_run_at: str | None = None
        self._last_outcome: str | None = None
        self._last_results: list[SourceWarmup] = []

    # -- control ---------------------------------------------------------

    def start(self) -> threading.Thread:
        """Start the daemon thread (once) and return it."""
        with self._lock:
            if self._thread is not None and self._thread.is_alive():
                return self._thread
            self._stop.clear()
            self._thread = threading.Thread(
                target=self._run, name="llm-prefix-keepwarm", daemon=True,
            )
            self._thread.start()
            return self._thread

    def stop(self, timeout: float = 5.0) -> bool:
        """Ask the thread to stop and wait up to *timeout* seconds for it.

        Returns true when it has ended. A pass in the middle of a request
        to the model server ends with that request (bounded by
        ``LLM_PREFIX_WARMUP_TIMEOUT_SECONDS``); the thread is a daemon, so a
        process that is exiting does not wait for it.
        """
        self._stop.set()
        with self._lock:
            thread = self._thread
        if thread is not None and thread is not threading.current_thread():
            thread.join(timeout)
            return not thread.is_alive()
        return True

    @property
    def running(self) -> bool:
        """True while the thread is alive and has not been asked to stop."""
        thread = self._thread
        return bool(thread is not None and thread.is_alive() and not self._stop.is_set())

    # -- one pass --------------------------------------------------------

    def _is_paused(self) -> bool:
        if self._paused is None:
            return False
        try:
            return bool(self._paused())
        except Exception:  # noqa: BLE001 - a broken pause check must not stop the keep-warm
            return False

    def run_once(self) -> list[SourceWarmup] | None:
        """Run one pass now; never raises.

        Returns
        -------
        list[SourceWarmup] | None
            One entry per source, as for :func:`warm_prefix_cache`; ``None``
            when nothing was attempted (paused, stopped, or another warm-up
            was running).
        """
        if self._stop.is_set():
            return None
        if self._is_paused():
            self._record("paused", [])
            return None
        try:
            results = warm_prefix_cache(
                self._system_prompt, self._router_factory,
                skip_used_within=self.interval_seconds,
                outcomes=self._outcomes, activity=self._activity,
                cancel=self._stop.is_set,
            )
        except Exception as exc:  # noqa: BLE001 - belt and braces: warm_prefix_cache already catches
            self._outcomes.emit(
                None, "crashed", logging.WARNING,
                "LLM prefix keep-warm: a pass stopped unexpectedly (%s)", _describe_failure(exc),
            )
            self._record("failed", [SourceWarmup(None, "failed", _describe_failure(exc))])
            return None
        if results is None:
            self._record("busy", [])
            return None
        if any(r.status == "failed" for r in results):
            outcome = "failed"
        elif any(r.status == "warmed" for r in results):
            outcome = "warmed"
        else:
            outcome = "skipped"
        self._record(outcome, results)
        return results

    def _record(self, outcome: str, results: list[SourceWarmup]) -> None:
        with self._lock:
            self._runs += 1
            self._last_run_at = self._now().isoformat()
            self._last_outcome = outcome
            self._last_results = list(results)

    def _run(self) -> None:
        logger.info(
            "LLM prefix keep-warm: every %.0f s; a source a real request used within that "
            "time is skipped", self.interval_seconds,
        )
        while not self._stop.is_set():
            if self._wait(self.interval_seconds):
                break
            self.run_once()
        logger.info("LLM prefix keep-warm: stopped")

    # -- what the admin routes show ---------------------------------------

    def status(self) -> dict[str, Any]:
        """A JSON-ready account of the keep-warm and its last pass.

        ``last_run_outcome`` is ``"warmed"`` (at least one prefix was sent
        and answered), ``"skipped"`` (every source was skipped: kept warm by
        traffic, on the retrieval path, or not allowed), ``"failed"`` (at
        least one request failed), ``"paused"`` (maintenance mode) or
        ``"busy"`` (another warm-up was running); ``None`` before the first
        pass. ``last_run`` has one entry per source, as
        ``POST /admin/llm/warmup`` does. No prompt text anywhere.
        """
        paused = self._is_paused()
        with self._lock:
            return {
                "enabled": True,
                "interval_seconds": self.interval_seconds,
                "running": self.running,
                "paused": paused,
                "runs": self._runs,
                "last_run_at": self._last_run_at,
                "last_run_outcome": self._last_outcome,
                "last_run": [r.as_dict() for r in self._last_results],
            }


#: The keep-warm :func:`start_keepwarm` started, if any.
_keepwarm: KeepWarm | None = None
_keepwarm_lock = threading.Lock()


def start_keepwarm(
    system_prompt: str,
    router_factory: Callable[[], LLMRouter],
    *,
    paused: Callable[[], bool] | None = None,
) -> KeepWarm | None:
    """Start the process's keep-warm if ``LLM_PREFIX_KEEPWARM_SECONDS`` asks for one.

    Returns ``None`` (and starts nothing) when the setting is ``0``, when
    ``LLM_PROVIDER=mock`` (there is no model server to keep warm), or when a
    keep-warm is already running. Never raises.

    Examples
    --------
    >>> start_keepwarm("You are a T-SQL expert.", lambda: None) is None
    True
    """
    global _keepwarm
    try:
        seconds = float(cfg.settings.llm_prefix_keepwarm_seconds)
        if seconds <= 0:
            return None
        if str(cfg.settings.llm_provider).lower() == "mock":
            logger.info("LLM prefix keep-warm: not started, LLM_PROVIDER=mock has no model server")
            return None
        with _keepwarm_lock:
            if _keepwarm is not None and _keepwarm.running:
                return None
            keepwarm = KeepWarm(system_prompt, router_factory, seconds, paused=paused)
            keepwarm.start()
            _keepwarm = keepwarm
            return keepwarm
    except Exception as exc:  # noqa: BLE001 - an optimisation, startup must not fail on it
        logger.warning("LLM prefix keep-warm could not be started: %s", _describe_failure(exc))
        return None


def stop_keepwarm(timeout: float = 5.0) -> None:
    """Stop the process's keep-warm, if one is running (``lifespan`` shutdown). Never raises."""
    global _keepwarm
    with _keepwarm_lock:
        keepwarm, _keepwarm = _keepwarm, None
    if keepwarm is None:
        return
    try:
        if not keepwarm.stop(timeout):
            logger.warning(
                "LLM prefix keep-warm: still finishing a request to the model server at "
                "shutdown; it is a daemon thread and ends with the process",
            )
    except Exception as exc:  # noqa: BLE001 - shutdown must not fail on it
        logger.warning("LLM prefix keep-warm could not be stopped: %s", _describe_failure(exc))


def keepwarm_status() -> dict[str, Any]:
    """The keep-warm's status, or the reason there is none.

    With no keep-warm running: ``{"enabled": False, "reason": ...}`` and the
    same keys with empty values. Safe to call from a request.

    Examples
    --------
    >>> keepwarm_status()["enabled"]
    False
    """
    with _keepwarm_lock:
        keepwarm = _keepwarm
    if keepwarm is not None:
        return keepwarm.status()
    seconds = float(cfg.settings.llm_prefix_keepwarm_seconds)
    if seconds <= 0:
        reason = "LLM_PREFIX_KEEPWARM_SECONDS is 0"
    elif str(cfg.settings.llm_provider).lower() == "mock":
        reason = "LLM_PROVIDER=mock has no model server"
    else:
        reason = "not started"
    return {
        "enabled": False,
        "reason": reason,
        "interval_seconds": seconds,
        "running": False,
        "paused": False,
        "runs": 0,
        "last_run_at": None,
        "last_run_outcome": None,
        "last_run": [],
    }